Initial Commit
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user