import { trace } from "@opentelemetry/api"; import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker"; import type { Authority, AuthorityVerifier, DomainTag, KeySlotPublic, SlotId, } from "@suaveplan/types"; import z from "zod"; import type { BodyCodec } from "../envelope/envelope-schema.js"; const TRACER_NAME = "bitpacker-crypto"; const SIGNED_PACK_SPAN = "bitpacker-crypto.signed-pack"; const SIGNED_VERIFY_SPAN = "bitpacker-crypto.signed-verify"; /** Default ed25519 signature length. */ export const DEFAULT_SIGNATURE_LENGTH = 64 as const; /** Default slot id used when no `slot` is supplied to `defineSignedBitSchema`. */ export const DEFAULT_SIGN_SLOT: SlotId = "sign" as SlotId; /** * Options for `defineSignedBitSchema`. * * - `domain` is required so two schemas signed under different domains * produce non-cross-replayable bytes (the tag participates in the * signature input via `buildSigningInput`). * - `verifier` is the dependency-injected verifier function — typically * `verifyAuthoritySignature` from `@suaveplan/crypto/authority`. Required * so this primitive has no runtime dependency on `@suaveplan/crypto`. * - `slot` defaults to `"sign"` to match the genesis purpose-tag convention. * - `signatureLength` defaults to 64 (ed25519). Other primitives may declare * different signature lengths in the future; this option exists so the * wire format can grow without redesigning the API. */ export interface SignedBitSchemaOptions { readonly domain: DomainTag; readonly verifier: AuthorityVerifier; readonly slot?: SlotId; readonly signatureLength?: number; } /** * A bit-packed payload signed by an `Authority` under a fixed `DomainTag`. * Wire format: `[bit-packed body][signature]`. * * - `pack(data, authority)` signs the bit-packed body with the * authority's slot under the configured domain * - `unpack(bytes, parentPublicSlot)` verifies the signature first; throws * `CRYPTO_VERIFY_FAILED` on mismatch; returns the decoded body otherwise * - `verify(bytes, parentPublicSlot)` returns `boolean` — does NOT throw on * verification failure (callers can enumerate candidate parent keys) * - `unpackSelf(bytes, getEmbeddedSlot)` is for trust-anchor bundles whose * own embedded sign-slot is the verifier; the body is decoded first to * extract the slot, then the signature is verified against it */ export interface SignedBitSchema { pack( data: T, authority: Authority, signal?: AbortSignal ): Promise; unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic): T; verify( bytes: Uint8Array, parentPublicSlot: KeySlotPublic ): boolean; unpackSelf( bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic ): T; readonly domain: DomainTag; readonly slot: SlotId; readonly signatureLength: number; } const OptionsRuntimeSchema = z .object({ domain: z.string(), verifier: z.function(), slot: z.string().optional(), signatureLength: z.number().int().min(8).max(256).optional(), }) .strict(); export function defineSignedBitSchema( bodyCodec: BodyCodec, options: SignedBitSchemaOptions ): SignedBitSchema { OptionsRuntimeSchema.parse(options); const domain = options.domain; const verifier = options.verifier; const slot = options.slot ?? DEFAULT_SIGN_SLOT; const signatureLength = options.signatureLength ?? DEFAULT_SIGNATURE_LENGTH; async function pack( data: T, authority: Authority, signal?: AbortSignal ): Promise { const tracer = trace.getTracer(TRACER_NAME); const span = tracer.startSpan(SIGNED_PACK_SPAN, { attributes: { "bitpacker.crypto.domain": String(domain), "bitpacker.crypto.slot": String(slot), }, }); try { const body = bodyCodec.pack(data); const signature = await authority.sign(domain, slot, body, signal); if (signature.length !== signatureLength) { throw new BitpackerError( BITPACKER_CODES.CRYPTO_SIGN_FAILED, `Authority returned signature of unexpected length: got ${signature.length}, expected ${signatureLength}`, { context: { actualLength: signature.length, expectedLength: signatureLength, domain: String(domain), slot: String(slot), }, } ); } const out = new Uint8Array(body.length + signatureLength); out.set(body, 0); out.set(signature, body.length); span.setAttribute("bitpacker.crypto.payload_bytes", out.length); return out; } finally { span.end(); } } function splitBytes(bytes: Uint8Array): { body: Uint8Array; signature: Uint8Array; } { if (bytes.length < signatureLength) { throw new BitpackerError( BITPACKER_CODES.CRYPTO_VERIFY_FAILED, `SignedBitSchema bytes too short to contain a signature: got ${bytes.length}, expected ≥ ${signatureLength}`, { context: { actualLength: bytes.length, signatureLength, }, } ); } const split = bytes.length - signatureLength; return { body: bytes.slice(0, split), signature: bytes.slice(split), }; } function verifyBody( body: Uint8Array, signature: Uint8Array, parentPublicSlot: KeySlotPublic ): boolean { const tracer = trace.getTracer(TRACER_NAME); const span = tracer.startSpan(SIGNED_VERIFY_SPAN, { attributes: { "bitpacker.crypto.domain": String(domain), "bitpacker.crypto.payload_bytes": body.length, }, }); try { return verifier(domain, parentPublicSlot, body, signature); } finally { span.end(); } } function unpack( bytes: Uint8Array, parentPublicSlot: KeySlotPublic ): T { const { body, signature } = splitBytes(bytes); if (!verifyBody(body, signature, parentPublicSlot)) { throw new BitpackerError( BITPACKER_CODES.CRYPTO_VERIFY_FAILED, "SignedBitSchema signature verification failed", { context: { domain: String(domain), parentAlgorithm: parentPublicSlot.algorithm, }, } ); } return bodyCodec.unpack(body); } function verify( bytes: Uint8Array, parentPublicSlot: KeySlotPublic ): boolean { if (bytes.length < signatureLength) return false; const split = bytes.length - signatureLength; const body = bytes.slice(0, split); const signature = bytes.slice(split); try { return verifyBody(body, signature, parentPublicSlot); } catch { return false; } } function unpackSelf( bytes: Uint8Array, getEmbeddedSlot: (decoded: T) => KeySlotPublic ): T { const { body, signature } = splitBytes(bytes); const decoded = bodyCodec.unpack(body); const embeddedSlot = getEmbeddedSlot(decoded); if (!verifyBody(body, signature, embeddedSlot)) { throw new BitpackerError( BITPACKER_CODES.CRYPTO_VERIFY_FAILED, "SignedBitSchema unpackSelf verification failed", { context: { domain: String(domain), embeddedAlgorithm: embeddedSlot.algorithm, }, } ); } return decoded; } return { pack, unpack, verify, unpackSelf, domain, slot, signatureLength, }; }