Audience. Independent security and cryptographic reviewers (the audit is a v1.0 gate item), and anyone embedding the Apache-2.0 verifier. This document specifies the cryptographic constructions independently of the code so they can be reviewed, argued about, and re-implemented — realizing backlog items R-37 (crypto spec) and R-38 (key-management security narrative) from
research/synthetic-personas-feedback.md.Status: alpha, unaudited. This is the intended design as implemented in
src/habitable/crypto.py,evidence.py,packet.py,verify.py, andtsa.pyat the time of writing. It has not had an independent cryptographic review. Where the spec and the code disagree, the code is the ground truth and the spec is the bug. Please report discrepancies viaSECURITY.md.
habitable's promise: no operator can read a union's data, and a packet's integrity is checkable by anyone without trusting this project. The cryptography serves four goals:
- Confidentiality at rest — vault data is unreadable without the user's passphrase.
- Confidentiality in transit — sync deltas are unreadable to a relay or network observer.
- Tamper-evidence — any alteration of a captured item or a custody record is detectable.
- Independent verifiability — a third party can confirm a packet was not altered after the fact, and bound when its contents existed, using only standard primitives.
Non-goals (stated so the threat model is honest): hiding sync metadata from a relay (see
relay-observability-matrix.md); resisting a forensic or
coercing adversary with the unlocked device (see the planned duress mitigation and its limits in
threat-model.md); proving authorship or depiction of a photo (a timestamp
bounds existence in time only).
All primitives come from cryptography (and asn1crypto for RFC 3161
parsing). habitable implements no primitive itself.
| Purpose | Primitive | Parameters |
|---|---|---|
| Authenticated encryption (AEAD) | ChaCha20-Poly1305 | 256-bit key, 96-bit nonce |
| Password-based key derivation | scrypt | N=2¹⁵–2²⁰ (profile-selected), r=8, p=1, 32-byte output |
| Key agreement (sync) | X25519 | ephemeral–static ECDH |
| Key derivation (sync) | HKDF-SHA256 | 32-byte output, context-bound info |
| Signatures | Ed25519 | over a SHA-256 hex digest (ASCII) |
| Content hashing / fixity | SHA-256 | streaming, 1 MiB chunks |
| Canonical bytes for hashing/signing | canonical JSON | sorted keys, (",",":"), UTF-8, no NaN |
| Trusted time | RFC 3161 | TSA-chosen digest (SHA-1…SHA-512 accepted on verify) |
Nonces are 96-bit random (os.urandom(12)) and prepended to ciphertext as nonce ‖ ciphertext.
With random nonces under ChaCha20-Poly1305 the safe message bound per key is well below the ~2⁴⁸
birthday limit for the volumes habitable handles; see §7.
A two-level key hierarchy keeps passphrase rotation and recovery cheap (no bulk re-encryption):
passphrase ──scrypt(salt,N,r,p)──▶ KEK ──AEAD-unwrap──▶ DEK ──AEAD──▶ vault blobs
- A random 256-bit data encryption key (DEK) encrypts every vault blob with ChaCha20-Poly1305.
- The DEK is wrapped under a key-encryption key (KEK) derived from the passphrase with scrypt over a random 128-bit salt.
- The DEK wrap is AEAD with associated data
b"habitable-dek-wrap-v1"(domain separation / versioning of the wrap format).
Keyfile format (habitable_keyfile_version = 1), a JSON object:
{
"habitable_keyfile_version": 1,
"aead": "chacha20poly1305",
"kdf": { "name": "scrypt", "salt": "<b64>", "n": 32768, "r": 8, "p": 1, "length": 32 },
"wrapped_dek": "<b64 of nonce‖ciphertext>"
}kdf.n is the only field that varies between the named cost profiles below (r/p are fixed);
open_keyfile reads whatever n the keyfile carries, so different vaults — or the same vault
before and after hardening — can run at different costs with no format change.
Every authentication failure (wrong passphrase, tampered keyfile, malformed base64) surfaces as a
single CryptoError — "decryption failed (wrong key or tampered data)" — never a bare library
exception and never a distinguishable oracle beyond pass/fail.
Vault.save preserves the existing encrypted filenames and AEAD associated data, but publishes its
five mutable state blobs (case.enc, custody.enc, deferred.enc, peer_have.enc, and
sync_security.enc) as one recoverable generation:
- Encrypt every new blob in memory. Write each ciphertext and an exact encrypted backup of every
existing live blob to uniquely named siblings in the vault directory; flush and
fsynceach staged file. - Atomically publish a small
.save-transaction.jsonmarker in the prepared phase. It contains only a random transaction id, the phase, and encrypted-state filenames—no case content, keys, identities, or record values. - Replace the five live blobs with same-directory renames, then sync the vault directory where the
host/filesystem supports directory
fsync. - Atomically change the marker to committed, then remove the encrypted backups, unused staged files, and marker (the marker is removed last).
Vault.open and the next Vault.save recover before reading or writing state. A prepared marker
restores every old ciphertext (or removes a newly created blob); a committed marker keeps the full
new generation and finishes cleanup. Recovery is repeatable if the recovery process itself is
interrupted: after restoring and syncing the old generation, prepared recovery removes and syncs
the journal before deleting backup copies, so a later open can safely discard any remaining
orphans. The journal is limited to 4 KiB and must be a regular file; recovery does not follow a
journal or encrypted-backup symlink, does not read from a FIFO, and streams backup restoration
instead of loading an unbounded artifact into memory. The vault format is unchanged, so vaults
created before this protocol still open and keep the same blob names.
This is a transaction for normal mutable-state saves, not a claim that every filesystem write in
the project is transactional. Keyfile changes, sealed-original creation, and DEK rotation retain
their separately documented write/recovery boundaries. Encrypted timestamp sidecars and their
legacy migration use the narrower protocol below; they do not join the five-blob transaction. The
strongest crash guarantee assumes a local filesystem that honors same-directory atomic replacement
and file/directory fsync. Directory syncing is best-effort on platforms that do not expose it;
network/exotic filesystems, lying storage hardware, media failure, and concurrent processes writing
the same vault can still violate durability or isolation.
The public TimestampToken and packet formats do not change. Inside an unlocked vault, however,
the primary, additional-authority, and archive tokens for one capture are one canonical JSON value:
{
"version": 1,
"capture_id": "<plaintext only inside the AEAD envelope>",
"primary": { "kind": "rfc3161", "tsa_name": "…", "token_b64": "…" },
"additional": [],
"archive": []
}That value is encrypted with the vault DEK and stored as
tokens/<sha256(UTF-8 capture_id)>.tokens.enc. Associated data is the domain-separated value
b"habitable:timestamp-token-sidecar:v1:" || ASCII(filename digest). On read, the capture id
inside the authenticated plaintext must hash back to the filename. This prevents raw capture ids
(including separators and ..) from becoming paths, binds ciphertext to its intended name, and
keeps token data, TSA name, and embedded genTime confidential while the vault is locked.
The hashed filename is deterministic. It therefore leaks equality/linkability for the same capture
id across observations or copied vault trees, and a party that can guess an id can test the guess.
Ciphertext length approximates per-capture token volume, while filesystem mtime/ctime expose
update timing to a storage observer; there is no padding or filesystem-metadata hiding. It is not
an anonymity mechanism. The encrypted sidecar supplies local confidentiality and
integrity only. It does not authenticate the timestamp authority, prove the token's imprint, or
make a token secret once exported: sync and packet formats intentionally carry the unchanged public
token structure, and recipients must still verify the token and its authority chain.
Writes use a flushed same-directory temporary ciphertext, owner-only 0600 mode on POSIX,
atomic replacement, and a directory fsync where supported. Reads require a regular file, use
no-follow/nonblocking opens, and reject tamper, symlinks, FIFOs, file replacement races, and
oversize records. Enumeration, read, temporary creation, rename, and unlink are all relative to one
no-follow directory descriptor whose device/inode is checked against the vault path on entry and
again before success. A concurrent directory swap therefore fails without following the replacement
symlink or reporting a detached write as successful. Platforms lacking descriptor-relative mkdir,
open, no-follow stat, scan, unlink, and rename support are unsupported for the whole vault:
creation preflights and refuses before making a vault, and open fails closed; there is no unsafe
path-based fallback. Creation accepts only an absent or real empty destination, creates
originals/ and tokens/ exclusively through a held no-follow root descriptor, and proves the new
token directory can be securely reopened before writing config, key, or case state.
Resource ceilings are 4,096 ordinary live/direct entries in tokens/ (prospective creation of the
4,097th sidecar is rejected, while an existing sidecar may be updated), plus at most 64
strictly named .token-atomic-<32 lowercase hex>.tmp crash remnants so cleanup cannot itself be
stranded at the live-entry limit; 32 MiB ciphertext; 24 MiB decrypted sidecar JSON; 16 MiB per
legacy JSON file; 8 MiB per token; 4,096 valid-UTF-8 characters per capture id/name field; and 256
tokens in each additional/archive list. Both encrypted and legacy JSON must decode as strict UTF-8;
a string/escape-aware text preflight rejects nesting deeper than 64, more than 8,192 outside-string
structural punctuation tokens, and number tokens longer than 128 characters. Integer parsing uses
the same explicit 128-character ceiling rather than Python's mutable process-global digit limit.
Exceeding a ceiling fails closed rather than making unlock or packet assembly unbounded. DEK
rotation is the explicit
exception to the ordinary live-entry count: while staging it may add exactly one encrypted
<sidecar>.new per existing sidecar, so token
directory entries can transiently double but remain bounded. SIGKILL can leave those stages;
retry/open may then require manual recovery under the documented nontransactional rotation boundary.
After a successful unlock, pre-sidecar vaults migrate deterministically before the vault is returned:
- Enumerate direct legacy
*.jsonentries under the real (not symlinked)tokens/directory; securely read and strictly validate every component and preserve list order. Top-level shape disambiguates suffix-bearing capture ids: an object intenant.additional.jsonis the primary token for capturetenant.additional, while a list is the additional-token component for capturetenant(and likewise for.archive.json). Other ambiguous shapes fail closed. The old format reused that one filename for both logical records, so it could never retain both at once: migration preserves the surviving object or list but cannot reconstruct a record an earlier legacy write already overwrote. Orphan token capture ids remain supported, so case-document membership cannot safely override the surviving shape. - Atomically write, sync, reread, decrypt, and compare the consolidated encrypted sidecar.
- Re-check that every remaining plaintext entry is the exact regular inode that was read, unlink
it, then sync
tokens/.
No legacy plaintext is deleted after a validation, encryption, publish, or durability failure. A
crash after encrypted publication can leave both generations; restart compares every legacy
component that still remains against the corresponding authenticated component and resumes
cleanup. Missing legacy components are treated as already removed, so interruption halfway through
the three-file cleanup is repeatable. Disagreement fails closed and preserves the plaintext for
manual recovery. At the 4,096-entry ceiling, migration alone may create one temporary 4,097th live
entry; restart accepts that overlap only when exactly one encrypted sidecar maps to a present legacy
group, both strictly decode, their remaining components match in order, and legacy cleanup returns
the directory to the ceiling. Any unrelated over-cap state fails closed. Orphan atomic-write
ciphertext temporaries are removed on the next migration.
Unlinking is cleanup, not secure erasure: old blocks, journaled filesystems, snapshots, backups,
swap, and storage forensics may retain pre-migration plaintext. The guarantees also assume no
concurrent writer and a local filesystem that truthfully implements regular-file checks, atomic
same-directory replacement, and flushes; directory fsync is unavailable on some platforms.
config.toml remains plaintext by design so policy is reviewable, including timestamp-authority
names/URLs and any user-edited peer, sharing, packet, or letter settings. The wrapped keyfile is
also visible. Do not put secrets in configuration. An unlocked process, malware, a compelled
passphrase, or memory inspection can read decrypted sidecars just as it can other vault contents.
-
Rotation (
habitable key rotate): re-derive a KEK from the new passphrase over a fresh salt and re-wrap the same DEK. Bulk data is untouched. Old keyfiles for the old passphrase remain valid until discarded — passphrase rotation is not revocation of a leaked DEK (that's DEK rotation, below). -
KDF hardening (
habitable key harden,crypto.harden_keyfile/Vault.harden_key): re-derive the KEK from the same passphrase at a stronger named cost profile, over a fresh salt, and re-wrap the same DEK. Cheap (only the keyfile changes) but every future unlock pays the new cost. Named profiles (crypto.KDF_PROFILES):Profile scrypt N ~memory Notes standard2¹⁵ ~32 MiB default at vault creation; interactive unlock on a low-end phone hardened2¹⁷ ~128 MiB key harden's default target; OWASP's current scrypt-minimumparanoid2²⁰ ~1 GiB for a device that can spare the time and memory Bump procedure: raising the cost is not automatic (no vault silently gets slower); an organizer runs
key hardenexplicitly, per device, when they judge their hardware can bear it — the interactive-unlock-on-old-hardware constraint (see tradeoffs) is a per-device judgment call, not a global one. RaisingKDF_PROFILESvalues in a future release (or adding a profile) does not by itself change any existing keyfile; only re-runningkey hardendoes. -
DEK rotation (
habitable key rotate-dek,Vault.rotate_dek): generates a fresh DEK and re-encrypts every vault blob, every encrypted timestamp-token sidecar, and every sealed original under it, then re-wraps the new DEK under the same passphrase. Sidecars are strictly decoded and re-encrypted with their filename-bound AAD; any legacy JSON migration finishes first. This is the actual remedy for a suspected DEK compromise that rotation alone cannot provide. Unlike rotation/hardening it is O(vault size), not O(1) — expensive but bounded, and meant to be rare. Each sealed original is decrypted and its fixity re-checked against the content hash already recorded in the case document before being re-sealed (a corrupt original is caught before, not after, it is carried into the new encryption). All re-encryption happens to staged*.newsiblings before any file is modified in place. Each intended stage is registered before creation, then exclusively created no-follow with owner-only0600mode, fully written, and file-synced. Its device/inode/size/mtime/ctime generation is recorded; ordinary failures remove only that exact generation, and every stage is checked before publication and immediately before its rename. Before publication, every unique path-stage parent andtokens/is directory-synced. The wrapped-key stage's recorded generation is then re-verified and pinned through an open no-follow descriptor before the first publish rename. A final pass renames data, original, and token stages first and syncs their directories; the pinned wrapped keyfile is renamed last as the generation commit and followed by another vault-root sync. If post-commit exact-byte verification detects a keyfile race, rotation performs a controlled atomic forward repair from the already-fsynced expected bytes before reraising the triggering error. During partial publication, if the fixed keyfile stage was detached or replaced, Habitable preserves a randomkeyfile.json.recovery-<32hex>.newand adds its path to the exception for manual recovery without overwriting the alien entry. A retry fails closed while any known root/original/keyfile stage, token-sidecar stage, exact.keyfile-forward-repair-<32hex>.tmp, or exactkeyfile.json.recovery-<32hex>.newremains. Known paths are checked directly, token enumeration is bounded, and the root recovery-artifact scan fails closed after 256 entries; exact-name symlinks and FIFOs block without being followed or removed. Directory sync remains best-effort where the host reports it unsupported. A process death can leave encrypted*.newdebris. A crash during the (slow) staging phase leaves live files untouched; a crash during the (fast, metadata-only) swap phase could leave a partially migrated vault recoverable by hand from the leftover*.new/*.encfiles — an accepted tradeoff at this effort tier, not a full transactional guarantee (see tradeoffs). -
Recovery blob (
habitable key backup/restore): the same DEK wrapped under an independent recovery passphrase, producing a standalone keyfile-format blob. Possession of the blob and the recovery passphrase reconstructs the DEK. This is the only way to recover a vault whose primary passphrase is lost. After a DEK rotation, old recovery blobs wrap the old DEK and no longer open the vault — take a fresh backup once rotation completes. -
Unrecoverability by design: there is no escrow, no operator key, no backdoor. Lose every passphrase and every recovery blob and the data is cryptographically gone. This is a deliberate safety property, documented for organizers in
key-management.mdand operationalized (without recreating a honeypot) inkey-custody-playbook.md. -
Memory hygiene: the DEK lives in a
SymmetricKeywhose raw bytes are never exposed outside the module (_raw()is private). Python offers no reliable zeroization of immutablebytes; this is called out as a review focus, not claimed as solved.
Implemented in src/habitable/threshold.py. This makes distributed custody cryptographic rather
than a matter of who-trusts-whom: recovery is split across N stewards so that any M of them —
but no fewer — can recover, and no single steward is a honeypot. The construction:
- Wrap. Generate a fresh uniformly-random 256-bit recovery secret
S. BecauseSis already full-entropy, it is the KEK: the DEK is wrapped directly withChaCha20-Poly1305underS(no scrypt), with associated datab"habitable-threshold-recovery-wrap-v1"for domain separation. The result is a recovery bundle — a JSON blob that is not secret on its own (it is useless withoutS). - Split.
Sis shared with Shamir's Secret Sharing over GF(2⁸) (the AES field, reduction polynomial0x11b). Each of the 32 bytes ofSis the constant term of an independent degreeM-1polynomial with fixed random higher coefficients; shareiis that set of polynomials evaluated at a distinct non-zero x-coordinatex_i ∈ [1, 255].Mshares reconstruct each byte by Lagrange interpolation atx = 0. Information-theoretically, anyM-1shares reveal nothing aboutS. - Bind. Every share records the bundle's
bundle_id(sha256(wrapped_dek)[:16]), so shares from different bundles cannot be silently combined and a mismatched set is rejected up front. - Recover. Collect ≥
Mdistinct shares (duplicates by x-coordinate are ignored, not double-counted), interpolateS, and AEAD-unwrap the DEK. A wrong, corrupt, or short share set fails the Poly1305 tag and surfaces as a singleCryptoError— never a silently-wrong key.
Bundle format (habitable_recovery_bundle_version = 1):
{
"habitable_recovery_bundle_version": 1,
"aead": "chacha20poly1305",
"scheme": "shamir-gf256",
"threshold": 2,
"shares": 3,
"bundle_id": "<hex16>",
"wrapped_dek": "<b64 of nonce‖ciphertext>"
}Share format (habitable_share_version = 1): { version, scheme, threshold, shares, index, steward, bundle_id, y: "<b64 of per-byte evaluations>" }.
Exposed as habitable key share (produce a bundle + one share per steward) and habitable key recover (rebuild the keyfile from a bundle and a quorum). The Shamir primitive is implemented in
this project because cryptography ships no threshold scheme; it is a small, self-contained target
for the independent review and should be a focus of it. Operational guidance lives in
key-custody-playbook.md.
Each device holds an Identity: an Ed25519 signing key (32-byte seed) and an X25519
key-agreement key (32-byte private), serialized as 64 raw bytes and stored encrypted in the
vault. The corresponding PublicIdentity is sign_public ‖ box_public (64 bytes).
- Fingerprint = the first 16 hex chars of
SHA-256(sign_public ‖ box_public), grouped asxxxx-xxxx-xxxx-xxxx. Peers compare fingerprints out of band to defeat key substitution; it is a short authenticator, not a collision-proof identifier (64-bit prefix — see tradeoffs). sign(message)→ Ed25519 signature.verify(pub, message, sig)returnsFalseon any failure (bad signature, malformed key) rather than raising — verification is total and side-effect-free.- Custody and packet signatures are taken over the ASCII hex of a SHA-256 digest. Sync-v2
messages and pairing invitations instead sign their canonical JSON bytes directly; both sides
deterministically reproduce those bytes with
canonical_json.
seal_to(recipient_pub, plaintext) is an ECIES-style anonymous-sender sealed box:
eph = X25519.generate()
shared = ECDH(eph_priv, recipient_box_pub)
key = HKDF-SHA256(shared, salt=None,
info = b"habitable-sealedbox-v1" ‖ eph_pub ‖ recipient_pub, len=32)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce=os.urandom(12), plaintext, aad = eph_pub)
wire = eph_pub(32) ‖ nonce(12) ‖ ciphertext
open_sealed reverses it with the recipient's static X25519 key. Properties:
- Confidential to the recipient's key. Only the holder of
box_privatecan derive the shared secret and open the box. - Forward secrecy for the sender. The sender's contribution is ephemeral and discarded; compromising the sender later does not decrypt past boxes.
- Anonymous sender at this primitive, by design. The sealed box itself does not authenticate who sent it
(there is no sender static key in the handshake). The HKDF
infoand theaadbind the ciphertext to this ephemeral and recipient public key, preventing key-reuse/cross-protocol confusion. Protocol v2 establishes sender authenticity and authorization at the higher layer: signed, recipient-sealed, case-bound pairing pins an exact identity and random pairing key, then each canonical message is Ed25519-signed and HMAC-authenticated before sealing. Seesync-protocol-v2.md.
The guard tests in tests/test_guards.py assert that no plaintext (note text, image bytes, or a
sender identity) reaches a relay or an on-disk mailbox — only sealed boxes and metadata.
At capture the original bytes are hashed with streaming SHA-256 and the sealed original is treated
as immutable. verify_fixity re-derives the hash on read and raises FixityError on mismatch, so
silent corruption or substitution becomes a failed check, never a quietly altered exhibit.
CustodyLog is an append-only, hash-linked log. Each entry's public payload is:
{ seq, action, item_id, hlc, actor_commitment, details{sorted}, prev_hash }
and entry_hash = SHA-256(canonical_json(public_payload)), with prev_hash chaining to the
previous entry (genesis = "0"*64). Walking the chain checks, for every entry: strictly
increasing seq, prev_hash equals the prior entry_hash, and recompute_hash() == entry_hash.
Any insertion, deletion, reorder, or edit breaks the chain at a precise seq.
Actor privacy. The actor is committed, not exported in clear:
actor_commitment = SHA-256("<salt_hex>:<actor>") with a fresh 128-bit salt per entry. The clear
actor, the actor_salt, the Ed25519 signature, and any identity/PII private_details are
vault-only — they are not part of public_payload (so they are neither hashed nor
reconstructable from an export) and are dropped by redacted() before export. A recipient thus
verifies the chain is intact without learning who did what. The salt makes the commitment
preimage-resistant against guessing a small actor space; reviewers should weigh that the commitment
is unkeyed SHA-256 over a low-entropy actor string protected only by the per-entry salt.
Optional per-entry signatures. When an Identity is supplied, the entry is Ed25519-signed over
entry_hash. verify(signer_keys=…) checks signatures whose actor_commitment maps to a known
public key. The exported integrity_proof() includes redacted entries plus a per-item summary and
the chain head_hash.
build_packet serializes the bundle with canonical_json, writes bundle.json, and signs it: the
producer's device Ed25519 key signs bundle_sha256 (hex, ASCII) into bundle.sig.json
({producer_fingerprint, sign_public(b64), bundle_sha256, signature(b64)}). The verifier recomputes
bundle_sha256, checks it matches the claimed value, and verifies the signature over it.
This signature is self-attesting and a reviewer must read it as such. sign_public is carried
in the same file it authenticates, so signature_ok means this bundle is internally consistent
with the key beside it — never the producer signed this. Anyone who rewrites bundle.json,
recomputes the (unkeyed) custody chain from §6.2, and signs with a fresh Ed25519 key produces a
packet where signature_ok is true. The measured consequences are tabulated in
tamper-challenge.md §4. Two mechanisms constrain it: an out-of-band key pin
(verify --expected-producer-key), and the packet seal below, which is the one that needs no
secret.
The content hash — never the file — is sent to an RFC 3161 TSA, which returns a signed token
bounding when that content existed (upper bound on creation). Tokens are stored opaquely
({kind, tsa_name, token_b64}; kind ∈ {rfc3161, dev}). verify_token follows the token's own
digest and signature algorithms (SHA-1…SHA-512, RSA-PKCS1v1.5 or ECDSA) rather than assuming
SHA-256, checks the signature and certificate, and reports trusted_chain if the TSA chains to a
supplied trusted root. Archive (re-)timestamping chains a new token over an existing one before a
TSA cert ages out; verify_archive_chain walks it and fails closed on any break. The dev token
kind is a non-production Ed25519 "authority" used only offline for tests/demos and labels itself as
such. Multiple-authority redundancy: a capture may be stamped by several authorities (the default
config ships more than one); the primary token is in timestamp and independent tokens over the same
hash are in additional_timestamps, so the existence proof does not rest on a single TSA. The
verifier accepts an item if at least one authority verifies and reports all that did.
Every construction above binds one value at a time: an item's content_hash, one custody entry's
predecessor. Nothing binds a packet as a whole, which is why a rewritten bundle is cheap. The seal
closes that:
seal_token = TSA.stamp( SHA-256( exact bytes of bundle.json ) )
The result is stored beside the producer signature, as a packet_seal member of bundle.sig.json,
in the same opaque {kind, tsa_name, token_b64} form as every other token:
{
"producer_fingerprint": "…", "sign_public": "…", "bundle_sha256": "…", "signature": "…",
"packet_seal": { "kind": "rfc3161", "tsa_name": "…", "token_b64": "…" }
}Because the imprint is a digest of the whole serialized bundle, the seal covers every field at
once — the narrative, unit, case_id, generated_at, each item's captured_at, each
shared_hash (i.e. the bytes a recipient can actually open), and the custody head_hash. There is
no partially-covered subset and no second canonicalization: the imprint is over the file as written.
The bundle format is unchanged and packet_version does not move. The seal lives in the signature
sidecar, which was never covered by the signature it contains, so a v1–v4 packet with a seal and one
without are both well-formed.
Verification (verify._verify_packet_seal): recompute the bundle digest, parse the token, run
the same verify_token used for item timestamps against that digest, and report four things —
present, verified, trusted (the authority chains to a caller-supplied certificate; dev never
does), and required. A present seal is always checked and a broken one is always a problem. An
absent seal is a state, not a failure, until the caller asserts require_packet_seal; requiring it
by default would fail every offline export in exchange for a guarantee an attacker sidesteps by
deleting one JSON key. seal_not_after compares the token's genTime against an instant the
recipient names.
What it proves, exactly. These exact bytes were countersigned by authority A no later than T.
It does not identify the producer. An adversary who can obtain a token from an authority the
recipient trusts can re-seal a rewritten bundle — they simply cannot backdate it, so the forgery
carries the true time of the forgery. The design rationale, the alternatives rejected (producer
certificate, key transparency log, TSA-countersigned key birth), and the residual attacks are in
adr/0011-authority-seal-over-the-whole-packet.md.
Stated plainly so a reviewer can target effort (and so the project isn't accused of hiding them):
- scrypt cost. N=2¹⁵ (≈32 MiB), the
standardprofile, targets an interactive unlock on a low-end phone and is still what new vaults get by default — that reality (a tenant's only, possibly old, device) doesn't go away. FIX-08 addressed the "no path to raise it" half of the problem with per-device, opt-inkey hardenprofiles (§3.1) rather than by silently raising the default; whetherstandarditself should move is a judgment call for the crypto audit, not resolved here. - The packet seal's residual is the recipient's trust store, not the construction. §6.5 binds
the whole bundle with a token, but a public authority stamps any digest for anyone, so an
adversary who can reach an authority the recipient anchors re-seals a rewritten packet and passes
everything except
seal_not_after. Reviewers should weigh whether "the forgery is forced to carry its true creation time" is the right guarantee to have chosen — and whether a recipient will in practice supply a date. A related judgement: a missing seal is not a failure by default, so a recipient who never passesrequire_packet_sealis at the pre-seal baseline. The reasoning, and the fact that no in-packet marker can fix it, is ADR 0011. - Random 96-bit nonces. Safe at habitable's message volumes; confirm no key is driven near the birthday bound, especially for the long-lived DEK.
- Sender authentication of sync payloads is not provided by the sealed-box primitive. Confirm protocol v2's exact encrypted allowlist, Ed25519 signature, pairing HMAC, case/recipient binding, and replay database all fail closed before merge.
- Actor commitment is unkeyed SHA-256 over
salt:actor; the salt is exported so the commitment is openable with the actor, not brute-force-resistant against a known small actor set absent the salt. By design the salt stays in the vault; confirm it never leaks into an export. - Fingerprint truncation. 64-bit out-of-band authenticator — adequate against accidental collision and casual substitution; not a cryptographic commitment to the full key. Consider surfacing the full hash for high-assurance comparison.
- Memory zeroization of key bytes is not guaranteed under CPython.
- DEK rotation's multi-file swap is not fully transactional.
Vault.rotate_dek(§3.1) stages every re-encrypted file—including token sidecars—before touching anything in place, so the expensive, failure-prone work (decrypt, validate tokens, re-verify fixity, re-encrypt) can't corrupt the live vault. Stages use exclusive no-follow owner-only creation, full writes, file flushes, and recorded filesystem generations. Ordinary staging failures clean only the exact app-created generation and sync the affected directories; a raced-in or replaced entry fails closed and is preserved. All stage directories are synced and the wrapped-key stage is pinned after generation/ownership verification before publication. Data, original, and token stages are then renamed and their directories synced before the pinned wrapped keyfile is renamed last and the vault root is synced. Process death may leave encrypted*.newdebris. Immediately before the first publish rename is attempted, cleanup becomes conservative: any exception may have arrived after the kernel committed a rename, so every remaining*.newstage andkeyfile.json.newis preserved. Deleting the wrapped new key at that point could make already-published data unrecoverable. If the final keyfile commit was attempted, all data already uses the new DEK; the in-memory vault adopts it, verifies the still-open staged inode against the live keyfile, and, if that proof fails, attempts a controlled atomic forward repair before reraising the original error. If forward repair itself fails, its artifact is retained and reported, and a separately named recovery artifact is attempted too. During partial publication, a detached or replaced fixed keyfile stage is never overwritten or deleted: a randomkeyfile.json.recovery-<32hex>.newis durably written and its path is attached to the exception for manual recovery. Failures after creating either recovery artifact retain and report it only when its bytes are positively verified against the expected wrapped key; otherwise the error explicitly says that no verified artifact was retained. The final swap is a tight loop of same-filesystem renames (each individually atomic), but a crash inside that loop — not during staging — can still leave a vault whose keyfile and blobs disagree about which DEK is current, recoverable by hand from the*.new/*.encfiles left behind. These recovery measures do not isolate concurrent writers; a reviewer should weigh whether that residual window is acceptable for the threat model or needs a real journal/commit marker. A retry treats known root/original/ keyfile stages, token-sidecar<name>.new, exact.keyfile-forward-repair-<32hex>.tmp, and exactkeyfile.json.recovery-<32hex>.newas ambiguous manual-recovery state. It fails closed without following, overwriting, or auto-deleting regular files, symlinks, or FIFOs at those names; the root scan is capped at 256 entries and near-miss names are not classified as recovery artifacts.
- Independent verification semantics, failure-by-failure:
verifier-decision-table.md. - The on-the-wire packet/bundle contract:
bundle-schema.md+packet-bundle.schema.json. - Embedding the Apache-2.0 verifier:
embedding-the-verifier.md. - Method, evidence theory, threat model:
evidence-method.md,threat-model.md.