Status: accepted · Date: 2026-10-31 · Owner: t06-crypto-package
Enforcement: packages/crypto/src/*.test.ts cover every acceptance criterion of ticket 06 (round-trip, AAD and header binding, published-vector KATs, link-key fixed vector, attestation, hash chain, bundle manifest schema, Argon2id RFC vector and frozen-clock yielding); pnpm --filter @wundklare/crypto test runs them; the Argon2id workerd measurement in docs/measurements/2026-11-argon2-workerd.md sets the parameters this ADR adopts; scripts/leaf-modules.test.mjs keeps . and the client modules (share-link, pin, kek, testing) leaf modules; hash-chain (imports canonicalize, outside the leaf allowlist) and argon2 (server, imports @noble/hashes) are deliberately not leaves; scripts/import-cycles.test.mjs keeps the package acyclic.
Decision
Wundklar encrypts clinical data on the device under one dependency-injected SubtleCrypto provider (plan §1.6). The server never sees plaintext for a share link; everything else may be decrypted in logged system jobs (server-built reports, KIM, MD export) because the Worker holds the KEK, which is the honest trust boundary (product doc, ADR-06/07).
Key hierarchy
KEK Worker secret WUNDKLAR_KEK, 32 B, versioned
├─ ODK org data key, AES-256, wrapped by KEK
│ ├─ field ciphertext *_ct (AES-256-GCM, AAD "<table>:<id>:<column>")
│ └─ DEK per object (wrapped by ODK; AAD "wundklar/dek-wrap/v1:<purpose>")
├─ OIK org intimate key pair, X25519 — private wrapped by KEK, public on devices
│ └─ intimate DEKs via ECIES to OIK public key
└─ device key X25519 per device (private under local key)
├─ ODK delivered via ECIES to device public key
└─ attestation = HMAC(HKDF(X25519(dev, srvEph), "wundklar/attestation/v1", session_id), nonce ‖ session_id)
Share link key = HKDF-SHA256(ikm = fragment(16 B) ‖ password(8 Crockford base32),
salt = linkId,
info = "wundklar/share-link/v1",
length = 32)
Local key = random 32 B, AES-GCM-wrapped by PBKDF2-SHA256(600_000) of the PIN.Primitives, as exported from @wundklare/crypto
.— WebCrypto AES-256-GCM behindsetSubtle/getSubtle; awk1.<keyRef>.<iv>.<ct>wire string whose GCM AAD is"wk1.<keyRef>."‖ the caller's AAD, so a rewrittenkeyReffails authentication;generateDek/wrapDek/unwrapDek(envelope);wrapTo/unwrapWith(ECIES over X25519 + HKDF-SHA256 + AES-256-GCM; X25519 runs on the injectedSubtleCryptotoo, raw 32-byte keys imported as PKCS#8, and the interop test pins it against RFC 7748 and@noble/curves);encryptField/decryptFieldwith<table>:<id>:<column>AAD;attestationProof/verifyAttestation;wk-share-v1bundle container (u32 be manifest length ‖ manifest JSON ‖(u32 be partLen ‖ partBytes)×N); decoding validates the manifest schema (version,createdAt,parts[{name, byteSize, mediaType?}], optional objectextras, no other keys) before trusting it../share-link—newFragmentKey()(16 random bytes, base64url, 22 chars);newPassword()(8 Crockford base32 chars, displayedXXXX-XXXX);deriveLinkKey(fragment, password, linkId);sealBundle/openBundleunder the derived link key with AADwundklar/share-link/bundle/v1../pin— PBKDF2-SHA256 600 000 iterations deriving an AES-GCM wrapping key for the 32-byte local key, AADwundklar/pin/v1../hash-chain— JCS (canonicalize) + SHA-256;hash_n = SHA-256(hash_{n-1} ‖ 0x0a ‖ JCS(event));genesisHash(orgId) = SHA-256("wundklar-trail-genesis:" + orgId);verifyChainreports the first bad index../kek— AES-GCM wrap of per-org secrets underWUNDKLAR_KEKwith AADorg:<id>:<purpose>:v<n>../argon2—@noble/hashes/argon2.jsasync with clock-independent pacing; PHC encode/parse/verify; constant-time compare; a process-wide semaphore defaulting to 2 concurrent hashes per isolate../testing— fixed vectors other packages' tests import: the share-link vector, RFC 5869 HKDF-SHA256 case 1, RFC 7914 PBKDF2-SHA256, RFC 7748 X25519 Alice/Bob, NIST GCM test cases 13 (empty input) and 14 for AES-256-GCM, hex helpers. The package tests assert the package's outputs against these frozen bytes; the Argon2id tests pin RFC 9106 §5.3 and the referencetest.cPHC string, cross-checked against OpenSSL.
Zero-knowledge share link
The device creates the fragment (16 B, URL #k=) and the 8-char Crockford password. Both live only in the creating device's local link vault (records, linkVault store) and never reach the server. The server stores (a) the ciphertext bundle object and (b) an Argon2id PHC hash of the password seen over TLS at create/open (released after a match). The link key is HKDF-SHA256(ikm = fragment ‖ password, salt = linkId, info = "wundklar/share-link/v1"); the server cannot derive it. Password rotation happens only on the creating device (re-encrypts from its link vault); if that device is gone the owner revokes and re-creates the link, so no server-side key copy is ever needed (DAG decision §1.6, SPEC_GATE finding 1).
Argon2id parameters
Measured in workerd (docs/measurements/2026-11-argon2-workerd.md, production wrapper incl. semaphore, frozen clock, workerd RSS sampled during execution): the plan's primary m = 19 MiB, t = 2, p = 1 reached p95 ≈ 400 ms at 4 concurrent (gate: 300 ms) with a 57 MiB working set. Per the rule (p95 > 300 ms or memory > 64 MiB at 4 concurrent), the live parameters are the fallback m = 12 MiB, t = 3, p = 1 (DEFAULT_ARGON2ID; 43 MiB working set at 4 concurrent). The fallback still measures p95 ≈ 385 ms at 4 concurrent in one isolate (it barely lowers CPU); the measurement doc records this as an open point for the owner. verifyPassword reads parameters from the PHC string, so hashes stored under either set keep verifying.
The semaphore limits concurrent hashes to 2 per isolate. Deployed Workers do not advance Date.now() during CPU work, so noble's clock-based asyncTick alone would hash without ever yielding; the wrapper forces a host yield every 1024 blocks (≈ 4 ms of CPU), which keeps other requests on the isolate answering within ~9 ms while a hash runs.
What is not here
- Field encryption key management (
org_keys, versioning, rotation) lives inpackages/db+ thekeysfeature (ticket 05/14), not here. SubtleCryptoinjection in React Native happens throughreact-native-quick-crypto(ticket 75). The provider issetSubtle()-compatible by design.- The MD export AES-256 ZIP writer is
@zip.js/zip.jsin a separate module (ticket 72, ADR-0030).
Why this split
The hidden coupling is "where the key is". Each module exports the primitives that correspond to one key level — DEK for objects, ODK for fields, KEK for server wrap, link key for the shared bundle — so a feature that touches one layer imports only the module it needs and reviewers can see the trust boundary in the import line. Attempts to "centralise" these primitives into one envelope service collapse the AAD distinctions that the audit relies on; keeping them separate per purpose keeps the AAD discipline textual rather than hoping for a developer convention.
Consequences
- Other packages compile against stable module paths.
@wundklare/crypto/testingcarries fixed vectors a tested package can pin against. - The ADR covers runtime constraints (bounded Argon2 concurrency; Argon2id parameters bound by a measurement) so follow-up tickets consume them without re-deciding.
- Any change to a wire format (
wk1,wk-share-v1, PHC format) is a breaking change and goes through this ADR.