mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 05:10:42 -07:00
Add cloud E2E crypto primitives
Client-side half of the cloud backup/sync privacy model (cloud repo docs/DESIGN.md): master key generation, BIP39 recovery phrase escrow (Argon2id KEK), X25519 device keypairs with sealed-box MK wrapping, and the VBX1 blob envelope — XChaCha20-Poly1305 under a per-blob content key wrapped by MK, with AAD binding each blob to its (object, role, version) slot so the server can't swap blobs undetected. Pure functions over bytes, no I/O. Keychain persistence and the sync engine come next. Deps: pynacl, mnemonic.
This commit is contained in:
@@ -7,6 +7,10 @@ pydantic>=2.5.0
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Cloud backup/sync E2E encryption (services/cloud_crypto.py)
|
||||
pynacl>=1.5.0
|
||||
mnemonic>=0.21
|
||||
|
||||
# ML models
|
||||
torch>=2.2.0
|
||||
transformers>=4.36.0,<=4.57.6
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Client-side cryptography for Voicebox Cloud backup & sync.
|
||||
|
||||
This is the auditable half of the cloud's privacy promise: every byte that
|
||||
leaves this machine is encrypted here first, and the server only ever stores
|
||||
ciphertext plus routing metadata. The key hierarchy (cloud repo,
|
||||
``docs/DESIGN.md``):
|
||||
|
||||
Recovery phrase (BIP39) Device X25519 keypairs
|
||||
| Argon2id(salt, params) | sealed box
|
||||
v v
|
||||
Recovery KEK --wrap--> Master Key (MK) <--wrapped to each device
|
||||
|
|
||||
| per-blob random Content Key (CK),
|
||||
| wrapped by MK inside the envelope
|
||||
v
|
||||
XChaCha20-Poly1305(CK) over each record/asset blob
|
||||
|
||||
Everything in this module is a pure function over bytes — no I/O, no storage,
|
||||
no network. Key persistence (OS keychain) and the sync engine live elsewhere.
|
||||
|
||||
Invariants this module enforces:
|
||||
- The Master Key is random, never derived from anything the server holds
|
||||
(API keys and wallet keys are auth/entitlement, never encryption roots).
|
||||
- Each blob's AAD binds it to its logical slot (object, role, version), so a
|
||||
server cannot substitute one blob for another without decryption failing.
|
||||
- Content Keys travel only inside the envelope, wrapped by MK — the database
|
||||
(local and cloud) stays free of key material.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import nacl.bindings
|
||||
import nacl.exceptions
|
||||
import nacl.pwhash
|
||||
import nacl.utils
|
||||
from mnemonic import Mnemonic
|
||||
from nacl.public import PrivateKey, PublicKey, SealedBox
|
||||
from nacl.secret import SecretBox
|
||||
|
||||
KEY_BYTES = 32
|
||||
|
||||
# Envelope framing: magic | alg | len(wrapped_ck) | wrapped_ck | nonce | ciphertext
|
||||
ENVELOPE_MAGIC = b"VBX1"
|
||||
ALG_XCHACHA20_POLY1305 = 1
|
||||
_WRAPPED_CK_LEN_BYTES = 2
|
||||
_NONCE_BYTES = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES # 24
|
||||
|
||||
# Argon2id cost for the recovery-phrase KEK. MODERATE (~256 MiB, interactive
|
||||
# latency) fits a desktop restore flow; the phrase itself already carries
|
||||
# 128 bits of entropy, so the KDF is hardening, not the main defense.
|
||||
_KDF_OPSLIMIT = nacl.pwhash.argon2id.OPSLIMIT_MODERATE
|
||||
_KDF_MEMLIMIT = nacl.pwhash.argon2id.MEMLIMIT_MODERATE
|
||||
|
||||
_mnemonic = Mnemonic("english")
|
||||
|
||||
|
||||
class CloudCryptoError(Exception):
|
||||
"""Envelope malformed, key wrong, or ciphertext tampered with."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Master Key + device keys
|
||||
|
||||
|
||||
def generate_master_key() -> bytes:
|
||||
"""The 32-byte root of content secrecy. Generated once per account, client-side."""
|
||||
return secrets.token_bytes(KEY_BYTES)
|
||||
|
||||
|
||||
def generate_device_keypair() -> tuple[bytes, bytes]:
|
||||
"""X25519 (private, public) for this install. The private key never leaves
|
||||
the device; the public key is registered with the cloud so existing devices
|
||||
can wrap MK to it."""
|
||||
private = PrivateKey.generate()
|
||||
return bytes(private), bytes(private.public_key)
|
||||
|
||||
|
||||
def wrap_master_key_for_device(master_key: bytes, device_public_key: bytes) -> bytes:
|
||||
"""Seal MK to another device's public key (run on an *existing* device when
|
||||
provisioning a new one). Only the target device's private key can open it."""
|
||||
return SealedBox(PublicKey(device_public_key)).encrypt(master_key)
|
||||
|
||||
|
||||
def unwrap_master_key_for_device(wrapped: bytes, device_private_key: bytes) -> bytes:
|
||||
"""Open a sealed MK with this device's private key."""
|
||||
try:
|
||||
return SealedBox(PrivateKey(device_private_key)).decrypt(wrapped)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("wrapped master key does not match this device key") from err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery phrase escrow
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryWrap:
|
||||
"""What the cloud stores in ``account_key``: ciphertext + the KDF context
|
||||
any client needs to re-derive the KEK from the phrase. Holds no secrets."""
|
||||
|
||||
wrapped_key: bytes
|
||||
kdf_salt: bytes
|
||||
kdf_params: str # JSON, e.g. {"m": ..., "t": ..., "p": 1}
|
||||
|
||||
|
||||
def generate_recovery_phrase() -> str:
|
||||
"""12-word BIP39 mnemonic (128-bit entropy) — shown to the user exactly once."""
|
||||
return _mnemonic.generate(strength=128)
|
||||
|
||||
|
||||
def validate_recovery_phrase(phrase: str) -> bool:
|
||||
"""Word-level checksum validation, for catching typos before an unwrap attempt."""
|
||||
return _mnemonic.check(_normalize_phrase(phrase))
|
||||
|
||||
|
||||
def _normalize_phrase(phrase: str) -> str:
|
||||
return " ".join(phrase.lower().split())
|
||||
|
||||
|
||||
def _derive_kek(phrase: str, salt: bytes, opslimit: int, memlimit: int) -> bytes:
|
||||
return nacl.pwhash.argon2id.kdf(
|
||||
KEY_BYTES,
|
||||
_normalize_phrase(phrase).encode("utf-8"),
|
||||
salt,
|
||||
opslimit=opslimit,
|
||||
memlimit=memlimit,
|
||||
)
|
||||
|
||||
|
||||
def wrap_master_key_with_phrase(master_key: bytes, phrase: str) -> RecoveryWrap:
|
||||
"""Argon2id-stretch the phrase into a KEK and wrap MK under it."""
|
||||
salt = nacl.utils.random(nacl.pwhash.argon2id.SALTBYTES)
|
||||
kek = _derive_kek(phrase, salt, _KDF_OPSLIMIT, _KDF_MEMLIMIT)
|
||||
return RecoveryWrap(
|
||||
wrapped_key=SecretBox(kek).encrypt(master_key),
|
||||
kdf_salt=salt,
|
||||
kdf_params=json.dumps({"m": _KDF_MEMLIMIT, "t": _KDF_OPSLIMIT, "p": 1}),
|
||||
)
|
||||
|
||||
|
||||
def unwrap_master_key_with_phrase(wrap: RecoveryWrap, phrase: str) -> bytes:
|
||||
"""Recover MK on a fresh device from the phrase + the stored KDF context."""
|
||||
try:
|
||||
params = json.loads(wrap.kdf_params)
|
||||
opslimit, memlimit = int(params["t"]), int(params["m"])
|
||||
except (ValueError, KeyError, TypeError) as err:
|
||||
raise CloudCryptoError("malformed KDF parameters") from err
|
||||
kek = _derive_kek(phrase, wrap.kdf_salt, opslimit, memlimit)
|
||||
try:
|
||||
return SecretBox(kek).decrypt(wrap.wrapped_key)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("recovery phrase does not unlock this account key") from err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blob envelope
|
||||
|
||||
|
||||
def _build_aad(object_id: str, role: str, version: int) -> bytes:
|
||||
# Binds a blob to its logical slot. Unambiguous because object_id is a UUID
|
||||
# and role is a fixed token — neither contains ":".
|
||||
return f"{object_id}:{role}:{version}".encode()
|
||||
|
||||
|
||||
def encrypt_blob(plaintext: bytes, master_key: bytes, *, object_id: str, role: str, version: int) -> bytes:
|
||||
"""Produce a self-describing VBX1 envelope: a fresh Content Key wrapped by
|
||||
MK rides in the header, and the AAD pins the blob to (object, role, version)."""
|
||||
content_key = secrets.token_bytes(KEY_BYTES)
|
||||
wrapped_ck = SecretBox(master_key).encrypt(content_key)
|
||||
nonce = nacl.utils.random(_NONCE_BYTES)
|
||||
ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
plaintext,
|
||||
_build_aad(object_id, role, version),
|
||||
nonce,
|
||||
content_key,
|
||||
)
|
||||
return b"".join(
|
||||
[
|
||||
ENVELOPE_MAGIC,
|
||||
bytes([ALG_XCHACHA20_POLY1305]),
|
||||
len(wrapped_ck).to_bytes(_WRAPPED_CK_LEN_BYTES, "big"),
|
||||
wrapped_ck,
|
||||
nonce,
|
||||
ciphertext,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def decrypt_blob(envelope: bytes, master_key: bytes, *, object_id: str, role: str, version: int) -> bytes:
|
||||
"""Open a VBX1 envelope. Raises CloudCryptoError if the envelope is
|
||||
malformed, the key is wrong, the ciphertext was modified, or the blob was
|
||||
served for a different (object, role, version) slot."""
|
||||
offset = len(ENVELOPE_MAGIC)
|
||||
if envelope[:offset] != ENVELOPE_MAGIC:
|
||||
raise CloudCryptoError("not a VBX1 envelope")
|
||||
if len(envelope) < offset + 1 + _WRAPPED_CK_LEN_BYTES:
|
||||
raise CloudCryptoError("envelope truncated")
|
||||
alg = envelope[offset]
|
||||
if alg != ALG_XCHACHA20_POLY1305:
|
||||
raise CloudCryptoError(f"unsupported envelope algorithm {alg}")
|
||||
offset += 1
|
||||
|
||||
wrapped_len = int.from_bytes(envelope[offset : offset + _WRAPPED_CK_LEN_BYTES], "big")
|
||||
offset += _WRAPPED_CK_LEN_BYTES
|
||||
wrapped_ck = envelope[offset : offset + wrapped_len]
|
||||
offset += wrapped_len
|
||||
nonce = envelope[offset : offset + _NONCE_BYTES]
|
||||
offset += _NONCE_BYTES
|
||||
ciphertext = envelope[offset:]
|
||||
if len(wrapped_ck) != wrapped_len or len(nonce) != _NONCE_BYTES or not ciphertext:
|
||||
raise CloudCryptoError("envelope truncated")
|
||||
|
||||
try:
|
||||
content_key = SecretBox(master_key).decrypt(wrapped_ck)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("master key does not unwrap this blob's content key") from err
|
||||
try:
|
||||
return nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
ciphertext,
|
||||
_build_aad(object_id, role, version),
|
||||
nonce,
|
||||
content_key,
|
||||
)
|
||||
except nacl.exceptions.CryptoError as err:
|
||||
raise CloudCryptoError("blob failed authentication (tampered, or served for the wrong slot)") from err
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the cloud E2E crypto primitives (services/cloud_crypto.py)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.cloud_crypto import (
|
||||
ALG_XCHACHA20_POLY1305,
|
||||
ENVELOPE_MAGIC,
|
||||
CloudCryptoError,
|
||||
RecoveryWrap,
|
||||
decrypt_blob,
|
||||
encrypt_blob,
|
||||
generate_device_keypair,
|
||||
generate_master_key,
|
||||
generate_recovery_phrase,
|
||||
unwrap_master_key_for_device,
|
||||
unwrap_master_key_with_phrase,
|
||||
validate_recovery_phrase,
|
||||
wrap_master_key_for_device,
|
||||
wrap_master_key_with_phrase,
|
||||
)
|
||||
|
||||
SLOT = {"object_id": "0c8f6f4e-9f5a-4a2f-8f6a-1d2e3f4a5b6c", "role": "audio", "version": 3}
|
||||
|
||||
|
||||
class TestMasterKey:
|
||||
def test_master_keys_are_random_32_bytes(self):
|
||||
a, b = generate_master_key(), generate_master_key()
|
||||
assert len(a) == 32
|
||||
assert a != b
|
||||
|
||||
|
||||
class TestDeviceWrap:
|
||||
def test_round_trip(self):
|
||||
mk = generate_master_key()
|
||||
private, public = generate_device_keypair()
|
||||
assert unwrap_master_key_for_device(wrap_master_key_for_device(mk, public), private) == mk
|
||||
|
||||
def test_wrong_device_key_fails(self):
|
||||
mk = generate_master_key()
|
||||
_, public = generate_device_keypair()
|
||||
other_private, _ = generate_device_keypair()
|
||||
with pytest.raises(CloudCryptoError):
|
||||
unwrap_master_key_for_device(wrap_master_key_for_device(mk, public), other_private)
|
||||
|
||||
|
||||
class TestRecoveryPhrase:
|
||||
def test_phrase_is_valid_bip39(self):
|
||||
phrase = generate_recovery_phrase()
|
||||
assert len(phrase.split()) == 12
|
||||
assert validate_recovery_phrase(phrase)
|
||||
|
||||
def test_typo_fails_checksum(self):
|
||||
words = generate_recovery_phrase().split()
|
||||
words[0] = "abandon" if words[0] != "abandon" else "ability"
|
||||
assert not validate_recovery_phrase(" ".join(words))
|
||||
|
||||
def test_round_trip(self):
|
||||
mk = generate_master_key()
|
||||
phrase = generate_recovery_phrase()
|
||||
assert unwrap_master_key_with_phrase(wrap_master_key_with_phrase(mk, phrase), phrase) == mk
|
||||
|
||||
def test_normalization_tolerates_case_and_whitespace(self):
|
||||
mk = generate_master_key()
|
||||
phrase = generate_recovery_phrase()
|
||||
wrap = wrap_master_key_with_phrase(mk, phrase)
|
||||
sloppy = f" {phrase.upper().replace(' ', ' ')} \n"
|
||||
assert unwrap_master_key_with_phrase(wrap, sloppy) == mk
|
||||
|
||||
def test_wrong_phrase_fails(self):
|
||||
wrap = wrap_master_key_with_phrase(generate_master_key(), generate_recovery_phrase())
|
||||
with pytest.raises(CloudCryptoError):
|
||||
unwrap_master_key_with_phrase(wrap, generate_recovery_phrase())
|
||||
|
||||
def test_malformed_kdf_params_fail(self):
|
||||
wrap = wrap_master_key_with_phrase(generate_master_key(), generate_recovery_phrase())
|
||||
broken = RecoveryWrap(wrapped_key=wrap.wrapped_key, kdf_salt=wrap.kdf_salt, kdf_params="not json")
|
||||
with pytest.raises(CloudCryptoError):
|
||||
unwrap_master_key_with_phrase(broken, generate_recovery_phrase())
|
||||
|
||||
|
||||
class TestEnvelope:
|
||||
def test_round_trip(self):
|
||||
mk = generate_master_key()
|
||||
plaintext = b"capture transcript \xf0\x9f\x8e\x99 and some audio bytes" * 100
|
||||
envelope = encrypt_blob(plaintext, mk, **SLOT)
|
||||
assert envelope[:4] == ENVELOPE_MAGIC
|
||||
assert envelope[4] == ALG_XCHACHA20_POLY1305
|
||||
assert decrypt_blob(envelope, mk, **SLOT) == plaintext
|
||||
|
||||
def test_fresh_content_key_per_blob(self):
|
||||
mk = generate_master_key()
|
||||
assert encrypt_blob(b"same", mk, **SLOT) != encrypt_blob(b"same", mk, **SLOT)
|
||||
|
||||
def test_wrong_master_key_fails(self):
|
||||
envelope = encrypt_blob(b"secret", generate_master_key(), **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(envelope, generate_master_key(), **SLOT)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slot",
|
||||
[
|
||||
{**SLOT, "object_id": "11111111-2222-3333-4444-555555555555"},
|
||||
{**SLOT, "role": "avatar"},
|
||||
{**SLOT, "version": 4},
|
||||
],
|
||||
)
|
||||
def test_wrong_slot_fails_aad(self, slot):
|
||||
mk = generate_master_key()
|
||||
envelope = encrypt_blob(b"secret", mk, **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(envelope, mk, **slot)
|
||||
|
||||
def test_tampered_ciphertext_fails(self):
|
||||
mk = generate_master_key()
|
||||
envelope = bytearray(encrypt_blob(b"secret", mk, **SLOT))
|
||||
envelope[-1] ^= 0x01
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(bytes(envelope), mk, **SLOT)
|
||||
|
||||
def test_bad_magic_and_truncation_fail(self):
|
||||
mk = generate_master_key()
|
||||
envelope = encrypt_blob(b"secret", mk, **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(b"NOPE" + envelope[4:], mk, **SLOT)
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(envelope[:20], mk, **SLOT)
|
||||
|
||||
def test_unknown_algorithm_fails(self):
|
||||
mk = generate_master_key()
|
||||
envelope = bytearray(encrypt_blob(b"secret", mk, **SLOT))
|
||||
envelope[4] = 99
|
||||
with pytest.raises(CloudCryptoError):
|
||||
decrypt_blob(bytes(envelope), mk, **SLOT)
|
||||
|
||||
def test_empty_plaintext_round_trips(self):
|
||||
mk = generate_master_key()
|
||||
assert decrypt_blob(encrypt_blob(b"", mk, **SLOT), mk, **SLOT) == b""
|
||||
Reference in New Issue
Block a user