Asset and Metadata Encryption
DraftAll asset bytes (originals, derivatives) and metadata blobs are encrypted client-side before crossing a network boundary. capsule-core::crypto::encryption is the only caller of AES-256-GCM. Two constructions:
| Payload | Construction | Why |
|---|---|---|
| Asset bytes | STREAM | Large, streamed, ranged reads, per-chunk authentication |
| Metadata blob | Standalone AEAD, fixed wire format | Small, always fetched whole, content-addressed |
Authenticated Asset Encryption
Section titled “Authenticated Asset Encryption”An asset is content-addressed by the hash of its ciphertext and encrypted under a per-encryption file key derived from an AMK.
Asset Key Derivation
Section titled “Asset Key Derivation”- An album carries a versioned AMK ledger (
AMK_v1..v_n, random 32 bytes each, new version per epoch bump), distributed over MLS (History Delivery) and stored hardware-wrapped on each member device. Keys MUST NOT be derived from MLS epoch internal state. - Derived mode (default):
file_key = HKDF-SHA512(ikm = AMK_v{amk_version}, salt = file_id || nonce_prefix, info = "asset-file/v1", L = 32). Never stored. nonce_prefixis a fresh CSPRNG draw on every encryption, so the file key is unique per encryption even for the samefile_idand epoch. This is what lets the STREAM counter start at zero without nonce reuse.- Wrapped mode (external-origin assets only, e.g. an adopted web-upload drop): the guest’s random key
Kcannot be re-derived, so it is carried aswrapped_file_key = wrap_nonce || AES-256-GCM(wrap_key, K)withwrap_key = HKDF-SHA512(AMK_v{n}, salt = file_id || wrap_nonce, info = "asset-keywrap/v1"), recorded in the signed manifest askey_mode = wrapped. Only the adoptingcreatesets it; any laterreplacere-encrypts under a derived key. This is the only stored per-file key (see Key Chain). - Distribution lag is not failure. A manifest whose
amk_versionis within the MLS-attested epoch range but whose AMK has not arrived is pending and retried. Only a version beyond the attested epoch, or still missing after the retry timeout, is escalated (Failure Modes).
Encryption Workflow
Section titled “Encryption Workflow”- Derive
file_keyfrom the current AMK with a fresh 7-bytenonce_prefix. - STREAM-encrypt; hash the ciphertext incrementally (algorithm fixed by
crypto_suite_id). - Build and sign the manifest (device + write-tier signatures).
- Upload via the Transfer — Upload.
An upload re-derives each ciphertext range from the plaintext on disk, so the plaintext MUST NOT change under it. A changed plaintext re-encrypted under the same file key and nonce would hand the server two ciphertexts under one AES-GCM nonce, so two guards stand before any re-derived byte is sent:
- Source identity, best-effort: building the bundle records the source’s size, modification time and file identifier, and every later read checks them first. A file system with coarse timestamps, or a writer that preserves them, can hide a change.
- Chunk tags, authoritative: the bundle’s hashing pass records every STREAM chunk’s authentication tag and persists them with the bundle, under the same key and nonce prefix, until every blob of the bundle is
Completedor the bundle is abandoned. A re-created session reuses the key and nonce prefix, so the tags outlive any one session. A re-derived chunk whose tag differs is never sent. A range is sent only from whole STREAM chunks that have been re-derived and checked, even where it starts or ends inside one.
Either mismatch aborts the blob with a typed source-changed error before that read’s bytes are sent. The finalize-time content-address check remains the backstop: a changed source can cost an upload, never store bytes under the wrong address.
Ranged read of plaintext byte B: i = B / plaintext_chunk; fetch ciphertext at offset i × ciphertext_chunk (chunk + 16-byte tag). Chunk i decrypts independently. Using the plaintext stride as the ciphertext offset is a bug; the formula is normative.
Re-keying on Rewrite
Section titled “Re-keying on Rewrite”replace, derivative-replace and metadata-update (actions) keep the same id and may land in the same epoch. The fresh nonce folded into the KDF salt re-rolls both key and nonce, so identical plaintext yields different ciphertext.
- MUST NOT reuse a prior
(key, nonce)pair. - The writer refuses a
nonce_prefix/ metadata nonce it has already emitted for that id (defense in depth over the CSPRNG).
STREAM Construction
Section titled “STREAM Construction”AES-256-GCM under STREAM (Hoang–Reyhanitabar–Rogaway–Vizár 2015) via RustCrypto aead::stream::{EncryptorBE32, DecryptorBE32}<Aes256Gcm>. Nonce = prefix(7) || counter(4) || last_flag(1); detects truncation, reordering and chunk deletion. Plaintext chunk 65,520 B → 64 KiB ciphertext chunk. The upload transport’s chunk alignment is a separate concern.
Metadata Encryption
Section titled “Metadata Encryption”| Data | Server view |
|---|---|
CBOR metadata blob (incl. LQIP, dominant_color) | Encrypted under the AMK; versioned and signed like a manifest |
owner_id, ciphertext hash, ciphertext size | Plaintext — routing and quota need them |
| AI embeddings (search, faces) | Plaintext locally only; always encrypted at rest on the server |
The blob plaintext is the signed SidecarV1 in canonical CBOR. Deterministic encoding is mandatory: the blob hash is signed, so encoder drift breaks cross-peer verification.
Metadata Blob Wire Format
Section titled “Metadata Blob Wire Format”Implementations MUST produce and consume exactly:
crypto_suite_id (u16 BE) || nonce (12, fresh CSPRNG) || AES-256-GCM ciphertext || tag (16)crypto_suite_idMUST equal the manifest’s (Versioning Identifiers); mismatch rejects at decode.- Key:
HKDF-SHA512(AMK_v{n}, salt = blob_id || nonce, info = "metadata-blob/v1", L = 32). - The content hash covers the whole byte string and is committed as
metadata_blob_hashon everycreate,replaceandmetadata-update, under both manifest signatures. - Local–server equivalence: on decode the plaintext MUST equal the locally stored signed sidecar byte-for-byte and the hash MUST equal
metadata_blob_hash; otherwise quarantine. See Metadata.
Validation
Section titled “Validation”- Round-trip and fixed vectors for STREAM and metadata AEAD.
- STREAM tamper: bit flip, swap, drop, last-flag toggle all rejected.
- Ranged read of chunk
imatches the plaintext slice; stitched ranges equal sequential decrypt. - Metadata blob determinism against the shared canonical-CBOR vectors — a blocking cross-language gate.
- Rewrite re-roll: same id and epoch yields different key and nonce; reused nonce refused.
- Sidecar → blob → sidecar is byte-identical and hash-matches; a one-byte local mutation fails.