Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Client-side envelope encryption and the Wundklar key hierarchy

The repository's architectural decision, rationale and consequences.

Markdown

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 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.

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 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

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/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.
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.