From b2346219b827ae587d9b331565314da22140c96c Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:02:12 +0000 Subject: [PATCH] feat(threshold): check certificate-core vectors; Details: TC-2223 --- tinycloud-threshold/vectors/check/README.md | 128 ++++++++++ .../vectors/check/cert-schema.ts | 75 ++++++ .../vectors/check/cert-vectors.ts | 139 +++++++++++ .../vectors/check/certificate-anchors.test.ts | 57 +++++ .../vectors/check/certificate-cli.test.ts | 12 + .../check/certificate-constants.test.ts | 89 +++++++ .../check/certificate-corruption.test.ts | 89 +++++++ .../vectors/check/certificate-test-input.ts | 11 + .../vectors/check/certificate-vectors.test.ts | 78 ++++++ .../vectors/check/certificate.test.ts | 159 +++++++++++++ .../vectors/check/certificates.ts | 222 ++++++++++++++++++ tinycloud-threshold/vectors/check/cli.ts | 21 +- tinycloud-threshold/vectors/check/protocol.ts | 8 +- tinycloud-threshold/vectors/check/schema.ts | 16 +- 14 files changed, 1086 insertions(+), 18 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/cert-schema.ts create mode 100644 tinycloud-threshold/vectors/check/cert-vectors.ts create mode 100644 tinycloud-threshold/vectors/check/certificate-anchors.test.ts create mode 100644 tinycloud-threshold/vectors/check/certificate-cli.test.ts create mode 100644 tinycloud-threshold/vectors/check/certificate-constants.test.ts create mode 100644 tinycloud-threshold/vectors/check/certificate-corruption.test.ts create mode 100644 tinycloud-threshold/vectors/check/certificate-test-input.ts create mode 100644 tinycloud-threshold/vectors/check/certificate-vectors.test.ts create mode 100644 tinycloud-threshold/vectors/check/certificate.test.ts create mode 100644 tinycloud-threshold/vectors/check/certificates.ts diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index 04588cc3..1b7ec3d0 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -86,3 +86,131 @@ Development dependencies are TypeScript 5.9.3 (Apache-2.0), `@types/bun` 1.3.1, read from the npm registry metadata for those exact versions. Details: TC-2219 + +## Certificate-core checker portion + +The default `bun run vectors` now checks both module-relative inputs: +`../v1/primitives.json` and `../v1/cert-core.json`. An explicit path checks +only that document: + +```sh +bun run vectors /absolute/path/to/cert-core.json +``` + +The existing frozen-install / `bun run check` entry runs all test files, +typechecking and both real CLI paths. No dependency versions changed. + +`cert-schema.ts` defines the current named certificate document shape. +`certificates.ts` computes canonical certificate and ceremony encodings, +tagged hashes, Ed25519 signatures, public-share commitment evaluations, +lineage, authenticated closed rounds, readiness/activation and fault verdicts. +`cert-vectors.ts` executes the operations and compares every declared output +or computed error. Format metadata, tags, bounds and role constants come from +compiled definitions, not fixture metadata. + +All 17 explicit synthetic-DID seed/public-key mappings are independently +checked with noble Ed25519. These labels form a test signing-key directory; +the checker does not resolve production DIDs. Certificates require the +external owner/network anchor, owner signature, canonical member directory, +commitments and pinned lineage. The checker evaluates public shares from the +input commitments rather than using expected public shares. + +Reference lookup indexes only independently recomputed hashes of input +bodies. It returns certificates and ceremonies only after verification. +Expected-output digests are comparison values, never reference authority. +Unresolved, ambiguous and unanchored references are rejected. Record order +does not establish trust. + +### Fixture-only omitted-anchor conventions + +For `stale-ceremony-predecessor`, `inputs.pinned_predecessor` selects the +independently verified certificate and its external anchor. The body's +encoded current digest does not select a predecessor; it must equal that +explicit pin. The declared stale current value is therefore checked against +the pin, not used as a fallback. + +For `genuine-old-response-after-rekey`, the inline certificate is +canonical-decoded to locate its previous digest. That digest must resolve to +an independently verified predecessor. The checker inherits only that +predecessor's rooted external anchor, verifies the inline successor's owner, +network, lineage and signature, then uses its verified current context. +The candidate body's owner/network do not establish an anchor. + +These are disclosed cross-record conventions for this fixture, not production +trust or freshness defaults. No trusted-log freshness input is invented. +`certificate-anchors.test.ts` exercises both conventions and the absence of +candidate-owner or first-record fallbacks. + +### Round, activation and fault boundaries + +Closed-round checking authenticates each exact member envelope under its +signed cohort and directory without fallback, preserves raw payloads and +owner order, verifies the closure, and computes its recursive transcript hash. +The owner relay is trusted; this is not consensus or a freshness mechanism. +Opaque inner payloads are retained, not interpreted as DKG phase outcomes. + +Activation checks every receipt signature, including surplus receipts, before +counting, receipt hashing or sorting. Receipts encode as +`01 || index:u16be || lp(signature)` and hash under READY. Their 32-byte hashes +sort in binary lexicographic order. Readiness and activation signatures remain +signed claims, not proofs of possession or durable installation. + +Fault checking first requires a verified certificate, matching amended +request/share bindings and the member signature. Only then do the two share +equations determine `Fault(member)` versus `NotAFault(ValidShare)`. Context, +helper or signature failures do not attribute an equation fault. + +### Chosen single-byte CLI probes + +Each probe uses an in-memory clone and the real CLI through `/dev/stdin`; +the producer file is not rewritten. Offsets below are zero-based decoded +field-byte offsets; each substitution is checked to change exactly one byte. +Offsets are decimal; byte values and XOR masks in the table are hexadecimal. + +| Record | Field / byte basis | Changed decision | +| --- | --- | --- | +| genesis-2of3 | outputs.digest[31] XOR 01 | Computed digest comparison | +| reshare-3of4 | outputs.digest[31] XOR 01 | Computed digest comparison | +| rekey-2of3 | outputs.digest[31] XOR 01 | Computed digest comparison | +| ceremony-epoch-0 | outputs.digest[31] XOR 01 | Computed authorization comparison | +| ceremony-epoch-1 | outputs.digest[31] XOR 01 | Computed authorization comparison | +| ceremony-epoch-2 | outputs.digest[31] XOR 01 | Computed authorization comparison | +| closed-round-1 | outputs.transcript_hash[31] XOR 01 | Computed recursive hash comparison | +| activate-all-2of3 | inputs.receipts[0].receipt_hash[31] XOR 01 | Computed receipt hash comparison | +| duplicate-member-did | inputs.body[143], 31 to 32 | Decoder succeeds instead of declared error | +| trailing-certificate-byte | error UTF-8[0] XOR 01 | Schema-only error-label probe | +| stale-ceremony-predecessor | inputs.body[0] XOR 01 | Actual error becomes InvalidEncoding | +| signed-valid-share | outputs.signing_preimage[31] XOR 01 | Computed signing hash comparison | +| signed-bad-share | outputs.signing_preimage[31] XOR 01 | Computed signing hash comparison | +| genuine-old-response-after-rekey | inputs.owner_signature[0] XOR 01 | NotAFault reason becomes InvalidSignature | +| activation-under-quorum | inputs.receipts[0].signature[0] XOR 01 | InvalidReadiness before counting | +| activation-zero-budget | budget encoded u16be[1] XOR 01 | Budget becomes 1; activation succeeds | +| activation-budget-overflow | inputs.candidate[31] XOR 01 | UnresolvedReference before budget evaluation | +| activation-duplicate-member | inputs.candidate[31] XOR 01 | UnresolvedReference before duplicate evaluation | + +The trailing-byte row also has a separate valid-body substitution proving its +actual expected-error comparison. Overflow and duplicate rows additionally +substitute a valid budget and unique receipts, respectively, proving semantic +success disagrees with their declared errors. Their one-byte reference probes +are not direct repairs of the budget or duplicated signature. No arbitrary +bit-flip assertion is made. + +Independent behavior tests also cover external anchors, canonical boundaries, +lineage, cohort selection, signature-before-quorum ordering and fault-check +precedence. Constant-substitution tests cover all eight tags, fixed format +metadata and alternate-domain matching signatures/outputs. + +### Certificate portion provenance and scope + +This portion uses the immutable certificate JSON, the approved Rust-free +byte-layout inventory and its two clarified cross-reference conventions, +existing independently written checker utilities, and the +[noble-curves 2.4.0 API README](https://github.com/paulmillr/noble-curves/blob/2.4.0/README.md). +No Rust source/tests/diffs, dependency implementation or other threshold +implementation was consulted. + +This is certificate-core checking only. DKG/resharing execution and transcript +outcome recomputation, and full S0.5 completion, remain outside this portion. +Producer API execution is distinct from this independent checker execution. + +Details: TC-2223 diff --git a/tinycloud-threshold/vectors/check/cert-schema.ts b/tinycloud-threshold/vectors/check/cert-schema.ts new file mode 100644 index 00000000..8044f578 --- /dev/null +++ b/tinycloud-threshold/vectors/check/cert-schema.ts @@ -0,0 +1,75 @@ +import { array, hexField, intField, invalid, literal, object, type Parsed, type Parser } from './schema.ts'; +import { CERT_TAGS } from './certificates.ts'; + +const text: Parser = (v, p) => typeof v === 'string' && new TextEncoder().encode(v).length >= 1 && new TextEncoder().encode(v).length <= 1024 ? v : invalid(p); +const h32 = hexField(32), h96 = hexField(96), index = intField(1, 16); +function hexBytes(max: number, min = 0): Parser { + return (v, p) => typeof v === 'string' && v.length >= min * 2 && v.length <= max * 2 && /^(?:[0-9a-f]{2})*$/.test(v) ? v : invalid(p); +} +function list(parse: Parser, min: number, max: number): Parser { + return (v, p) => { + if (!Array.isArray(v) || v.length < min || v.length > max) return invalid(p); + return v.map((entry: unknown, i) => parse(entry, `${p}[${i}]`)); + }; +} +function nullable(parse: Parser): Parser { return (v, p) => v === null ? null : parse(v, p); } +const body = hexBytes(65536, 1), signature = hexBytes(512), prior = nullable(h32); +const certificateInputs = object({ body, expected_owner: text, network: text, owner_signature: signature, pinned_predecessor: prior }); +const ceremonyInputs = object({ body, expected_owner: text, network: text, signature, pinned_predecessor: prior }); +const certificateOutput = object({ + commitments: list(h96, 2, 16), digest: h32, epoch: intField(0, Number.MAX_SAFE_INTEGER), genesis_t: intField(2, 16), members: intField(2, 16), mpk: h96, owner_statement: h32, + public_shares: list(object({ index, point: h96 }), 2, 16), rekey: intField(0, Number.MAX_SAFE_INTEGER), threshold: intField(2, 16), +}); +const receipt = object({ index, signature }); +const receiptOutput = object({ encoding: hexBytes(519, 7), index, readiness_preimage: h32, receipt_hash: h32, signature }); +const activationErrorInputs = object({ activation_preimage: h32, budget: nullable(intField(0, 65535)), candidate: h32, owner_signature: signature, receipts: list(receipt, 0, 16) }); +const faultInputs = object({ certificate: h32, request: body, share: hexField(259), signature }); +const cases = { + 'genesis-2of3': object({ name: literal('genesis-2of3'), operation: literal('verify_certificate'), inputs: certificateInputs, outputs: certificateOutput }), + 'reshare-3of4': object({ name: literal('reshare-3of4'), operation: literal('verify_certificate'), inputs: certificateInputs, outputs: certificateOutput }), + 'rekey-2of3': object({ name: literal('rekey-2of3'), operation: literal('verify_certificate'), inputs: certificateInputs, outputs: certificateOutput }), + 'ceremony-epoch-0': object({ name: literal('ceremony-epoch-0'), operation: literal('authorize_ceremony'), inputs: ceremonyInputs, outputs: object({ digest: h32, signing_preimage: h32 }) }), + 'ceremony-epoch-1': object({ name: literal('ceremony-epoch-1'), operation: literal('authorize_ceremony'), inputs: ceremonyInputs, outputs: object({ digest: h32, signing_preimage: h32 }) }), + 'ceremony-epoch-2': object({ name: literal('ceremony-epoch-2'), operation: literal('authorize_ceremony'), inputs: ceremonyInputs, outputs: object({ digest: h32, signing_preimage: h32 }) }), + 'closed-round-1': object({ name: literal('closed-round-1'), operation: literal('verify_closed_round'), inputs: object({ ceremony_digest: h32, closure: body, messages: list(object({ body, signature }), 0, 65535), owner_signature: signature, previous: h32, round: intField(0, 255) }), outputs: object({ owner_preimage: h32, transcript_hash: h32 }) }), + 'activate-all-2of3': object({ name: literal('activate-all-2of3'), operation: literal('verify_activation'), inputs: object({ candidate: h32, owner_signature: signature, quorum: literal('all'), receipts: list(receiptOutput, 0, 16) }), outputs: object({ accepted: (v: unknown, p: string) => v === true ? true : invalid(p), activation_preimage: h32 }) }), + 'duplicate-member-did': object({ name: literal('duplicate-member-did'), operation: literal('decode_certificate'), inputs: object({ body }), error: literal('InvalidEncoding') }), + 'trailing-certificate-byte': object({ name: literal('trailing-certificate-byte'), operation: literal('decode_certificate'), inputs: object({ body }), error: literal('InvalidEncoding') }), + 'stale-ceremony-predecessor': object({ name: literal('stale-ceremony-predecessor'), operation: literal('authorize_ceremony'), inputs: object({ body, pinned_predecessor: h32, signature }), error: literal('InvalidCeremony') }), + 'signed-valid-share': object({ name: literal('signed-valid-share'), operation: literal('verify_fault_proof'), inputs: faultInputs, outputs: object({ signing_preimage: h32, verdict: literal('NotAFault'), reason: literal('ValidShare') }) }), + 'signed-bad-share': object({ name: literal('signed-bad-share'), operation: literal('verify_fault_proof'), inputs: faultInputs, outputs: object({ signing_preimage: h32, verdict: literal('Fault'), member: index }) }), + 'genuine-old-response-after-rekey': object({ name: literal('genuine-old-response-after-rekey'), operation: literal('verify_fault_proof'), inputs: object({ certificate_body: body, owner_signature: signature, request: body, share: hexField(259), signature }), outputs: object({ verdict: literal('NotAFault'), reason: literal('ContextMismatch') }) }), + 'activation-under-quorum': object({ name: literal('activation-under-quorum'), operation: literal('verify_activation'), inputs: activationErrorInputs, error: literal('NotEnoughReady') }), + 'activation-zero-budget': object({ name: literal('activation-zero-budget'), operation: literal('verify_activation'), inputs: activationErrorInputs, error: literal('InvalidReadiness') }), + 'activation-budget-overflow': object({ name: literal('activation-budget-overflow'), operation: literal('verify_activation'), inputs: activationErrorInputs, error: literal('InvalidReadiness') }), + 'activation-duplicate-member': object({ name: literal('activation-duplicate-member'), operation: literal('verify_activation'), inputs: activationErrorInputs, error: literal('InvalidReadiness') }), +}; +export type CertificateCase = Parsed<(typeof cases)[keyof typeof cases]>; +const parseCases: Parser = (v, p) => { + if (!Array.isArray(v) || v.length !== Object.keys(cases).length) return invalid(p); + const seen = new Set(); + return v.map((entry: unknown, i) => { + if (!entry || typeof entry !== 'object' || !('name' in entry) || typeof entry.name !== 'string' || !Object.hasOwn(cases, entry.name) || seen.has(entry.name)) return invalid(`${p}[${i}]`); + seen.add(entry.name); + return cases[entry.name as keyof typeof cases](entry, `${p}[${i}].${entry.name}`); + }); +}; +const sections: Parser = (v, p) => { + const expected = ['3.0', '3.8', '3.9', '3.12', 'S0.1 amendment']; + if (!Array.isArray(v) || v.length !== expected.length || v.some((entry, i) => entry !== expected[i])) return invalid(p); + return expected; +}; +const parseFile = object({ + cases: parseCases, + dst: object({ activation: literal(CERT_TAGS.activation), ceremony: literal(CERT_TAGS.ceremony), certificate: literal(CERT_TAGS.certificate), closure: literal(CERT_TAGS.closure), message: literal(CERT_TAGS.message), readiness: literal(CERT_TAGS.readiness), statement: literal(CERT_TAGS.statement), transcript: literal(CERT_TAGS.transcript) }), + encoding: literal('lowercase hex; unsigned integers big-endian; lp=u32be length followed by bytes'), format_version: literal(1), + seeds: object({ ed25519: array(object({ did: text, public_key: h32, seed: h32 }), 17), test_dealer_genesis: intField(0, 255), test_dealer_rekey: intField(0, 255), test_dealer_reshare: intField(0, 255) }), + spec_sections: sections, suite: literal('TC-THRESHOLD-V1'), +}); +export type CertificateFile = Parsed; +export function parseCertificateVectors(value: unknown): CertificateFile { return parseFile(value, 'certificates'); } +export function certificateCase(file: CertificateFile, name: N): Extract { + const found = file.cases.find((entry): entry is Extract => entry.name === name); + if (!found) return invalid(name); + return found; +} diff --git a/tinycloud-threshold/vectors/check/cert-vectors.ts b/tinycloud-threshold/vectors/check/cert-vectors.ts new file mode 100644 index 00000000..d945eefe --- /dev/null +++ b/tinycloud-threshold/vectors/check/cert-vectors.ts @@ -0,0 +1,139 @@ +import { bytes, hex, ProtocolError, tagged } from './protocol.ts'; +import { parseCertificateVectors, type CertificateCase, type CertificateFile } from './cert-schema.ts'; +import { CERT_TAGS, signingKeys, decodeCertificate, verifyCertificate, authorizeCeremony, verifyClosedRound, verifyActivation, verifyFaultProof, type Anchor, type SigningKeys, type VerifiedCertificate, type VerifiedCeremony } from './certificates.ts'; + +interface CertificateSource { body: string; signature: string; anchor?: Anchor; pinned?: string | null } +type CeremonyCase = Extract; +export interface CertificateReport { passed: string[]; signingKeys: number } + +export class CertificateRegistry { + readonly keys: SigningKeys; + private readonly certificates = new Map(); + private readonly ceremonies = new Map(); + private readonly verifiedCertificates = new Map(); + private readonly verifiedCeremonies = new Map(); + private readonly visiting = new Set(); + constructor(file: CertificateFile) { + this.keys = signingKeys(file.seeds.ed25519); + for (const c of file.cases) { + let source: CertificateSource | undefined; + if (c.operation === 'verify_certificate') { + source = { body: c.inputs.body, signature: c.inputs.owner_signature, anchor: { owner: c.inputs.expected_owner, network: c.inputs.network }, pinned: c.inputs.pinned_predecessor }; + } else if (c.operation === 'verify_fault_proof' && 'certificate_body' in c.inputs) { + source = { body: c.inputs.certificate_body, signature: c.inputs.owner_signature }; + } + if (source) { + const digest = hex(tagged(CERT_TAGS.certificate, bytes(source.body))); + this.certificates.set(digest, [...(this.certificates.get(digest) ?? []), source]); + } + if (c.operation === 'authorize_ceremony') { + const digest = hex(tagged(CERT_TAGS.ceremony, bytes(c.inputs.body))); + this.ceremonies.set(digest, [...(this.ceremonies.get(digest) ?? []), c]); + } + } + } + certificate(digest: string): VerifiedCertificate { + const sources = this.certificates.get(digest); + if (!sources?.length) throw new ProtocolError('UnresolvedReference'); + if (sources.length !== 1) throw new ProtocolError('AmbiguousReference'); + const cached = this.verifiedCertificates.get(digest); + if (cached) return cached; + if (this.visiting.has(digest)) throw new ProtocolError('UnresolvedReference'); + this.visiting.add(digest); + try { + const source = sources[0]!, body = decodeCertificate(bytes(source.body)); + const prior = source.pinned === undefined ? (body.previous ? hex(body.previous) : null) : source.pinned; + const previous = prior === null ? undefined : this.certificate(prior); + const anchor = source.anchor ?? previous?.anchor; + if (!anchor) throw new ProtocolError('UnresolvedAnchor'); + const verified = verifyCertificate(bytes(source.body), bytes(source.signature), anchor, this.keys, previous); + this.verifiedCertificates.set(digest, verified); + return verified; + } finally { this.visiting.delete(digest); } + } + ceremony(digest: string): VerifiedCeremony { + const sources = this.ceremonies.get(digest); + if (!sources?.length) throw new ProtocolError('UnresolvedReference'); + if (sources.length !== 1) throw new ProtocolError('AmbiguousReference'); + const cached = this.verifiedCeremonies.get(digest); + if (cached) return cached; + const input = sources[0]!.inputs; + const previous = input.pinned_predecessor === null ? undefined : this.certificate(input.pinned_predecessor); + const anchor = 'expected_owner' in input ? { owner: input.expected_owner, network: input.network } : previous?.anchor; + if (!anchor) throw new ProtocolError('UnresolvedAnchor'); + const verified = authorizeCeremony(bytes(input.body), bytes(input.signature), anchor, this.keys, previous); + this.verifiedCeremonies.set(digest, verified); + return verified; + } +} +function compareOutputs(actual: Record, expected: object) { + for (const [field, value] of Object.entries(expected)) { + if (JSON.stringify(actual[field]) !== JSON.stringify(value)) throw new Error(`${field}: observed ${JSON.stringify(actual[field])}; expected ${JSON.stringify(value)}`); + } +} +function execute(c: CertificateCase, registry: CertificateRegistry): Record { + switch (c.operation) { + case 'verify_certificate': { + const cert = registry.certificate(hex(tagged(CERT_TAGS.certificate, bytes(c.inputs.body)))), b = cert.body; + return { commitments: b.commitments.map(hex), digest: hex(cert.digest), epoch: Number(b.epoch), genesis_t: b.genesisThreshold, members: b.members.length, mpk: hex(b.mpk), owner_statement: hex(cert.ownerPreimage), public_shares: b.members.map(m => ({ index: m.index, point: hex(cert.keyset.publicShares.get(m.index)!.toBytes()) })), rekey: Number(b.rekey), threshold: b.threshold }; + } + case 'authorize_ceremony': { + const ceremony = registry.ceremony(hex(tagged(CERT_TAGS.ceremony, bytes(c.inputs.body)))); + return { digest: hex(ceremony.digest), signing_preimage: hex(ceremony.digest) }; + } + case 'verify_closed_round': { + const i = c.inputs; + const round = verifyClosedRound(registry.ceremony(i.ceremony_digest), bytes(i.closure), bytes(i.owner_signature), i.round, bytes(i.previous), i.messages.map(m => ({ body: bytes(m.body), signature: bytes(m.signature) })), registry.keys); + return { owner_preimage: hex(round.ownerPreimage), transcript_hash: hex(round.transcript) }; + } + case 'verify_activation': { + const i = c.inputs, cert = registry.certificate(i.candidate); + const result = verifyActivation(cert, i.receipts.map(r => ({ index: r.index, signature: bytes(r.signature) })), 'budget' in i ? i.budget : null, bytes(i.owner_signature), registry.keys); + if ('activation_preimage' in i && i.activation_preimage !== hex(result.preimage)) throw new ProtocolError('InvalidReadiness'); + for (const [position, receipt] of i.receipts.entries()) { + if ('encoding' in receipt) { + const computed = result.receipts[position]!; + compareOutputs({ encoding: hex(computed.encoding), index: computed.index, readiness_preimage: hex(computed.preimage), receipt_hash: hex(computed.hash), signature: hex(computed.signature) }, receipt); + } + } + return { accepted: true, activation_preimage: hex(result.preimage) }; + } + case 'decode_certificate': { + decodeCertificate(bytes(c.inputs.body)); + return {}; + } + case 'verify_fault_proof': { + const i = c.inputs; + let cert: VerifiedCertificate; + try { + cert = registry.certificate('certificate' in i ? i.certificate : hex(tagged(CERT_TAGS.certificate, bytes(i.certificate_body)))); + } catch (e) { + if (e instanceof ProtocolError && !['UnresolvedReference', 'AmbiguousReference', 'UnresolvedAnchor'].includes(e.code)) return { verdict: 'NotAFault', reason: e.code }; + throw e; + } + return { ...verifyFaultProof(cert, bytes(i.request), bytes(i.share), bytes(i.signature), registry.keys) }; + } + default: { const exhaustive: never = c; throw new Error(`unsupported operation: ${exhaustive}`); } + } +} +export function checkCertificateVectors(input: unknown): CertificateReport { + const file = parseCertificateVectors(input), registry = new CertificateRegistry(file), passed: string[] = []; + for (const c of file.cases) { + try { + let result: Record | undefined; + let error: string | undefined; + try { result = execute(c, registry); } catch (e) { + if (!(e instanceof ProtocolError)) throw e; + error = e.code; + } + if ('error' in c) { + if (error !== c.error) throw new Error(`computed error: observed ${error}; expected ${c.error}`); + } else { + if (error !== undefined) throw new Error(`computed error: ${error}`); + compareOutputs(result!, c.outputs); + } + passed.push(c.name); + } catch (e) { throw new Error(`${c.name}: ${e instanceof Error ? e.message : String(e)}`); } + } + return { passed, signingKeys: registry.keys.size }; +} diff --git a/tinycloud-threshold/vectors/check/certificate-anchors.test.ts b/tinycloud-threshold/vectors/check/certificate-anchors.test.ts new file mode 100644 index 00000000..2e8982fc --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate-anchors.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'bun:test'; +import { ed25519 } from '@noble/curves/ed25519.js'; +import { bytes, hex, tagged, ProtocolError } from './protocol.ts'; +import { CERT_TAGS, decodeCertificate, encodeCertificate, verifyFaultProof } from './certificates.ts'; +import { CertificateRegistry } from './cert-vectors.ts'; +import { certificateCase } from './cert-schema.ts'; +import { certificateFixture } from './certificate-test-input.ts'; + +function errorCode(f: () => unknown, code: string) { + try { f(); } catch (e) { + expect(e).toBeInstanceOf(ProtocolError); + expect((e as ProtocolError).code).toBe(code); + return; + } + throw new Error(`expected ${code}`); +} + +test('stale ceremony inherits only the explicit pinned certificate anchor, never its encoded current digest', () => { + const file = structuredClone(certificateFixture), row = certificateCase(file, 'stale-ceremony-predecessor'); + const registry = new CertificateRegistry(file), root = registry.certificate(row.inputs.pinned_predecessor); + const inputs = certificateCase(file, 'genesis-2of3').inputs; + expect(root.anchor).toEqual({ owner: inputs.expected_owner, network: inputs.network }); + errorCode(() => registry.ceremony(hex(tagged(CERT_TAGS.ceremony, bytes(row.inputs.body)))), 'InvalidCeremony'); + row.inputs.pinned_predecessor = '00'.repeat(32); + errorCode(() => new CertificateRegistry(file).ceremony(hex(tagged(CERT_TAGS.ceremony, bytes(row.inputs.body)))), 'UnresolvedReference'); +}); + +test('inline old-response certificate is verified as a successor under the resolved predecessor anchor', () => { + const file = structuredClone(certificateFixture), row = certificateCase(file, 'genuine-old-response-after-rekey'); + const registry = new CertificateRegistry(file), body = decodeCertificate(bytes(row.inputs.certificate_body)); + const predecessor = registry.certificate(hex(body.previous!)); + const current = registry.certificate(hex(tagged(CERT_TAGS.certificate, bytes(row.inputs.certificate_body)))); + expect(current.anchor).toEqual(predecessor.anchor); + expect(current.body.epoch).toBe(predecessor.body.epoch + 1n); + const result = verifyFaultProof(current, bytes(row.inputs.request), bytes(row.inputs.share), bytes(row.inputs.signature), registry.keys); + expect(result).toEqual({ verdict: 'NotAFault', reason: 'ContextMismatch' }); +}); + +test('a self-signed inline owner replacement cannot choose its own external anchor', () => { + const file = structuredClone(certificateFixture), row = certificateCase(file, 'genuine-old-response-after-rekey'); + const body = decodeCertificate(bytes(row.inputs.certificate_body)); + body.owner = file.seeds.ed25519[1]!.did; + row.inputs.certificate_body = hex(encodeCertificate(body)); + const digest = tagged(CERT_TAGS.certificate, bytes(row.inputs.certificate_body)); + row.inputs.owner_signature = hex(ed25519.sign(tagged(CERT_TAGS.statement, digest), bytes(file.seeds.ed25519[1]!.seed))); + errorCode(() => new CertificateRegistry(file).certificate(hex(digest)), 'InvalidCertificate'); +}); + +test('inline certificate without a supplied or predecessor-rooted anchor has no fallback', () => { + const file = structuredClone(certificateFixture), row = certificateCase(file, 'genuine-old-response-after-rekey'); + const root = decodeCertificate(bytes(certificateCase(file, 'genesis-2of3').inputs.body)); + root.owner = file.seeds.ed25519[1]!.did; + row.inputs.certificate_body = hex(encodeCertificate(root)); + const digest = tagged(CERT_TAGS.certificate, bytes(row.inputs.certificate_body)); + row.inputs.owner_signature = hex(ed25519.sign(tagged(CERT_TAGS.statement, digest), bytes(file.seeds.ed25519[1]!.seed))); + errorCode(() => new CertificateRegistry(file).certificate(hex(digest)), 'UnresolvedAnchor'); +}); diff --git a/tinycloud-threshold/vectors/check/certificate-cli.test.ts b/tinycloud-threshold/vectors/check/certificate-cli.test.ts new file mode 100644 index 00000000..208cb326 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate-cli.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from 'bun:test'; + +const cwd = new URL('.', import.meta.url).pathname; +const cli = new URL('./cli.ts', import.meta.url).pathname; +const input = new URL('../v1/cert-core.json', import.meta.url).pathname; + +test('real CLI computes the supplied certificate-core document', () => { + const result = Bun.spawnSync([process.execPath, cli, input], { + cwd, env: process.env, stdout: 'pipe', stderr: 'pipe', timeout: 30000, + }); + expect({ exit: result.exitCode, error: result.stderr.toString() }).toEqual({ exit: 0, error: '' }); +}); diff --git a/tinycloud-threshold/vectors/check/certificate-constants.test.ts b/tinycloud-threshold/vectors/check/certificate-constants.test.ts new file mode 100644 index 00000000..bc3ab468 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate-constants.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'bun:test'; +import { ed25519 } from '@noble/curves/ed25519.js'; +import { bytes, be, concat, hex, lp, tagged } from './protocol.ts'; +import { certificateCase, type CertificateFile } from './cert-schema.ts'; +import { certificateFixture, runCertificateCli } from './certificate-test-input.ts'; +import { CertificateRegistry } from './cert-vectors.ts'; + +const tags = { + activation: 'TC-THRESHOLD-V1-ACTIVATE', ceremony: 'TC-THRESHOLD-V1-CEREMONY-AUTH', + certificate: 'TC-THRESHOLD-V1-NETWORK-CERT', closure: 'TC-THRESHOLD-V1-ROUND-CLOSE', + message: 'TC-THRESHOLD-V1-CEREMONY-MSG', readiness: 'TC-THRESHOLD-V1-READY', + statement: 'TC-THRESHOLD-V1-CERT-STATEMENT', transcript: 'TC-THRESHOLD-V1-TRANSCRIPT', +}; +function sign(file: CertificateFile, index: number, preimage: Uint8Array): string { + return hex(ed25519.sign(preimage, bytes(file.seeds.ed25519[index]!.seed))); +} +function rejects(file: CertificateFile, component: string) { + const result = runCertificateCli(file); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(component); +} + +for (const key of Object.keys(tags) as (keyof typeof tags)[]) { + test(`certificate metadata pins the ${key} domain`, () => { + const file = structuredClone(certificateFixture); + Reflect.set(file.dst, key, `${tags[key]}X`); + rejects(file, `dst.${key}`); + }); +} +for (const field of ['suite', 'format_version', 'encoding', 'spec_sections'] as const) { + test(`certificate format metadata ${field} is compiled`, () => { + const file = structuredClone(certificateFixture); + Reflect.set(file, field, field === 'format_version' ? 2 : field === 'spec_sections' ? ['other'] : 'other'); + rejects(file, field); + }); +} + +for (const key of Object.keys(tags) as (keyof typeof tags)[]) { + test(`certificate computation pins ${key} even with matching alternate-domain signatures or outputs`, () => { + const file = structuredClone(certificateFixture), alternate = `${tags[key]}X`; + if (key === 'certificate' || key === 'statement') { + const row = certificateCase(file, 'genesis-2of3'); + const digest = tagged(key === 'certificate' ? alternate : tags.certificate, bytes(row.inputs.body)); + const preimage = tagged(key === 'statement' ? alternate : tags.statement, digest); + row.inputs.owner_signature = sign(file, 0, preimage); + row.outputs.digest = hex(digest); row.outputs.owner_statement = hex(preimage); + rejects(file, 'genesis-2of3'); + } else if (key === 'ceremony') { + const row = certificateCase(file, 'ceremony-epoch-0'), preimage = tagged(alternate, bytes(row.inputs.body)); + row.inputs.signature = sign(file, 0, preimage); + row.outputs.digest = hex(preimage); row.outputs.signing_preimage = hex(preimage); + rejects(file, 'ceremony-epoch-0'); + } else if (key === 'message' || key === 'closure' || key === 'transcript') { + const row = certificateCase(file, 'closed-round-1'); + if (key === 'message') { + const m = row.inputs.messages[0]!; + m.signature = sign(file, 1, tagged(alternate, bytes(m.body))); + } + const list = concat(be(BigInt(row.inputs.messages.length), 2), ...row.inputs.messages.flatMap(m => [lp(bytes(m.body)), lp(bytes(m.signature))])); + const closure = bytes(row.inputs.closure); + closure.set(tagged(key === 'transcript' ? alternate : tags.transcript, list), 34); + row.inputs.closure = hex(closure); + const preimage = tagged(key === 'closure' ? alternate : tags.closure, closure); + row.inputs.owner_signature = sign(file, 0, preimage); + row.outputs.owner_preimage = hex(preimage); + row.outputs.transcript_hash = hex(tagged(key === 'transcript' ? alternate : tags.transcript, concat(bytes(row.inputs.previous), lp(closure), lp(bytes(row.inputs.owner_signature))))); + rejects(file, 'closed-round-1'); + } else { + const row = certificateCase(file, 'activate-all-2of3'); + if (key === 'readiness') { + const r = row.inputs.receipts[0]!, cert = new CertificateRegistry(file).certificate(row.inputs.candidate); + const preimage = tagged(alternate, concat(cert.digest, cert.body.transcript, be(BigInt(r.index), 2), cert.keyset.publicShares.get(r.index)!.toBytes())); + r.readiness_preimage = hex(preimage); r.signature = sign(file, 1, preimage); + r.encoding = hex(concat(be(1n, 1), be(BigInt(r.index), 2), lp(bytes(r.signature)))); + r.receipt_hash = hex(tagged(tags.readiness, bytes(r.encoding))); + } + const sorted = row.inputs.receipts.map(r => bytes(r.receipt_hash)).sort((a, b) => Buffer.compare(a, b)); + const preimage = tagged(key === 'activation' ? alternate : tags.activation, concat(bytes(row.inputs.candidate), ...sorted)); + row.inputs.owner_signature = sign(file, 0, preimage); row.outputs.activation_preimage = hex(preimage); + rejects(file, 'activate-all-2of3'); + } + }); +} + +test('receipt hash independently pins READY in addition to the readiness signing preimage', () => { + const file = structuredClone(certificateFixture), row = certificateCase(file, 'activate-all-2of3'); + row.inputs.receipts[0]!.receipt_hash = hex(tagged(`${tags.readiness}X`, bytes(row.inputs.receipts[0]!.encoding))); + rejects(file, 'activate-all-2of3'); +}); diff --git a/tinycloud-threshold/vectors/check/certificate-corruption.test.ts b/tinycloud-threshold/vectors/check/certificate-corruption.test.ts new file mode 100644 index 00000000..2e2598d3 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate-corruption.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'bun:test'; +import { be, bytes, hex } from './protocol.ts'; +import { certificateCase, type CertificateCase } from './cert-schema.ts'; +import { certificateFixture, runCertificateCli } from './certificate-test-input.ts'; + +interface Probe { + path: (string | number)[]; offset: number; mask: number; encoding: 'hex' | 'utf8' | 'u16'; + outcome: string; observed?: string; schema?: boolean; +} +const probes: Record = { + 'genesis-2of3': { path: ['outputs', 'digest'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed genesis digest differs' }, + 'reshare-3of4': { path: ['outputs', 'digest'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed reshare digest differs' }, + 'rekey-2of3': { path: ['outputs', 'digest'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed rekey digest differs' }, + 'ceremony-epoch-0': { path: ['outputs', 'digest'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed genesis authorization differs' }, + 'ceremony-epoch-1': { path: ['outputs', 'digest'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed reshare authorization differs' }, + 'ceremony-epoch-2': { path: ['outputs', 'digest'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed rekey authorization differs' }, + 'closed-round-1': { path: ['outputs', 'transcript_hash'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed recursive transcript differs' }, + 'activate-all-2of3': { path: ['inputs', 'receipts', 0, 'receipt_hash'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed receipt hash differs' }, + 'duplicate-member-did': { path: ['inputs', 'body'], offset: -1, mask: 0, encoding: 'hex', outcome: 'duplicate DID byte restored, decoder succeeds', observed: 'undefined' }, + 'trailing-certificate-byte': { path: ['error'], offset: 0, mask: 1, encoding: 'utf8', outcome: 'declared error label rejected by schema; valid-body semantic probe below', schema: true }, + 'stale-ceremony-predecessor': { path: ['inputs', 'body'], offset: 0, mask: 1, encoding: 'hex', outcome: 'version changes actual result to InvalidEncoding', observed: 'InvalidEncoding' }, + 'signed-valid-share': { path: ['outputs', 'signing_preimage'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed valid-share signing hash differs' }, + 'signed-bad-share': { path: ['outputs', 'signing_preimage'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed bad-share signing hash differs' }, + 'genuine-old-response-after-rekey': { path: ['inputs', 'owner_signature'], offset: 0, mask: 1, encoding: 'hex', outcome: 'invalid successor signature precedes stale context, NotAFault reason differs', observed: 'reason: observed "InvalidSignature"' }, + 'activation-under-quorum': { path: ['inputs', 'receipts', 0, 'signature'], offset: 0, mask: 1, encoding: 'hex', outcome: 'invalid receipt precedes counting', observed: 'InvalidReadiness' }, + 'activation-zero-budget': { path: ['inputs', 'budget'], offset: 1, mask: 1, encoding: 'u16', outcome: 'budget 0 becomes 1, valid all-member activation succeeds', observed: 'undefined' }, + 'activation-budget-overflow': { path: ['inputs', 'candidate'], offset: 31, mask: 1, encoding: 'hex', outcome: 'candidate reference becomes unresolved before budget evaluation', observed: 'UnresolvedReference' }, + 'activation-duplicate-member': { path: ['inputs', 'candidate'], offset: 31, mask: 1, encoding: 'hex', outcome: 'candidate reference becomes unresolved before duplicate evaluation', observed: 'UnresolvedReference' }, +}; + +for (const original of certificateFixture.cases) { + const probe = probes[original.name]; + test(`certificate one-byte CLI decision: ${original.name}: ${probe.outcome}`, () => { + const file = structuredClone(certificateFixture); + let target: object = certificateCase(file, original.name); + for (const part of probe.path.slice(0, -1)) { + const next: unknown = Reflect.get(target, part); + if (!next || typeof next !== 'object') throw new Error('not a field container'); + target = next; + } + const field = probe.path.at(-1)!; + const value: unknown = Reflect.get(target, field); + if (probe.encoding === 'u16' ? typeof value !== 'number' : typeof value !== 'string') throw new Error('wrong probe field type'); + const before = typeof value === 'number' ? be(BigInt(value), 2) : Buffer.from(String(value), probe.encoding === 'u16' ? 'hex' : probe.encoding); + const after = new Uint8Array(before); + let offset = probe.offset, mask = probe.mask; + if (original.name === 'duplicate-member-did') { + const valid = bytes(certificateCase(file, 'genesis-2of3').inputs.body); + expect(valid.length).toBe(before.length); + const differences = [...before.keys()].filter(i => before[i] !== valid[i]); + expect(differences.length).toBe(1); + offset = differences[0]!; mask = before[offset]! ^ valid[offset]!; + } + if (offset < 0 || offset >= after.length) throw new Error('probe offset outside field'); + after[offset] = after[offset]! ^ mask; + expect(after.length).toBe(before.length); + expect(after.reduce((n, byte, i) => n + Number(byte !== before[i]), 0)).toBe(1); + Reflect.set(target, field, probe.encoding === 'u16' ? Number(BigInt(`0x${hex(after)}`)) : Buffer.from(after).toString(probe.encoding)); + const result = runCertificateCli(file), error = result.stderr.toString(); + expect(result.exitCode).toBe(1); + expect(error).toContain(original.name); + if (probe.schema) expect(error).toContain('schema:'); + if (probe.observed) expect(error).toContain(probe.observed); + }, 30000); +} + +test('trailing-byte row compares the actual decoder outcome with a valid input', () => { + const file = structuredClone(certificateFixture); + certificateCase(file, 'trailing-certificate-byte').inputs.body = certificateCase(file, 'genesis-2of3').inputs.body; + const result = runCertificateCli(file); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain('trailing-certificate-byte: computed error: observed undefined'); +}); + +test('overflow row also compares semantic success after substituting a valid budget', () => { + const file = structuredClone(certificateFixture); + certificateCase(file, 'activation-budget-overflow').inputs.budget = 1; + const result = runCertificateCli(file); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain('activation-budget-overflow: computed error: observed undefined'); +}); + +test('duplicate row also compares semantic success after replacing the duplicate receipt', () => { + const file = structuredClone(certificateFixture), values = certificateCase(file, 'activate-all-2of3').inputs.receipts; + certificateCase(file, 'activation-duplicate-member').inputs.receipts = values.map(r => ({ index: r.index, signature: r.signature })); + const result = runCertificateCli(file); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain('activation-duplicate-member: computed error: observed undefined'); +}); diff --git a/tinycloud-threshold/vectors/check/certificate-test-input.ts b/tinycloud-threshold/vectors/check/certificate-test-input.ts new file mode 100644 index 00000000..abda70e1 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate-test-input.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs'; +import { parseCertificateVectors } from './cert-schema.ts'; + +const input: unknown = JSON.parse(readFileSync(new URL('../v1/cert-core.json', import.meta.url), 'utf8')); +export const certificateFixture = parseCertificateVectors(input); +export function runCertificateCli(value: unknown) { + return Bun.spawnSync([process.execPath, new URL('./cli.ts', import.meta.url).pathname, '/dev/stdin'], { + cwd: new URL('.', import.meta.url).pathname, env: process.env, + stdin: Buffer.from(JSON.stringify(value)), stdout: 'pipe', stderr: 'pipe', timeout: 30000, + }); +} diff --git a/tinycloud-threshold/vectors/check/certificate-vectors.test.ts b/tinycloud-threshold/vectors/check/certificate-vectors.test.ts new file mode 100644 index 00000000..c249e3c4 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate-vectors.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from 'bun:test'; +import { bytes, hex, tagged, ProtocolError } from './protocol.ts'; +import { CERT_TAGS } from './certificates.ts'; +import { certificateCase } from './cert-schema.ts'; +import { CertificateRegistry, checkCertificateVectors } from './cert-vectors.ts'; +import { certificateFixture, runCertificateCli } from './certificate-test-input.ts'; + +function errorCode(f: () => unknown, code: string) { + try { f(); } catch (e) { + expect(e).toBeInstanceOf(ProtocolError); + expect((e as ProtocolError).code).toBe(code); + return; + } + throw new Error(`expected ${code}`); +} + +test('all named certificate operations and public key mappings are computed', () => { + const report = checkCertificateVectors(certificateFixture); + expect(report.passed).toEqual(certificateFixture.cases.map(c => c.name)); + expect(report.signingKeys).toBe(17); +}); + +test('expected digest bytes never establish reference authority', () => { + const file = structuredClone(certificateFixture); + const certificate = certificateCase(file, 'genesis-2of3'), ceremony = certificateCase(file, 'ceremony-epoch-0'); + const certDigest = hex(tagged(CERT_TAGS.certificate, bytes(certificate.inputs.body))); + const authDigest = hex(tagged(CERT_TAGS.ceremony, bytes(ceremony.inputs.body))); + certificate.outputs.digest = '00'.repeat(32); ceremony.outputs.digest = '00'.repeat(32); + const registry = new CertificateRegistry(file); + expect(hex(registry.certificate(certDigest).digest)).toBe(certDigest); + expect(hex(registry.ceremony(authDigest).digest)).toBe(authDigest); + errorCode(() => registry.certificate('00'.repeat(32)), 'UnresolvedReference'); + errorCode(() => registry.ceremony('00'.repeat(32)), 'UnresolvedReference'); + expect(() => checkCertificateVectors(file)).toThrow('genesis-2of3'); +}); + +test('unverified candidate signatures never become cached authority', () => { + const file = structuredClone(certificateFixture), first = certificateCase(file, 'genesis-2of3'); + first.inputs.owner_signature = '00'.repeat(64); + const registry = new CertificateRegistry(file), digest = hex(tagged(CERT_TAGS.certificate, bytes(first.inputs.body))); + errorCode(() => registry.certificate(digest), 'InvalidSignature'); + errorCode(() => registry.certificate(digest), 'InvalidSignature'); +}); + +test('duplicate certificate and ceremony input references are ambiguous even with differing expected outputs', () => { + const certs = structuredClone(certificateFixture); + const first = certificateCase(certs, 'genesis-2of3'), second = certificateCase(certs, 'reshare-3of4'); + second.inputs = structuredClone(first.inputs); + const registry = new CertificateRegistry(certs); + errorCode(() => registry.certificate(hex(tagged(CERT_TAGS.certificate, bytes(first.inputs.body)))), 'AmbiguousReference'); + const ceremonies = structuredClone(certificateFixture), auth = certificateCase(ceremonies, 'ceremony-epoch-0'); + certificateCase(ceremonies, 'ceremony-epoch-1').inputs = structuredClone(auth.inputs); + errorCode(() => new CertificateRegistry(ceremonies).ceremony(hex(tagged(CERT_TAGS.ceremony, bytes(auth.inputs.body)))), 'AmbiguousReference'); +}); + +test('cross-case verification is independent of case ordering', () => { + const file = structuredClone(certificateFixture); file.cases.reverse(); + expect(checkCertificateVectors(file).passed).toEqual(file.cases.map(c => c.name)); +}); + +test('all seventeen seed/public-key mappings are checked, not merely looked up', () => { + for (let i = 0; i < certificateFixture.seeds.ed25519.length; i++) { + const file = structuredClone(certificateFixture), key = file.seeds.ed25519[i]!; + key.public_key = (key.public_key[0] === '0' ? '1' : '0') + key.public_key.slice(1); + expect(() => checkCertificateVectors(file)).toThrow('InvalidSigningKey'); + } +}); + +test('schema refuses missing, duplicate and additional certificate records or fields', () => { + const missing = structuredClone(certificateFixture); missing.cases.pop(); + expect(() => checkCertificateVectors(missing)).toThrow(); + const duplicate = structuredClone(certificateFixture); duplicate.cases[1] = duplicate.cases[0]!; + expect(() => checkCertificateVectors(duplicate)).toThrow(); + const extra = structuredClone(certificateFixture); Reflect.set(extra.cases[0]!.inputs, 'anchor_fallback', true); + expect(() => checkCertificateVectors(extra)).toThrow(); + const empty = structuredClone(certificateFixture); empty.cases = []; + expect(runCertificateCli(empty).exitCode).toBe(1); +}); diff --git a/tinycloud-threshold/vectors/check/certificate.test.ts b/tinycloud-threshold/vectors/check/certificate.test.ts new file mode 100644 index 00000000..540eb1b2 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificate.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from 'bun:test'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { ed25519 } from '@noble/curves/ed25519.js'; +import { be, concat, hex, tagged, ProtocolError, encodeIdentity, encodeRequest, contextDigest, requestDigest, encodeShare, signingPreimage, h1 } from './protocol.ts'; +import { signingKeys, encodeCertificate, decodeCertificate, verifyCertificate, encodeCeremony, authorizeCeremony, verifyClosedRound, verifyActivation, verifyFaultProof, type CertificateBody, type VerifiedCertificate } from './certificates.ts'; + +const seed = (n: number) => new Uint8Array(32).fill(n); +const directory = [21, 22, 23, 24, 25].map((n, i) => ({ did: `independent:${i}`, seed: hex(seed(n)), public_key: hex(ed25519.getPublicKey(seed(n))) })); +const keys = signingKeys(directory); +const anchor = { owner: 'independent:0', network: 'tinycloud:test:checker-independent' }; +const base: CertificateBody = { + ...anchor, rekey: 0n, epoch: 0n, genesisThreshold: 2, threshold: 2, + members: [1, 2, 3].map(index => ({ index, did: `independent:${index}` })), + mpk: bls.G2.Point.BASE.multiply(13n).toBytes(), commitments: [13n, 5n].map(n => bls.G2.Point.BASE.multiply(n).toBytes()), transcript: seed(77), +}; +function signCertificate(body: CertificateBody, previous?: VerifiedCertificate) { + const encoded = encodeCertificate(body); + const digest = tagged('TC-THRESHOLD-V1-NETWORK-CERT', encoded); + const signature = ed25519.sign(tagged('TC-THRESHOLD-V1-CERT-STATEMENT', digest), seed(21)); + return verifyCertificate(encoded, signature, anchor, keys, previous); +} +function errorCode(operation: () => unknown, code: string) { + try { operation(); } catch (e) { + expect(e).toBeInstanceOf(ProtocolError); + expect((e as ProtocolError).code).toBe(code); + return; + } + throw new Error(`expected ${code}`); +} +const lp = (b: Uint8Array) => concat(be(BigInt(b.length), 4), b); + +test('signing directory derives public keys and rejects duplicates or inconsistent mappings', () => { + expect(hex(keys.get('independent:1')!)).toBe(directory[1]!.public_key); + errorCode(() => signingKeys([...directory, directory[0]!]), 'InvalidSigningKey'); + errorCode(() => signingKeys([{ ...directory[0]!, public_key: directory[1]!.public_key }]), 'InvalidSigningKey'); +}); + +test('canonical certificates evaluate shares and verify a pinned reshare/rekey chain', () => { + const genesis = signCertificate(base); + expect(hex(encodeCertificate(decodeCertificate(genesis.encoded)))).toBe(hex(genesis.encoded)); + expect([...genesis.keyset.publicShares.values()].map(p => hex(p.toBytes()))).toEqual([18n, 23n, 28n].map(n => hex(bls.G2.Point.BASE.multiply(n).toBytes()))); + const reshare = signCertificate({ ...base, epoch: 1n, previous: genesis.digest, threshold: 3, members: [...base.members, { index: 4, did: 'independent:4' }], commitments: [13n, 5n, 7n].map(n => bls.G2.Point.BASE.multiply(n).toBytes()) }, genesis); + const rekey = signCertificate({ ...base, epoch: 2n, rekey: 1n, previous: reshare.digest, previousMpk: base.mpk, mpk: bls.G2.Point.BASE.multiply(19n).toBytes(), commitments: [19n, 3n].map(n => bls.G2.Point.BASE.multiply(n).toBytes()) }, reshare); + expect(rekey.body.rekey).toBe(1n); + expect(rekey.body.epoch).toBe(2n); + errorCode(() => signCertificate({ ...base, epoch: 2n, previous: genesis.digest }, genesis), 'InvalidCertificate'); + errorCode(() => signCertificate({ ...base, epoch: 1n, previous: genesis.digest, mpk: rekey.body.mpk, commitments: rekey.body.commitments }, genesis), 'InvalidCertificate'); +}); + +test('certificate anchors and signatures are checked independently of candidate fields', () => { + const good = signCertificate(base); + errorCode(() => verifyCertificate(good.encoded, good.signature, { ...anchor, owner: 'independent:1' }, keys), 'InvalidCertificate'); + errorCode(() => verifyCertificate(good.encoded, new Uint8Array(64), anchor, keys), 'InvalidSignature'); +}); + +test('canonical certificate rules reject duplicate DIDs, trailing bytes, identity public shares and identity top coefficients', () => { + errorCode(() => decodeCertificate(encodeCertificate({ ...base, members: [{ index: 1, did: 'same' }, { index: 2, did: 'same' }, { index: 3, did: 'other' }] })), 'InvalidEncoding'); + errorCode(() => decodeCertificate(concat(encodeCertificate(base), be(0n, 1))), 'InvalidEncoding'); + errorCode(() => decodeCertificate(encodeCertificate({ ...base, commitments: [base.mpk, bls.G2.Point.ZERO.toBytes()] })), 'InvalidEncoding'); + errorCode(() => decodeCertificate(encodeCertificate({ ...base, commitments: [base.mpk, bls.G2.Point.BASE.multiply(13n).negate().toBytes()] })), 'InvalidEncoding'); +}); + +function ceremony(previous?: VerifiedCertificate, kind = 1) { + const body = { ...anchor, kind, id: seed(31), current: previous?.digest, threshold: 2, rekey: kind === 4 ? 1n : 0n, epoch: previous ? previous.body.epoch + 1n : 0n, recipients: [1, 2, 3].map(index => ({ index, did: `independent:${index}`, hpke: seed(index + 90) })) }; + const encoded = encodeCeremony(body); + const signature = ed25519.sign(tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', encoded), seed(21)); + return authorizeCeremony(encoded, signature, anchor, keys, previous); +} + +test('ceremony authorization binds external anchor, current certificate and counters', () => { + const genesis = signCertificate(base), authorized = ceremony(genesis, 2); + expect(authorized.body.epoch).toBe(1n); + errorCode(() => authorizeCeremony(authorized.encoded, authorized.signature, { ...anchor, network: 'other' }, keys, genesis), 'InvalidCeremony'); + const stale = { ...authorized.body, current: seed(19) }, encoded = encodeCeremony(stale); + errorCode(() => authorizeCeremony(encoded, ed25519.sign(tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', encoded), seed(21)), anchor, keys, genesis), 'InvalidCeremony'); + const invalid = { ...ceremony().body, threshold: 3 }; + const invalidBytes = encodeCeremony(invalid); + errorCode(() => authorizeCeremony(invalidBytes, ed25519.sign(tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', invalidBytes), seed(21)), anchor, keys), 'InvalidCeremony'); +}); + +function closed(previousCertificate?: VerifiedCertificate, cohort = 1, signer = 22) { + const auth = ceremony(previousCertificate, previousCertificate ? 2 : 1), previous = seed(0), round = 1; + const payload = new Uint8Array([255, 0, 61]); + const body = concat(be(1n, 1), auth.digest, be(BigInt(round), 1), be(BigInt(cohort), 1), be(1n, 2), lp(payload)); + const messages = [{ body, signature: ed25519.sign(tagged('TC-THRESHOLD-V1-CEREMONY-MSG', body), seed(signer)) }]; + const list = concat(be(1n, 2), ...messages.flatMap(m => [lp(m.body), lp(m.signature)])); + const closure = concat(be(1n, 1), auth.digest, be(BigInt(round), 1), tagged('TC-THRESHOLD-V1-TRANSCRIPT', list), previous); + const signature = ed25519.sign(tagged('TC-THRESHOLD-V1-ROUND-CLOSE', closure), seed(21)); + return { auth, previous, round, messages, closure, signature, payload }; +} + +test('closed rounds authenticate exact envelopes and preserve raw payload bytes', () => { + const round = closed(); + const result = verifyClosedRound(round.auth, round.closure, round.signature, round.round, round.previous, round.messages, keys); + expect(hex(result.messages[0]!.payload)).toBe(hex(round.payload)); + expect(hex(result.transcript)).toBe(hex(tagged('TC-THRESHOLD-V1-TRANSCRIPT', concat(round.previous, lp(round.closure), lp(round.signature))))); + errorCode(() => verifyClosedRound(round.auth, round.closure, round.signature, 2, round.previous, round.messages, keys), 'InvalidCeremony'); + errorCode(() => verifyClosedRound(round.auth, round.closure, round.signature, round.round, seed(9), round.messages, keys), 'InvalidCeremony'); + errorCode(() => verifyClosedRound(round.auth, round.closure, round.signature, round.round, round.previous, [{ ...round.messages[0]!, signature: new Uint8Array(64) }], keys), 'InvalidSignature'); +}); + +test('signed cohort selects its own directory with no recipient fallback', () => { + const genesis = signCertificate(base), round = closed(genesis, 2); + const swapped = { ...round.auth.body, recipients: round.auth.body.recipients.map((r, i) => ({ ...r, did: `independent:${i === 0 ? 2 : i === 1 ? 1 : 3}` })) }; + const encoded = encodeCeremony(swapped); + const auth = authorizeCeremony(encoded, ed25519.sign(tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', encoded), seed(21)), anchor, keys, genesis); + const body = concat(be(1n, 1), auth.digest, be(1n, 1), be(2n, 1), be(1n, 2), lp(round.payload)); + const messages = [{ body, signature: ed25519.sign(tagged('TC-THRESHOLD-V1-CEREMONY-MSG', body), seed(23)) }]; + const list = concat(be(1n, 2), lp(body), lp(messages[0]!.signature)); + const closure = concat(be(1n, 1), auth.digest, be(1n, 1), tagged('TC-THRESHOLD-V1-TRANSCRIPT', list), round.previous); + const signature = ed25519.sign(tagged('TC-THRESHOLD-V1-ROUND-CLOSE', closure), seed(21)); + errorCode(() => verifyClosedRound(auth, closure, signature, 1, round.previous, messages, keys), 'InvalidSignature'); +}); + +function receipts(cert: VerifiedCertificate) { + return cert.body.members.map(m => { + const preimage = tagged('TC-THRESHOLD-V1-READY', concat(cert.digest, cert.body.transcript, be(BigInt(m.index), 2), cert.keyset.publicShares.get(m.index)!.toBytes())); + return { index: m.index, signature: ed25519.sign(preimage, seed(21 + m.index)) }; + }); +} +function activationSignature(cert: VerifiedCertificate, values: { index: number; signature: Uint8Array }[]) { + const hashes = values.map(r => tagged('TC-THRESHOLD-V1-READY', concat(be(1n, 1), be(BigInt(r.index), 2), lp(r.signature)))).sort((a, b) => Buffer.compare(a, b)); + return ed25519.sign(tagged('TC-THRESHOLD-V1-ACTIVATE', concat(cert.digest, ...hashes)), seed(21)); +} + +test('activation canonical receipts and binary sorted hashes are independent of receipt order', () => { + const cert = signCertificate(base), values = receipts(cert), signature = activationSignature(cert, values); + const forward = verifyActivation(cert, values, null, signature, keys); + const reverse = verifyActivation(cert, [...values].reverse(), null, signature, keys); + expect(hex(forward.preimage)).toBe(hex(reverse.preimage)); + expect(hex(forward.receipts[0]!.encoding)).toBe(hex(concat(be(1n, 1), be(1n, 2), lp(values[0]!.signature)))); + errorCode(() => verifyActivation(cert, values.slice(0, 2), null, signature, keys), 'NotEnoughReady'); + errorCode(() => verifyActivation(cert, values, 0, signature, keys), 'InvalidReadiness'); + errorCode(() => verifyActivation(cert, values, 65535, signature, keys), 'InvalidReadiness'); + errorCode(() => verifyActivation(cert, [values[0]!, values[0]!, values[1]!], null, signature, keys), 'InvalidReadiness'); +}); + +test('all receipt signatures are checked, including surplus and under-quorum inputs', () => { + const cert = signCertificate({ ...base, members: [...base.members, { index: 4, did: 'independent:4' }] }); + const values = receipts(cert), signature = activationSignature(cert, values); + values[3] = { ...values[3]!, signature: new Uint8Array(64) }; + errorCode(() => verifyActivation(cert, values, 1, signature, keys), 'InvalidReadiness'); + errorCode(() => verifyActivation(cert, [values[3]!], null, signature, keys), 'InvalidReadiness'); +}); + +test('fault attribution follows context and member signature checks before equations', () => { + const cert = signCertificate(base), identity = encodeIdentity({ mode: 2, network: anchor.network, path: 'independent/fault', version: 1n }); + const context = cert.keyset.context; + const request = encodeRequest({ ...context, context: contextDigest(context), identity, tpk: bls.G1.Point.BASE.multiply(11n).toBytes() }); + const share = encodeShare({ member: 1, context: contextDigest(context), request: requestDigest(request), c1: bls.G1.Point.BASE.multiply(7n).toBytes(), c2: bls.G2.Point.BASE.multiply(7n).toBytes(), c3: h1(identity).multiply(18n).add(bls.G1.Point.BASE.multiply(77n)).toBytes() }); + const signature = ed25519.sign(signingPreimage(share, requestDigest(request), cert.digest), seed(22)); + expect(verifyFaultProof(cert, request, share, signature, keys).verdict).toBe('NotAFault'); + const bad = share.slice(); bad.set(bls.G1.Point.BASE.toBytes(), 211); + const badSignature = ed25519.sign(signingPreimage(bad, requestDigest(request), cert.digest), seed(22)); + expect(verifyFaultProof(cert, request, bad, badSignature, keys).verdict).toBe('Fault'); + expect(verifyFaultProof(cert, request, bad, new Uint8Array(64), keys).reason).toBe('InvalidSignature'); + const mixed = bad.slice(); mixed[35] = mixed[35]! ^ 1; + expect(verifyFaultProof(cert, request, mixed, badSignature, keys).reason).toBe('ContextMismatch'); +}); diff --git a/tinycloud-threshold/vectors/check/certificates.ts b/tinycloud-threshold/vectors/check/certificates.ts new file mode 100644 index 00000000..1d261264 --- /dev/null +++ b/tinycloud-threshold/vectors/check/certificates.ts @@ -0,0 +1,222 @@ +import { ed25519 } from '@noble/curves/ed25519.js'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { Reader, ProtocolError, be, bytes, concat, hex, lp, tagged, g2, decodeRequest, decodeShare, contextDigest, requestDigest, signingPreimage, verifyShare, type Bytes, type G2, type Keyset } from './protocol.ts'; + +export const CERT_TAGS = { + activation: 'TC-THRESHOLD-V1-ACTIVATE', ceremony: 'TC-THRESHOLD-V1-CEREMONY-AUTH', + certificate: 'TC-THRESHOLD-V1-NETWORK-CERT', closure: 'TC-THRESHOLD-V1-ROUND-CLOSE', + message: 'TC-THRESHOLD-V1-CEREMONY-MSG', readiness: 'TC-THRESHOLD-V1-READY', + statement: 'TC-THRESHOLD-V1-CERT-STATEMENT', transcript: 'TC-THRESHOLD-V1-TRANSCRIPT', +} as const; +const utf8 = new TextEncoder(); +const MAX_U64 = (1n << 64n) - 1n; +export interface SigningInput { did: string; seed: string; public_key: string } +export type SigningKeys = Map; +export interface Anchor { owner: string; network: string } +export interface Member { index: number; did: string } +export interface CertificateBody extends Anchor { + rekey: bigint; epoch: bigint; genesisThreshold: number; threshold: number; + members: Member[]; mpk: Bytes; commitments: Bytes[]; transcript: Bytes; + previous?: Bytes; previousMpk?: Bytes; +} +export interface VerifiedCertificate { + body: CertificateBody; encoded: Bytes; signature: Bytes; digest: Bytes; ownerPreimage: Bytes; anchor: Anchor; keyset: Keyset; +} +export interface Recipient extends Member { hpke: Bytes } +export interface CeremonyBody extends Anchor { + kind: number; id: Bytes; current?: Bytes; threshold: number; rekey: bigint; epoch: bigint; recipients: Recipient[]; +} +export interface VerifiedCeremony { + body: CeremonyBody; encoded: Bytes; signature: Bytes; digest: Bytes; anchor: Anchor; previous?: VerifiedCertificate; +} +export interface SignedMessage { body: Bytes; signature: Bytes } +export interface Receipt { index: number; signature: Bytes } +export interface CheckedReceipt extends Receipt { encoding: Bytes; preimage: Bytes; hash: Bytes } +export interface FaultResult { verdict: 'Fault' | 'NotAFault'; reason?: string; member?: number; signing_preimage?: string } + +function equal(a: Bytes, b: Bytes): boolean { return hex(a) === hex(b); } +function option(value: Bytes | undefined): Bytes { return value === undefined ? be(0n, 1) : concat(be(1n, 1), value); } +function readOption(r: Reader, width: number): Bytes | undefined { + const flag = r.int(1); + if (flag === 0n) return undefined; + if (flag !== 1n) throw new ProtocolError('InvalidEncoding'); + return r.take(width); +} +function directory(members: Member[], threshold: number) { + if (threshold < 2 || threshold > members.length || members.length > 16) throw new ProtocolError('InvalidEncoding'); + const seen = new Set(); + for (const [i, m] of members.entries()) { + const length = utf8.encode(m.did).length; + if (m.index !== i + 1 || length < 1 || length > 256 || seen.has(m.did)) throw new ProtocolError('InvalidEncoding'); + seen.add(m.did); + } +} +function signatureValid(keys: SigningKeys, did: string, signature: Bytes, preimage: Bytes): boolean { + const key = keys.get(did); + if (!key || signature.length !== 64) return false; + try { return ed25519.verify(signature, preimage, key, { zip215: false }); } catch { return false; } +} +export function signingKeys(inputs: SigningInput[]): SigningKeys { + const keys: SigningKeys = new Map(); + for (const input of inputs) { + try { + const secret = bytes(input.seed), publicKey = bytes(input.public_key), length = utf8.encode(input.did).length; + if (length < 1 || length > 256 || keys.has(input.did) || secret.length !== 32 || publicKey.length !== 32 || !equal(ed25519.getPublicKey(secret), publicKey)) throw new Error(); + keys.set(input.did, publicKey); + } catch { throw new ProtocolError('InvalidSigningKey'); } + } + return keys; +} +export function encodeCertificate(c: CertificateBody): Bytes { + return concat(be(1n, 1), lp(utf8.encode(c.network)), lp(utf8.encode(c.owner)), be(c.rekey, 8), be(c.epoch, 8), be(BigInt(c.genesisThreshold), 2), be(BigInt(c.threshold), 2), be(BigInt(c.members.length), 2), be(BigInt(c.members.length), 2), + ...c.members.flatMap(m => [be(BigInt(m.index), 2), lp(utf8.encode(m.did))]), c.mpk, be(BigInt(c.commitments.length), 2), ...c.commitments, c.transcript, option(c.previous), option(c.previousMpk)); +} +function publicShares(c: CertificateBody): Map { + const commitments = c.commitments.map(p => g2(p, true)); + const shares = new Map(); + for (const m of c.members) { + let sum = bls.G2.Point.ZERO; + for (const p of [...commitments].reverse()) sum = (sum.equals(bls.G2.Point.ZERO) ? bls.G2.Point.ZERO : sum.multiply(BigInt(m.index))).add(p); + if (sum.equals(bls.G2.Point.ZERO)) throw new ProtocolError('InvalidEncoding'); + shares.set(m.index, sum); + } + return shares; +} +export function decodeCertificate(encoded: Bytes): CertificateBody { + try { + const r = new Reader(encoded); r.version(); + const network = r.text(), owner = r.text(256), rekey = r.int(8), epoch = r.int(8), genesisThreshold = Number(r.int(2)), threshold = Number(r.int(2)), n = Number(r.int(2)), count = Number(r.int(2)); + if (n > 16 || n < 2 || count !== n || threshold < 2 || threshold > n || genesisThreshold < 2 || genesisThreshold > 16) throw new ProtocolError('InvalidEncoding'); + const members = Array.from({ length: count }, () => ({ index: Number(r.int(2)), did: r.text(256) })); + directory(members, threshold); + const mpk = r.take(96), size = Number(r.int(2)); + if (size !== threshold) throw new ProtocolError('InvalidEncoding'); + const commitments = Array.from({ length: size }, () => r.take(96)); + const c: CertificateBody = { network, owner, rekey, epoch, genesisThreshold, threshold, members, mpk, commitments, transcript: r.take(32), previous: readOption(r, 32), previousMpk: readOption(r, 96) }; + r.end(); + g2(mpk); commitments.forEach(p => g2(p, true)); g2(commitments[threshold - 1]!); + if (!equal(mpk, commitments[0]!) || (!c.previous && (c.previousMpk || epoch !== 0n || rekey !== 0n || genesisThreshold !== threshold || n < 2 * threshold - 1))) throw new ProtocolError('InvalidEncoding'); + publicShares(c); + if (!equal(encodeCertificate(c), encoded)) throw new ProtocolError('InvalidEncoding'); + return c; + } catch (e) { + if (e instanceof ProtocolError) throw new ProtocolError('InvalidEncoding'); + throw e; + } +} +export function verifyCertificate(encoded: Bytes, signature: Bytes, anchor: Anchor, keys: SigningKeys, previous?: VerifiedCertificate): VerifiedCertificate { + const c = decodeCertificate(encoded), invalid = () => { throw new ProtocolError('InvalidCertificate'); }; + if (c.owner !== anchor.owner || c.network !== anchor.network) invalid(); + const digest = tagged(CERT_TAGS.certificate, encoded), ownerPreimage = tagged(CERT_TAGS.statement, digest); + if (!signatureValid(keys, anchor.owner, signature, ownerPreimage)) throw new ProtocolError('InvalidSignature'); + if (!previous) { + if (c.previous || c.previousMpk || c.rekey !== 0n || c.epoch !== 0n) invalid(); + } else { + const p = previous.body; + if (previous.anchor.owner !== anchor.owner || previous.anchor.network !== anchor.network || !c.previous || !equal(c.previous, previous.digest) || p.epoch === MAX_U64 || c.epoch !== p.epoch + 1n) invalid(); + if (c.previousMpk) { + if (!equal(c.previousMpk, p.mpk) || equal(c.mpk, p.mpk) || p.rekey === MAX_U64 || c.rekey !== p.rekey + 1n || c.genesisThreshold !== c.threshold || c.members.length < 2 * c.threshold - 1) invalid(); + } else if (!equal(c.mpk, p.mpk) || c.rekey !== p.rekey || c.genesisThreshold !== p.genesisThreshold) invalid(); + } + return { body: c, encoded, signature, digest, ownerPreimage, anchor: { ...anchor }, keyset: { mpk: g2(c.mpk), publicShares: publicShares(c), context: { network: c.network, rekey: c.rekey, epoch: c.epoch, certificate: digest, threshold: c.threshold, members: c.members.length } } }; +} +export function encodeCeremony(c: CeremonyBody): Bytes { + return concat(be(1n, 1), be(BigInt(c.kind), 1), c.id, lp(utf8.encode(c.network)), lp(utf8.encode(c.owner)), option(c.current), be(BigInt(c.threshold), 2), be(BigInt(c.recipients.length), 2), be(c.rekey, 8), be(c.epoch, 8), be(BigInt(c.recipients.length), 2), ...c.recipients.flatMap(m => [be(BigInt(m.index), 2), lp(utf8.encode(m.did)), m.hpke])); +} +export function decodeCeremony(encoded: Bytes): CeremonyBody { + const r = new Reader(encoded); r.version(); + const kind = Number(r.int(1)), id = r.take(32), network = r.text(), owner = r.text(256), current = readOption(r, 32), threshold = Number(r.int(2)), n = Number(r.int(2)), rekey = r.int(8), epoch = r.int(8), count = Number(r.int(2)); + if (kind < 1 || kind > 4 || count !== n || n < 2 || n > 16) throw new ProtocolError('InvalidCeremony'); + const recipients = Array.from({ length: count }, () => ({ index: Number(r.int(2)), did: r.text(256), hpke: r.take(32) })); + directory(recipients, threshold); r.end(); + const c = { kind, id, network, owner, current, threshold, rekey, epoch, recipients }; + if (!equal(encodeCeremony(c), encoded)) throw new ProtocolError('InvalidCeremony'); + return c; +} +export function authorizeCeremony(encoded: Bytes, signature: Bytes, anchor: Anchor, keys: SigningKeys, previous?: VerifiedCertificate): VerifiedCeremony { + const c = decodeCeremony(encoded), invalid = () => { throw new ProtocolError('InvalidCeremony'); }; + if (c.owner !== anchor.owner || c.network !== anchor.network) invalid(); + if (c.kind === 1) { + if (previous || c.current || c.epoch !== 0n || c.rekey !== 0n || c.recipients.length < 2 * c.threshold - 1) invalid(); + } else { + if (!previous || !c.current || !equal(c.current, previous.digest) || previous.anchor.owner !== anchor.owner || previous.anchor.network !== anchor.network || previous.body.epoch === MAX_U64 || c.epoch !== previous.body.epoch + 1n) invalid(); + if (c.kind === 4) { + if (previous!.body.rekey === MAX_U64 || c.rekey !== previous!.body.rekey + 1n || c.recipients.length < 2 * c.threshold - 1) invalid(); + } else if (c.rekey !== previous!.body.rekey) invalid(); + } + const digest = tagged(CERT_TAGS.ceremony, encoded); + if (!signatureValid(keys, anchor.owner, signature, digest)) throw new ProtocolError('InvalidSignature'); + return { body: c, encoded, signature, digest, anchor: { ...anchor }, previous }; +} +export function verifyClosedRound(ceremony: VerifiedCeremony, closure: Bytes, ownerSignature: Bytes, round: number, previous: Bytes, messages: SignedMessage[], keys: SigningKeys): { ownerPreimage: Bytes; transcript: Bytes; messages: { body: Bytes; signature: Bytes; payload: Bytes }[] } { + const r = new Reader(closure); r.version(); + const context = r.take(32), closedRound = Number(r.int(1)), listHash = r.take(32), prior = r.take(32); r.end(); + if (!equal(context, ceremony.digest) || closedRound !== round || !equal(prior, previous) || previous.length !== 32 || messages.length > 65535) throw new ProtocolError('InvalidCeremony'); + let total = 2; + const authenticated = messages.map(message => { + total += 8 + message.body.length + message.signature.length; + if (total > 4194304 || message.signature.length > 512) throw new ProtocolError('InvalidEncoding'); + const m = new Reader(message.body); m.version(); + const digest = m.take(32), messageRound = Number(m.int(1)), cohort = Number(m.int(1)), index = Number(m.int(2)), payload = m.field(65536); m.end(); + if (!equal(digest, context) || messageRound !== round || (cohort !== 1 && cohort !== 2)) throw new ProtocolError('InvalidCeremony'); + const successor = ceremony.body.kind === 2 || ceremony.body.kind === 3; + if ((!successor && cohort !== 1) || (successor && ((round === 1 || round === 3) ? cohort !== 2 : round === 2 && cohort !== 1))) throw new ProtocolError('InvalidCeremony'); + const members = cohort === 1 ? ceremony.body.recipients : ceremony.previous?.body.members; + const member = members?.find(entry => entry.index === index); + if (!member) throw new ProtocolError('InvalidCeremony'); + if (!signatureValid(keys, member.did, message.signature, tagged(CERT_TAGS.message, message.body))) throw new ProtocolError('InvalidSignature'); + return { ...message, payload }; + }); + const ordered = concat(be(BigInt(messages.length), 2), ...messages.flatMap(m => [lp(m.body), lp(m.signature)])); + if (!equal(tagged(CERT_TAGS.transcript, ordered), listHash)) throw new ProtocolError('InvalidCeremony'); + const ownerPreimage = tagged(CERT_TAGS.closure, closure); + if (!signatureValid(keys, ceremony.anchor.owner, ownerSignature, ownerPreimage)) throw new ProtocolError('InvalidSignature'); + const transcript = tagged(CERT_TAGS.transcript, concat(previous, lp(closure), lp(ownerSignature))); + return { ownerPreimage, transcript, messages: authenticated }; +} +export function verifyActivation(cert: VerifiedCertificate, receipts: Receipt[], budget: number | null, signature: Bytes, keys: SigningKeys): { preimage: Bytes; receipts: CheckedReceipt[] } { + let quorum = cert.body.members.length; + if (budget !== null) { + quorum = cert.body.threshold + budget; + if (!Number.isInteger(budget) || budget <= 0 || budget > 65535 || quorum > 65535 || quorum > cert.body.members.length) throw new ProtocolError('InvalidReadiness'); + } + const seen = new Set(); + const checked = receipts.map(receipt => { + const member = cert.body.members.find(m => m.index === receipt.index); + if (!member || seen.has(receipt.index) || receipt.signature.length > 512) throw new ProtocolError('InvalidReadiness'); + seen.add(receipt.index); + const preimage = tagged(CERT_TAGS.readiness, concat(cert.digest, cert.body.transcript, be(BigInt(receipt.index), 2), cert.keyset.publicShares.get(receipt.index)!.toBytes())); + if (!signatureValid(keys, member.did, receipt.signature, preimage)) throw new ProtocolError('InvalidReadiness'); + return { ...receipt, preimage }; + }); + if (checked.length < quorum) throw new ProtocolError('NotEnoughReady'); + const encoded = checked.map(r => { + const encoding = concat(be(1n, 1), be(BigInt(r.index), 2), lp(r.signature)); + return { ...r, encoding, hash: tagged(CERT_TAGS.readiness, encoding) }; + }); + const sorted = encoded.map(r => r.hash).sort((a, b) => Buffer.compare(a, b)); + const preimage = tagged(CERT_TAGS.activation, concat(cert.digest, ...sorted)); + if (!signatureValid(keys, cert.anchor.owner, signature, preimage)) throw new ProtocolError('InvalidSignature'); + return { preimage, receipts: encoded }; +} +export function verifyFaultProof(cert: VerifiedCertificate, request: Bytes, share: Bytes, signature: Bytes, keys: SigningKeys): FaultResult { + let preimage: Bytes; + try { + const req = decodeRequest(request), s = decodeShare(share), c = cert.keyset.context; + if (!equal(req.context, contextDigest(c)) || req.network !== c.network || req.rekey !== c.rekey || req.epoch !== c.epoch || !equal(req.certificate, cert.digest) || !equal(s.context, req.context) || !equal(s.request, requestDigest(request)) || !cert.keyset.publicShares.has(s.member)) throw new ProtocolError('ContextMismatch'); + preimage = signingPreimage(share, requestDigest(request), cert.digest); + const member = cert.body.members.find(m => m.index === s.member)!; + if (!signatureValid(keys, member.did, signature, preimage)) return { verdict: 'NotAFault', reason: 'InvalidSignature' }; + } catch (e) { + if (e instanceof ProtocolError) return { verdict: 'NotAFault', reason: e.code }; + throw e; + } + try { + verifyShare(cert.keyset, request, share); + return { signing_preimage: hex(preimage), verdict: 'NotAFault', reason: 'ValidShare' }; + } catch (e) { + if (e instanceof ProtocolError && e.code === 'Invalid') return { signing_preimage: hex(preimage), verdict: 'Fault', member: e.member }; + if (e instanceof ProtocolError) return { verdict: 'NotAFault', reason: e.code }; + throw e; + } +} diff --git a/tinycloud-threshold/vectors/check/cli.ts b/tinycloud-threshold/vectors/check/cli.ts index acc0cf62..c8667ab7 100644 --- a/tinycloud-threshold/vectors/check/cli.ts +++ b/tinycloud-threshold/vectors/check/cli.ts @@ -1,13 +1,22 @@ import { readFileSync } from 'node:fs'; import { checkVectors } from './vectors.ts'; +import { checkCertificateVectors } from './cert-vectors.ts'; try { - if (process.argv.length > 3) throw new Error('usage: bun cli.ts [primitive-vector-file]'); - const path = process.argv[2] ?? new URL('../v1/primitives.json', import.meta.url); - const input: unknown = JSON.parse(readFileSync(path, 'utf8')); - const report = checkVectors(input); - for (const name of report.passed) console.log(`PASS ${name}`); - console.log(`Primitive vectors: ${report.passed.length}/${report.passed.length}; combinations: ${report.combinations.map(m => m.join('+')).join(', ')}`); + if (process.argv.length > 3) throw new Error('usage: bun cli.ts [vector-file]'); + const paths = process.argv[2] ? [process.argv[2]] : [new URL('../v1/primitives.json', import.meta.url), new URL('../v1/cert-core.json', import.meta.url)]; + for (const path of paths) { + const input: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (input && typeof input === 'object' && 'suite' in input && input.suite === 'TC-THRESHOLD-V1') { + const report = checkCertificateVectors(input); + for (const name of report.passed) console.log(`PASS ${name}`); + console.log(`Certificate vectors: ${report.passed.length}/${report.passed.length}; signing keys: ${report.signingKeys}`); + } else { + const report = checkVectors(input); + for (const name of report.passed) console.log(`PASS ${name}`); + console.log(`Primitive vectors: ${report.passed.length}/${report.passed.length}; combinations: ${report.combinations.map(m => m.join('+')).join(', ')}`); + } + } } catch (e) { console.error(`FAIL ${e instanceof Error ? e.message : String(e)}`); process.exitCode = 1; diff --git a/tinycloud-threshold/vectors/check/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts index 04453884..5b796bd6 100644 --- a/tinycloud-threshold/vectors/check/protocol.ts +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -35,7 +35,7 @@ export function be(value: bigint, length: number): Bytes { return bytes(value.toString(16).padStart(length * 2, '0')); } function integer(b: Bytes): bigint { return b.length ? BigInt(`0x${hex(b)}`) : 0n; } -function lp(b: Bytes): Bytes { return concat(be(BigInt(b.length), 4), b); } +export function lp(b: Bytes): Bytes { return concat(be(BigInt(b.length), 4), b); } function equal(a: Bytes, b: Bytes): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } function sha256(b: Bytes): Bytes { return new Uint8Array(createHash('sha256').update(b).digest()); } export function tagged(tag: string, body: Bytes): Bytes { return sha256(concat(lp(encoder.encode(tag)), body)); } @@ -96,7 +96,7 @@ export function encodeGT(gt: GT): Bytes { return concat(...coefficients.map(c => be(c, 48))); } -class Reader { +export class Reader { private offset = 0; constructor(private readonly input: Bytes) { if (input.length > 65536) throw new ProtocolError('InvalidEncoding'); @@ -111,8 +111,8 @@ class Reader { if (length > cap) throw new ProtocolError('InvalidEncoding'); return this.take(length); } - text(): string { - const b = this.field(1024); + text(cap = 1024): string { + const b = this.field(cap); if (b.length === 0) throw new ProtocolError('InvalidEncoding'); try { return decoder.decode(b); } catch { throw new ProtocolError('InvalidEncoding'); } } diff --git a/tinycloud-threshold/vectors/check/schema.ts b/tinycloud-threshold/vectors/check/schema.ts index 9fc94293..43d5b7a2 100644 --- a/tinycloud-threshold/vectors/check/schema.ts +++ b/tinycloud-threshold/vectors/check/schema.ts @@ -1,18 +1,18 @@ import { DST, PEDERSEN_H_MESSAGE } from './protocol.ts'; -type Parser = (value: unknown, path: string) => T; -type Parsed

= P extends Parser ? T : never; -function invalid(path: string): never { throw new Error(`schema: ${path}`); } -function literal(expected: T): Parser { +export type Parser = (value: unknown, path: string) => T; +export type Parsed

= P extends Parser ? T : never; +export function invalid(path: string): never { throw new Error(`schema: ${path}`); } +export function literal(expected: T): Parser { return (v, p) => v === expected ? expected : invalid(p); } -function hexField(length: number): Parser { +export function hexField(length: number): Parser { return (v, p) => typeof v === 'string' && v.length === length * 2 && /^[0-9a-f]+$/.test(v) ? v : invalid(p); } -function intField(min: number, max: number): Parser { +export function intField(min: number, max: number): Parser { return (v, p) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max ? v : invalid(p); } -function object>>(shape: S): Parser<{ [K in keyof S]: Parsed }> { +export function object>>(shape: S): Parser<{ [K in keyof S]: Parsed }> { return (v, p) => { if (!v || typeof v !== 'object' || Array.isArray(v)) return invalid(p); const input = v as Record; @@ -22,7 +22,7 @@ function object>>(shape: S): Parser<{ [ return result as { [K in keyof S]: Parsed }; }; } -function array(parse: Parser, length: number): Parser { +export function array(parse: Parser, length: number): Parser { return (v, p) => { if (!Array.isArray(v) || v.length !== length) return invalid(p); return v.map((x: unknown, i) => parse(x, `${p}[${i}]`));