Files
suaveplan/openspec/archive/2026-05-03-extract-shared-crypto-interfaces/proposal.md
2026-05-27 23:29:53 +01:00

12 KiB

Extract shared crypto interfaces — break crypto ↔ bitpacker-crypto cycle

Packages: @suaveplan/types, @suaveplan/crypto, @suaveplan/bitpacker-crypto Status: PROPOSED Type: architectural refactor (no wire-format change)

Why

The just-archived bitpacker-crypto-rewrite (2026-05-02) shipped seven bit-packer-aware primitives (SignedBitSchema, MultiSignedBitSchema, HashChainSchema, EncryptedBitSchema, EnvelopeSchema, SchemaFingerprint, HashAlgorithmRegistry) but deferred the canary migration of crypto::ChainBundle → EnvelopeSchema because that migration would have created a circular package dependency:

@suaveplan/crypto → @suaveplan/bitpacker-crypto → @suaveplan/crypto

The → @suaveplan/crypto edge from bitpacker-crypto exists because the rewrite's primitives directly import Authority, DomainTag, KeySlotPublic, verifyAuthoritySignature, aeadEncrypt, and aeadDecrypt from @suaveplan/crypto's subpath exports. With those direct imports in place, crypto cannot adopt any of the primitives without inducing a cycle.

AGENTS.md §0 rule 5 already mandates the resolution:

If the new type or abstraction is shared across ≥2 packages, it belongs in @suaveplan/types (zero deps, types-only). Add it there first, then reference it. This prevents circular dependencies and keeps the public contract in one place.

This change applies that rule retroactively: extracts the cross-package type contract (Authority, DomainTag, KeySlotPublic/Private, SlotId, AuthorityVerifier, AeadOps, AeadAlgorithm) into @suaveplan/types and refactors the four bitpacker-crypto primitives that need a verifier or AEAD engine to accept them via dependency injection. After this change, bitpacker-crypto has zero runtime dependency on @suaveplan/crypto; the canary migration of crypto::ChainBundle becomes unblocked (queued as a follow-up so the two changes review independently).

What changes

ADDED — @suaveplan/types

  • domain-tag.ts — branded DomainTag type alias (zero runtime). Source of truth for the type; the closed list of registered tag VALUES (DOMAIN_VAULT_V1, DOMAIN_PUBLIC_KEYCHAIN_V1, …) and the encodeDomainTag / buildSigningInput runtime helpers stay in @suaveplan/crypto/domain-tags.
  • authority.ts — Authority interface, KeySlotPublic<TLayoutFields>, KeySlotPrivate<TLayoutFields>, SlotId (string alias), and the AuthorityVerifier function-type. The coordinate field on KeySlotPublic is typed as unknown here because its concrete shape (DerivationCoordinate<TLayoutFields>) lives in @suaveplan/crypto and is not part of the cross-package contract — verifiers only read algorithm and publicKey.
  • aead-ops.ts — AeadAlgorithm discriminator ("AES-256-GCM", "ChaCha20-Poly1305", "XChaCha20-Poly1305"), AeadOpsOptions (nonce, optional aad), and the AeadOps operations bundle (encrypt + decrypt).
  • Sibling .md per file (≥200 words each, basic + advanced examples).

MODIFIED — @suaveplan/crypto

  • domain-tags.ts — re-exports DomainTag from @suaveplan/types instead of declaring it locally. The closed list of DOMAIN_* constants and the encodeDomainTag / buildSigningInput / isDomainTag runtime helpers stay here verbatim.
  • authority/authority.ts — re-exports Authority, KeySlotPublic, SlotId from @suaveplan/types. InMemoryAuthority, inMemoryAuthority, and verifyAuthoritySignature remain unchanged in shape; their signatures now reference the canonical types from @suaveplan/types.
  • No wire-format change. No public API removal. Existing consumers continue to import Authority from @suaveplan/crypto/authority and DomainTag from @suaveplan/crypto/domain-tags — these subpaths re-export the canonical types so call-sites do not change.

MODIFIED — @suaveplan/bitpacker-crypto

  • Drops @suaveplan/crypto from runtime dependencies. Moved to devDependencies (used only by tests and benchmarks).
  • All four DI-using primitives gain a required option:
    • defineSignedBitSchema(bodyCodec, { domain, verifier, ... }) — verifier: AuthorityVerifier is required
    • defineMultiSignedBitSchema(bodyCodec, { domain, verifier, signerCount, ... }) — verifier required
    • defineHashChainSchema({ domain, verifier, payloadCodec, ... }) — verifier required
    • defineEncryptedBitSchema(bodyCodec, { key, aead, ... }) — aead: AeadOps is required
  • Canonical wiring (caller-side):
    import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
    import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
    // pass { verifier: verifyAuthoritySignature }, { aead: { encrypt: aeadEncrypt, decrypt: aeadDecrypt } }
    
  • Constants the AEAD primitive previously imported from @suaveplan/crypto (AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH, AEAD_XCHACHA_NONCE_LENGTH) are now declared locally in encrypted-bitschema.ts. They are algorithm-fixed values; duplication is acceptable because the values are part of the bit-packer-crypto wire format.
  • All *.test.ts files import the canonical wiring from tests/helpers/crypto-deps.ts (a new test-only file) and pass the bindings into every primitive constructor.
  • tests/helpers/fake-authority.ts updated to import Authority / KeySlotPublic / SlotId types from @suaveplan/types (runtime helpers buildSigningInput and ed25519 continue to come from @suaveplan/crypto — the fake is a test asset and depending on crypto's runtime there is fine).

Out of scope (queued as follow-ups)

  • Canary migration of crypto::ChainBundle → EnvelopeSchema. Becomes unblocked by this change; queued as chain-bundle-adopt-envelope-schema. Wire format stays byte-identical and is pinned by snapshot test in that follow-up.
  • Canary migration of crypto::PublicBundle → SignedBitSchema + EnvelopeSchema. Same shape; queued as public-bundle-adopt-envelope-schema.
  • Network-side adoption (network-message-signing-adopt-signed-bitschema, audit-log-adopt-hashchain-schema, etc.) — already queued in bitpacker-crypto-rewrite archived tasks §19. None of those need to change as a result of this refactor; the type imports they will use resolve through @suaveplan/types and @suaveplan/crypto exactly as before.

Parity Checklist

Capability Pre-refactor (bitpacker-crypto-rewrite archive) Post-refactor Notes
SignedBitSchema pack/unpack/verify/unpackSelf ✅ ✅ Adds required verifier option
MultiSignedBitSchema co-signing ✅ ✅ Adds required verifier option
HashChainSchema append-only chain ✅ ✅ Adds required verifier option
EncryptedBitSchema AEAD envelope ✅ ✅ Adds required aead: AeadOps option
EnvelopeSchema magic+version+body ✅ ✅ Unchanged
SchemaFingerprint canonical-hash ✅ ✅ Unchanged
HashAlgorithmRegistry pluggable hashes ✅ ✅ Unchanged
OTel spans on all primitives ✅ ✅ Unchanged
100/100/100/100 coverage ✅ ✅ Re-asserted by Z4 gate
bitpacker-crypto → crypto runtime edge ✅ (present) 🚫 (eliminated) The whole point of this refactor
Authority / DomainTag / KeySlotPublic / AuthorityVerifier / AeadOps interfaces Local to @suaveplan/crypto ⬆️ Promoted to @suaveplan/types Single source of truth
Closed list of DOMAIN_* runtime constants ✅ in @suaveplan/crypto ✅ in @suaveplan/crypto Unchanged — runtime values stay where they are

Source mapping

Every requirement in the spec deltas is grounded in either an existing consumer call-site or an AGENTS.md rule.

Requirement Source
Authority lives in @suaveplan/types AGENTS.md §0 rule 5; consumer: bitpacker-crypto's 4 DI primitives
DomainTag lives in @suaveplan/types AGENTS.md §0 rule 5; consumer: bitpacker-crypto's 4 DI primitives
KeySlotPublic/Private live in @suaveplan/types AGENTS.md §0 rule 5; consumer: bitpacker-crypto's SignedBitSchema/MultiSignedBitSchema/HashChainSchema
AuthorityVerifier function-type lives in @suaveplan/types DI shape needed for bitpacker-crypto's 3 sign-using primitives to drop the @suaveplan/crypto runtime edge
AeadOps lives in @suaveplan/types DI shape needed for bitpacker-crypto::EncryptedBitSchema to drop the @suaveplan/crypto runtime edge
AeadAlgorithm lives in @suaveplan/types Co-located with AeadOps; bitpacker-crypto::EncryptedBitSchema's wire format pins these three names
SignedBitSchema.verifier required option Removes the previous import { verifyAuthoritySignature } from "@suaveplan/crypto" runtime dep
MultiSignedBitSchema.verifier required option Same
HashChainSchema.verifier required option Same
EncryptedBitSchema.aead required option Removes the previous import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto" runtime dep
bitpacker-crypto package.json drops @suaveplan/crypto from dependencies Confirms cycle is eliminated; turbo build verifies

Migration impact

Internal (in this change)

  • @suaveplan/types gains 3 new files (domain-tag.ts, authority.ts, aead-ops.ts) plus 3 sibling .md docs. Zero runtime emitted (per the existing tests/zero-runtime.test.ts byte-budget guard).
  • @suaveplan/crypto's domain-tags.ts and authority/authority.ts re-export the canonical types from @suaveplan/types. No runtime change. Existing 265/265 tests stay green.
  • @suaveplan/bitpacker-crypto changes are larger:
    • 4 primitives gain required DI options (verifier or aead).
    • 16 test files updated to inject the canonical bindings via the new tests/helpers/crypto-deps.ts.
    • package.json deps shuffled (@suaveplan/crypto → devDependencies).
    • Must end at 100/100/100/100 coverage (the existing gate).
  • All 3 sibling .md docs in bitpacker-crypto's primitive files updated to reflect the new DI option.

External (outside this change, queued as follow-ups)

  • Existing consumers of Authority, DomainTag, verifyAuthoritySignature from @suaveplan/crypto continue to work unchanged. Crypto's subpaths re-export the canonical types so all current call-sites compile without modification.
  • The canary migrations of crypto::ChainBundle and PublicBundle become possible. Both queued as separate OpenSpec changes.

Verification approach

  • bun scripts/folder-reorg/generate-manifest.ts — confirms the dep graph is acyclic after the refactor.
  • bunx turbo lint typecheck test --filter=@suaveplan/types — types package green; zero-runtime guard satisfied.
  • bunx turbo lint typecheck test --filter=@suaveplan/crypto — 265 existing tests stay green; no behavioral regression.
  • bunx turbo lint typecheck test --filter=@suaveplan/bitpacker-crypto — green after DI updates.
  • bunx turbo test:coverage --filter=@suaveplan/bitpacker-crypto — re-asserts 100/100/100/100.
  • bunx turbo build --filter=...@suaveplan/bitpacker-crypto — every dependent compiles green.
  • Grep guard: ! grep -rE "from \"@suaveplan/crypto\"|from \"@suaveplan/crypto/" packages/bitpacker/bitpacker-crypto/src/ — zero matches in production source files (test helpers may import; production cannot).

Risks

  • Breaking change for any consumer that already wired bitpacker-crypto primitives without a verifier / aead option. Risk is theoretical because no genesis package consumed the rewrite primitives yet — this change ships before any consumer adoption. Mitigated by making the option required (TypeScript catches missing wiring at compile time, not at runtime).
  • @suaveplan/types zero-runtime byte budget. New files emit only type declarations; the existing per-file 200-byte budget guard is unaffected. Verified by running the existing tests/zero-runtime.test.ts after the change.
  • Doc drift. Four bitpacker-crypto primitive .md docs and the package README.md need updates to show the new DI options. Caught by the AGENTS.md §4a doc-drift gate before the change ships.
  • Re-archive coordination. The just-archived bitpacker-crypto-rewrite (2026-05-02) deferred the canary; this change unblocks it. We do NOT re-archive the rewrite — the deferral note in tasks.md §14 correctly points at this follow-up.