# SignedBitSchema Bit-packed payload signed by an `Authority` under a fixed `DomainTag`. Wire format is `[bit-packed body][signature]`. Verifies before decoding so a tampered payload never even reaches the body codec. Replaces the ad-hoc `BitPacker.pack(...) → sign(...) → concat(...)` pattern that crypto chain bundles, public bundles, packer chunks, and game-server state-sync encoders had been re-authoring. _(no submodules)_ ## Purpose Every signed wire format in the stack follows the same recipe: bit-pack a structured body, sign the bytes with an authority slot, frame `[body][signature]`, then on the verify side strip the signature, verify it against a parent's public slot, and only then decode the body. Five plus suaveplan/upstream call-sites had been manually re-authoring this dance, each slightly differently. `SignedBitSchema` consolidates the pattern so the domain separation, signature length, sign-slot dispatch, and verify-then-decode order are all driven by a single primitive. The domain `DomainTag` is required, not optional, so a signature produced under one domain (e.g. `DOMAIN_PUBLIC_KEYCHAIN_V1`) cannot be replayed under a different domain (e.g. `DOMAIN_AUDIT_V1`) — the tag bytes participate in the signing input via `@suaveplan/crypto::buildSigningInput`. Cross-protocol replay is rejected at the bytes-don't-match level rather than at any application-layer check. ## Features - Pluggable body codec (`BodyCodec`) so the body can be a legacy `defineBitSchema` codec, a modern `BitPacker`-backed codec, an outer envelope, or any future encoder satisfying the two-method contract - `pack(data, authority, signal?)` is async — delegates signing to `Authority.sign(domain, slot, payload, signal)` so HSM/threshold backends Just Work without the primitive having to know - `unpack(bytes, parentPublicSlot)` enforces verify-then-decode order — body codec is NEVER invoked on a tampered payload - `verify(bytes, parentPublicSlot)` returns `boolean` so callers can enumerate candidate parent keys without exception flow - `unpackSelf(bytes, getEmbeddedSlot)` supports trust-anchor bundles whose own embedded sign slot is the verifier - AbortSignal threaded through `Authority.sign` per suaveplan cancellation rules - `signatureLength` configurable (defaults to ed25519's 64) so future primitives with different signature shapes inherit the schema for free - OTel spans `bitpacker-crypto.signed-pack` and `bitpacker-crypto.signed-verify` with `domain` + `payload_bytes` attrs ## Usage: Basic Example ```ts import { defineSignedBitSchema } from "@suaveplan/bitpacker-crypto"; import { defineBitSchema } from "@suaveplan/bitpacker"; import { DOMAIN_PUBLIC_KEYCHAIN_V1 } from "@suaveplan/crypto/domain-tags"; import { verifyAuthoritySignature } from "@suaveplan/crypto/authority"; interface LinkBody { parentSeq: number; purposeBits: number; } const linkSchema = defineBitSchema([ { name: "parentSeq", type: "uint", bits: 32 }, { name: "purposeBits", type: "uint", bits: 16 }, ]); const link = defineSignedBitSchema( { pack: (b) => linkSchema.pack(b), unpack: (bytes) => linkSchema.unpack(bytes), }, { domain: DOMAIN_PUBLIC_KEYCHAIN_V1, verifier: verifyAuthoritySignature, } ); const packed = await link.pack( { parentSeq: 7, purposeBits: 0xf0 }, authority ); const decoded = link.unpack(packed, parentPublicSlot); ``` The `verifier` option is required and accepts an `AuthorityVerifier` function (`(domain, publicSlot, payload, signature) => boolean`). The canonical implementation is `verifyAuthoritySignature` from `@suaveplan/crypto/authority`. Passing it via DI keeps `@suaveplan/bitpacker-crypto` free of any runtime dependency on `@suaveplan/crypto`, which is what allows future `crypto::ChainBundle`-style consumers to adopt this primitive without inducing a circular package dependency. ## Usage: Advanced Example — trust anchor via `unpackSelf` ```ts const trustAnchor = defineSignedBitSchema(codec, { domain: DOMAIN_BITPACKER_SIGNED_V1, }); // Verify against the bundle's OWN embedded sign slot. const bundle = trustAnchor.unpackSelf(bytes, (decoded) => decoded.embeddedSlot); ``` `unpackSelf` decodes the body once to extract the embedded sign slot, then verifies the signature against that slot. Only used for the root link of a chain (every other link verifies against its parent). ## API Reference ### `interface SignedBitSchemaOptions` - `domain: DomainTag` (required) — the domain under which the body is signed - `verifier: AuthorityVerifier` (required) — pure verifier function; typically `verifyAuthoritySignature` from `@suaveplan/crypto/authority` - `slot?: SlotId = "sign"` — the authority slot used for signing - `signatureLength?: number = 64` — wire-format signature length ### `defineSignedBitSchema(bodyCodec, options): SignedBitSchema` Returns: - `pack(data: T, authority: Authority, signal?: AbortSignal): Promise` - throws `CRYPTO_SIGN_FAILED` if the authority returns a wrong-length signature - `unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic): T` - throws `CRYPTO_VERIFY_FAILED` on verify failure (BEFORE body decode) - `verify(bytes: Uint8Array, parentPublicSlot: KeySlotPublic): boolean` - `unpackSelf(bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic): T` - `domain: DomainTag`, `slot: SlotId`, `signatureLength: number` ## Implementation Notes - The order `verify → decode` is load-bearing. A tampered byte string must never reach the body codec because the codec may have side effects, may log unexpected payloads, or may be slow. Verify-then-decode is asserted by a spec-conformance test that injects a tracking codec and checks `decodeCalls === 0` after a tampered unpack. - `verify(bytes, slot)` deliberately swallows structural exceptions and returns `false` so callers can probe candidate parent keys against an arbitrary byte string without try/catch boilerplate. `unpack(...)` is the throw-on-failure variant. - The body codec is held by reference; mutating it after construction changes future pack/unpack behavior. Treat the codec as immutable. - `Authority.sign` is async because real backends (HSM, TPM, threshold signing, network-attached signers) are async. Even the in-memory v1 authority returns a Promise to preserve the contract. - Signature length is enforced on pack to surface a misbehaving authority early. The check is identity-checking the `Promise.length`, not the algorithm — a future ECDSA-backed authority signing into an ed25519-shaped slot would be caught here. - OTel spans cost essentially nothing under the no-op tracer; they materialise once a real provider is wired. The spans deliberately do NOT carry the signature itself (sensitive material) or the body bytes (potentially sensitive); only metadata: domain, slot, payload_bytes.