Files
suaveplan/openspec/archive/2026-04-19-crypto-primitive-replaceability/proposal.md
2026-05-27 23:29:53 +01:00

7.6 KiB
Raw Blame History

Change: crypto-primitive-replaceability

Why

enterprise-crypto establishes @suaveplan/hash + @suaveplan/crypto at enterprise quality but keeps a direct runtime dependency on @noble/{hashes,curves,ciphers}. That delivers audited primitives today but leaves two properties on the table: supply-chain ownership (every byte of crypto code lives under @suaveplan/* with no external runtime dep) and per-primitive replaceability (ability to swap individual primitives from the vendored noble impl to an owned native impl incrementally, under a rigorous CI gate).

Those properties matter because primitive-level crypto is the one place where a single compromised upstream release — or a single npm-registry takeover — silently owns every downstream service we ship. Vendoring noble source into our tree and adding a per-primitive selector lets us: (a) freeze the exact bytes we trust; (b) review upstream diffs on our cadence instead of theirs; (c) replace individual primitives with owned implementations one at a time, without ever going through a big-bang rewrite; (d) leave cryptographically dangerous primitives (curves, AES, MACs) on the audited noble impl indefinitely if we choose — that is a legitimate terminal state.

This change builds on top of enterprise-crypto's primitives, it does not replace them. The public surface of @suaveplan/crypto does not change. What changes is where the underlying bytes come from and how we swap them.

What Changes

Package family (new)

  • NEW @suaveplan/crypto-curve — ECC primitives (secp256k1, Ed25519, X25519, P-256) as vendor-fork of @noble/curves, exposed behind per-primitive selector modules. Dangerous-tier CI gate.
  • NEW @suaveplan/crypto-cipher — AEAD primitives (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305) as vendor-fork of @noble/ciphers, same selector architecture. Dangerous-tier CI gate.
  • NEW @suaveplan/crypto-utils — pure-composition utilities (hex, base64, base64url, base58, concatBytes, xor, I2OSP / OS2IP, Merkle helpers, constant-time-equal, zeroize, KAT harness, differential-fuzz harness, constant-time lint). No vendored code. Depends only on @suaveplan/error, @suaveplan/types, tslib, zod.

Package rename (no external consumers — genesis only)

  • RENAME @suaveplan/hash → @suaveplan/crypto-hash so the family shares the crypto-* prefix. The one in-tree consumer (@suaveplan/crypto) is updated in the same change. No other workspace imports @suaveplan/hash.

Replaceability architecture (new, applied to crypto-hash / crypto-curve / crypto-cipher)

Every hash, curve, and AEAD primitive is refactored to the following layout:

src/primitives/<id>/
  <id>.types.ts         # Interface
  <id>.ts               # Public API — re-exports from <id>.select.ts
  <id>.vectors.ts       # RFC/NIST/Wycheproof KATs, used by tests
  <id>.test.ts          # Runs vectors × every impl + cross-impl equivalence
  <id>.md
  <id>.select.ts        # GENERATED — do not edit
  impl/
    <id>.noble.ts       # Vendored-noble impl (Phase 0: via @noble/* dep; Phase 0.5: vendored source)
    <id>.native.ts      # Owned impl (starts: throw PrimitiveNotImplemented)

A root crypto.config.ts maps primitiveId → 'noble' | 'native'. A pre-build codegen script (scripts/gen-crypto-selects.ts) emits each <id>.select.ts as a one-line re-export of the chosen impl, so Vite tree-shakes the unused impl out. Flipping a primitive is a one-line config change.

Modifications

  • MODIFIED @suaveplan/crypto — @noble/curves and @noble/ciphers imports are rewired to @suaveplan/crypto-curve and @suaveplan/crypto-cipher. @noble/* removed from dependencies. Public API surface unchanged.
  • MODIFIED @suaveplan/crypto-hash (formerly @suaveplan/hash) — each primitive (sha256 / sha384 / sha512 / hmac / hkdf / pbkdf2 / argon2id / encoding) refactored into the replaceability layout. Public API surface unchanged.

CI gate for flipping a primitive to native

A primitive default SHALL NOT change from 'noble' to 'native' in crypto.config.ts unless the flip PR demonstrates:

  1. Full KAT suite (RFC / NIST / Wycheproof) passes on the native impl.
  2. Nightly differential fuzz (≥ 1M inputs over 7 days) shows zero mismatches vs noble.
  3. Constant-time lint: automated scan for secret-dependent branches / secret-indexed lookups + reviewer checklist + dudect-style statistical timing test on Bun.
  4. Dangerous tier (every primitive in crypto-curve and crypto-cipher, plus HMAC and any MAC-like primitive in crypto-hash): third-party cryptographic audit sign-off attached to the flip PR, referencing the exact commit SHA of the native impl. Hashes, HKDF, and PBKDF2 can flip at gate 3 without audit.

Vendoring hygiene (Phase 0.5 — after initial rollout)

  • @noble/{hashes,curves,ciphers} source copied into packages/universal/crypto-{hash,curve,cipher}/src/vendored/ at a pinned upstream commit, with LICENSE preserved.
  • NOBLE_UPSTREAM.md per package records upstream repo, commit SHA, sync date, reviewer, and files copied.
  • scripts/sync-noble.ts fetches upstream, diffs, and opens a PR for human review — never auto-merges.
  • After vendoring: @noble/* removed from every package's dependencies; impl/<id>.noble.ts imports from ../vendored/... relative paths.

Capabilities

New Capabilities

  • crypto-curve: ECC primitive family (secp256k1, Ed25519, X25519, P-256) with per-primitive noble/native selector and dangerous-tier audit gate.
  • crypto-cipher: AEAD primitive family (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305) with per-primitive selector and dangerous-tier audit gate.
  • crypto-utils: encoding + byte + test-harness utilities. Zero vendored code, pure-composition only.

Modified Capabilities

  • crypto-hash (renamed from hash): primitives refactored into the per-primitive selector layout. Public API unchanged.
  • crypto: curve and cipher primitive sources migrate from @noble/* to @suaveplan/crypto-{curve,cipher}. @noble/* removed from dependencies. Public API unchanged.

Impact

  • Consumers of @suaveplan/crypto: no source changes required. Public API stable.
  • Consumers of @suaveplan/hash: one-line import rewrite @suaveplan/hash → @suaveplan/crypto-hash. Codemod provided.
  • Bundle size: neutral in Phase 0. Tree-shakes per-primitive as natives land.
  • CI: four new tasks — crypto:kat (PR-blocking), crypto:fuzz (nightly), crypto:timing (nightly), crypto:noble-sync (weekly).
  • Phase 0 exit criteria: four packages publish at 0.0.0 with 95% coverage; every primitive selector defaults to 'noble'; every .native.ts stub is present; @suaveplan/crypto has zero @noble/* imports in source and zero @noble/* entries in its direct dependencies; crypto-hash / crypto-curve / crypto-cipher still declare @noble/* as catalog deps (vendoring deferred to Phase 0.5).
  • Supersession: updates the @noble/* dep decision documented in enterprise-crypto without reverting any of its other work. enterprise-crypto remains the enterprise-quality baseline; this change layers replaceability on top.

Relationship to enterprise-crypto

enterprise-crypto is the enterprise-quality baseline for @suaveplan/crypto + @suaveplan/hash: OTel, structured errors, Zod schemas, 100% coverage, benchmark suite. This change preserves every one of those properties and adds the replaceability architecture. The two changes are composable — whichever ships first, the other rebases cleanly by updating import paths (for rename) and adding the impl/ and select.ts files per primitive (for architecture).