Initial Commit
This commit is contained in:
143
openspec/specs/crypto-hash/spec.md
Normal file
143
openspec/specs/crypto-hash/spec.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# crypto-hash
|
||||
|
||||
## Purpose
|
||||
|
||||
Hashing substrate for the Suaveplan monorepo: SHA-2 digests, HMAC, HKDF, PBKDF2, Argon2id, scrypt, BLAKE2b, password hashing (Argon2id PHC + BLAKE2b MCF), and hex/Base64 encoding helpers. Per-primitive selector layout enables replacing the noble-backed default with a native engine without changing any caller imports.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Package identity
|
||||
|
||||
`@suaveplan/hash` SHALL be renamed to `@suaveplan/crypto-hash` to share the `crypto-*` prefix with the `crypto-curve`, `crypto-cipher`, and `crypto-utils` sibling packages. Every in-tree import of `@suaveplan/hash` SHALL be updated in the same change.
|
||||
|
||||
#### Scenario: Package name updated
|
||||
|
||||
- **WHEN** `packages/universal/crypto-hash/package.json` is inspected
|
||||
- **THEN** `name` is `@suaveplan/crypto-hash`
|
||||
- **AND** no other workspace `package.json` lists `@suaveplan/hash` as a dependency
|
||||
- **AND** no source file in the repo contains the string `"@suaveplan/hash"` as an import specifier
|
||||
|
||||
### Requirement: Per-primitive replaceability layout
|
||||
|
||||
Every hash, MAC, and KDF primitive in `@suaveplan/crypto-hash` SHALL live under `src/primitives/<id>/` with both a vendor-forked `noble` implementation and a `native` implementation slot, with the active implementation selected per-primitive at build time via the repo-root `crypto.config.ts`.
|
||||
|
||||
#### Scenario: Every primitive has both impl files
|
||||
|
||||
- **WHEN** the package is inspected
|
||||
- **THEN** every primitive id — `sha256`, `sha384`, `sha512`, `hmac`, `hkdf`, `pbkdf2`, `argon2id` — has both `src/primitives/<id>/impl/<id>.noble.ts` and `src/primitives/<id>/impl/<id>.native.ts`
|
||||
- **AND** the `native.ts` file is either a working implementation or a stub that throws `PrimitiveNotImplemented('<id>')`
|
||||
|
||||
#### Scenario: Selection driven by crypto.config.ts
|
||||
|
||||
- **WHEN** the root `crypto.config.ts` maps `<id>` to `'noble'` or `'native'`
|
||||
- **THEN** `src/primitives/<id>/<id>.select.ts` is regenerated to re-export from the chosen impl
|
||||
- **AND** the build fails if any `*.select.ts` was hand-edited (generated banner check)
|
||||
|
||||
### Requirement: Shared test vectors and differential equivalence
|
||||
|
||||
Every primitive SHALL have an `<id>.vectors.ts` file containing RFC / NIST / Wycheproof known-answer vectors, executed against every present implementation with cross-impl equivalence asserted.
|
||||
|
||||
#### Scenario: KATs pass on every impl
|
||||
|
||||
- **WHEN** `bunx turbo test --filter=@suaveplan/crypto-hash` runs
|
||||
- **THEN** each primitive's vector suite runs against its `noble` and `native` impls
|
||||
- **AND** native impls that throw `PrimitiveNotImplemented` are reported as `pending`, not failing
|
||||
|
||||
#### Scenario: Cross-impl equivalence
|
||||
|
||||
- **WHEN** a primitive has both a working `noble` and `native` impl
|
||||
- **THEN** the test suite executes a differential-equivalence check on the shared vectors
|
||||
- **AND** any byte-level divergence fails the test
|
||||
|
||||
### Requirement: Public API stability
|
||||
|
||||
The public API surface of `@suaveplan/crypto-hash` SHALL be identical to the surface that `@suaveplan/hash` exposed at the point of rename. All subpath exports (`./hash`, `./mac`, `./kdf`, `./encoding`, `./errors`) remain available with identical signatures.
|
||||
|
||||
#### Scenario: Subpath exports preserved
|
||||
|
||||
- **WHEN** a consumer imports from any subpath previously exposed by `@suaveplan/hash`
|
||||
- **THEN** the exported functions have identical signatures and behaviour as before the rename
|
||||
|
||||
### Requirement: CI gate for flipping default to native
|
||||
|
||||
A primitive's default SHALL NOT be changed from `'noble'` to `'native'` in `crypto.config.ts` unless the flip PR demonstrates:
|
||||
|
||||
1. Full KAT suite passes on the native impl.
|
||||
2. Nightly differential fuzz (≥ 1M inputs over 7 days) has zero mismatches.
|
||||
3. Constant-time lint passes and `dudect` statistical test shows no measurable secret-dependent timing.
|
||||
4. For `hmac` and any primitive in the **dangerous tier** (defined in `crypto-curve` and `crypto-cipher` specs), a third-party audit sign-off is attached to the PR, referencing the exact commit SHA of the native impl.
|
||||
|
||||
#### Scenario: Flip PR blocked without evidence
|
||||
|
||||
- **WHEN** a PR modifies `crypto.config.ts` to change a `crypto-hash` entry from `'noble'` to `'native'`
|
||||
- **THEN** CI requires the flip PR template with links to the KAT run, 7-day fuzz history, timing report, and (for HMAC) audit document
|
||||
- **AND** the merge is blocked if any evidence link is missing or red
|
||||
|
||||
### 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 -->
|
||||
Reference in New Issue
Block a user