README.md

@pneumo/crypto #

WebCrypto implementation of SPEC §6. The same source runs in browsers, service workers, and Node 26. Runtime code uses globalThis.crypto.subtle and getRandomValues; it has no Node dependencies. The library typecheck uses ES2023 + DOM declarations and explicitly excludes Node types. Tests and vector scripts have a separate typecheck with Node declarations.

API #

All binary arguments and results are Uint8Array, not encoded strings, unless noted. Asynchronous operations return promises. Schemas, labels, and wire constants come from @pneumo/protocol.

API Result / contract
newKdfParams() Protocol KdfParams: PBKDF2-SHA256, fresh 16-byte b64u salt, 600,000 iterations.
deriveMasterKey(passphrase, kdf) 32-byte MK; UTF-8 after NFC normalization, PBKDF2-HMAC-SHA256. The client enforces the 12-character minimum.
deriveKeys(mk) DerivedKeys: { contentKey, authKey }; non-extractable AES-256-GCM CryptoKey and 32 auth bytes, HKDF-SHA256 with the §6.2 labels.
keyCheck(mk) 32-byte HMAC-SHA256 key check (§6.3).
encryptPush(contentKey, pushId, payload) Protocol Envelope, with a fresh 12-byte nonce and push-id AAD; validates the input payload.
decryptPush(contentKey, pushId, envelope) Authenticated, UTF-8/JSON-decoded and protocol-schema-validated PushPayload; rejects malformed input or authentication failure.
generateFileKey() Fresh random 32-byte FK. Use a new FK for each file.
createFileEncryptor(fk, fileId) TransformStream<Uint8Array, Uint8Array> producing the §6.6 header and authenticated blocks.
createFileDecryptor(fk, fileId) Same stream type; verifies the header, each block, and the final-block marker.
encryptBlob(fk, fileId, blob) Encrypted Blob.
decryptBlob(fk, fileId, blob) Decrypted Blob; MIME metadata is not preserved.
buildPairingUri({ origin, code, mk }) §6.7 URI; accepts an exact HTTP(S) URL.origin, a canonical b64u 16-byte pairing code, and a 32-byte MK.
parsePairingUri(uri) { origin, code, mk }; rejects wrong scheme/host/version, credentials, paths, fragments, whitespace, missing/duplicate/unknown fields, invalid origins, and noncanonical/wrong-length binary values.
b64uEncode(bytes) / b64uDecode(value) Canonical, unpadded RFC 4648 base64url, including strict validation of trailing pad bits. Empty bytes/string are valid.

PairingData is the pairing builder's input type. DerivedKeys describes the result of deriveKeys; protocol DTOs are not redefined here.

Streaming #

Input chunk boundaries are irrelevant, including empty input chunks. Encryptors buffer at most one 65,536-byte plaintext block; decryptors buffer at most one 65,552-byte ciphertext block, plus their header position. A full block is held until another byte arrives, so the last block always uses the authenticated last flag. Empty files have one authenticated empty block; an empty last block after another block is invalid. Nonces use a uint64 big-endian chunk counter.

Use the protocol's ciphertextSize(plainSize) before uploading. Consume the readable side concurrently with writing (or use pipeThrough) to respect stream backpressure. A decryptor can emit authenticated earlier blocks before a later block fails: discard all output if the stream fails, and do not treat partial output as a complete file. Blob helpers buffer the result and reject on failure. Callers enforce the deployment's envelope/file size limits.

Verification #

mise run check:protocol
mise exec -- pnpm --filter @pneumo/crypto vectors:generate
python3 -I scripts/crosscheck_vectors.py packages/crypto/vectors/v1.json

The crypto Vitest project checks vector freshness in memory, all vector cases, Python's independent KDF/HKDF/HMAC calculations, streaming/tamper behavior, and browser-safe source imports. No root task changes are needed.

Vectors v1 #

vectors/v1.json is generated in full by scripts/generate-vectors.ts from the pure buildVectors() function in scripts/vectors.ts. Run vectors:generate after changing the generator; never edit the JSON by hand. Commit messages must explicitly mention vector regeneration. These contract vectors freeze after M1.

The top-level $comment documents the format for the Kotlin consumer. All binary values, including SHA-256 digests, are canonical unpadded b64u. version is 1. Sections:

Section Shape / meaning
kdf Array of { name, passphrase, params: { alg, iterations, salt }, masterKey }. One 600,000-iteration case; fast 1,000-iteration cases include NFC-equivalent input. Passphrases are fixed fake test strings.
hkdf Array of { name, masterKey, label, length, output }, covering content, auth, and check subkeys; empty salt, SHA-256, output length in bytes.
keyCheck, authKey Arrays of { name, masterKey, output }; key check includes the §6.3 HMAC step, auth is the §6.2 auth subkey.
envelope Array of { name, masterKey, pushId, nonce, payload, envelope: { v, n, c } } for all three payload variants with fixed nonces.
file { key, fileId, plaintextPattern, cases }. Each case has { name, size, ciphertextLength } and either ciphertext (sizes 0, 1, 65,535, 65,536, 65,537) or sha256 (200,000 bytes). Plaintext byte i is (i * 31 + 17) & 255.
negative Array of { name, operation, source, mutation, expected: \"reject\" }. Source references an envelope name or file size. Mutations specify byte XOR, truncation, appended bytes, or replacement AAD ids; apply them to the referenced positive vector before decrypting. Exact tagged mutation shapes are in $comment.

The deterministic seam (deriveHkdf and fixed-nonce encryptPushWithNonce) is internal, not a package export. Salts, FKs, and ids enter through ordinary API arguments. Production entry points always generate random salts, nonces, and FKs.

scripts/crosscheck_vectors.py uses only Python's standard library: PBKDF2-HMAC, NFC normalization, HMAC-SHA256, and RFC 5869 HKDF written out directly. It checks every kdf, hkdf, keyCheck, and authKey output. Run with python3 -I for isolated imports. Kotlin cross-checks AES-GCM envelopes/files in M4.

Security boundary #

See SPEC §6.11 (threat model). Encryption protects content from bucket compromise and Web Push providers, not a compromised origin serving modified JavaScript, a compromised device, or metadata disclosure. Key storage is outside this package. Never log passphrases, MKs, FKs, auth keys, pairing URIs, or device tokens.