Files
skills/docs/foundation/bitpacker/bitpacker-crypto/src/signed/signed-bitschema.md
2026-05-29 08:20:56 +01:00

6.9 KiB

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<T>) 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

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<LinkBody>([
    { name: "parentSeq", type: "uint", bits: 32 },
    { name: "purposeBits", type: "uint", bits: 16 },
]);

const link = defineSignedBitSchema<LinkBody>(
    {
        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

const trustAnchor = defineSignedBitSchema<TrustAnchorBundle>(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<T>(bodyCodec, options): SignedBitSchema<T>

Returns:

  • pack(data: T, authority: Authority, signal?: AbortSignal): Promise<Uint8Array>
    • throws CRYPTO_SIGN_FAILED if the authority returns a wrong-length signature
  • unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T
    • throws CRYPTO_VERIFY_FAILED on verify failure (BEFORE body decode)
  • verify(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): boolean
  • unpackSelf(bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>): 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<Uint8Array>.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.