mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
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.
229 lines
8.8 KiB
Python
229 lines
8.8 KiB
Python
"""
|
|
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
|