Initial Commit
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user