Files
suaveplan/packages/foundation/bitpacker/bitpacker-crypto/src/signed/signed-bitschema.ts
2026-05-27 23:47:53 +01:00

245 lines
8.3 KiB
TypeScript

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<T> {
pack(
data: T,
authority: Authority,
signal?: AbortSignal
): Promise<Uint8Array>;
unpack(bytes: Uint8Array, parentPublicSlot: KeySlotPublic<unknown>): T;
verify(
bytes: Uint8Array,
parentPublicSlot: KeySlotPublic<unknown>
): boolean;
unpackSelf(
bytes: Uint8Array,
getEmbeddedSlot: (decoded: T) => KeySlotPublic<unknown>
): 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<T>(
bodyCodec: BodyCodec<T>,
options: SignedBitSchemaOptions
): SignedBitSchema<T> {
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<Uint8Array> {
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<unknown>
): 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<unknown>
): 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<unknown>
): 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<unknown>
): 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,
};
}