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 legacydefineBitSchemacodec, a modernBitPacker-backed codec, an outer envelope, or any future encoder satisfying the two-method contract pack(data, authority, signal?)is async — delegates signing toAuthority.sign(domain, slot, payload, signal)so HSM/threshold backends Just Work without the primitive having to knowunpack(bytes, parentPublicSlot)enforces verify-then-decode order — body codec is NEVER invoked on a tampered payloadverify(bytes, parentPublicSlot)returnsbooleanso callers can enumerate candidate parent keys without exception flowunpackSelf(bytes, getEmbeddedSlot)supports trust-anchor bundles whose own embedded sign slot is the verifier- AbortSignal threaded through
Authority.signper suaveplan cancellation rules signatureLengthconfigurable (defaults to ed25519's 64) so future primitives with different signature shapes inherit the schema for free- OTel spans
bitpacker-crypto.signed-packandbitpacker-crypto.signed-verifywithdomain+payload_bytesattrs
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 signedverifier: AuthorityVerifier(required) — pure verifier function; typicallyverifyAuthoritySignaturefrom@suaveplan/crypto/authorityslot?: SlotId = "sign"— the authority slot used for signingsignatureLength?: 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_FAILEDif the authority returns a wrong-length signature
- throws
unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T- throws
CRYPTO_VERIFY_FAILEDon verify failure (BEFORE body decode)
- throws
verify(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): booleanunpackSelf(bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>): Tdomain: DomainTag,slot: SlotId,signatureLength: number
Implementation Notes
- The order
verify → decodeis 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 checksdecodeCalls === 0after a tampered unpack. verify(bytes, slot)deliberately swallows structural exceptions and returnsfalseso 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.signis 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.