Skip to content

Signed Manifests and Provenance

Draft

Every stored asset carries a client-signed manifest; its manifests form an append-only, hash-chained provenance log. This separates legitimate writes from malicious or buggy ones after the fact and defeats stale revival.

Structs: capsule-core/src/crypto/provenance/. Verification: the verify_asset chokepoint (Write Authorization). Server-signed counterpart: custody receipts.

A signed manifest, not a Merkle tree: STREAM already catches chunk tampering, truncation and reordering. Load-bearing fields:

FieldRule
crypto_suite_id, protocol_versionSigned suite and album protocol pin; not downgradable.
file_id, album_id, amk_versionamk_version selects the epoch whose write-tier key verifies write_sig.
ciphertext_hashContent address of the original blob, hashed while streaming.
metadata_blob_hashContent address of the encrypted metadata blob. Present on create, replace, metadata-update only.
plaintext_size, chunk_size, nonce_prefixSTREAM parameters; nonce_prefix random per file.
key_mode, wrapped_file_keyderived (default) or wrapped (key sealed under the AMK). See below.
created_by_user, created_by_deviceThe signer of this record; the creator is the create record’s signer.
client_versionExact build; audit-only.
timestampSelf-asserted; audit-only.
actionClosed set owned by Authorization.
prior_provenance_hashSHA-256 of the previous manifest. Null only on create, else rejected by client and server.
retention_untilSet only on delete (retention).
device_sig, write_sigHybrid Ed25519 + ML-DSA-65 over all fields. Both, and both halves, must verify.

device_sig (uploading DSK, chained to the user IK via the device directory) proves provenance; write_sig (epoch write-tier key) proves write authority at amk_version.

Physical placement. Never embedded in ciphertext. The manifest is the bundle’s envelope object: server-visible canonical CBOR (no secrets) uploaded under the provenance blob role and stored verbatim, which makes the index rebuildable without PostgreSQL. The server also projects the fields it reads into PostgreSQL. Other blobs are referenced by hash only.

The signed bytes are the served bytes. No surface (sync feed, index rebuild, federation) may re-encode a manifest from the projection: it holds no signature, and signatures cover one exact encoding.

Rewrite re-rolls keys (Re-keying on Rewrite). The signed metadata_blob_hash binds server metadata to the client’s sidecar.

Wrapped key mode exists only for an adopted web-upload drop: set on the adopting create, never on replace. It is authorization-neutral (Asset Key Derivation).

Wire presence rules (normative); present-null and absent keys sign differently:

  • prior_provenance_hash and retention_until encode as present null when unset.
  • metadata_blob_hash, key_mode, wrapped_file_key, upgraded_from, and the DerivativeManifest’s amk_version and protocol_version encode as absent keys when unset. Absent key_mode means derived; writing it explicitly breaks verification.
  • A key the reader does not know is kept verbatim, re-emitted canonically beside the known keys, and covered by both signatures (invariant 2). A newer writer’s manifest that only adds a field verifies on an older reader; stripping the field fails verification.

client_version / generated_by_client pin a write to its exact build:

client_version = client_id "/" semver "+" commit [".dirty"]
client_id capsule-ios | capsule-android | capsule-web | capsule-cli | <out-of-repo client id>
commit git hash of the client's own tree (full, or a prefix of at least 12 hex chars)

Example: capsule-ios/1.4.2+9f3a1c7d2b4e; nothing else (no OS or model). Audit-only: the server enforces only the size bound. Parser: capsule-core/src/client_build.rs.

Every data or metadata change appends a record anchored by its manifest.

ProvenanceRecord { version, asset_id, manifest, prior_provenance_hash }. version is an integer (currently 1) that sorts first in canonical CBOR and is decoded before anything else. A record of a version the reader does not implement is quarantined and kept, never applied or dropped (invariants 3 and 5). The record’s hash mirrors the manifest’s (so the link is signed) and is a checked invariant: a record whose copies differ is rejected. Rewriting any past record breaks the forward walk from create.

Clients cache the verified head (O(1) steady state); full walks run on first fetch, restore, or suspected tamper. A signed checkpoint record is the designated v2 extension.

What an Attacker With All Current Keys Still Cannot Do

Section titled “What an Attacker With All Current Keys Still Cannot Do”

Holding every current DSK, AMK and write-tier key, an attacker can append, but cannot rewrite a past record (that needs the earlier device’s hybrid signature, verifiable via the device directory) and cannot remove one (successors carry its hash). History is read-only.

Older clients (#475) copied created_by_* from the chain head into continuations while signing with the current device; such records from a second device of the account never verify, and exist in stored libraries.

Quarantine in place, with a distinct signal. No re-signing (rewrites history) and no accept-with-gap (trusts an unverifiable field). The chain stalls: later records fail as ForgedChain; the asset stays at its last verified state.

A BadDeviceSig becomes LegacyInheritedDevice { named_device, signing_device, record_hash } only when all hold (unreachable without an account device key):

  1. the record is a continuation;
  2. device_sig verifies under a different device of the same directory, added no later than the record’s timestamp;
  3. every other check passes (epoch attested, write_sig, chain link).

record_hash lets later ForgedChain verdicts be filed under the stall rather than as an attack. Reporting changes; acceptance does not. A shared-album member’s write cannot meet (2) and stays Rejected(BadDeviceSig). No migration.

  • Client: append-only media/{YYYY}/{YYYY-MM}/{uuid}.provenance.cbor, kept as a tombstone after hard-delete. A non-authoritative cache, rebuilt from the server on mismatch.
  • Server: the append-only envelope sequence, served unchanged. It reads prior_provenance_hash, action, retention_until to enforce chain advance and the purge floor keylessly (Server-Side Validation Invariants).

No API overwrites or deletes an entry: the action set has no such action and invariant 17 refuses non-advancing writes.

Derivatives are signed so no client can silently overwrite another’s. DerivativeManifest differs from AssetManifest in:

  • source_asset_id;
  • role, a closed set: thumbnail | preview | embedding (LQIP lives in the signed sidecar instead);
  • format, closed; sentinel original means the tier is the original (no blob);
  • generated_by_*, generated_at, model_id/model_version (embeddings);
  • amk_version, protocol_version: required on every real write (absent only in legacy fixtures);
  • prior_provenance_hash, chained per (asset_id, role);
  • device_sig and write_sig, under the same rules as the asset manifest.

It travels as the derivative_manifest blob, served verbatim; only it carries the nonce_prefix and amk_version that open the derivative. An overwrite is a derivative-replace on the role’s chain. verify_derivative checks both signatures, source_asset_id, and the chain link; the receiver walks each role’s chain from its start, and a refused or forked link stops it: prefix accepted, rest quarantined and surfaced, never applied.

Negative cases map to damage scenarios.

  • verify_asset: fixed positive vectors; one negative fixture each for reader-signed, retired epoch, wrong epoch, forged cert chain (unknown device or late added_at), replay, suite downgrade.
  • Wrapped key. Tampering with key_mode or wrapped_file_key fails verification; a valid wrapped key unwraps and decrypts.
  • Chain advance. A wrong prior_provenance_hash is rejected by both client and server.
  • Append-only. Dropped or rewritten records break the walk; the server refuses overwrite/delete; a tampered cache is rebuilt.
  • Derivative poisoning. A derivative-replace that does not chain to the (asset_id, role) head is rejected, and the existing derivative is kept.
  • Client build. Producers emit the grammar with a real commit; arbitrary strings still verify.
  • Byte-identical serving (smoke). Every serving surface returns uploaded bytes; a server re-encode fails the signature.
  • All current keys. Rewriting a past record is detected.

The cross-module upload→verify path is listed under Module Map — E2E.