Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-bin (crypto-completion)
**Package**: `@suaveplan/crypto-bin`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. This is a new package; the entire spec below is `## ADDED Requirements`.
## ADDED Requirements
### Requirement: hash-password CLI hash mode
The package SHALL ship a `suaveplan-hash-password` bin that, when invoked with `--password <pw>` and no `--verify`, hashes the password using the requested algorithm (default `argon2id`, opt-in `--algo blake2b`) and writes the resulting stored-hash string to stdout, exiting with code 0. The CLI SHALL route through `@suaveplan/cli`'s arg parser (so `--help` works automatically) and SHALL NEVER call `process.exit` directly — it SHALL use `cli.exit(code)` to keep test harness compatibility.
#### Scenario: argon2id mode prints stored hash
- **WHEN** `suaveplan-hash-password --password "correct horse battery staple"` is invoked
- **THEN** stdout contains a string starting with `"$argon2id$"` AND the exit code is 0
#### Scenario: blake2b mode prints stored hash
- **WHEN** `suaveplan-hash-password --password "..." --algo blake2b` is invoked
- **THEN** stdout contains a string starting with `"$blake2b$"` AND the exit code is 0
**Verified by:** `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"argon2id mode writes a $argon2id$-prefixed hash to stdout and exits 0"` AND `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"blake2b mode writes a $blake2b$-prefixed hash to stdout and exits 0"`
---
### Requirement: hash-password CLI verify mode
When invoked with `--password <pw>` and `--verify <stored>`, the bin SHALL detect the algorithm from the stored hash's prefix, run the matching verify function (`verifyPasswordArgon2id` or `verifyPasswordBlake2b`), exit with code 0 on match and code 1 on mismatch. The bin SHALL NOT print anything to stdout in verify mode (so the exit code is the only signal — suitable for use in shell pipelines).
#### Scenario: matching password exits 0
- **WHEN** the stored hash and the password agree
- **THEN** the exit code is 0 AND stdout is empty
#### Scenario: non-matching password exits 1
- **WHEN** the stored hash and the password disagree
- **THEN** the exit code is 1 AND stdout is empty
**Verified by:** `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"verify mode exits 0 when password matches stored hash"` AND `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"verify mode exits 1 when password does not match stored hash"`
---
### Requirement: hash-password CLI argument validation
The bin SHALL reject malformed invocations (missing `--password`, unknown `--algo`, malformed `--verify` value) with a non-zero exit code and a `SuaveplanError` whose code is one of `CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS`, `CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO`, or `CRYPTO.HASH_PASSWORD_CLI_VERIFY_FAILED`. The error message SHALL be human-readable and SHALL be printed to stderr (not stdout, so it does not contaminate pipe output).
#### Scenario: missing --password rejected
- **WHEN** `suaveplan-hash-password` is invoked with no `--password`
- **THEN** the exit code is non-zero AND stderr mentions `--password` AND the underlying error code is `CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS`
#### Scenario: unknown algorithm rejected
- **WHEN** `suaveplan-hash-password --password pw --algo bcrypt` is invoked
- **THEN** the exit code is non-zero AND the underlying error code is `CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO` AND `context.algo === "bcrypt"`
**Verified by:** `packages/crypto/crypto-bin/src/hash-password/hash-password.spec-conformance.test.ts::"missing --password emits HASH_PASSWORD_CLI_BAD_ARGS"` AND `packages/crypto/crypto-bin/src/hash-password/hash-password.spec-conformance.test.ts::"unknown --algo emits HASH_PASSWORD_CLI_UNKNOWN_ALGO"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,95 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-cipher (crypto-completion)
**Package**: `@suaveplan/crypto-cipher`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Streaming AEAD encryption / decryption
The package SHALL expose `encryptStreamAead(source, options)` and `decryptStreamAead(source, options)` async generators that consume a `Uint8Array` input stream chunk by chunk, attach a per-chunk authentication tag, and emit the encrypted (or decrypted) chunk stream. Each chunk SHALL use a per-chunk nonce derived deterministically from a base nonce plus a 32-bit chunk counter so the entire stream binds to a single key without nonce reuse.
#### Scenario: round-trip preserves bytes
- **WHEN** `encryptStreamAead(plaintext, { algorithm, key, baseNonce })` is consumed and its output is fed to `decryptStreamAead(ciphertext, { algorithm, key, baseNonce })`
- **THEN** the concatenated output equals the original plaintext byte-for-byte
#### Scenario: tag mismatch detected on tampered chunk
- **WHEN** any byte of any encrypted chunk's tag is flipped before decryption
- **THEN** `decryptStreamAead` throws a `SuaveplanError` with code `CRYPTO_CIPHER.STREAM_TAG_MISMATCH` and `context.chunkIndex` points at the tampered chunk
#### Scenario: chunk-counter overflow rejected
- **WHEN** the chunk counter reaches `2^32`
- **THEN** the next chunk emission throws `CRYPTO_CIPHER.STREAM_OUT_OF_ORDER` with `context.counter === 2 ** 32`
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"round-trips a multi-chunk plaintext through encryptStreamAead and decryptStreamAead"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"throws STREAM_TAG_MISMATCH when a chunk tag is flipped"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts::"throws STREAM_OUT_OF_ORDER on chunk-counter overflow"`
---
### Requirement: AbortSignal propagation through streaming AEAD
The package SHALL honour an optional `AbortSignal` passed via `EncryptStreamAeadOptions.signal` and `DecryptStreamAeadOptions.signal`. When the signal is aborted mid-stream the generator SHALL flush the in-flight chunk, release any pending cipher state, and throw `SuaveplanError` with code `CRYPTO_CIPHER.STREAM_ABORTED`.
#### Scenario: abort mid-stream rejects with STREAM_ABORTED
- **WHEN** an `AbortController` is signalled while `encryptStreamAead` is mid-iteration
- **THEN** the next `await generator.next()` rejects with `CRYPTO_CIPHER.STREAM_ABORTED` and `context.chunksProcessed` reflects the count emitted before abort
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"aborts mid-stream and rejects with STREAM_ABORTED"`
---
### Requirement: AES-GCM streaming engine adapter
The package SHALL expose `aesGcmStreamingEngine: StreamingAeadEngine` that wraps the existing `aes-256-gcm` primitive. Per-chunk nonce SHALL be a 12-byte big-endian concatenation of an 8-byte caller-supplied prefix and a 4-byte chunk counter (RFC 5116 §3.2 compatible). The engine SHALL reject `baseNonce.length !== 8` with `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE`.
#### Scenario: 8-byte base nonce accepted
- **WHEN** `aesGcmStreamingEngine.encrypt({ baseNonce: 8-byte prefix, key, plaintext, counter: 0 })` is called
- **THEN** the produced nonce equals `baseNonce ‖ counter_be32(0)` (12 bytes total)
#### Scenario: wrong-length base nonce rejected
- **WHEN** `baseNonce.length === 12`
- **THEN** the engine throws `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE` with `context.expectedLength === 8` and `context.actualLength === 12`
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead-aes-gcm/streaming-aead-aes-gcm.test.ts::"derives 12-byte nonce from 8-byte base + counter"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead-aes-gcm/streaming-aead-aes-gcm.spec-conformance.test.ts::"rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE"`
---
### Requirement: XChaCha20-Poly1305 streaming engine adapter
The package SHALL expose `xchacha20StreamingEngine: StreamingAeadEngine` that wraps the existing `xchacha20-poly1305` primitive. Per-chunk nonce SHALL be a 24-byte big-endian concatenation of a 20-byte caller-supplied prefix and a 4-byte chunk counter. The engine SHALL reject `baseNonce.length !== 20` with `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE`.
#### Scenario: 20-byte base nonce accepted
- **WHEN** `xchacha20StreamingEngine.encrypt({ baseNonce: 20-byte prefix, key, plaintext, counter: 5 })` is called
- **THEN** the produced nonce equals `baseNonce ‖ counter_be32(5)` (24 bytes total)
#### Scenario: wrong-length base nonce rejected
- **WHEN** `baseNonce.length === 24`
- **THEN** the engine throws `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE` with `context.expectedLength === 20` and `context.actualLength === 24`
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead-xchacha20/streaming-aead-xchacha20.test.ts::"derives 24-byte nonce from 20-byte base + counter"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead-xchacha20/streaming-aead-xchacha20.spec-conformance.test.ts::"rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE"`
---
### Requirement: StreamingAeadEngine contract
The package SHALL define a `StreamingAeadEngine` interface in `streaming-aead.types.ts` with `encrypt(input: StreamingChunkInput): Promise<StreamingChunkOutput>` and `decrypt(input: StreamingChunkInput): Promise<Uint8Array>` methods, plus `nonceBaseLength: number` and `tagLength: number` constants. Both built-in engines (AES-GCM, XChaCha20-Poly1305) SHALL satisfy this interface and SHALL be substitutable for any caller-supplied engine.
#### Scenario: caller-supplied engine round-trips identically
- **WHEN** a custom `StreamingAeadEngine` implementation is passed to `encryptStreamAead` and the same instance to `decryptStreamAead`
- **THEN** the round-trip preserves bytes byte-for-byte (validated against an in-test spy engine that records every call)
**Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts::"StreamingAeadEngine contract is stable for custom engines"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,74 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-curve (crypto-completion)
**Package**: `@suaveplan/crypto-curve`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: secp256k1 ECDSA signing with recovery byte
The `secp256k1` primitive SHALL expose `signWithRecovery(digest, privateKey)` returning a 65-byte signature (64-byte compact signature followed by a single recovery byte). The recovery byte SHALL be the byte returned by `secp256k1.sign(digest, privateKey).recovery` from the `@noble/curves` library; valid values are `0` or `1`.
#### Scenario: produces 65-byte signature
- **WHEN** `signWithRecovery(digest, privateKey)` is called for a valid digest and private key
- **THEN** the result is a `Uint8Array` of length 65 AND `result[64]` is `0` or `1`
#### Scenario: signWithRecovery output verifies via standard verify
- **WHEN** the first 64 bytes of `signWithRecovery(digest, privateKey)` are passed to the existing `verify(digest, sig64, publicKey)`
- **THEN** the signature verifies successfully
**Verified by:** `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"signWithRecovery returns 65-byte signature with recovery byte 0 or 1"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"signWithRecovery output verifies via standard verify on first 64 bytes"`
---
### Requirement: secp256k1 public-key recovery
The `secp256k1` primitive SHALL expose `recoverPublicKey(signatureWithRecovery, digest)` returning the 33-byte compressed public key recovered from a 65-byte signature-with-recovery and the original digest. Inputs may be supplied as `Uint8Array` or hex strings.
#### Scenario: recovers the signing public key
- **WHEN** `signWithRecovery(digest, privateKey)` produces `sig` and `recoverPublicKey(sig, digest)` is called
- **THEN** the recovered public key equals `getPublicKey(privateKey)` byte-for-byte
#### Scenario: missing recovery byte rejected
- **WHEN** a 64-byte signature (without recovery byte) is passed to `recoverPublicKey`
- **THEN** it throws `SuaveplanError` with code `CRYPTO_CURVE.RECOVERY_BYTE_MISSING` and `context.signatureLength === 64`
#### Scenario: invalid recovery byte rejected
- **WHEN** a 65-byte signature is passed but `signatureWithRecovery[64]` is `2`, `3`, or any value > 3
- **THEN** it throws `SuaveplanError` with code `CRYPTO_CURVE.RECOVERY_BYTE_INVALID` and `context.recoveryByte` reflects the offending value
**Verified by:** `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"recoverPublicKey returns the same key as getPublicKey for the signer"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"throws RECOVERY_BYTE_MISSING for 64-byte signature"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"throws RECOVERY_BYTE_INVALID when recovery byte is out of range"`
---
### Requirement: secp256k1 verify-with-recovery
The `secp256k1` primitive SHALL expose `verifyWithRecovery(signatureWithRecovery, digest, expectedPublicKey)` returning `true` only when the public key recovered from the signature equals `expectedPublicKey` (compared in constant time on the byte buffer). When the recovered key differs, the function SHALL return `false` (it does NOT throw on mismatch — mismatch is a routine path).
#### Scenario: returns true on matching key
- **WHEN** `verifyWithRecovery(sig, digest, expectedPublicKey)` is called with the public key that produced `sig`
- **THEN** the result is `true`
#### Scenario: returns false on non-matching key
- **WHEN** `verifyWithRecovery(sig, digest, otherPublicKey)` is called with a public key that did not produce `sig`
- **THEN** the result is `false` (no exception is thrown)
#### Scenario: comparison is constant-time
- **WHEN** the recovered public key and `expectedPublicKey` differ in their first byte vs differ only in their last byte
- **THEN** the timing distribution observed across many invocations does not reveal the position of the difference (verified via a statistical timing test)
**Verified by:** `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"verifyWithRecovery returns true on matching public key"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"verifyWithRecovery returns false on non-matching public key"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"verifyWithRecovery comparison is constant-time"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,79 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-hash (crypto-completion)
**Package**: `@suaveplan/crypto-hash`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: scrypt KDF primitive
The package SHALL expose `scrypt(password, salt, options)` from `primitives/scrypt/scrypt.ts`, where `options` carries `N` (CPU/memory cost, must be a power of two ≥ 2), `r` (block size), `p` (parallelisation), and `dkLen` (output length in bytes). The implementation SHALL match RFC 7914 byte-for-byte and SHALL use the `@noble/hashes/scrypt::scryptAsync` engine in the noble selector. Output is a `Uint8Array` of length `dkLen`.
#### Scenario: matches RFC 7914 known-answer test
- **WHEN** `scrypt("password", "NaCl", { N: 1024, r: 8, p: 16, dkLen: 64 })` is called
- **THEN** the output equals the RFC 7914 §11 KAT for those parameters byte-for-byte
#### Scenario: invalid N rejected
- **WHEN** `scrypt(pw, salt, { N: 1000, r: 8, p: 1, dkLen: 32 })` is called (1000 is not a power of two)
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.SCRYPT_INVALID_N` and `context.N === 1000`
**Verified by:** `packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.test.ts::"matches RFC 7914 known-answer test for N=1024 r=8 p=16"` AND `packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.spec-conformance.test.ts::"throws SCRYPT_INVALID_N when N is not a power of two"`
---
### Requirement: blake2b hash primitive
The package SHALL expose `blake2b(message, options)` from `primitives/blake2b/blake2b.ts`, where `options` carries `dkLen` (1..64), optional `key` (≤ 64 bytes), optional `salt` (16 bytes), and optional `personalization` (16 bytes). The implementation SHALL match RFC 7693 byte-for-byte.
#### Scenario: matches RFC 7693 known-answer test
- **WHEN** `blake2b("abc", { dkLen: 64 })` is called
- **THEN** the output equals the RFC 7693 §B Appendix KAT for the input "abc" byte-for-byte
#### Scenario: dkLen > 64 rejected
- **WHEN** `blake2b(msg, { dkLen: 65 })` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.BLAKE2B_OUTPUT_TOO_LONG` and `context.requestedLength === 65`
#### Scenario: key longer than 64 bytes rejected
- **WHEN** `blake2b(msg, { dkLen: 32, key: 65-byte-buffer })` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.BLAKE2B_KEY_TOO_LONG` and `context.keyLength === 65`
**Verified by:** `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.test.ts::"matches RFC 7693 known-answer test for input \"abc\""` AND `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.spec-conformance.test.ts::"throws BLAKE2B_OUTPUT_TOO_LONG when dkLen exceeds 64"` AND `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.spec-conformance.test.ts::"throws BLAKE2B_KEY_TOO_LONG when key exceeds 64 bytes"`
---
### Requirement: blake2b password hashing and verification
The package SHALL expose `hashPasswordBlake2b(password, options)` returning a stored hash in the format `$blake2b$v=1$cf=<costFactor>$<saltHex>$<hashHex>`, where `costFactor` controls iteration count (default 14, doubling each level) and a 16-byte random salt is generated when not supplied. It SHALL also expose `verifyPasswordBlake2b(storedHash, password)` returning `true` when the password verifies and `false` otherwise. Comparison SHALL be constant-time over the hash bytes.
#### Scenario: hash and verify round-trip
- **WHEN** `hashPasswordBlake2b("correct horse battery staple")` produces `stored` and `verifyPasswordBlake2b(stored, "correct horse battery staple")` is called
- **THEN** the result is `true`
#### Scenario: wrong password verifies as false
- **WHEN** `verifyPasswordBlake2b(stored, "wrong password")` is called
- **THEN** the result is `false` (no exception thrown)
#### Scenario: salt is fresh per call
- **WHEN** `hashPasswordBlake2b("same password")` is called twice
- **THEN** the two stored hashes are not byte-equal (different salts → different outputs)
#### Scenario: malformed stored hash rejected
- **WHEN** `verifyPasswordBlake2b("not-a-valid-format", pw)` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.BLAKE2B_PASSWORD_FORMAT_INVALID`
**Verified by:** `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts::"hash and verify round-trip with same password"` AND `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts::"verify returns false for wrong password"` AND `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts::"salt is fresh per call so identical inputs hash differently"` AND `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.spec-conformance.test.ts::"throws BLAKE2B_PASSWORD_FORMAT_INVALID for malformed stored hash"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,110 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-keys (crypto-completion)
**Package**: `@suaveplan/crypto-keys`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Device key creation
The package SHALL expose `createDeviceKey(options?)` from `keypair/device/device.ts` that generates a new `DeviceKey` carrying `BaseKeySchema` metadata (`s` secret bytes, `v` version, `m` master version, `u` user id, `d` depth, `o` key offset). Device keys SHALL always have `o === 7`. Generated keys SHALL pass `isValidDeviceKey`; an internally-failed validation SHALL throw `SuaveplanError` with code `CRYPTO_KEYS.DEVICE_INVALID_KEY`.
#### Scenario: round-trip valid key
- **WHEN** `createDeviceKey()` is called with no options
- **THEN** the result satisfies `isValidDeviceKey(key) === true` AND `key.o === 7` AND `key.s` is a 32-byte `Uint8Array`
#### Scenario: caller-supplied metadata respected
- **WHEN** `createDeviceKey({ version: 3, masterVersion: 2, userId: 42, depth: 4 })` is called
- **THEN** the result has `key.v === 3`, `key.m === 2`, `key.u === 42`, `key.d === 4`, `key.o === 7`
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"createDeviceKey produces a valid device key with offset 7"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"createDeviceKey honors version / masterVersion / userId / depth options"`
---
### Requirement: Device key derivation from master
The package SHALL expose `deriveDeviceKeyFromMaster(options)` that derives a per-device `DeviceKey` from a `MasterKey` plus a numeric `deviceId`. Derivation SHALL use the existing `generateDerivedKey` (from `crypto-keys/keypair/derivation`) with key offset `7` and a context derived from the device id. The same `(masterKey, deviceId)` SHALL always produce the same `DeviceKey.s` byte sequence. Invalid master keys SHALL throw `CRYPTO_KEYS.DEVICE_INVALID_MASTER`.
#### Scenario: deterministic derivation
- **WHEN** `deriveDeviceKeyFromMaster({ masterKey, deviceId: 1 })` is called twice
- **THEN** both results have byte-equal `.s` fields
#### Scenario: different device ids produce different keys
- **WHEN** `deriveDeviceKeyFromMaster({ masterKey, deviceId: 1 })` and `deriveDeviceKeyFromMaster({ masterKey, deviceId: 2 })` are called
- **THEN** the two results have different `.s` fields
#### Scenario: invalid master key rejected
- **WHEN** `deriveDeviceKeyFromMaster({ masterKey: { s: shortBytes } as MasterKey, deviceId: 1 })` is called with a master key that fails `validateMasterKey`
- **THEN** it throws `SuaveplanError` with code `CRYPTO_KEYS.DEVICE_INVALID_MASTER`
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"deriveDeviceKeyFromMaster is deterministic for fixed master and deviceId"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"different deviceIds derive different keys"` AND `packages/crypto/crypto-keys/src/keypair/device/device.spec-conformance.test.ts::"throws DEVICE_INVALID_MASTER for an invalid master key"`
---
### Requirement: Device UUID generation
The package SHALL expose `generateDeviceUUID(options)` returning an RFC 4122-compliant UUID v4 string deterministically derived from a `DeviceKey`. The version nibble (bits 12-15 of `time_hi_and_version`) SHALL be set to `4`; the variant bits (bits 6-7 of `clock_seq_hi`) SHALL be set to `10`. The same `DeviceKey` plus context SHALL always produce the same UUID.
#### Scenario: UUID structure conforms to v4
- **WHEN** `generateDeviceUUID({ deviceMasterKey })` is called
- **THEN** the result matches `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/`
#### Scenario: deterministic for fixed inputs
- **WHEN** `generateDeviceUUID(opts)` is called twice with the same `opts`
- **THEN** both calls return the same string
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"generateDeviceUUID returns an RFC 4122 v4 UUID"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"generateDeviceUUID is deterministic for fixed inputs"`
---
### Requirement: Device metadata extraction and comparison
The package SHALL expose `extractDeviceMetadata(deviceKey)` returning `DeviceMetadata` (`version`, `masterVersion`, `userId`, `depth`, `keyOffset`) and `compareDeviceKeys(a, b)` returning `true` only when the two `DeviceKey` instances have identical `s`, `v`, `m`, `u`, `d`, `o` fields. The `s`-byte comparison SHALL be constant-time. The package SHALL also expose `isValidDeviceKey(deviceKey)` returning `true` only for keys passing structural validation (correct field types and `o === 7`).
#### Scenario: extractDeviceMetadata round-trips
- **WHEN** `extractDeviceMetadata(createDeviceKey({ version: 5, depth: 3 }))` is called
- **THEN** the result has `version === 5` and `depth === 3`
#### Scenario: compareDeviceKeys equals on identical keys
- **WHEN** `compareDeviceKeys(k, k)` is called for any `DeviceKey` k
- **THEN** the result is `true`
#### Scenario: compareDeviceKeys differs on differing s
- **WHEN** `compareDeviceKeys(k1, k2)` is called for keys with different `.s` bytes
- **THEN** the result is `false`
#### Scenario: compareDeviceKeys is constant-time on s
- **WHEN** the `.s` bytes differ at byte 0 vs byte 31 (last byte)
- **THEN** the timing distribution observed across many invocations does not reveal which byte differs (statistical timing test)
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"extractDeviceMetadata round-trips a created device key"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"compareDeviceKeys is true for identical keys"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"compareDeviceKeys is false for keys with differing s"` AND `packages/crypto/crypto-keys/src/keypair/device/device.spec-conformance.test.ts::"compareDeviceKeys runs in constant time over s"`
---
### Requirement: generateDerivedDeviceId back-compat alias
The package SHALL expose `generateDerivedDeviceId(options)` as a thin alias for `generateDeviceUUID(options)`, retained for upstream-API back-compat. The alias SHALL NOT use `@deprecated` JSDoc (banned in genesis per CLAUDE.md §2). Its sibling `.md` SHALL document it with prose: "Backwards-compatibility alias for `generateDeviceUUID`."
#### Scenario: alias produces identical output
- **WHEN** `generateDerivedDeviceId(opts)` and `generateDeviceUUID(opts)` are called with the same options
- **THEN** both return the same string
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"generateDerivedDeviceId returns the same UUID as generateDeviceUUID"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,107 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto (crypto-completion)
**Package**: `@suaveplan/crypto`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: High-level streaming AEAD façade
The package SHALL expose `encryptStreamAead(opts)` and `decryptStreamAead(opts)` from `aead/streaming/streaming-aead.ts` that select the engine (`xchacha20-poly1305` or `aes-256-gcm`) from `opts.algorithm` and delegate to the corresponding `StreamingAeadEngine` in `@suaveplan/crypto-cipher`. The package SHALL re-export the named engines `aesGcmStreamingEngine` and `xchacha20StreamingEngine` so callers wanting the engine directly do not need to depend on `crypto-cipher`.
#### Scenario: facade routes to AES-GCM engine
- **WHEN** `encryptStreamAead({ algorithm: "aes-256-gcm", key, baseNonce: 8-byte, plaintext })` is called
- **THEN** the produced ciphertext decrypts via the standalone `aesGcmStreamingEngine` exported from this package, byte-for-byte
#### Scenario: facade routes to XChaCha20-Poly1305 engine
- **WHEN** `encryptStreamAead({ algorithm: "xchacha20-poly1305", key, baseNonce: 20-byte, plaintext })` is called
- **THEN** the produced ciphertext decrypts via the standalone `xchacha20StreamingEngine` exported from this package, byte-for-byte
#### Scenario: unknown algorithm rejected
- **WHEN** `encryptStreamAead({ algorithm: "rot13" as never, ... })` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO.STREAM_AEAD_HEADER_INVALID` and `context.algorithm === "rot13"`
**Verified by:** `packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts::"facade routes aes-256-gcm to aesGcmStreamingEngine"` AND `packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts::"facade routes xchacha20-poly1305 to xchacha20StreamingEngine"` AND `packages/crypto/crypto/src/aead/streaming/streaming-aead.spec-conformance.test.ts::"throws STREAM_AEAD_HEADER_INVALID on unknown algorithm"`
---
### Requirement: Web-Streams adapters for streaming AEAD
The package SHALL expose `createEncryptionStream(opts) → TransformStream<Uint8Array, Uint8Array>` and `createDecryptionStream(opts) → TransformStream<Uint8Array, Uint8Array>` from `aead/streaming/web-streams.ts`. These SHALL be drop-in `pipeThrough` targets for any Web-Streams `ReadableStream<Uint8Array>` source (e.g. `Blob.stream()`, `Response.body`, `fetch().body`). The package SHALL also expose `collectStream(stream) → Promise<Uint8Array>` (concatenates all chunks), `createStreamKey(rng?) → Uint8Array` (returns 32 random bytes), and `createStreamEncryption(opts)` (combined key + base-nonce + initialised stream factory).
#### Scenario: pipeThrough round-trips a Blob
- **WHEN** `blob.stream().pipeThrough(createEncryptionStream(opts)).pipeThrough(createDecryptionStream(opts))` is collected via `collectStream`
- **THEN** the collected bytes equal the original blob bytes
#### Scenario: legacy aliases preserved
- **WHEN** `streamEncrypt`, `streamDecrypt`, `ChaCha20Stream`, `AESStream` are imported
- **THEN** all four resolve to defined values that satisfy the documented upstream contract
**Verified by:** `packages/crypto/crypto/src/aead/streaming/web-streams.test.ts::"pipeThrough round-trips a multi-chunk source through encrypt then decrypt"` AND `packages/crypto/crypto/src/aead/streaming/web-streams.test.ts::"streamEncrypt / streamDecrypt / ChaCha20Stream / AESStream aliases resolve and round-trip"`
---
### Requirement: Password-based encryption helpers
The package SHALL expose `encryptWithPassword(password, plaintext, options?)` and `decryptWithPassword(password, ciphertext, options?)` from `password/encrypt-with-password.ts`. The `options.kdf` field SHALL accept `"argon2id"` (default), `"scrypt"`, or `"pbkdf2"`. The output blob SHALL begin with a versioned 16-byte self-describing header `{ magic: "SVPP", version: 1, kdf, cipher, reserved, saltLen, nonceLen, kdfParamsLen }` followed by salt, nonce, CBOR-encoded `kdfParams`, then AEAD ciphertext. `decryptWithPassword` SHALL parse the header to recover all parameters and SHALL NOT require any out-of-band metadata from the caller.
#### Scenario: round-trip with default Argon2id
- **WHEN** `encryptWithPassword("correct horse battery staple", plaintext)` produces `blob` and `decryptWithPassword("correct horse battery staple", blob)` is called
- **THEN** the result equals `plaintext` byte-for-byte
#### Scenario: round-trip with scrypt KDF
- **WHEN** `encryptWithPassword(pw, plaintext, { kdf: "scrypt" })` produces `blob` and `decryptWithPassword(pw, blob)` is called
- **THEN** the result equals `plaintext` byte-for-byte AND the blob's KDF byte signals scrypt
#### Scenario: blob header self-describes
- **WHEN** the same `(password, plaintext)` is encrypted twice with different `kdfParams`
- **THEN** the two blobs have different KDF-params bytes in their headers AND both decrypt successfully without the caller passing `kdfParams` to `decryptWithPassword`
#### Scenario: unknown KDF rejected
- **WHEN** `encryptWithPassword(pw, plaintext, { kdf: "bcrypt" as never })` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO.PASSWORD_KDF_UNKNOWN` and `context.kdf === "bcrypt"`
#### Scenario: corrupt header rejected
- **WHEN** `decryptWithPassword(pw, blob)` is called with a `blob` whose first four bytes are not `"SVPP"`
- **THEN** it throws `SuaveplanError` with code `CRYPTO.PASSWORD_BLOB_HEADER_INVALID`
#### Scenario: unsupported version rejected
- **WHEN** `decryptWithPassword(pw, blob)` is called with a `blob` whose version byte is `99`
- **THEN** it throws `SuaveplanError` with code `CRYPTO.PASSWORD_BLOB_VERSION_UNSUPPORTED` and `context.version === 99`
**Verified by:** `packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"round-trips with default Argon2id KDF"` AND `packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"round-trips with scrypt KDF"` AND `packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"blob header is self-describing across kdfParams variants"` AND `packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_KDF_UNKNOWN on unknown KDF name"` AND `packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_BLOB_HEADER_INVALID on corrupt magic bytes"` AND `packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_BLOB_VERSION_UNSUPPORTED on unknown version byte"`
---
### Requirement: deriveKeyFromPassword
The package SHALL expose `deriveKeyFromPassword(password, salt, options?)` from `password/derive-key-from-password.ts`. The `options` SHALL accept `kdf: "argon2id" | "scrypt" | "pbkdf2"` (default `"argon2id"`), `kdfParams: Record<string, number>` (algorithm-specific), and `dkLen: number` (default 32). The function SHALL return a `Uint8Array` of length `dkLen` suitable for direct use as a symmetric AEAD key. Identical `(password, salt, kdf, kdfParams)` SHALL produce byte-identical output.
#### Scenario: deterministic given fixed inputs
- **WHEN** `deriveKeyFromPassword(pw, salt, { kdf: "scrypt", kdfParams: { N: 1024, r: 8, p: 1 } })` is called twice
- **THEN** both calls return byte-identical 32-byte Uint8Arrays
#### Scenario: dkLen honored
- **WHEN** `deriveKeyFromPassword(pw, salt, { kdf: "argon2id", dkLen: 64 })` is called
- **THEN** the result is a 64-byte Uint8Array
**Verified by:** `packages/crypto/crypto/src/password/derive-key-from-password.test.ts::"deterministic for fixed password / salt / params"` AND `packages/crypto/crypto/src/password/derive-key-from-password.test.ts::"honors dkLen option"`
<!-- markdownlint-enable MD024 -->