245 lines
8.3 KiB
TypeScript
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,
|
|
};
|
|
}
|