979 lines
68 KiB
Markdown
979 lines
68 KiB
Markdown
# crypto Specification
|
|
|
|
## Purpose
|
|
|
|
TBD - created by archiving change enterprise-crypto. Update Purpose after archive.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: Single canonical implementation
|
|
|
|
The package SHALL implement every primitive on top of `@noble/ciphers`, `@noble/curves`, and `@noble/hashes` — NOT on top of `node:crypto` or `crypto.subtle`. Behaviour MUST be identical across Bun, Node, and modern browsers.
|
|
|
|
#### Scenario: Same output across runtimes
|
|
|
|
- **WHEN** `sha256(x)` runs under Bun AND under a browser with the same input
|
|
- **THEN** the returned bytes are equal
|
|
|
|
### Requirement: Constant-time comparison
|
|
|
|
The package SHALL export `constantTimeEqual(a, b)` that returns `true` iff `a` and `b` have the same length AND every byte is equal. Execution time MUST be independent of the position of the first differing byte.
|
|
|
|
#### Scenario: Equal buffers
|
|
|
|
- **WHEN** `constantTimeEqual(a, b)` is called with byte-equal inputs
|
|
- **THEN** it returns `true`
|
|
|
|
#### Scenario: Different length rejected in constant time
|
|
|
|
- **WHEN** `constantTimeEqual(a, b)` is called with unequal-length inputs
|
|
- **THEN** it returns `false` without branching on content
|
|
|
|
### Requirement: Zeroization
|
|
|
|
The package SHALL export `zeroize(buf)` that fills `buf` with zero bytes.
|
|
|
|
#### Scenario: In-place zero
|
|
|
|
- **WHEN** `zeroize(x)` is called on a non-empty `Uint8Array`
|
|
- **THEN** every element of `x` is `0` afterwards
|
|
|
|
### Requirement: Hash surface
|
|
|
|
The package SHALL export `sha256`, `sha384`, `sha512` one-shot digest functions each returning a `Uint8Array` of the correct length (32, 48, 64 bytes respectively).
|
|
|
|
#### Scenario: Round-trip determinism
|
|
|
|
- **WHEN** `sha256(x)` is called twice with identical input
|
|
- **THEN** both calls return byte-identical output
|
|
|
|
### Requirement: HMAC, HKDF, PBKDF2, Argon2id
|
|
|
|
The package SHALL export `hmac`, `hkdf`, `pbkdf2`, `argon2id` with the algorithm selected by a string parameter for HMAC/HKDF/PBKDF2 (`"SHA-256" | "SHA-384" | "SHA-512"`). Argon2id is parameterised by `timeCost`, `memoryCost` (KiB), `parallelism`, and `length`.
|
|
|
|
#### Scenario: HMAC determinism
|
|
|
|
- **WHEN** `hmac("SHA-256", key, data)` is called twice with identical inputs
|
|
- **THEN** both calls return byte-identical output
|
|
|
|
#### Scenario: HKDF output length honoured
|
|
|
|
- **WHEN** `hkdf("SHA-256", ikm, { salt, info, length: 42 })` is called
|
|
- **THEN** the returned array has byte length 42
|
|
|
|
#### Scenario: PBKDF2 iterations below floor rejected
|
|
|
|
- **WHEN** `pbkdf2(algo, password, { iterations: 999, ... })` is called
|
|
- **THEN** it throws `CryptoError("CRYPTO.KDF.INVALID_PARAMS")`
|
|
|
|
#### Scenario: Argon2id parameter validation
|
|
|
|
- **WHEN** `argon2id(password, { memoryCost: 512, ... })` is called with `memoryCost < 8`
|
|
- **THEN** it throws `CryptoError("CRYPTO.KDF.INVALID_PARAMS")` (Argon2id rfc 9106 minimum)
|
|
|
|
### Requirement: AEAD (AES-256-GCM, ChaCha20-Poly1305)
|
|
|
|
The package SHALL export `aeadEncrypt` and `aeadDecrypt` dispatching by algorithm string. The key/nonce lengths SHALL be validated at the boundary. Decryption with a wrong tag SHALL throw `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` with no plaintext leakage.
|
|
|
|
#### Scenario: AES-256-GCM round-trip
|
|
|
|
- **WHEN** `aeadDecrypt("AES-256-GCM", key, aeadEncrypt("AES-256-GCM", key, p, { nonce, aad }), { nonce, aad })` is called
|
|
- **THEN** the returned bytes equal `p`
|
|
|
|
#### Scenario: ChaCha20-Poly1305 round-trip
|
|
|
|
- **WHEN** the same round-trip is performed with `"ChaCha20-Poly1305"`
|
|
- **THEN** the returned bytes equal the plaintext
|
|
|
|
#### Scenario: Tampered ciphertext rejected
|
|
|
|
- **WHEN** `aeadDecrypt` is called with a ciphertext whose tag was flipped
|
|
- **THEN** it throws `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` AND does not return any bytes
|
|
|
|
#### Scenario: Wrong key length rejected
|
|
|
|
- **WHEN** `aeadEncrypt("AES-256-GCM", key, p, { nonce })` is called with `key.length !== 32`
|
|
- **THEN** it throws `CryptoError("CRYPTO.KEY.INVALID_LENGTH")`
|
|
|
|
#### Scenario: Wrong nonce length rejected
|
|
|
|
- **WHEN** AES-256-GCM nonce length is not 12 OR ChaCha20-Poly1305 nonce length is not 12
|
|
- **THEN** `aeadEncrypt` throws `CryptoError("CRYPTO.AEAD.INVALID_NONCE")`
|
|
|
|
### Requirement: Ed25519, X25519, ECDSA-P256
|
|
|
|
The package SHALL export namespaces `ed25519`, `x25519`, `ecdsaP256` with `generateKeypair`, `sign`/`agree`, and `verify` where applicable.
|
|
|
|
#### Scenario: Ed25519 sign/verify round-trip
|
|
|
|
- **WHEN** `ed25519.verify(pk, ed25519.sign(sk, m), m)` is called
|
|
- **THEN** it returns `true`
|
|
|
|
#### Scenario: Ed25519 mismatched message
|
|
|
|
- **WHEN** `ed25519.verify(pk, signatureOfM, otherMessage)` is called
|
|
- **THEN** it returns `false`
|
|
|
|
#### Scenario: X25519 shared secret symmetry
|
|
|
|
- **WHEN** both parties compute `x25519.agree(mySecret, theirPublic)`
|
|
- **THEN** they arrive at the same shared secret
|
|
|
|
#### Scenario: ECDSA-P256 sign/verify round-trip
|
|
|
|
- **WHEN** `ecdsaP256.verify(pk, ecdsaP256.sign(sk, m), m)` is called
|
|
- **THEN** it returns `true`
|
|
|
|
### Requirement: Deterministic RNG injection
|
|
|
|
Every function that consumes randomness SHALL accept `{ rng? }` to override the default. The default randomness source lives in exactly one file (`src/random/random-bytes.ts`) and is the only place that reads `crypto.getRandomValues`.
|
|
|
|
#### Scenario: Injected RNG used
|
|
|
|
- **WHEN** `randomBytes(16, { rng: (n) => new Uint8Array(n).fill(7) })` is called
|
|
- **THEN** it returns a 16-byte array of 0x07s
|
|
|
|
#### Scenario: No module-scope non-determinism
|
|
|
|
- **WHEN** `src/` is scanned for `crypto.getRandomValues`, `Math.random`, `Date.now`
|
|
- **THEN** those symbols appear only inside `src/random/random-bytes.ts`
|
|
|
|
### Requirement: Structured errors never leak secrets
|
|
|
|
All thrown errors SHALL be `CryptoError` subclasses with codes prefixed `CRYPTO.`. Error messages and `context` MUST NOT contain plaintext, key bytes, signatures, or authentication tags. Lengths, algorithm names, and boolean facts are allowed.
|
|
|
|
#### Scenario: Decryption failure message is generic
|
|
|
|
- **WHEN** `aeadDecrypt` fails
|
|
- **THEN** the thrown `CryptoError`'s `context` contains at most `{ algorithm, ciphertextLength }` — never raw bytes
|
|
|
|
### Requirement: 100% test coverage
|
|
|
|
`vitest.config.ts` MUST enforce `statements: 100`, `branches: 100`, `functions: 100`, `lines: 100`. Property-based tests (fast-check) cover: AEAD round-trip, Ed25519 sign/verify, X25519 symmetry, ECDSA-P256 round-trip, hash determinism, constant-time-equal invariants.
|
|
|
|
#### Scenario: Coverage threshold enforced
|
|
|
|
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/crypto` runs
|
|
- **THEN** it exits non-zero if any metric drops below 100
|
|
|
|
### Requirement: Primitive-surface symbols sourced from `@suaveplan/crypto-core`
|
|
|
|
`@suaveplan/crypto` SHALL NOT contain its own implementation of the cryptographic primitives (AEAD, key agreement, signing, sealed-box, auth-box, raw key generation). Every primitive symbol exposed in the package's public surface MUST be a typed re-export from `@suaveplan/crypto-core`. The opinionated modules (`keychain`, `derivation`, `bundles`, `domain-tags`, `authority`) MAY internally import from `@suaveplan/crypto-core` but MUST NOT re-implement primitive operations.
|
|
|
|
#### Scenario: no primitive impl files remain in `crypto/src/`
|
|
|
|
- **WHEN** the test scans `crypto/src/` for files under `aead/`, `agree/`, `auth-box/`, `sealed-box/`, `sign/`, `primitives/`, or `engines/`
|
|
- **THEN** zero files are found
|
|
|
|
#### Scenario: every previously-public primitive symbol is still importable
|
|
|
|
- **WHEN** a consumer imports any of `aead`, `ed25519`, `x25519`, `sealedBox`, `authBox`, `randomBytes`, `zeroize`, `constantTimeEqual`, etc. from `@suaveplan/crypto`
|
|
- **THEN** the symbol resolves and has the same type signature it had before this change
|
|
|
|
#### Scenario: barrel diff is byte-identical
|
|
|
|
- **WHEN** the post-change `dist/index.d.ts` is diffed against the pre-change snapshot at `tests/before.dts.txt`
|
|
- **THEN** the diff is zero lines (the public type surface is unchanged)
|
|
|
|
### Requirement: Keypair, Keychain storage, BIP39, and legacy Vault live inside `@suaveplan/crypto`
|
|
|
|
`@suaveplan/crypto` SHALL own the keypair generation/validation surface, the in-memory and serialized keychain storage adapters, the BIP39 mnemonic generation/restoration helpers, and the existing pre-Authority `Vault` class. The packages `@suaveplan/crypto-keys` and `@suaveplan/crypto-vault` MUST be deleted; their content is folded under `crypto/src/keychain/` and `crypto/src/vault/` respectively.
|
|
|
|
To avoid name collision with the new opinionated 8-slot `Keychain`, the folded simple-storage `Keychain` class is renamed `KeypairStore` (and its adapter interface to `KeypairStoreAdapter`). To avoid collision with the future three-regime `Vault` (slice 3), the folded class is renamed `LegacyVault` and lives transitionally at `crypto/src/vault/legacy-vault/`.
|
|
|
|
The misleadingly-named `ChainSigner` from `crypto-vault` MUST be dropped (it does not actually sign under a hierarchy); slice 3 introduces `Authority` to take its place.
|
|
|
|
#### Scenario: keypair generation surface available
|
|
|
|
- **WHEN** a consumer imports `Keypair`, `generateKeypair`, `validateKeypair` from `@suaveplan/crypto`
|
|
- **THEN** all three resolve and behave identically to their pre-fold equivalents in `@suaveplan/crypto-keys`
|
|
|
|
#### Scenario: BIP39 surface available
|
|
|
|
- **WHEN** a consumer imports `generateMnemonic`, `validateMnemonic`, `mnemonicToSeed` from `@suaveplan/crypto`
|
|
- **THEN** all three resolve and behave identically to their pre-fold equivalents in `@suaveplan/crypto-vault`
|
|
|
|
#### Scenario: legacy Vault surface available transitionally
|
|
|
|
- **WHEN** a consumer imports `LegacyVault` from `@suaveplan/crypto/vault/legacy-vault`
|
|
- **THEN** it resolves and behaves identically to the pre-fold `crypto-vault` `Vault` class (renamed for collision avoidance)
|
|
|
|
#### Scenario: ChainSigner is gone
|
|
|
|
- **WHEN** a consumer attempts to import `ChainSigner` from any `@suaveplan/crypto*` package
|
|
- **THEN** the import fails (the symbol does not exist anywhere in genesis)
|
|
|
|
#### Scenario: deleted packages no longer referenced
|
|
|
|
- **WHEN** the test runs `grep -rln "@suaveplan/crypto-keys\|@suaveplan/crypto-vault" packages/`
|
|
- **THEN** zero matches are found (excluding `openspec/archive/`)
|
|
|
|
### Requirement: Opinionated layer modules — Identity, Revocation, Audit, Packer, Vault
|
|
|
|
`@suaveplan/crypto` SHALL ship the seven opinionated-layer modules: `identity` (root + server identities + lifecycle ops), `revocation` (`RevocationList` — root-signed CRL), `audit-log` (append-only Merkle log), `packer` (`Packer` / `Unpacker` encrypt-and-sign envelopes with replay protection), `chunked-packer` (`ChunkedPacker` / `ChunkedUnpacker` per-chunk-signed P2P fragmentation), `vault` (three-regime serialisation), and `wire-format` (committed schemas + magic bytes). Every module exposes its surface via the package barrel and via a dedicated subpath export.
|
|
|
|
#### Scenario: identity hierarchy operations available
|
|
|
|
- **WHEN** a consumer imports `createRoot`, `createServer`, `rollServer`, `revokeServer` from `@suaveplan/crypto`
|
|
- **THEN** all four resolve and produce typed `RootIdentity` / `ServerIdentity` / `RolledServer` / `RevokedServer` values
|
|
|
|
#### Scenario: encrypt-and-sign envelopes round-trip with replay protection
|
|
|
|
- **WHEN** `Packer.pack` then `Unpacker.unpack` runs with matching parties, AEAD key, nonce, and domain tag
|
|
- **THEN** the unpacked plaintext equals the original; replay of the same envelope rejects with a coded error
|
|
|
|
#### Scenario: chunked transport detects tampering and reassembles in order
|
|
|
|
- **WHEN** `ChunkedPacker.pack` produces N chunks and one chunk's data byte is flipped
|
|
- **THEN** `ChunkedUnpacker.reassemble` throws a coded error citing the tampered chunk index
|
|
|
|
#### Scenario: revocation list verifies under root authority
|
|
|
|
- **WHEN** a `RevocationList` is appended to and verified against the issuing root's public sign slot
|
|
- **THEN** verification succeeds; mutating any past entry causes verification to throw
|
|
|
|
#### Scenario: audit log root signature detects tampering
|
|
|
|
- **WHEN** an `AuditLog` is signed and any past event is then mutated
|
|
- **THEN** subsequent `verify` throws `CryptoError(AUTHORITY.REVOKED)` with `audit.verify.root_mismatch`
|
|
|
|
#### Scenario: three-regime vault primitives round-trip
|
|
|
|
- **WHEN** `sealPrivateKeyBlob` then `openPrivateKeyBlob` runs with the same password
|
|
- **THEN** the bytes round-trip; `changePassword` rotates the AEAD key without re-deriving the secret
|
|
|
|
#### Scenario: wire-format barrel re-exports every committed schema
|
|
|
|
- **WHEN** a consumer imports from `@suaveplan/crypto/wire-format`
|
|
- **THEN** `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, and the `*_FORMAT_VERSION` constants are all importable
|
|
|
|
### Requirement: `keychain` CLI surface and TTY-only password input
|
|
|
|
`@suaveplan/crypto-cli` SHALL ship a `keychain` binary exposing eight subcommands: `root init`, `server init`, `server roll`, `server revoke`, `inspect`, `verify`, `export-public`, `restore`. Every subcommand MUST be invokable with the documented argv shape; the CLI MUST reject `--password` flag input with a security-pointer error and accept passwords only via TTY prompt or `KEYCHAIN_PASSWORD` env var.
|
|
|
|
The functional API in `@suaveplan/crypto-cli` (`rootInit`, `serverInit`, `serverRoll`, `serverRevokeCmd`, `inspect`, `verify`, `exportPublic`, `restore`) is the canonical surface; the bin entry is a thin argv parser that dispatches to it.
|
|
|
|
#### Scenario: every documented subcommand exists and is invokable
|
|
|
|
- **WHEN** the bin is invoked with `--help`
|
|
- **THEN** all eight subcommand strings appear in the printed help text
|
|
|
|
#### Scenario: `--password` flag rejected
|
|
|
|
- **WHEN** any invocation includes `--password` or `--password=value`
|
|
- **THEN** the bin exits with code 2 and a security-pointer error pointing at the TTY/env-var alternatives
|
|
|
|
#### Scenario: state-changing commands honour idempotency keys
|
|
|
|
- **WHEN** `serverRoll` (or `serverRevokeCmd`) is invoked twice with the same `idempotencyKey` and a shared ledger
|
|
- **THEN** the second invocation is a no-op (returns the same identity / does not re-revoke)
|
|
|
|
### Requirement: Import-allowlist conformance gate
|
|
|
|
The repository SHALL ship `scripts/conformance/check-crypto-core-allowlist.ts`, a script that scans every workspace package's `src/` for imports of `@suaveplan/crypto-core`, `@suaveplan/crypto-cipher`, `@suaveplan/crypto-curve`, or `@suaveplan/crypto-hash/mac` and fails when a package outside the committed allowlist (`scripts/conformance/crypto-core-allowlist.yml`) imports any of them. Encoding helpers from `@suaveplan/crypto-utils` and pure-hash uses of `@suaveplan/crypto-hash` (sha256 / sha384 / sha512 / hkdf / pbkdf2 / argon2id) MUST remain open.
|
|
|
|
#### Scenario: monorepo currently has zero violations
|
|
|
|
- **WHEN** `bun scripts/conformance/check-crypto-core-allowlist.ts` runs
|
|
- **THEN** exit code is 0 and stdout reports `crypto-core allowlist: 0 violations`
|
|
|
|
#### Scenario: a non-allowlisted package importing a primitive is flagged
|
|
|
|
- **WHEN** a fixture package outside the allowlist imports `aead` from `@suaveplan/crypto-core`
|
|
- **THEN** the script exits non-zero and lists the violating import as an `[error]` finding citing the package, file, line, and import path
|
|
|
|
### Requirement: `slsa` no longer imports the opinionated `@suaveplan/crypto`
|
|
|
|
`@suaveplan/slsa` MUST NOT import from `@suaveplan/crypto`. Direct primitive imports go through `@suaveplan/crypto-core` (DSSE PAE verification) and `slsa` is added to the import-allowlist with the rationale that DSSE is governed by an external spec without Suaveplan domain tags. Builder-identity SIGNING (a future change) goes through `Authority`.
|
|
|
|
#### Scenario: `slsa` imports `@suaveplan/crypto-core` for ed25519 verification
|
|
|
|
- **WHEN** the test scans `slsa/src/` for `@suaveplan/crypto"` imports
|
|
- **THEN** zero matches are found (only `@suaveplan/crypto-core`, `@suaveplan/crypto-hash`, `@suaveplan/crypto-utils` remain)
|
|
|
|
### 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"`
|
|
|
|
### Requirement: ChainBundle wire format is byte-identical to v0x0001 layout
|
|
|
|
The `packChainBundle(linkBytes)` function SHALL produce bytes in the layout `[CHAIN_BUNDLE_MAGIC (4 bytes)][CHAIN_BUNDLE_FORMAT_VERSION (u8)][linkCount (u8)][...linkBytes concatenated]`. `CHAIN_BUNDLE_HEADER_BYTE_SIZE` SHALL be `6` (4 magic + 1 version + 1 linkCount). The implementation MAY consolidate the header construction through `@suaveplan/bitpacker-crypto::EnvelopeSchema`, but the resulting bytes MUST be byte-identical to the v0x0001 layout pinned by snapshot test.
|
|
|
|
#### Scenario: snapshot bytes match v0x0001 layout
|
|
|
|
- **WHEN** `packChainBundle([fixedDeterministicLinkBytes])` runs with a fixed deterministic link payload
|
|
- **THEN** the resulting bytes are byte-identical to the v0x0001 snapshot pinned in `chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"`
|
|
|
|
#### Scenario: header size constant is 6
|
|
|
|
- **WHEN** `CHAIN_BUNDLE_HEADER_BYTE_SIZE` is read
|
|
- **THEN** the value is exactly `6`
|
|
|
|
#### Scenario: bad magic surfaces as `CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.bad-magic`
|
|
|
|
- **WHEN** `parseChainBundle(bytes)` is called on bytes whose first 4 bytes do not match `CHAIN_BUNDLE_MAGIC`
|
|
- **THEN** the call throws `CryptoError` with code `CHAIN.WRONG_FORMAT` and message `crypto.chain-bundle.bad-magic`
|
|
|
|
#### Scenario: bad version surfaces as `CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.unsupported-version`
|
|
|
|
- **WHEN** `parseChainBundle(bytes)` is called on bytes whose 5th byte (formatVersion) is not `CHAIN_BUNDLE_FORMAT_VERSION`
|
|
- **THEN** the call throws `CryptoError` with code `CHAIN.WRONG_FORMAT` and message `crypto.chain-bundle.unsupported-version` and `context.version` carrying the actual version byte
|
|
|
|
#### Scenario: too-short bytes surface as `CryptoError CHAIN.WRONG_FORMAT crypto.chain-bundle.bytes-too-short`
|
|
|
|
- **WHEN** `parseChainBundle(bytes)` is called on a buffer shorter than `CHAIN_BUNDLE_HEADER_BYTE_SIZE`
|
|
- **THEN** the call throws `CryptoError` with code `CHAIN.WRONG_FORMAT` and message `crypto.chain-bundle.bytes-too-short`
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad magic"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad version"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects too-short payload"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|
|
|
|
### Requirement: Eight-slot Keychain with full-width derivation coordinates
|
|
|
|
`@suaveplan/crypto` SHALL define a `Keychain` type representing a coherent identity composed of eight default slots, each addressed by a `DerivationCoordinate` and carrying `{algorithm, notBefore, notAfter, secretKey?, publicKey?}`. The eight default slot purposes are: `master`, `encrypt`, `sign`, `kx`, `salt`, `hash`, `stream`, `device` — each represented as a 4-byte ASCII purpose tag (`"mast"`, `"encr"`, `"sign"`, `"kx "`, `"salt"`, `"hash"`, `"strm"`, `"dev "`).
|
|
|
|
A `DerivationCoordinate` carries:
|
|
|
|
- **`purpose`** — 4-byte ASCII (the slot type)
|
|
- **`application`** — 4-byte ASCII (which product/component, e.g. `"chat"`, `"vlt "`, `"genr"` default)
|
|
- **`layoutId`** — `u8` selecting which `DerivationLayout` interprets the u64 fields below
|
|
- Layout-specific fields filling the remaining 64 bits (default `DERIVATION_LAYOUT_V1`: `masterVersion u16`, `userId u32`, `depth u8`, `slotVersion u8`)
|
|
|
|
The `(purpose, application)` pair maps onto libsodium's 8-byte `ctx`; the four layout fields pack into libsodium's `subkey_id` (u64). Together they form the 16-byte derivation namespace this package consumes. The `algorithm` field is required and is one of `ed25519`, `ecdsa-p256`, `x25519`, `xchacha20-poly1305`, `aes-256-gcm`, `chacha20-poly1305`, `argon2id`, `hkdf-sha256` depending on slot purpose.
|
|
|
|
#### Scenario: deterministic generation from a master and coordinate set
|
|
|
|
- **WHEN** `generateKeychain` is called twice with the same master key and the same coordinate inputs
|
|
- **THEN** both results are byte-equal across every slot
|
|
|
|
#### Scenario: userId beyond the legacy 4-bit ceiling is supported
|
|
|
|
- **WHEN** a caller invokes `generateKeychain({ userId: 1_000_000, ... })`
|
|
- **THEN** the call succeeds — the default `DERIVATION_LAYOUT_V1` allocates 32 bits to `userId`, supporting up to ~4.3 billion distinct users per `(masterVersion, application)` pair (regression-test against the legacy 4-bit ceiling)
|
|
|
|
#### Scenario: per-purpose algorithm overrides honored
|
|
|
|
- **WHEN** `generateKeychain` is called with a per-purpose algorithm override map
|
|
- **THEN** the resulting slot's `algorithm` field reflects the override and the slot's key material has the override's key length
|
|
|
|
#### Scenario: extract public-only keychain
|
|
|
|
- **WHEN** `extractPublicKeychain(keychain)` is called on a keychain with both secret and public material
|
|
- **THEN** the returned keychain contains only `publicKey` per slot; every `secretKey` field is `undefined` and the result is a deep clone with no shared buffers
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/keychain/keychain.test.ts::"produces all eight default slots from a deterministic master"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"deterministic: same master + coords yield byte-equal keychain"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"userId beyond the legacy 4-bit ceiling round-trips"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"honours per-purpose algorithm override"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"drops every secretKey and copies publicKey buffers"` AND `packages/crypto/crypto/src/keychain/keychain.test.ts::"produces a deep clone with no shared buffers"`
|
|
|
|
---
|
|
|
|
### Requirement: DerivationLayout registry — pluggable u64 layout
|
|
|
|
`@suaveplan/crypto` SHALL ship a `DerivationLayout` registry mapping `layoutId: u8` to a `BitSchema` describing how the 64-bit `subkey_id` half of the derivation namespace is structured. The default `DERIVATION_LAYOUT_V1` (id 0) splits as `{masterVersion: u16, userId: u32, depth: u8, slotVersion: u8}`. Consumers MAY register additional layouts via `defineDerivationLayout(id, schema)`.
|
|
|
|
The `layoutId` byte is recorded in every wire-format payload that carries a `DerivationCoordinate`. Verifiers look up the layout by id at parse time; an unknown layout id is a parse failure with `KEYCHAIN.UNKNOWN_LAYOUT`. A custom layout whose `byteSize` is not exactly 8 is rejected at registration with `KEYCHAIN.LAYOUT_TOO_WIDE`. Re-registering an existing id is rejected with `KEYCHAIN.LAYOUT_ALREADY_REGISTERED`.
|
|
|
|
This is what makes the package future-proof: adding a v2 layout for a new product is non-breaking; old vaults still parse under their original layout id; the verifier dispatches by id.
|
|
|
|
#### Scenario: default v1 layout fills exactly 64 bits
|
|
|
|
- **WHEN** the `DERIVATION_LAYOUT_V1` schema's `byteSize` is checked
|
|
- **THEN** the schema reports exactly 8 bytes (16 + 32 + 8 + 8 = 64 bits) — required to fit libsodium's `subkey_id`
|
|
|
|
#### Scenario: registering a custom 8-byte layout
|
|
|
|
- **WHEN** `defineDerivationLayout(id, customSchema)` is called with a schema whose `byteSize === 8`
|
|
- **THEN** the layout is registered and round-trips a typical record under that id
|
|
|
|
#### Scenario: oversized custom layout is rejected
|
|
|
|
- **WHEN** `defineDerivationLayout(id, oversizedSchema)` is called with a schema whose `byteSize > 8`
|
|
- **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_TOO_WIDE`
|
|
|
|
#### Scenario: undersized custom layout is rejected
|
|
|
|
- **WHEN** `defineDerivationLayout(id, undersizedSchema)` is called with a schema whose `byteSize < 8`
|
|
- **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_TOO_WIDE`
|
|
|
|
#### Scenario: re-registration is rejected
|
|
|
|
- **WHEN** `defineDerivationLayout(id, schema)` is called for an id already in the registry
|
|
- **THEN** the call throws `CryptoError` code `KEYCHAIN.LAYOUT_ALREADY_REGISTERED`
|
|
|
|
#### Scenario: parsing an unknown layout id fails distinctly
|
|
|
|
- **WHEN** a wire-format payload's `layoutId` is not registered
|
|
- **THEN** parsing throws `CryptoError` code `KEYCHAIN.UNKNOWN_LAYOUT`, naming the unregistered id
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"has byteSize === 8 (fits libsodium subkey_id)"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"v1 layout is registered at id 0 by default"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"registers a new 8-byte custom layout"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects layout exceeding 8 bytes with LAYOUT_TOO_WIDE"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects layout smaller than 8 bytes with LAYOUT_TOO_WIDE"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"rejects re-registration with LAYOUT_ALREADY_REGISTERED"` AND `packages/crypto/crypto/src/derivation/layouts/layouts.test.ts::"throws UNKNOWN_LAYOUT for unregistered id"`
|
|
|
|
---
|
|
|
|
### Requirement: Validity windows enforced at every verify call
|
|
|
|
`@suaveplan/crypto` SHALL enforce per-slot `notBefore` / `notAfter` validity windows at every signature verification, AEAD decryption, and key-agreement operation. The current time is read from an injectable `Clock` provided at construction; production paths MUST NOT call `Date.now()` directly inside the keychain / authority / packer / bundle modules.
|
|
|
|
A keychain slot whose validity window has expired or has not yet begun MUST cause the operation to fail with `CryptoError` code `KEYCHAIN.NOT_YET_VALID` or `KEYCHAIN.EXPIRED`. Strict mode is the default; relaxed mode (used only for archival inspection) is opt-in via an explicit `acceptExpired: true` option, and MUST NOT bypass `NOT_YET_VALID`.
|
|
|
|
#### Scenario: signing with an expired slot fails
|
|
|
|
- **WHEN** an `Authority` is asked to sign with a slot whose `notAfter` is before `clock.now()`
|
|
- **THEN** the call throws `CryptoError` code `KEYCHAIN.EXPIRED`
|
|
|
|
#### Scenario: verifying against a not-yet-valid slot fails
|
|
|
|
- **WHEN** a verify path is called against a public keychain slot whose `notBefore` is after `clock.now()`
|
|
- **THEN** the call throws `CryptoError` code `KEYCHAIN.NOT_YET_VALID`
|
|
|
|
#### Scenario: clock exactly at notBefore or notAfter passes
|
|
|
|
- **WHEN** a verify call observes `clock.now() === notBefore` or `clock.now() === notAfter`
|
|
- **THEN** the verify succeeds (the window is inclusive at both endpoints)
|
|
|
|
#### Scenario: relaxed mode allows reading after notAfter only
|
|
|
|
- **WHEN** an inspection helper is called with `acceptExpired: true` against a slot whose `notAfter` is past
|
|
- **THEN** the call succeeds; the same call against a `NOT_YET_VALID` slot still throws
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"passes when clock is exactly at notBefore"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"passes when clock is exactly at notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"throws KEYCHAIN.NOT_YET_VALID before notBefore"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"throws KEYCHAIN.EXPIRED after notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"acceptExpired allows reading after notAfter"` AND `packages/crypto/crypto/src/keychain/validity/validity.test.ts::"acceptExpired does NOT bypass NOT_YET_VALID"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"`
|
|
|
|
---
|
|
|
|
### Requirement: Domain-separation tag on every signed payload
|
|
|
|
`@suaveplan/crypto` SHALL prepend a domain-separation tag to every payload before passing it to a signing primitive. Tags are typed as branded `string` constants exported from `domain-tags/`: `DOMAIN_VAULT_V1`, `DOMAIN_PUBLIC_KEYCHAIN_V1`, `DOMAIN_PACKER_V1`, `DOMAIN_CHUNK_V1`, `DOMAIN_CRL_V1`, `DOMAIN_AUDIT_V1`, `DOMAIN_SLSA_BUILDER_V1`, plus the bitpacker-aware tags `DOMAIN_BITPACKER_SIGNED_V1`, `DOMAIN_BITPACKER_HASHCHAIN_V1`, `DOMAIN_BITPACKER_MULTISIGN_V1`. The `ALL_DOMAIN_TAGS` registry MUST contain exactly the v1 set.
|
|
|
|
Verifiers MUST require the same tag at verify time; a payload signed under tag `A` MUST fail verification if presented to a verifier expecting tag `B` (cross-protocol replay defence). The tag MUST be part of the signed bytes, not metadata: an attacker swapping the tag prefix without re-signing fails verification.
|
|
|
|
`encodeDomainTag(tag)` SHALL produce ASCII bytes followed by a `0x00` terminator; `concatTagAndPayload(tag, payload)` SHALL concatenate the encoded tag and the payload bytes.
|
|
|
|
#### Scenario: registry size and uniqueness
|
|
|
|
- **WHEN** `ALL_DOMAIN_TAGS` is enumerated
|
|
- **THEN** it contains exactly 10 tags in v1, each a unique string
|
|
|
|
#### Scenario: encoded tag is ASCII-plus-NUL
|
|
|
|
- **WHEN** `encodeDomainTag(tag)` is called for any registered tag
|
|
- **THEN** the result is the ASCII bytes of the tag followed by a single `0x00` byte
|
|
|
|
#### Scenario: distinct tags produce distinct signed inputs over the same payload
|
|
|
|
- **WHEN** two different domain tags are concatenated with the same payload
|
|
- **THEN** the resulting byte sequences differ
|
|
|
|
#### Scenario: cross-protocol replay rejected
|
|
|
|
- **WHEN** an envelope packed under `DOMAIN_VAULT_V1` is presented to an `Unpacker` configured for `DOMAIN_PACKER_V1`
|
|
- **THEN** the call throws a `CryptoError` whose code identifies a domain mismatch (no plaintext is returned)
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"ALL_DOMAIN_TAGS contains 10 tags in v1"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"each tag is a unique string"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"produces ASCII bytes followed by 0x00 terminator"` AND `packages/crypto/crypto/src/domain-tags/domain-tags.test.ts::"produces different bytes for different domains over the same payload"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"`
|
|
|
|
---
|
|
|
|
### Requirement: Public bundles are parent-signed and carry an issuer reference
|
|
|
|
`@suaveplan/crypto` SHALL provide `packPublicBundle(keychain, parentAuthority, options)` that emits a bit-packed binary bundle in the wire format below, with a trailing 64-byte ed25519 signature produced by `parentAuthority.sign(DOMAIN_PUBLIC_KEYCHAIN_V1, "sign", bundleBytes)`. The signature covers everything in the bundle including the embedded `issuerRef` — swapping the issuer reference invalidates the signature.
|
|
|
|
`unpackPublicBundle(bytes, parentSignPublicKey)` verifies the trailing signature against the supplied parent public sign key, returning the parsed bundle and the verified `issuerRef`. A `parentSignPublicKey` of `null` selects self-verify mode, valid only for root identities (the bundle's own embedded `sign` slot is used).
|
|
|
|
The wire format identifier is `PUBLIC_BUNDLE_MAGIC` (4 ASCII bytes), `PUBLIC_BUNDLE_FORMAT_VERSION` is `1` in v1, and the header byte size is committed as `PUBLIC_BUNDLE_HEADER_BYTE_SIZE`. The slot record layout is committed as `KEY_SLOT_PUBLIC_BYTE_SIZE` and `KEY_SLOT_PUBLIC_BYTES = 17 + 48 = 65` bytes.
|
|
|
|
For a root bundle the issuer `(masterVersion, userId, depth, slotVersion)` equals the subject's, and the parent authority is the keychain itself (self-signing). Tampered bundle bytes (slot data, signature, magic, version, slotCount, length) MUST cause parse / verify to fail.
|
|
|
|
#### Scenario: self-signed bundle round-trips and self-verifies
|
|
|
|
- **WHEN** `packPublicBundle(rootKeychain, rootAuthority)` is called and the bundle is then verified in self-verify mode
|
|
- **THEN** the call succeeds and the parsed bundle's slots match the input keychain
|
|
|
|
#### Scenario: parent-signed bundle verifies against parent's sign pubkey
|
|
|
|
- **WHEN** `packPublicBundle(serverKeychain, rootAuthority)` is called and the result is verified against the root's public sign key
|
|
- **THEN** the call succeeds; verification against any non-root public key fails
|
|
|
|
#### Scenario: tampered slot bytes fail verification
|
|
|
|
- **WHEN** any byte of a packed bundle's slot section is mutated
|
|
- **THEN** verification fails
|
|
|
|
#### Scenario: tampered signature fails verification
|
|
|
|
- **WHEN** any byte of the trailing 64-byte signature is mutated
|
|
- **THEN** verification fails
|
|
|
|
#### Scenario: parse rejects malformed payloads
|
|
|
|
- **WHEN** the bundle has bad magic, wrong format version, bad slotCount, a too-short payload, or extra trailing bytes
|
|
- **THEN** `unpackPublicBundle` throws a parse error
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"self-signed bundle round-trips and self-verifies"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"parent-signed (third-party) bundle verifies against parent's sign pubkey"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"tampered slot bytes fail verification"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"tampered signature fails verification"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"bad magic fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"wrong format version fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"bad slotCount fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"short payload fails parse"` AND `packages/crypto/crypto/src/bundles/public-bundle/public-bundle.test.ts::"extra bytes fails parse"`
|
|
|
|
---
|
|
|
|
### Requirement: Chain bundle traversal — concatenated parent-signed links
|
|
|
|
`@suaveplan/crypto` SHALL provide a `ChainBundle` wire format and a `verifyChain(bundle, trustAnchor, clock, isRevoked?)` walker. A `ChainBundle` is a header followed by a concatenation of `PublicBundle` records ordered leaf-first. The wire format identifier is `CHAIN_BUNDLE_MAGIC`, the format version is `CHAIN_BUNDLE_FORMAT_VERSION` (currently `1`), the header byte size is `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, and `linkCount` is bounded by `MAX_CHAIN_LINKS` (16).
|
|
|
|
The walker MUST: verify each link's signature against the next link's `sign` slot (or against `trustAnchor` for the final link), reject expired or not-yet-valid slots via the configured `Clock`, reject revoked links via the optional `isRevoked` predicate, reject `linkCount === 0`, reject `linkCount > MAX_CHAIN_LINKS`, reject mismatched `issuerRef` between adjacent links, reject bad magic / version / length / linkCount overflows at parse time. The bundle wire format MUST be byte-identical to the v1 snapshot.
|
|
|
|
#### Scenario: 3-link chain verifies end-to-end
|
|
|
|
- **WHEN** a chain bundle is built as `[subService, server, root]` (each parent-signed by the next)
|
|
- **THEN** `verifyChain` succeeds; the parsed chain has length 3
|
|
|
|
#### Scenario: tampered link fails verification at that link
|
|
|
|
- **WHEN** any byte of a non-leaf link is mutated
|
|
- **THEN** `verifyChain` rejects
|
|
|
|
#### Scenario: revoked link fails via predicate
|
|
|
|
- **WHEN** the `isRevoked` predicate returns true for an intermediate link
|
|
- **THEN** `verifyChain` rejects
|
|
|
|
#### Scenario: expired sign slot fails
|
|
|
|
- **WHEN** any link's `sign` slot has `notAfter < clock.now()`
|
|
- **THEN** `verifyChain` rejects with `KEYCHAIN.EXPIRED`
|
|
|
|
#### Scenario: mismatched issuer coord fails
|
|
|
|
- **WHEN** an intermediate link's `issuerRef` does not equal the next link's signing-slot coordinate
|
|
- **THEN** `verifyChain` rejects
|
|
|
|
#### Scenario: link count bounds enforced
|
|
|
|
- **WHEN** packing requests `0` links or more than `MAX_CHAIN_LINKS`
|
|
- **THEN** packing throws
|
|
|
|
#### Scenario: parse rejects malformed payloads
|
|
|
|
- **WHEN** the bundle has bad magic, bad version, out-of-range linkCount, length mismatch, or is too short
|
|
- **THEN** `parseChainBundle` throws
|
|
|
|
#### Scenario: wire format is byte-identical to v1 snapshot
|
|
|
|
- **WHEN** a fixed-input chain is packed and compared against the committed v0x0001 snapshot
|
|
- **THEN** the bytes match exactly
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packs and parses a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifies a 3-link chain"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects tampered link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects revoked link via predicate"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects expired sign slot"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"verifyChain rejects mismatched issuer coord"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects 0-link"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"packChainBundle rejects > MAX_CHAIN_LINKS"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad magic"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects bad version"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects out-of-range linkCount"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects length mismatch"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"parseChainBundle rejects too-short payload"`
|
|
|
|
---
|
|
|
|
### Requirement: Wire-format schemas exposed and committed
|
|
|
|
`@suaveplan/crypto` SHALL expose a `wire-format` barrel (`@suaveplan/crypto/wire-format`) that re-exports every committed wire-format identifier and byte-size constant from its owning module:
|
|
|
|
- `PUBLIC_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `PUBLIC_BUNDLE_HEADER_BYTE_SIZE` (from `bundles/public-bundle/`)
|
|
- `CHAIN_BUNDLE_MAGIC`, `CHAIN_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_HEADER_BYTE_SIZE`, `MAX_CHAIN_LINKS` (from `bundles/chain-bundle/`)
|
|
- `V1_COORDINATE_BYTE_SIZE`, `COORDINATE_HEADER_BYTE_SIZE` (from `derivation/coordinate/`)
|
|
- `KEY_SLOT_META_PRIVATE_SCHEMA`, `KEY_SLOT_META_PUBLIC_SCHEMA`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE` (from `keychain/key-slot/`)
|
|
|
|
Each schema's byte-size is committed as a constant: `KEY_SLOT_PRIVATE_BYTE_SIZE === 17 + 80 === 97` and `KEY_SLOT_PUBLIC_BYTE_SIZE === 17 + 48 === 65`. Any field-width change MUST update both the constant and its consumer test, providing a loud signal for wire-format regression.
|
|
|
|
#### Scenario: barrel re-exports every committed identifier
|
|
|
|
- **WHEN** a consumer imports from `@suaveplan/crypto/wire-format`
|
|
- **THEN** `PUBLIC_BUNDLE_MAGIC`, `CHAIN_BUNDLE_MAGIC`, `PUBLIC_BUNDLE_FORMAT_VERSION`, `CHAIN_BUNDLE_FORMAT_VERSION`, `KEY_SLOT_PRIVATE_BYTE_SIZE`, `KEY_SLOT_PUBLIC_BYTE_SIZE`, `V1_COORDINATE_BYTE_SIZE`, `MAX_CHAIN_LINKS` are all importable
|
|
|
|
#### Scenario: key-slot byte sizes are exactly the committed constants
|
|
|
|
- **WHEN** the key-slot byte-size test runs
|
|
- **THEN** the private slot is 97 bytes and the public slot is 65 bytes
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"private slot is 17 + 80 = 97 bytes"` AND `packages/crypto/crypto/src/keychain/key-slot/key-slot.test.ts::"public slot is 17 + 48 = 65 bytes"` AND `packages/crypto/crypto/src/bundles/chain-bundle/chain-bundle.test.ts::"chain-bundle wire format is byte-identical to v0x0001 snapshot"`
|
|
|
|
---
|
|
|
|
### Requirement: Authority — signing abstraction over hierarchical identity
|
|
|
|
`@suaveplan/crypto` SHALL define an `Authority` interface that signs payloads with a named slot under a domain tag, exposing `sign(domain, slotPurpose, payload, signal?): Promise<Uint8Array>` and `dispose(): void`. The interface is the only signing surface exposed to callers outside the package; raw `secretKey: Uint8Array` references are not passed across package boundaries.
|
|
|
|
V1 ships an `InMemoryAuthority` backend that holds an unlocked `Keychain` and signs from its slots. The interface is shaped so future backends (`YubiKeyAuthority`, `TpmAuthority`, `ThresholdAuthority`) can be substituted without changes to callers.
|
|
|
|
`Authority` MUST: produce signatures that verify under the corresponding public sign slot, fail under a different domain tag, throw `KEYCHAIN.UNKNOWN_PURPOSE` for unknown slots, throw `KEYCHAIN.SLOT_INVALID` when signing with a non-sign slot, throw `KEYCHAIN.EXPIRED` when the slot's `notAfter` is past, throw `AUTHORITY.REVOKED` when an `isRevoked` predicate matches, throw `AUTHORITY.DISPOSED` after `dispose()`, throw `AUTHORITY.ABORTED` when invoked with an already-aborted signal. `dispose()` MUST zeroise held secret material and MUST be idempotent.
|
|
|
|
#### Scenario: sign and verify under matching slot and domain
|
|
|
|
- **WHEN** `authority.sign(domain, "sign", payload)` is called
|
|
- **THEN** the returned signature verifies against the public sign slot's key under the same domain
|
|
|
|
#### Scenario: signature does not verify under a different domain tag
|
|
|
|
- **WHEN** the signature is verified under a different domain tag
|
|
- **THEN** verification returns false
|
|
|
|
#### Scenario: unknown slot purpose rejected
|
|
|
|
- **WHEN** `authority.sign(domain, "bogus", payload)` is called
|
|
- **THEN** the call throws `KEYCHAIN.UNKNOWN_PURPOSE`
|
|
|
|
#### Scenario: signing with a non-signing slot rejected
|
|
|
|
- **WHEN** `authority.sign(domain, "encr", payload)` is called
|
|
- **THEN** the call throws `KEYCHAIN.SLOT_INVALID`
|
|
|
|
#### Scenario: dispose zeroises and locks
|
|
|
|
- **WHEN** `authority.dispose()` is called and `authority.sign(...)` is then invoked
|
|
- **THEN** post-dispose secret-key buffers contain zeros and the call throws `AUTHORITY.DISPOSED`; calling `dispose()` again is a no-op
|
|
|
|
#### Scenario: aborted signal cancels signing
|
|
|
|
- **WHEN** `authority.sign(..., signal)` is called with an already-aborted signal
|
|
- **THEN** the call throws `AUTHORITY.ABORTED`
|
|
|
|
#### Scenario: revoked slot fails before signing
|
|
|
|
- **WHEN** an `isRevoked` predicate returns true for the slot and `authority.sign(...)` is called
|
|
- **THEN** the call throws `AUTHORITY.REVOKED` before any signing primitive executes
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/authority/authority.test.ts::"signs with the sign slot and verifies"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"signature does NOT verify under a different domain tag"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.UNKNOWN_PURPOSE for an unknown slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.SLOT_INVALID when signing with a non-sign slot"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.DISPOSED after dispose"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose zeroises secret-key buffers"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"dispose is idempotent"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.ABORTED when signal already aborted"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws KEYCHAIN.EXPIRED when slot's notAfter is past"` AND `packages/crypto/crypto/src/authority/authority.test.ts::"throws AUTHORITY.REVOKED when isRevoked predicate matches"`
|
|
|
|
---
|
|
|
|
### Requirement: Hierarchical identity — Root and Server
|
|
|
|
`@suaveplan/crypto` SHALL provide `createRoot(options)`, `createServer(rootAuthority, options)`, `rollServer(rootAuthority, server)`, and `revokeServer(rootAuthority, server, reason)`. A `RootIdentity` is a Keychain whose `sign` slot is the trust anchor for everything beneath it. A `ServerIdentity` is a Keychain whose public bundle has been signed by the root's sign slot under `DOMAIN_PUBLIC_KEYCHAIN_V1`.
|
|
|
|
`rollServer` MUST produce a new `ServerIdentity` whose version is one greater than the prior, emit a record of the prior version (used by `RevocationList`), and accept missing optional `rng`. `revokeServer` MUST return a revocation entry without producing a new identity. `createRoot` and `createServer` MUST honour optional `notBefore`, `notAfter`, and `masterKey`. `createRoot` and `createServer` MUST accept missing optional `rng`. Nested servers (depth ≥ 2) MUST be traversable via the chain-bundle walker.
|
|
|
|
#### Scenario: createRoot produces a self-verifying identity
|
|
|
|
- **WHEN** `createRoot({...})` is called
|
|
- **THEN** the result's public bundle verifies in self-verify mode
|
|
|
|
#### Scenario: createServer is signed under root
|
|
|
|
- **WHEN** `createServer(rootAuthority, ...)` is called
|
|
- **THEN** the server's public bundle verifies against the root's public sign key
|
|
|
|
#### Scenario: rollServer increments version and emits prior
|
|
|
|
- **WHEN** `rollServer(rootAuthority, server@N)` is called
|
|
- **THEN** the result is `server@(N+1)` and a `priorVersion === N` record is emitted
|
|
|
|
#### Scenario: revokeServer returns an entry without re-issuing
|
|
|
|
- **WHEN** `revokeServer(rootAuthority, server, reason)` is called
|
|
- **THEN** the call returns a revocation entry and no new server identity
|
|
|
|
#### Scenario: optional notBefore, notAfter, masterKey honoured
|
|
|
|
- **WHEN** `createRoot` or `createServer` is called with explicit `notBefore` / `notAfter` / `masterKey`
|
|
- **THEN** the resulting identity's slots reflect those inputs
|
|
|
|
#### Scenario: optional rng accepted
|
|
|
|
- **WHEN** `createRoot`, `createServer`, or `rollServer` is called without a custom `rng`
|
|
- **THEN** the call succeeds
|
|
|
|
#### Scenario: nested server depth-2 builds a 3-link chain
|
|
|
|
- **WHEN** a sub-service is issued under a server that was issued under a root
|
|
- **THEN** a 3-link chain bundle is constructible and verifies against the root anchor
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot produces a self-verifying root identity"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer is signed under root"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer increments version + emits priorVersion"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"revokeServer returns an entry without re-issuing"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot honours optional notBefore/notAfter and masterKey"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createServer honours optional notBefore/notAfter"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"rollServer accepts no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"createRoot/createServer accept no rng option"` AND `packages/crypto/crypto/src/identity/identity.test.ts::"nested server (depth 2) builds a 3-link chain"`
|
|
|
|
---
|
|
|
|
### Requirement: RevocationList — root-signed CRL
|
|
|
|
`@suaveplan/crypto` SHALL provide a `RevocationList` type representing an append-only list of revocation entries, each entry signed by the root's sign slot under `DOMAIN_CRL_V1`. Entries are immutable: any past entry's bytes are part of the signed payload; mutation invalidates the list signature.
|
|
|
|
The list exposes `append(entry, rootAuthority)`, `verify(rootPublicKey)`, and `check(ref): boolean`. `check` MUST report true for revoked refs and false otherwise.
|
|
|
|
#### Scenario: append then verify succeeds
|
|
|
|
- **WHEN** an entry is appended with `rootAuthority` and `verify(rootPublicKey)` is called
|
|
- **THEN** verification returns true
|
|
|
|
#### Scenario: mutating a past entry invalidates the list
|
|
|
|
- **WHEN** any byte of any past entry is mutated and `verify(rootPublicKey)` is called
|
|
- **THEN** verification returns false
|
|
|
|
#### Scenario: check reports per-entry correctly
|
|
|
|
- **WHEN** a server identity at a given version is appended and `check` is queried for that ref vs a different version
|
|
- **THEN** `check` returns true for the appended ref and false for the other
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"append then verify succeeds"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"mutating a past entry invalidates the list"` AND `packages/crypto/crypto/src/revocation/revocation-list.test.ts::"check returns true for revoked refs and false otherwise"`
|
|
|
|
---
|
|
|
|
### Requirement: AuditLog — append-only Merkle log
|
|
|
|
`@suaveplan/crypto` SHALL provide an `AuditLog` type recording every issue / roll / revoke event, structured as an append-only Merkle tree. The current Merkle root is signed by the root's sign slot under `DOMAIN_AUDIT_V1`. Any modification to a past entry changes the Merkle root and invalidates the signature.
|
|
|
|
The log exposes `append(event, rootAuthority)`, `signRoot(rootAuthority)`, `verify(rootPublicKey)`, `proveInclusion(index)`, and `verifyInclusion(index, proof, expectedRoot)`. The Merkle construction MUST handle odd leaf counts by duplicating the last leaf at each level. `signRoot` over an empty log MUST produce a documented zero root. `eventCount` MUST track appends.
|
|
|
|
#### Scenario: append then verify succeeds
|
|
|
|
- **WHEN** an event is appended and the root signature is verified against the root public key
|
|
- **THEN** verification returns true
|
|
|
|
#### Scenario: tampering with a past entry invalidates the root
|
|
|
|
- **WHEN** any byte of any past event is mutated
|
|
- **THEN** signature verification returns false
|
|
|
|
#### Scenario: inclusion proof verifies
|
|
|
|
- **WHEN** an event is appended and `proveInclusion` is requested for it
|
|
- **THEN** the proof verifies under the signed root
|
|
|
|
#### Scenario: inclusion proof for out-of-range index throws
|
|
|
|
- **WHEN** `proveInclusion(index)` is called with an out-of-range index
|
|
- **THEN** the call throws
|
|
|
|
#### Scenario: verifyInclusion rejects wrong / mis-sized expected roots
|
|
|
|
- **WHEN** `verifyInclusion(index, proof, expectedRoot)` is called with an expected root that is wrong or of mismatched length
|
|
- **THEN** the call returns false
|
|
|
|
#### Scenario: odd leaf count handled
|
|
|
|
- **WHEN** the log has an odd number of leaves and the last leaf is duplicated for Merkle construction
|
|
- **THEN** inclusion proofs still verify
|
|
|
|
#### Scenario: empty log signRoot produces zero root
|
|
|
|
- **WHEN** `signRoot` is called on an empty log
|
|
- **THEN** the produced root is the documented zero value
|
|
|
|
#### Scenario: verify fails with a wrong sign slot
|
|
|
|
- **WHEN** the log is verified against an unrelated public sign key
|
|
- **THEN** verification returns false
|
|
|
|
#### Scenario: eventCount tracks appends
|
|
|
|
- **WHEN** `n` events are appended
|
|
- **THEN** `eventCount === n`
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"append then root signature verifies"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"tampering with a past entry invalidates the root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof verifies for appended entries"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof for out-of-range index throws"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for a wrong expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verifyInclusion returns false for length-mismatched expected root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"inclusion proof handles odd leaf count (last leaf duplicated)"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"signRoot on empty log produces a zero root"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"verify fails with a wrong sign slot"` AND `packages/crypto/crypto/src/audit-log/audit-log.test.ts::"eventCount tracks appends"`
|
|
|
|
---
|
|
|
|
### Requirement: Packer — encrypt-and-sign envelope
|
|
|
|
`@suaveplan/crypto` SHALL provide a `Packer` / `Unpacker` pair implementing a schema-driven encrypt-and-sign envelope. The envelope MUST: round-trip a value byte-for-byte under matching parties / nonce / domain, reject envelopes packed under a different domain (cross-protocol replay defence), reject envelopes whose `issuedAt` is older than a configurable replay window, reject envelopes whose `nonce` has been seen within a configurable nonce-cache window, validate pack-side nonce length, reject envelopes whose stated ciphertext length overruns the buffer, reject too-short envelopes.
|
|
|
|
#### Scenario: round-trip preserves the value
|
|
|
|
- **WHEN** `packer.pack(payload, ...)` is called and the result is `unpacker.unpack(envelope, ...)`-ed
|
|
- **THEN** the returned payload byte-equals the original
|
|
|
|
#### Scenario: replay outside window rejected
|
|
|
|
- **WHEN** an envelope older than the replay window is presented
|
|
- **THEN** `unpacker.unpack` throws
|
|
|
|
#### Scenario: nonce dedup rejects replay
|
|
|
|
- **WHEN** the same envelope is presented twice in succession
|
|
- **THEN** the first call succeeds; the second throws
|
|
|
|
#### Scenario: cross-protocol domain tag rejected
|
|
|
|
- **WHEN** an envelope packed under one domain is presented to an `Unpacker` configured for another
|
|
- **THEN** `unpacker.unpack` throws
|
|
|
|
#### Scenario: invalid pack-side nonce length rejected
|
|
|
|
- **WHEN** `packer.pack` is called with a nonce of the wrong length
|
|
- **THEN** the call throws
|
|
|
|
#### Scenario: malformed envelope rejected
|
|
|
|
- **WHEN** an envelope's stated ciphertext length overruns the buffer, OR the envelope is too short
|
|
- **THEN** parsing throws
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/packer/packer.test.ts::"round-trip preserves the value"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"replay outside window rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"nonce dedup rejects replay"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"cross-protocol domain tag rejected"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects invalid pack-side nonce length"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects envelope where stated ciphertext length overruns buffer"` AND `packages/crypto/crypto/src/packer/packer.test.ts::"rejects too-short envelope"`
|
|
|
|
---
|
|
|
|
### Requirement: ChunkedPacker — per-chunk-signed P2P fragmentation
|
|
|
|
`@suaveplan/crypto` SHALL provide `ChunkedPacker` / `ChunkedUnpacker` implementing per-chunk signed fragmentation under `DOMAIN_CHUNK_V1`. Reassembly MUST: byte-equal the original payload on round-trip, reject tampered chunks at reassembly, reject missing chunks, reassemble correctly when chunks arrive out of order, reject malformed chunks (too short, wrong `dataLen`, mismatched total), reject empty chunk lists, reject invalid chunk sizes.
|
|
|
|
#### Scenario: round-trip reassembles byte-identical
|
|
|
|
- **WHEN** a payload is fragmented into N chunks and reassembled
|
|
- **THEN** the reassembled bytes byte-equal the original
|
|
|
|
#### Scenario: tampered chunk rejected at reassembly
|
|
|
|
- **WHEN** any byte of a chunk is mutated
|
|
- **THEN** reassembly throws
|
|
|
|
#### Scenario: missing chunk rejected
|
|
|
|
- **WHEN** any chunk is omitted
|
|
- **THEN** reassembly throws
|
|
|
|
#### Scenario: out-of-order chunks reassemble
|
|
|
|
- **WHEN** chunks are presented in non-sequential order
|
|
- **THEN** reassembly succeeds and byte-equals the original
|
|
|
|
#### Scenario: malformed chunks rejected
|
|
|
|
- **WHEN** a chunk is too short, has the wrong `dataLen` field, or has a mismatched total
|
|
- **THEN** reassembly throws
|
|
|
|
#### Scenario: empty list and invalid sizes rejected
|
|
|
|
- **WHEN** an empty chunk list is presented, or chunking is requested with an invalid size
|
|
- **THEN** the call throws
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"round-trip reassembles byte-identical"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"tampered chunk rejected at reassembly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"missing chunk rejected"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"out-of-order chunks reassemble correctly"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects malformed chunk (too short)"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunk with wrong dataLen field"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects chunks with mismatched total"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects empty chunks list"` AND `packages/crypto/crypto/src/chunked-packer/chunked-packer.test.ts::"rejects invalid chunk size"`
|
|
|
|
### Requirement: Trust-anchor bootstrap
|
|
|
|
`@suaveplan/crypto` SHALL ship a trust-anchor loader at `src/trust-anchors/` that reads a self-verifying root public bundle from a caller-supplied filesystem reader and returns both the raw bytes (for direct use with `verifyChain`) and the parsed `PublicBundle` (for diagnostics). The loader SHALL self-verify the bundle in root-mode (`verifyPublicBundle(bytes, null)`) on every load — bytes that do not self-verify SHALL be rejected with `CRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILED`.
|
|
|
|
The package SHALL expose:
|
|
|
|
- `loadTrustAnchor(reader, path): Promise<TrustAnchor>` — explicit-path entry.
|
|
- `loadTrustAnchorFromEnv(reader, options): Promise<TrustAnchor>` — env-driven entry. `options.getenv` is required (the universal package never reads `process.env` directly); production wires `(name) => process.env[name]`, tests inject deterministic stubs. `options.envVar` defaults to `SUAVEPLAN_TRUST_ANCHOR_PATH`.
|
|
- `DEFAULT_TRUST_ANCHOR_ENV_VAR` — the canonical env-var name as a `const`.
|
|
- Types `TrustAnchor`, `TrustAnchorReader`, `TrustAnchorEnvGetter`, `LoadTrustAnchorFromEnvOptions`.
|
|
|
|
The universal-tier package MUST NOT import `node:fs` or read `process.env` at the module-import level; all platform access is caller-injected via the `TrustAnchorReader` interface and the optional `getenv` callback. Production wires `(path) => fs.readFile(path)`; tests inject deterministic stubs.
|
|
|
|
#### Scenario: round-trip self-verify
|
|
|
|
- **WHEN** a self-signed root public bundle is supplied via the reader and `loadTrustAnchor(reader, path)` is called
|
|
- **THEN** the call returns `{ bytes, bundle }` where `bytes` is byte-identical to the input and `bundle` has a 32-byte `sign` slot public key
|
|
|
|
#### Scenario: env-var default name
|
|
|
|
- **WHEN** `loadTrustAnchorFromEnv(reader, { getenv })` is called without `options.envVar`
|
|
- **THEN** `getenv` is invoked with `"SUAVEPLAN_TRUST_ANCHOR_PATH"` and the returned path is read
|
|
|
|
#### Scenario: env-var name override
|
|
|
|
- **WHEN** `loadTrustAnchorFromEnv(reader, { envVar: "VAULT_ANCHOR", getenv })` is called
|
|
- **THEN** `getenv` is invoked with `"VAULT_ANCHOR"` and the returned path is read
|
|
|
|
#### Scenario: missing env-var rejected
|
|
|
|
- **WHEN** `loadTrustAnchorFromEnv(reader, { envVar, getenv: () => undefined })` is called
|
|
- **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.PATH_MISSING` and `context.envVar` reflects the requested name
|
|
|
|
#### Scenario: empty env-var string rejected
|
|
|
|
- **WHEN** `loadTrustAnchorFromEnv(reader, { envVar, getenv: () => "" })` is called
|
|
- **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.PATH_MISSING`
|
|
|
|
#### Scenario: reader failure surfaces structured error
|
|
|
|
- **WHEN** the reader's `read(path)` rejects (e.g. ENOENT)
|
|
- **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.READ_FAILED`, with the reader's error attached via `cause` and `context.path` equal to the requested path
|
|
|
|
#### Scenario: tampered bytes rejected
|
|
|
|
- **WHEN** the loader receives bytes that parse but fail `verifyPublicBundle(bytes, null)`
|
|
- **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILED` and `context.byteLength` reflects the actual length
|
|
|
|
#### Scenario: garbage bytes rejected
|
|
|
|
- **WHEN** the loader receives bytes that fail to parse as a public bundle
|
|
- **THEN** the call throws `CryptoError` with code `CRYPTO.TRUST_ANCHOR.SELF_VERIFY_FAILED`
|
|
|
|
**Verified by:** `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"loads, self-verifies, and returns byte-identical bytes"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"the returned bundle parses identically to a fresh verify on the bytes"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"uses the default env var name SUAVEPLAN_TRUST_ANCHOR_PATH"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"honors a caller-supplied envVar override"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.test.ts::"getenv is invoked exactly once with the resolved env-var name"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.PATH_MISSING when env var is unset"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.PATH_MISSING when env var is empty string"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.READ_FAILED when reader rejects"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.SELF_VERIFY_FAILED on tampered bundle bytes"` AND `packages/crypto/crypto/src/trust-anchors/trust-anchor.spec-conformance.test.ts::"throws TRUST_ANCHOR.SELF_VERIFY_FAILED on garbage bytes"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|