Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
/**
* Test-only DI bindings for `@suaveplan/bitpacker-crypto` primitives.
*
* Implemented directly against the lower-level `@suaveplan/crypto-core`
* (sign/verify) and `@suaveplan/crypto-cipher` (AEAD) packages so this
* test surface has NO dependency on `@suaveplan/crypto`. That breaks an
* otherwise-circular dep chain: `crypto` may depend on `bitpacker-crypto`
* (e.g. for `EnvelopeSchema` in `chain-bundle`) without inducing a cycle
* via this test helper.
*
* Production callers should still wire `verifyAuthoritySignature` from
* `@suaveplan/crypto/authority` and `aeadEncrypt` / `aeadDecrypt` from
* `@suaveplan/crypto` — those are the canonical implementations. This
* file mirrors their shape for test purposes only.
*/
import * as aesGcm from "@suaveplan/crypto-cipher/aes-256-gcm";
import * as chacha20 from "@suaveplan/crypto-cipher/chacha20-poly1305";
import * as xchacha20 from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import { ed25519 } from "@suaveplan/crypto-core/sign";
import type {
AeadAlgorithm,
AeadOps,
AeadOpsOptions,
AuthorityVerifier,
DomainTag,
} from "@suaveplan/types";
const ED25519_ALGORITHM_ID = 0;
/**
* Inline copy of `buildSigningInput` from `@suaveplan/crypto/domain-tags`.
* Reproduced here to keep this test helper free of any `@suaveplan/crypto`
* runtime dep. The byte layout is identical: `tag-bytes ‖ 0x00 ‖ payload`.
*/
function buildSigningInput(domain: DomainTag, payload: Uint8Array): Uint8Array {
const tag = new TextEncoder().encode(domain);
const out = new Uint8Array(tag.length + 1 + payload.length);
out.set(tag, 0);
out[tag.length] = 0;
out.set(payload, tag.length + 1);
return out;
}
/**
* Test verifier — mirrors `verifyAuthoritySignature` from
* `@suaveplan/crypto/authority` byte-for-byte. Only ed25519 is supported
* (matches the canonical impl); other algorithms throw to preserve the
* "structural error → swallow as false" convention used by primitives.
*/
export const cryptoVerifier: AuthorityVerifier = (
domain,
publicSlot,
payload,
signature
) => {
if (publicSlot.algorithm !== ED25519_ALGORITHM_ID) {
throw new Error(
`cryptoVerifier: unsupported algorithm ${publicSlot.algorithm}`
);
}
const message = buildSigningInput(domain, payload);
return ed25519.verify(publicSlot.publicKey, signature, message);
};
function encrypt(
algorithm: AeadAlgorithm,
key: Uint8Array,
plaintext: Uint8Array,
options: AeadOpsOptions
): Uint8Array {
switch (algorithm) {
case "AES-256-GCM":
return aesGcm.encrypt(key, options.nonce, plaintext, options.aad);
case "ChaCha20-Poly1305":
return chacha20.encrypt(key, options.nonce, plaintext, options.aad);
case "XChaCha20-Poly1305":
return xchacha20.encrypt(
key,
options.nonce,
plaintext,
options.aad
);
}
}
function decrypt(
algorithm: AeadAlgorithm,
key: Uint8Array,
ciphertext: Uint8Array,
options: AeadOpsOptions
): Uint8Array {
switch (algorithm) {
case "AES-256-GCM":
return aesGcm.decrypt(key, options.nonce, ciphertext, options.aad);
case "ChaCha20-Poly1305":
return chacha20.decrypt(
key,
options.nonce,
ciphertext,
options.aad
);
case "XChaCha20-Poly1305":
return xchacha20.decrypt(
key,
options.nonce,
ciphertext,
options.aad
);
}
}
/**
* Test AEAD bundle — dispatches to the per-algorithm functions exposed by
* `@suaveplan/crypto-cipher`. Behaviorally identical to the canonical
* `{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` shape from
* `@suaveplan/crypto`.
*/
export const cryptoAead: AeadOps = { encrypt, decrypt };

View File

@@ -0,0 +1,106 @@
/**
* Test-only fake `Authority` that signs with a fresh ed25519 keypair and
* exposes the matching `KeySlotPublic` for verification. Avoids the
* full-keychain construction machinery in `@suaveplan/crypto`'s test surface
* which would require a deep import on every primitive's tests.
*
* Only the fields actually touched by `verifyAuthoritySignature` are
* populated correctly (`algorithm` + `publicKey`). All other slot fields
* carry placeholder values that satisfy the type but are not checked at
* runtime by the verify path.
*/
import { ed25519 } from "@suaveplan/crypto-core/sign";
import type {
Authority,
DomainTag,
KeySlotPublic,
SlotId,
} from "@suaveplan/types";
/**
* Inline copy of `buildSigningInput` from `@suaveplan/crypto/domain-tags`.
* Reproduced here to keep this test helper free of any `@suaveplan/crypto`
* runtime dep (which would induce a cycle now that `@suaveplan/crypto`
* itself depends on this package via `chain-bundle`'s `EnvelopeSchema`
* adoption). Byte layout is identical: `tag-bytes ‖ 0x00 ‖ payload`.
*/
function buildSigningInput(domain: DomainTag, payload: Uint8Array): Uint8Array {
const tag = new TextEncoder().encode(domain);
const out = new Uint8Array(tag.length + 1 + payload.length);
out.set(tag, 0);
out[tag.length] = 0;
out.set(payload, tag.length + 1);
return out;
}
const ED25519_ALGORITHM_ID = 0;
export interface FakeAuthorityHandle {
readonly authority: Authority;
readonly publicSlot: KeySlotPublic<unknown>;
readonly secretKey: Uint8Array;
readonly publicKey: Uint8Array;
}
function makePlaceholderSlot(publicKey: Uint8Array): KeySlotPublic<unknown> {
return {
coordinate: {
application: "test" as never,
purpose: "sign" as never,
layoutId: 0 as never,
layoutFields: {} as never,
} as never,
algorithm: ED25519_ALGORITHM_ID as never,
notBefore: 0,
notAfter: Number.MAX_SAFE_INTEGER,
publicKey,
hasPublic: true,
};
}
/**
* Build a fake `Authority` backed by a fresh ed25519 keypair. The returned
* `publicSlot` matches the secretKey and is suitable for passing to
* `verifyAuthoritySignature` or any consumer of `KeySlotPublic`.
*/
export function createFakeAuthority(): FakeAuthorityHandle {
const keypair = ed25519.generateKeypair();
const secretKey = keypair.secretKey;
const publicKey = keypair.publicKey;
let disposed = false;
const publicSlot = makePlaceholderSlot(publicKey);
const authority: Authority = {
async sign(
domain: DomainTag,
_slot: SlotId,
payload: Uint8Array,
signal?: AbortSignal
): Promise<Uint8Array> {
if (disposed) {
throw new Error("authority disposed");
}
if (signal?.aborted === true) {
throw new Error("aborted");
}
const message = buildSigningInput(domain, payload);
return Promise.resolve(ed25519.sign(secretKey, message));
},
getPublicSlot(_slot: SlotId): KeySlotPublic<unknown> | undefined {
if (disposed) return undefined;
return publicSlot;
},
hasSlot(_slot: SlotId): boolean {
return !disposed;
},
dispose(): void {
disposed = true;
},
get disposed(): boolean {
return disposed;
},
};
return { authority, publicSlot, secretKey, publicKey };
}

View File

@@ -0,0 +1,270 @@
/**
* Minimal in-memory recording tracer for assert-on-span testing without
* pulling `@opentelemetry/sdk-trace-base` as a workspace dep.
*
* Implements just enough of the `@opentelemetry/api` `Tracer` /
* `TracerProvider` surface to capture span name + attributes from primitive
* code paths. Tests call `installRecordingTracer()` in a setup, exercise the
* primitive, then `recorded.spans` returns the captured spans. Always restore
* the previous global tracer provider in a `try { ... } finally { ... }` so
* tests don't pollute each other.
*/
import {
type Attributes,
type Context,
type Span,
type SpanContext,
SpanKind,
type SpanOptions,
type SpanStatus,
SpanStatusCode,
type Tracer,
type TracerProvider,
trace,
} from "@opentelemetry/api";
export interface RecordedSpan {
readonly name: string;
readonly attributes: Attributes;
readonly status?: SpanStatus;
readonly events: {
readonly name: string;
readonly attributes?: Attributes;
}[];
readonly ended: boolean;
}
class RecordingSpan implements Span {
public readonly name: string;
public readonly attributes: Attributes = {};
public readonly events: { name: string; attributes?: Attributes }[] = [];
public ended = false;
public status: SpanStatus | undefined;
public constructor(name: string, attrs?: Attributes) {
this.name = name;
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
this.attributes[k] = v;
}
}
}
public spanContext(): SpanContext {
return {
traceId: "00000000000000000000000000000000",
spanId: "0000000000000000",
traceFlags: 0,
};
}
public setAttribute(key: string, value: unknown): this {
this.attributes[key] = value as never;
return this;
}
public setAttributes(attrs: Attributes): this {
for (const [k, v] of Object.entries(attrs)) {
this.attributes[k] = v;
}
return this;
}
public addEvent(name: string, attrs?: Attributes): this {
const event: { name: string; attributes?: Attributes } = { name };
if (attrs !== undefined) {
event.attributes = attrs;
}
this.events.push(event);
return this;
}
public addLink(): this {
return this;
}
public addLinks(): this {
return this;
}
public setStatus(status: SpanStatus): this {
this.status = status;
return this;
}
public updateName(name: string): this {
(this as { name: string }).name = name;
return this;
}
public end(): void {
this.ended = true;
}
public isRecording(): boolean {
return !this.ended;
}
public recordException(): void {
this.setStatus({ code: SpanStatusCode.ERROR });
}
}
class RecordingTracer implements Tracer {
public readonly spans: RecordingSpan[] = [];
public startSpan(name: string, options?: SpanOptions): Span {
const span = new RecordingSpan(name, options?.attributes);
this.spans.push(span);
return span;
}
public startActiveSpan<F extends (span: Span) => unknown>(
nameOrOptions: string,
optionsOrFn: SpanOptions | F,
contextOrFn?: Context | F,
fn?: F
): ReturnType<F> {
const name = nameOrOptions;
const callback =
typeof fn === "function"
? fn
: typeof contextOrFn === "function"
? contextOrFn
: (optionsOrFn as F);
const opts =
typeof optionsOrFn === "function" ? undefined : optionsOrFn;
const span = this.startSpan(name, opts);
try {
return callback(span) as ReturnType<F>;
} finally {
if (!span.isRecording()) {
// already ended in the callback
} else {
span.end();
}
}
}
}
class RecordingTracerProvider implements TracerProvider {
public readonly tracers = new Map<string, RecordingTracer>();
public getTracer(name: string): Tracer {
let tracer = this.tracers.get(name);
if (tracer === undefined) {
tracer = new RecordingTracer();
this.tracers.set(name, tracer);
}
return tracer;
}
public allSpans(): readonly RecordedSpan[] {
const all: RecordedSpan[] = [];
for (const tracer of this.tracers.values()) {
for (const span of tracer.spans) {
all.push({
name: span.name,
attributes: { ...span.attributes },
...(span.status !== undefined
? { status: span.status }
: {}),
events: span.events.map((e) => ({
name: e.name,
...(e.attributes !== undefined
? { attributes: e.attributes }
: {}),
})),
ended: span.ended,
});
}
}
return all;
}
}
export interface RecordingTracerSession {
readonly provider: RecordingTracerProvider;
spansFor(tracerName: string): readonly RecordedSpan[];
allSpans(): readonly RecordedSpan[];
restore(): void;
}
/**
* Install a recording tracer as the global provider, returning a handle to
* inspect captured spans and a `restore()` to undo. Pair every call with
* `restore()` in a `finally` block.
*
* Implementation note: the `@opentelemetry/api` package keeps a one-shot
* global tracer provider — subsequent `setGlobalTracerProvider` calls log
* a warning and no-op unless we first call `trace.disable()`. The session
* surface always disables before installing so back-to-back tests work.
*/
export function installRecordingTracer(): RecordingTracerSession {
const provider = new RecordingTracerProvider();
trace.disable();
trace.setGlobalTracerProvider(provider);
return {
provider,
spansFor(tracerName: string): readonly RecordedSpan[] {
const tracer = provider.tracers.get(tracerName);
if (tracer === undefined) return [];
return tracer.spans.map((s) => ({
name: s.name,
attributes: { ...s.attributes },
...(s.status !== undefined ? { status: s.status } : {}),
events: s.events.map((e) => ({
name: e.name,
...(e.attributes !== undefined
? { attributes: e.attributes }
: {}),
})),
ended: s.ended,
}));
},
allSpans(): readonly RecordedSpan[] {
return provider.allSpans();
},
restore(): void {
trace.disable();
},
};
}
/**
* Convenience: run `fn` with an installed tracer, collecting spans.
*
* Awaits async callbacks so `session.restore()` runs only after the
* callback's Promise settles. Callers passing a sync callback get sync
* behavior.
*/
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => Promise<T>
): Promise<T>;
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => T
): T;
export function withRecordingTracer<T>(
fn: (session: RecordingTracerSession) => T | Promise<T>
): T | Promise<T> {
const session = installRecordingTracer();
let result: T | Promise<T>;
try {
result = fn(session);
} catch (err) {
session.restore();
throw err;
}
if (
result !== null &&
typeof result === "object" &&
"then" in (result as object) &&
typeof (result as { then: unknown }).then === "function"
) {
return (result as Promise<T>).finally(() => session.restore());
}
session.restore();
return result;
}
export { SpanKind, SpanStatusCode };

View File

@@ -0,0 +1,27 @@
/**
* Test-only `DomainTag` constants for `@suaveplan/bitpacker-crypto` tests.
*
* Mirrors the canonical `DOMAIN_BITPACKER_*` constants from
* `@suaveplan/crypto/domain-tags` byte-for-byte, but declared inline so
* this test surface has NO runtime dep on `@suaveplan/crypto`. That breaks
* a cycle that would otherwise form when `@suaveplan/crypto` itself adopts
* primitives from this package (e.g. `chain-bundle` adopting
* `EnvelopeSchema`).
*
* Production callers should import the canonical constants from
* `@suaveplan/crypto/domain-tags` directly. These test mirrors exist solely
* to give the bitpacker-crypto test suite valid `DomainTag`-typed values.
*/
import type { DomainTag } from "@suaveplan/types";
const tag = (s: string): DomainTag => s as DomainTag;
export const DOMAIN_BITPACKER_SIGNED_V1 = tag("suaveplan.bitpacker-signed.v1");
export const DOMAIN_BITPACKER_MULTISIGN_V1 = tag(
"suaveplan.bitpacker-multisign.v1"
);
export const DOMAIN_BITPACKER_HASHCHAIN_V1 = tag(
"suaveplan.bitpacker-hashchain.v1"
);
export const DOMAIN_PUBLIC_KEYCHAIN_V1 = tag("suaveplan.public-keychain.v1");
export const DOMAIN_AUDIT_V1 = tag("suaveplan.audit.v1");

View File

@@ -0,0 +1,24 @@
/**
* Vitest setup polyfills for @suaveplan/bitpacker-crypto.
*
* The package's primitives consume `globalThis.crypto.getRandomValues` for
* AEAD nonces and `globalThis.crypto.subtle` for sha256 digests. Modern Node
* (≥ 19) exposes these on `globalThis.crypto`, but the vitest forks pool can
* sometimes start in a context where the global is not yet attached. Pull it
* explicitly from `node:crypto.webcrypto` if missing.
*/
import { webcrypto } from "node:crypto";
if (typeof globalThis.crypto?.getRandomValues !== "function") {
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
configurable: true,
writable: true,
});
}
if (typeof globalThis.crypto?.getRandomValues !== "function") {
throw new Error(
"[bitpacker-crypto polyfills] failed to install globalThis.crypto.getRandomValues"
);
}