# Client-side envelope encryption and the Wundklar key hierarchy

The repository's architectural decision, rationale and consequences.

# Client-side envelope encryption and the Wundklar key hierarchy

**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 [#decision]

Wundklar encrypts clinical data on the device under one dependency-injected `SubtleCrypto` provider ([plan §1.6](../wundklar/IMPLEMENTATION_PLAN.md)). 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](../wundklar/docs/product/product-doc.en.md)).

### Key hierarchy [#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` [#primitives-as-exported-from-wundklarecrypto]

- `.` — WebCrypto AES-256-GCM behind `setSubtle/getSubtle`; a `wk1.<keyRef>.<iv>.<ct>` wire string whose GCM AAD is `"wk1.<keyRef>."` ‖ the caller's AAD, so a rewritten `keyRef` fails authentication; `generateDek/wrapDek/unwrapDek` (envelope); `wrapTo/unwrapWith` (ECIES over X25519 + HKDF-SHA256 + AES-256-GCM; X25519 runs on the injected `SubtleCrypto` too, raw 32-byte keys imported as PKCS#8, and the interop test pins it against RFC 7748 and `@noble/curves`); `encryptField/decryptField` with `<table>:<id>:<column>` AAD; `attestationProof/verifyAttestation`; `wk-share-v1` bundle container (u32 be manifest length ‖ manifest JSON ‖ `(u32 be partLen ‖ partBytes)×N`); decoding validates the manifest schema (`version`, `createdAt`, `parts[{name, byteSize, mediaType?}]`, optional object `extras`, no other keys) before trusting it.
- `./share-link` — `newFragmentKey()` (16 random bytes, base64url, 22 chars); `newPassword()` (8 Crockford base32 chars, displayed `XXXX-XXXX`); `deriveLinkKey(fragment, password, linkId)`; `sealBundle/openBundle` under the derived link key with AAD `wundklar/share-link/bundle/v1`.
- `./pin` — PBKDF2-SHA256 600 000 iterations deriving an AES-GCM wrapping key for the 32-byte local key, AAD `wundklar/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)`; `verifyChain` reports the first bad index.
- `./kek` — AES-GCM wrap of per-org secrets under `WUNDKLAR_KEK` with AAD `org:<id>:<purpose>:v<n>`.
- `./argon2` — `@noble/hashes/argon2.js` async 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 reference `test.c` PHC string, cross-checked against OpenSSL.

### Zero-knowledge share link [#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 [#argon2id-parameters]

Measured in workerd ([`docs/measurements/2026-11-argon2-workerd.md`](../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 [#what-is-not-here]

- Field encryption key management (`org_keys`, versioning, rotation) lives in `packages/db` + the `keys` feature (ticket 05/14), not here.
- `SubtleCrypto` injection in React Native happens through `react-native-quick-crypto` (ticket 75). The provider is `setSubtle()`-compatible by design.
- The MD export AES-256 ZIP writer is `@zip.js/zip.js` in a separate module (ticket 72, ADR-0030).

## Why this split [#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 [#consequences]

- Other packages compile against stable module paths. `@wundklare/crypto/testing` carries 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.

