From ffd5c150522c6076212c2b0df9771529ac8a98bd Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:52:40 +0000 Subject: [PATCH 01/10] feat(threshold): verify DKG and reshare transcripts in the independent checker; Details: TC-2223 --- tinycloud-threshold/vectors/check/README.md | 65 ++++- tinycloud-threshold/vectors/check/ceremony.ts | 254 ++++++++++++++++++ tinycloud-threshold/vectors/check/cli.ts | 20 +- tinycloud-threshold/vectors/check/dkg.ts | 188 +++++++++++++ tinycloud-threshold/vectors/check/protocol.ts | 6 +- tinycloud-threshold/vectors/check/reshare.ts | 100 +++++++ tinycloud-threshold/vectors/check/schema.ts | 14 +- .../vectors/check/transcripts.ts | 171 ++++++++++++ 8 files changed, 794 insertions(+), 24 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/ceremony.ts create mode 100644 tinycloud-threshold/vectors/check/dkg.ts create mode 100644 tinycloud-threshold/vectors/check/reshare.ts create mode 100644 tinycloud-threshold/vectors/check/transcripts.ts diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index 04588cc3..d791fdbb 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -1,23 +1,66 @@ -# Primitive vector checker +# Independent vector and transcript checker -Independent TypeScript computation for `../v1/primitives.json`, using -`@noble/curves` 2.4.0 and Bun 1.4.4. All scalar inputs are public test values. +Independent TypeScript computation for `../v1/primitives.json`, +`../v1/dkg-genesis.json` and `../v1/reshare.json`, using `@noble/curves` 2.4.0 +and Bun 1.4.4. All scalar inputs are public test values. ```sh bun install --frozen-lockfile && bun run check ``` -`check` runs the Bun tests, TypeScript checking, and the real vector CLI. The -existing `threshold` CI job invokes this command. To check another JSON file: +`check` runs the Bun tests, TypeScript checking, and the real vector CLI over +all three committed documents. The existing `threshold` CI job invokes this +command. To check one JSON file (primitive, DKG or redistribution; the kind is +read from its `spec_sections`): ```sh -bun run vectors /absolute/path/to/primitives.json +bun run vectors /absolute/path/to/document.json ``` The CLI reports each computed record and exits nonzero on a schema, byte, -equation, or expected-error disagreement. Its default path is relative to the +equation, or expected-error disagreement. Its default paths are relative to the CLI module, not the calling directory. +## Transcript verification (S0.5) + +`dkg.ts` (`dkgVerifyTranscript`) and `reshare.ts` (`reshareVerifyTranscript`) +recompute a whole genesis DKG or reshare/refresh outcome from the raw signed +records, using `ceremony.ts` for the S0.2 layer. Check order: + +1. Byte-length sum of every closure body/signature and member envelope/signature + above 4194304 → `LimitExceeded`, before any decoding or signature. +2. Ceremony statement: decode (`InvalidEncoding`), owner/network against the + anchor, owner signature, kind and generation rules; genesis `n < 2t−1` → + `UnsupportedFaultBudget`. +3. Redistribution only: the current certificate (decode, anchor, owner + signature, evaluated public shares), then ceremony/certificate binding + (`ContextMismatch`), successor epoch/re-key and refresh shape. +4. Each present record k as closed round k from the start link H0: closure + ceremony/round/previous hash, owner closure signature, each envelope + (decode, ceremony/round, signer in its signed cohort, strict RFC 8032 + signature), then the ordered-list commitment. +5. The S0.3/S0.4 classification rules (missing, conflicting, malformed), QUAL + or D, R4–R6 extraction and reconstruction, then the output identity checks. + An abort (`InsufficientQualified`, `InsufficientRecovery`) must be the last + record; missing or additional records are `InvalidRound`. + +Conditions SPEC describes without naming an error use the defaults in +`DEFAULT_ERROR` (`ceremony.ts`), ruled by Naomi on 2026-10-30. + +`testkit.ts` builds signed transcripts from the published fixture seeds in +`../v1/cert-core.json`, with payload encoders written separately from the +decoders; expected outputs are computed in the scalar domain. `dkg.test.ts` and +`reshare.test.ts` cover every disqualification/exclusion reason and its +precedence, extraction and reconstruction, identity rejections, caps, entry +checks and strict signatures. `probes.ts` and `transcript-corruption.test.ts` +flip one byte in each field class of every committed record and require the +ordered reason; re-signed flips reach the rule-level outcome. +`transcript-vectors.test.ts` perturbs expected outputs and runs the real CLI. + +The checker does not open sealed dealings (it holds no recipient keys); a +sealed dealing is decoded by layout only, as SPEC requires of the public +verifier. + ## Computations - H1 and Pedersen h use the specified RFC 9380 custom DSTs. @@ -71,9 +114,11 @@ Dependency implementation files were not opened or used as design inputs. | Consulted source | Use | | --- | --- | | TinyCloud approved design/plan and `../../SPEC.md` §§3.1–3.6, S0.1 appendix | Equations, DSTs, encodings, error ordering and RequestDigest amendment | -| `../v1/primitives.json` and JSON-only schema/byte-layout handoff | Public inputs, expected bytes and current record format | +| `../../SPEC.md` §§3.0, 3.7–3.12, S0.2, S0.3 and S0.4 appendices; plan r2 §§3.0–3.11, §5, §6 | Ceremony, certificate, envelope, closure and transcript layouts; DKG and redistribution rules (TC-2223) | +| `../v1/primitives.json`, `../v1/dkg-genesis.json`, `../v1/reshare.json`, `../v1/cert-core.json` seeds | Public inputs, expected bytes and fixture signing seeds | | [RFC 9380 §§5.2–5.3.1](https://www.rfc-editor.org/rfc/rfc9380#section-5.2) | Scalar-field reduction and SHA-256 XMD algorithm | -| [noble-curves 2.4.0 API documentation](https://github.com/paulmillr/noble-curves/blob/2.4.0/README.md) | Group operations, hash-to-curve, pairings and field API | +| [RFC 8032 §5.1](https://www.rfc-editor.org/rfc/rfc8032#section-5.1) | Ed25519 encodings for the strict-verification tests | +| [noble-curves 2.4.0 API documentation](https://github.com/paulmillr/noble-curves/blob/2.4.0/README.md) | Group operations, hash-to-curve, pairings, field API and Ed25519 `zip215: false` | | npm registry version metadata | Version, integrity and license declarations | ## Dependencies @@ -85,4 +130,4 @@ Development dependencies are TypeScript 5.9.3 (Apache-2.0), `@types/bun` 1.3.1, `@types/react` 19.3.0 and `csstype` 3.2.3 (all MIT). License declarations were read from the npm registry metadata for those exact versions. -Details: TC-2219 +Details: TC-2219, TC-2223 diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts new file mode 100644 index 00000000..f8e947c1 --- /dev/null +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -0,0 +1,254 @@ +import { ed25519 } from '@noble/curves/ed25519.js'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { ProtocolError, Reader, R, be, concat, equal, g2, lp, tagged, type Bytes, type G2 } from './protocol.ts'; + +/** SPEC §3.3 and the S0.3/S0.4 appendix start labels. */ +export const TAG = { + ceremonyAuth: 'TC-THRESHOLD-V1-CEREMONY-AUTH', + ceremonyMsg: 'TC-THRESHOLD-V1-CEREMONY-MSG', + roundClose: 'TC-THRESHOLD-V1-ROUND-CLOSE', + transcript: 'TC-THRESHOLD-V1-TRANSCRIPT', + networkCert: 'TC-THRESHOLD-V1-NETWORK-CERT', + certStatement: 'TC-THRESHOLD-V1-CERT-STATEMENT', + commitments: 'TC-THRESHOLD-V1-COMMITMENTS', + dealing: 'TC-THRESHOLD-V1-DEALING', + dkgStart: 'TC-THRESHOLD-V1-DKG-START', + reshareStart: 'TC-THRESHOLD-V1-RESHARE-START', +} as const; + +/** SPEC §3.2 caps and the S0.2 appendix ordered-input bound. */ +export const LIMIT = { members: 16, did: 256, signature: 512, message: 65536, transcript: 4194304 } as const; + +/** + * Typed errors for conditions SPEC describes but does not name. Every entry is + * a default ruled by Naomi on 2026-10-30 (TC-2223); SPEC- and vector-named + * codes (LimitExceeded, InvalidRound for record counts, InvalidCeremony, + * ContextMismatch, IdentityPoint, InsufficientQualified, InsufficientRecovery, + * UnsupportedFaultBudget, InvalidEncoding for decoders) are used literally. + */ +export const DEFAULT_ERROR = { + /** Owner or member signature fails strict RFC 8032 verification, or the DID has no key. */ + signature: 'InvalidSignature', + /** Closure body non-canonical, or names another ceremony/round/previous hash/ordered list. */ + closure: 'InvalidRound', + /** Member envelope names another ceremony or round. */ + envelopeBinding: 'InvalidRound', + /** Envelope cohort/index does not resolve in the signed cohort. */ + signer: 'InvalidIndex', + /** Ceremony owner/network differs from the anchor. */ + ceremonyAnchor: 'InvalidCeremony', + /** Current certificate owner/network differs from the anchor. */ + certificateAnchor: 'InvalidCertificate', + /** Reshare B′_0 differs from the current mpk. */ + reshareMpk: 'ContextMismatch', +} as const; + +export function fail(code: string): never { throw new ProtocolError(code); } + +export interface Anchor { owner: string; network: string } +export interface Statement { body: Bytes; signature: Bytes } +export interface RoundRecord { closure: Statement; messages: Statement[] } +/** The injected DID verifier: strict RFC 8032 Ed25519 over the allow-listed keys. */ +export type Verifier = (did: string, message: Bytes, signature: Bytes) => boolean; + +export function ed25519Verifier(keys: ReadonlyMap): Verifier { + return (did, message, signature) => { + const key = keys.get(did); + if (!key || signature.length !== 64) return false; + try { return ed25519.verify(signature, message, key, { zip215: false }); } catch { return false; } + }; +} + +const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); +function did(r: Reader): string { + const b = r.field(LIMIT.did); + if (b.length === 0) fail('InvalidEncoding'); + try { return utf8.decode(b); } catch { return fail('InvalidEncoding'); } +} +function option(r: Reader, length: number): Bytes | undefined { + const marker = r.int(1); + if (marker === 0n) return undefined; + if (marker !== 1n) fail('InvalidEncoding'); + return r.take(length); +} +/** A u16 list count, at most 16, checked before allocation. */ +export function listCount(r: Reader): number { + const n = Number(r.int(2)); + if (n > LIMIT.members) fail('InvalidEncoding'); + return n; +} +/** S0.2 directory rules: contiguous 1..=n, strictly increasing, distinct DIDs, 2 <= t <= n <= 16. */ +function checkDirectory(t: number, n: number, entries: { index: number; did: string }[]) { + if (t < 2 || t > n || n > LIMIT.members || entries.length !== n) fail('InvalidEncoding'); + if (entries.some((e, k) => e.index !== k + 1)) fail('InvalidEncoding'); + if (new Set(entries.map(e => e.did)).size !== n) fail('InvalidEncoding'); +} + +export const KIND = { genesis: 1, reshare: 2, refresh: 3, rekey: 4 } as const; +export interface Ceremony { + kind: number; id: Bytes; network: string; owner: string; current?: Bytes; + t: number; n: number; rekey: bigint; epoch: bigint; + directory: { index: number; did: string; hpke: Bytes }[]; + digest: Bytes; +} +export function decodeCeremony(body: Bytes): Ceremony { + const r = new Reader(body); r.version(); + const kind = Number(r.int(1)); + if (kind < KIND.genesis || kind > KIND.rekey) fail('InvalidEncoding'); + const id = r.take(32), network = r.text(), owner = did(r), current = option(r, 32); + const t = Number(r.int(2)), n = Number(r.int(2)), rekey = r.int(8), epoch = r.int(8); + const entries = listCount(r); + const directory = Array.from({ length: entries }, () => ({ index: Number(r.int(2)), did: did(r), hpke: r.take(32) })); + r.end(); + checkDirectory(t, n, directory); + return { kind, id, network, owner, ...(current ? { current } : {}), t, n, rekey, epoch, directory, digest: tagged(TAG.ceremonyAuth, body) }; +} +/** Decode, anchor, owner signature. Kind-specific rules belong to the caller. */ +export function authorizeCeremony(anchor: Anchor, statement: Statement, verify: Verifier): Ceremony { + const c = decodeCeremony(statement.body); + if (c.owner !== anchor.owner || c.network !== anchor.network) fail(DEFAULT_ERROR.ceremonyAnchor); + if (statement.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (!verify(anchor.owner, c.digest, statement.signature)) fail(DEFAULT_ERROR.signature); + return c; +} + +const G2_ZERO = bls.G2.Point.ZERO; +/** k·P for any k mod r, including zero and the identity point. */ +export function mul(p: G2, k: bigint): G2 { + const s = ((k % R) + R) % R; + return s === 0n || p.equals(G2_ZERO) ? G2_ZERO : p.multiply(s); +} +/** Σ_k x^k·C_k. */ +export function evaluate(commitments: readonly G2[], x: number): G2 { + let acc = G2_ZERO, power = 1n; + for (const c of commitments) { acc = acc.add(mul(c, power)); power = (power * BigInt(x)) % R; } + return acc; +} + +export interface Certificate { + network: string; owner: string; rekey: bigint; epoch: bigint; genesisT: number; t: number; n: number; + members: { index: number; did: string }[]; mpk: G2; commitments: G2[]; transcript: Bytes; + previous?: Bytes; previousMpk?: G2; publicShares: G2[]; digest: Bytes; +} +export function decodeCertificate(body: Bytes): Omit { + const r = new Reader(body); r.version(); + const network = r.text(), owner = did(r), rekey = r.int(8), epoch = r.int(8); + const genesisT = Number(r.int(2)), t = Number(r.int(2)), n = Number(r.int(2)); + const memberCount = listCount(r); + const members = Array.from({ length: memberCount }, () => ({ index: Number(r.int(2)), did: did(r) })); + const mpkBytes = r.take(96); + const commitmentCount = listCount(r); + const commitmentBytes = Array.from({ length: commitmentCount }, () => r.take(96)); + const transcript = r.take(32), previous = option(r, 32), previousMpkBytes = option(r, 96); + r.end(); + checkDirectory(t, n, members); + if (genesisT < 2 || genesisT > LIMIT.members || commitmentCount !== t) fail('InvalidEncoding'); + const mpk = g2(mpkBytes), commitments = commitmentBytes.map(b => g2(b, true)); + if (!commitments[0]!.equals(mpk)) fail('InvalidEncoding'); + if (commitments[t - 1]!.equals(G2_ZERO)) fail('IdentityPoint'); + const previousMpk = previousMpkBytes ? g2(previousMpkBytes) : undefined; + // §3.9 field combinations: genesis has neither predecessor field and zero counters; re-key has both. + if (!previous && (previousMpk || epoch !== 0n || rekey !== 0n)) fail('InvalidEncoding'); + return { + network, owner, rekey, epoch, genesisT, t, n, members, mpk, commitments, transcript, + ...(previous ? { previous } : {}), ...(previousMpk ? { previousMpk } : {}), digest: tagged(TAG.networkCert, body), + }; +} +export function verifyCertificate(anchor: Anchor, statement: Statement, verify: Verifier): Certificate { + const c = decodeCertificate(statement.body); + if (c.owner !== anchor.owner || c.network !== anchor.network) fail(DEFAULT_ERROR.certificateAnchor); + if (statement.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (!verify(anchor.owner, tagged(TAG.certStatement, c.digest), statement.signature)) fail(DEFAULT_ERROR.signature); + const publicShares = c.members.map(m => evaluate(c.commitments, m.index)); + if (publicShares.some(p => p.equals(G2_ZERO))) fail('IdentityPoint'); + return { ...c, publicShares }; +} + +/** S0.2 ordered member list and its commitment. */ +export function orderedListHash(messages: readonly Statement[]): Bytes { + if (messages.length > 0xffff) fail(DEFAULT_ERROR.closure); + return tagged(TAG.transcript, concat(be(BigInt(messages.length), 2), ...messages.flatMap(m => [lp(m.body), lp(m.signature)]))); +} +export function transcriptUpdate(previous: Bytes, closure: Statement): Bytes { + return tagged(TAG.transcript, concat(previous, lp(closure.body), lp(closure.signature))); +} +export function encodeClosure(ceremony: Bytes, round: number, listHash: Bytes, previous: Bytes): Bytes { + return concat(be(1n, 1), ceremony, be(BigInt(round), 1), listHash, previous); +} +export function encodeEnvelope(ceremony: Bytes, round: number, cohort: number, index: number, payload: Bytes): Bytes { + return concat(be(1n, 1), ceremony, be(BigInt(round), 1), be(BigInt(cohort), 1), be(BigInt(index), 2), lp(payload)); +} + +export const COHORT = { recipients: 1, dealers: 2 } as const; +/** An authenticated message retained in owner order: the exact envelope plus its resolved signer. */ +export interface Authenticated { envelope: Bytes; cohort: number; index: number; payload: Bytes } +/** Index → DID, per signed cohort. Genesis supplies only the recipient cohort. */ +export type Cohorts = Partial>>; + +/** + * Verifies one closed round. Order: closure decode and its ceremony/round/previous + * binding, owner closure signature, then each member envelope (decode, ceremony/round, + * signer resolution, signature), then the ordered-list commitment. + */ +export function verifyClosedRound(ceremony: Bytes, round: number, previous: Bytes, record: RoundRecord, cohorts: Cohorts, owner: string, verify: Verifier): Authenticated[] { + const body = record.closure.body; + if (body.length !== 98 || body[0] !== 1) fail(DEFAULT_ERROR.closure); + if (!equal(body.slice(1, 33), ceremony) || body[33] !== round || !equal(body.slice(66, 98), previous)) fail(DEFAULT_ERROR.closure); + if (record.closure.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (!verify(owner, tagged(TAG.roundClose, body), record.closure.signature)) fail(DEFAULT_ERROR.signature); + const messages = record.messages.map(m => { + const r = new Reader(m.body); r.version(); + const digest = r.take(32), envelopeRound = Number(r.int(1)), cohort = Number(r.int(1)), index = Number(r.int(2)); + const payload = r.field(LIMIT.message); + r.end(); + if (m.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (!equal(digest, ceremony) || envelopeRound !== round) fail(DEFAULT_ERROR.envelopeBinding); + const signer = cohorts[cohort]?.get(index); + if (signer === undefined) fail(DEFAULT_ERROR.signer); + if (!verify(signer, tagged(TAG.ceremonyMsg, m.body), m.signature)) fail(DEFAULT_ERROR.signature); + return { envelope: m.body, cohort, index, payload }; + }); + if (!equal(orderedListHash(record.messages), body.slice(34, 66))) fail(DEFAULT_ERROR.closure); + return messages; +} + +/** The S0.2 transcript chain over every present record; record k must be round k ≤ maxRound. */ +export function verifyChain(ceremony: Bytes, start: Bytes, records: readonly RoundRecord[], maxRound: number, cohorts: Cohorts, owner: string, verify: Verifier) { + if (records.length > maxRound) fail('InvalidRound'); + let hash = start; + const rounds = records.map((record, k) => { + const messages = verifyClosedRound(ceremony, k + 1, hash, record, cohorts, owner, verify); + hash = transcriptUpdate(hash, record.closure); + return messages; + }); + return { rounds, hash }; +} + +/** Byte-length sum checked before any signature (S0.3/S0.4 appendices). */ +export function checkTranscriptSize(records: readonly RoundRecord[]) { + let total = 0; + for (const r of records) { + total += r.closure.body.length + r.closure.signature.length; + for (const m of r.messages) total += m.body.length + m.signature.length; + } + if (total > LIMIT.transcript) fail('LimitExceeded'); +} + +/** Per-sender classification within one cohort of a closed round. */ +export type Classified = { kind: 'missing' } | { kind: 'conflicting' } | { kind: 'single'; payload: Bytes; position: number }; +export function classify(messages: readonly Authenticated[], cohort: number, index: number): Classified { + let found: { envelope: Bytes; payload: Bytes; position: number } | undefined; + for (const [position, m] of messages.entries()) { + if (m.cohort !== cohort || m.index !== index) continue; + if (!found) found = { envelope: m.envelope, payload: m.payload, position }; + else if (!equal(found.envelope, m.envelope)) return { kind: 'conflicting' }; + } + return found ? { kind: 'single', payload: found.payload, position: found.position } : { kind: 'missing' }; +} + +/** Strictly increasing member indices 1..=16 (payload index lists). */ +export function memberIndex(r: Reader, previous: number): number { + const index = Number(r.int(2)); + if (index < 1 || index > LIMIT.members || index <= previous) fail('InvalidEncoding'); + return index; +} diff --git a/tinycloud-threshold/vectors/check/cli.ts b/tinycloud-threshold/vectors/check/cli.ts index acc0cf62..bdf0e83d 100644 --- a/tinycloud-threshold/vectors/check/cli.ts +++ b/tinycloud-threshold/vectors/check/cli.ts @@ -1,13 +1,25 @@ import { readFileSync } from 'node:fs'; import { checkVectors } from './vectors.ts'; +import { checkTranscripts, transcriptKind } from './transcripts.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 defaults = ['../v1/primitives.json', '../v1/dkg-genesis.json', '../v1/reshare.json'].map(p => new URL(p, import.meta.url)); + +function check(input: unknown) { + if (transcriptKind(input) !== undefined) { + const report = checkTranscripts(input); + for (const name of report.passed) console.log(`PASS ${name}`); + console.log(`${report.kind === 'dkg' ? 'DKG' : 'Reshare'} transcripts: ${report.passed.length}/${report.passed.length}`); + return; + } 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(', ')}`); +} + +try { + if (process.argv.length > 3) throw new Error('usage: bun cli.ts [vector-file]'); + const paths = process.argv[2] === undefined ? defaults : [process.argv[2]]; + for (const path of paths) check(JSON.parse(readFileSync(path, 'utf8'))); } catch (e) { console.error(`FAIL ${e instanceof Error ? e.message : String(e)}`); process.exitCode = 1; diff --git a/tinycloud-threshold/vectors/check/dkg.ts b/tinycloud-threshold/vectors/check/dkg.ts new file mode 100644 index 00000000..79a65ad5 --- /dev/null +++ b/tinycloud-threshold/vectors/check/dkg.ts @@ -0,0 +1,188 @@ +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { DST, PEDERSEN_H_MESSAGE, ProtocolError, Reader, bytes, g2, scalar, tagged, type Bytes, type G2 } from './protocol.ts'; +import { + COHORT, KIND, TAG, authorizeCeremony, checkTranscriptSize, classify, evaluate, fail, listCount, + memberIndex, mul, verifyChain, type Anchor, type Authenticated, type Ceremony, type Classified, type RoundRecord, + type Statement, type Verifier, +} from './ceremony.ts'; + +const G2_BASE = bls.G2.Point.BASE, G2_ZERO = bls.G2.Point.ZERO; +/** SPEC §3.3 Pedersen generator h. */ +export const PEDERSEN_H: G2 = bls.G2.hashToCurve(bytes(PEDERSEN_H_MESSAGE), { DST: DST.pedersen_h }); + +export interface Exclusion { member: number; reason: string; round?: number } + +/** `01 || t:u16 || [P:96] || ...` coefficient vectors; individual coefficients may be the identity. */ +function points(r: Reader): G2[] { + return Array.from({ length: listCount(r) }, () => g2(r.take(96), true)); +} +/** R1 deal (shared by genesis and redistribution): commitments and the recipient list. */ +export function decodeDeal(payload: Bytes): { commitments: G2[]; recipients: number[] } { + const r = new Reader(payload); r.version(); + const commitments = points(r); + const recipients: number[] = []; + for (let k = listCount(r), last = 0; k > 0; k--) { + last = memberIndex(r, last); + // §3.10 sealed dealing envelope: 01 || enc:32 || lp(ciphertext), decoded by layout only. + const sealed = new Reader(r.field(65536)); sealed.version(); sealed.take(32); sealed.field(65536); sealed.end(); + recipients.push(last); + } + r.end(); + return { commitments, recipients }; +} +/** R2 complaints. */ +export function decodeComplaints(payload: Bytes): number[] { + const r = new Reader(payload); r.version(); + const dealers: number[] = []; + for (let k = listCount(r), last = 0; k > 0; k--) dealers.push(last = memberIndex(r, last)); + r.end(); + return dealers; +} +/** R3 answers, R5 extraction complaints and R6 values: indexed (s, s′) pairs. */ +function decodePairs(payload: Bytes): Map { + const r = new Reader(payload); r.version(); + const pairs = new Map(); + for (let k = listCount(r), last = 0; k > 0; k--) { + last = memberIndex(r, last); + pairs.set(last, [scalar(r.take(32)), scalar(r.take(32))]); + } + r.end(); + return pairs; +} +function decodeFeldman(payload: Bytes): G2[] { + const r = new Reader(payload); r.version(); + const coefficients = points(r); + r.end(); + return coefficients; +} +/** Decoded payload, or undefined when the payload is malformed. */ +export function attempt(decode: (b: Bytes) => T, payload: Bytes): T | undefined { + try { return decode(payload); } catch (e) { + if (e instanceof ProtocolError) return undefined; + throw e; + } +} + +function pedersen(c: readonly G2[], j: number, [s, s2]: readonly [bigint, bigint]): boolean { + return mul(G2_BASE, s).add(mul(PEDERSEN_H, s2)).equals(evaluate(c, j)); +} +function feldman(a: readonly G2[], j: number, s: bigint): boolean { + return mul(G2_BASE, s).equals(evaluate(a, j)); +} +/** Coefficients of the degree t−1 polynomial through t points, mod r. */ +export function interpolateCoefficients(points: readonly [number, bigint][]): bigint[] { + const Fr = bls.fields.Fr, coefficients = points.map(() => 0n); + for (const [xj, yj] of points) { + let basis = [1n], denominator = 1n; + for (const [xm] of points) { + if (xm === xj) continue; + const next = basis.map(() => 0n).concat(0n); + for (const [k, b] of basis.entries()) { next[k + 1] = Fr.add(next[k + 1]!, b); next[k] = Fr.sub(next[k]!, Fr.mul(b, BigInt(xm))); } + basis = next; + denominator = Fr.mul(denominator, Fr.create(BigInt(xj - xm))); + } + const factor = Fr.mul(yj, Fr.inv(denominator)); + for (const [k, b] of basis.entries()) coefficients[k] = Fr.add(coefficients[k]!, Fr.mul(b, factor)); + } + return coefficients; +} + +export interface DkgOutput { + qual: number[]; disqualified: Exclusion[]; reconstructed: number[]; + commitments: G2[]; mpk: G2; publicShares: G2[]; transcriptHash: Bytes; ceremony: Ceremony; initialTranscript: Bytes; +} +export function dkgInitialTranscript(ceremonyDigest: Bytes): Bytes { return tagged(TAG.dkgStart, ceremonyDigest); } + +/** Genesis-only ceremony rules (S0.2 appendix and §3.0 budget). */ +export function authorizeGenesis(anchor: Anchor, statement: Statement, verify: Verifier): Ceremony { + const c = authorizeCeremony(anchor, statement, verify); + if (c.kind !== KIND.genesis || c.current || c.rekey !== 0n || c.epoch !== 0n) fail('InvalidCeremony'); + if (c.n < 2 * c.t - 1) fail('UnsupportedFaultBudget'); + return c; +} + +/** dkg_verify_transcript: SPEC §3.7 with the S0.3 appendix rules, from raw signed records only. */ +export function dkgVerifyTranscript(anchor: Anchor, statement: Statement, records: readonly RoundRecord[], verify: Verifier): DkgOutput { + checkTranscriptSize(records); + const ceremony = authorizeGenesis(anchor, statement, verify); + const { t, n } = ceremony, members = ceremony.directory.map(d => d.index); + const initialTranscript = dkgInitialTranscript(ceremony.digest); + const recipients = new Map(ceremony.directory.map(d => [d.index, d.did])); + const { rounds, hash } = verifyChain(ceremony.digest, initialTranscript, records, 6, { [COHORT.recipients]: recipients }, anchor.owner, verify); + if (rounds.length < 3) fail('InvalidRound'); + const sender = (round: Authenticated[], i: number): Classified => classify(round, COHORT.recipients, i); + const [r1, r2, r3, r4, r5, r6] = rounds; + + const deals = new Map(); + const complainers = new Map>(members.map(i => [i, new Set()])); + for (const j of members) { + const m = sender(r2!, j); + if (m.kind !== 'single') continue; + const dealers = attempt(decodeComplaints, m.payload); + if (!dealers || dealers.some(i => i > n)) continue; + for (const i of dealers) if (i !== j) complainers.get(i)!.add(j); + } + const disqualified: Exclusion[] = []; + for (const i of members) { + const reason = ((): Exclusion | undefined => { + const deal = sender(r1!, i); + if (deal.kind === 'missing') return { member: i, reason: 'MissingDeal' }; + if (deal.kind === 'conflicting') return { member: i, reason: 'Equivocated', round: 1 }; + const decoded = attempt(decodeDeal, deal.payload); + if (!decoded || decoded.commitments.length !== t || decoded.recipients.length !== n || decoded.recipients.some((x, k) => x !== k + 1)) return { member: i, reason: 'MalformedDeal' }; + if (sender(r2!, i).kind === 'conflicting') return { member: i, reason: 'Equivocated', round: 2 }; + const answer = sender(r3!, i); + if (answer.kind === 'conflicting') return { member: i, reason: 'Equivocated', round: 3 }; + const required = complainers.get(i)!; + if (required.size >= t) return { member: i, reason: 'Complaints' }; + const pairs = answer.kind === 'single' ? attempt(decodePairs, answer.payload) : undefined; + const used = pairs && [...pairs.keys()].every(j => j <= n) ? pairs : undefined; + if (used && [...used].some(([j, pair]) => !pedersen(decoded.commitments, j, pair))) return { member: i, reason: 'InvalidAnswer' }; + if ([...required].some(j => !used?.has(j))) return { member: i, reason: 'MissingAnswer' }; + deals.set(i, decoded.commitments); + return undefined; + })(); + if (reason) disqualified.push(reason); + } + const qual = [...deals.keys()]; + if (qual.length < t) { + if (rounds.length !== 3) fail('InvalidRound'); + fail('InsufficientQualified'); + } + if (rounds.length < 5) fail('InvalidRound'); + + const extraction = new Map(); + for (const i of qual) { + const m = sender(r4!, i); + const coefficients = m.kind === 'single' ? attempt(decodeFeldman, m.payload) : undefined; + if (coefficients && coefficients.length === t) extraction.set(i, coefficients); + } + const reconstruct = new Set(qual.filter(i => !extraction.has(i))); + for (const j of members) { + const m = sender(r5!, j); + const entries = m.kind === 'single' ? attempt(decodePairs, m.payload) : undefined; + for (const [i, pair] of entries ?? []) { + const a = extraction.get(i); + if (i !== j && a && pedersen(deals.get(i)!, j, pair) && !feldman(a, j, pair[0])) reconstruct.add(i); + } + } + const reconstructed = qual.filter(i => reconstruct.has(i)); + if (rounds.length !== (reconstructed.length ? 6 : 5)) fail('InvalidRound'); + for (const i of reconstructed) { + const values: [number, bigint][] = []; + for (const j of members) { + const m = sender(r6!, j); + const pair = (m.kind === 'single' ? attempt(decodePairs, m.payload) : undefined)?.get(i); + if (pair && pedersen(deals.get(i)!, j, pair)) values.push([j, pair[0]]); + } + if (values.length < t) fail('InsufficientRecovery'); + extraction.set(i, interpolateCoefficients(values.slice(0, t)).map(a => mul(G2_BASE, a))); + } + + const commitments = Array.from({ length: t }, (_, k) => qual.reduce((acc, i) => acc.add(extraction.get(i)![k]!), G2_ZERO)); + const mpk = commitments[0]!; + if (mpk.equals(G2_ZERO) || commitments[t - 1]!.equals(G2_ZERO)) fail('IdentityPoint'); + const publicShares = members.map(j => evaluate(commitments, j)); + if (publicShares.some(p => p.equals(G2_ZERO))) fail('IdentityPoint'); + return { qual, disqualified, reconstructed, commitments, mpk, publicShares, transcriptHash: hash, ceremony, initialTranscript }; +} diff --git a/tinycloud-threshold/vectors/check/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts index 04453884..95e394f2 100644 --- a/tinycloud-threshold/vectors/check/protocol.ts +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -35,8 +35,8 @@ 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); } -function equal(a: Bytes, b: Bytes): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } +export function lp(b: Bytes): Bytes { return concat(be(BigInt(b.length), 4), b); } +export 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'); diff --git a/tinycloud-threshold/vectors/check/reshare.ts b/tinycloud-threshold/vectors/check/reshare.ts new file mode 100644 index 00000000..bd1082ee --- /dev/null +++ b/tinycloud-threshold/vectors/check/reshare.ts @@ -0,0 +1,100 @@ +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { Reader, equal, scalar, tagged, type Bytes, type G2 } from './protocol.ts'; +import { + COHORT, DEFAULT_ERROR, KIND, TAG, authorizeCeremony, checkTranscriptSize, classify, evaluate, fail, listCount, + memberIndex, mul, verifyCertificate, verifyChain, type Anchor, type Ceremony, type Certificate, type RoundRecord, + type Statement, type Verifier, +} from './ceremony.ts'; +import { attempt, decodeComplaints, decodeDeal, type Exclusion } from './dkg.ts'; + +const G2_BASE = bls.G2.Point.BASE, G2_ZERO = bls.G2.Point.ZERO, Fr = bls.fields.Fr; + +/** Redistribution R3: `01 || count:u16 || [(recipient:u16 || value:32)]`. */ +function decodeValues(payload: Bytes): Map { + const r = new Reader(payload); r.version(); + const values = new Map(); + for (let k = listCount(r), last = 0; k > 0; k--) { + last = memberIndex(r, last); + values.set(last, scalar(r.take(32))); + } + r.end(); + return values; +} + +export function reshareInitialTranscript(ceremonyDigest: Bytes): Bytes { return tagged(TAG.reshareStart, ceremonyDigest); } + +/** S0.4 entry checks: kind, current-certificate binding, successor generation, refresh shape. */ +export function authorizeRedistribution(anchor: Anchor, statement: Statement, certificate: Statement, verify: Verifier): { ceremony: Ceremony; current: Certificate } { + const c = authorizeCeremony(anchor, statement, verify); + if (c.kind !== KIND.reshare && c.kind !== KIND.refresh) fail('InvalidCeremony'); + const current = verifyCertificate(anchor, certificate, verify); + if (!c.current || !equal(c.current, current.digest)) fail('ContextMismatch'); + if (current.epoch === 0xffffffffffffffffn || c.epoch !== current.epoch + 1n || c.rekey !== current.rekey) fail('InvalidCeremony'); + if (c.kind === KIND.refresh && (c.t !== current.t || c.n !== current.n || c.directory.some((d, k) => d.did !== current.members[k]!.did))) fail('InvalidCeremony'); + return { ceremony: c, current }; +} + +export interface ReshareOutput { + threshold: number; members: number; dealers: number[]; excluded: Exclusion[]; + commitments: G2[]; mpk: G2; publicShares: G2[]; transcriptHash: Bytes; ceremony: Ceremony; initialTranscript: Bytes; +} + +/** reshare_verify_transcript: SPEC §3.8 with the S0.4 appendix rules, from raw signed records only. */ +export function reshareVerifyTranscript(anchor: Anchor, statement: Statement, certificate: Statement, records: readonly RoundRecord[], verify: Verifier): ReshareOutput { + checkTranscriptSize(records); + const { ceremony, current } = authorizeRedistribution(anchor, statement, certificate, verify); + const t2 = ceremony.t, n2 = ceremony.n, oldMembers = current.members.map(m => m.index); + const initialTranscript = reshareInitialTranscript(ceremony.digest); + const cohorts = { + [COHORT.recipients]: new Map(ceremony.directory.map(d => [d.index, d.did])), + [COHORT.dealers]: new Map(current.members.map(m => [m.index, m.did])), + }; + const { rounds, hash } = verifyChain(ceremony.digest, initialTranscript, records, 3, cohorts, anchor.owner, verify); + if (rounds.length !== 3) fail('InvalidRound'); + const [r1, r2, r3] = rounds; + + const complainers = new Map>(oldMembers.map(i => [i, new Set()])); + for (const d of ceremony.directory) { + const m = classify(r2!, COHORT.recipients, d.index); + const dealers = m.kind === 'single' ? attempt(decodeComplaints, m.payload) : undefined; + if (!dealers || dealers.some(i => i > current.n)) continue; + for (const i of dealers) complainers.get(i)!.add(d.index); + } + const excluded: Exclusion[] = []; + const candidates: { index: number; position: number; commitments: G2[] }[] = []; + for (const i of oldMembers) { + const reason = ((): Exclusion | undefined => { + const deal = classify(r1!, COHORT.dealers, i); + if (deal.kind === 'missing') return { member: i, reason: 'MissingDeal' }; + if (deal.kind === 'conflicting') return { member: i, reason: 'Equivocated', round: 1 }; + const decoded = attempt(decodeDeal, deal.payload); + const b = decoded?.commitments; + if (!decoded || !b || b.length !== t2 || !b[0]!.equals(current.publicShares[i - 1]!) || b[t2 - 1]!.equals(G2_ZERO) + || decoded.recipients.length !== n2 || decoded.recipients.some((x, k) => x !== k + 1)) return { member: i, reason: 'MalformedDeal' }; + const answer = classify(r3!, COHORT.dealers, i); + if (answer.kind === 'conflicting') return { member: i, reason: 'Equivocated', round: 3 }; + const required = complainers.get(i)!; + if (required.size >= t2) return { member: i, reason: 'Complaints' }; + const values = answer.kind === 'single' ? attempt(decodeValues, answer.payload) : undefined; + const used = values && [...values.keys()].every(j => j <= n2) ? values : undefined; + if (used && [...used].some(([j, g]) => !mul(G2_BASE, g).equals(evaluate(b, j)))) return { member: i, reason: 'InvalidAnswer' }; + if ([...required].some(j => !used?.has(j))) return { member: i, reason: 'MissingAnswer' }; + candidates.push({ index: i, position: deal.position, commitments: b }); + return undefined; + })(); + if (reason) excluded.push(reason); + } + const selected = candidates.sort((a, b) => a.position - b.position).slice(0, current.t); + if (selected.length < current.t) fail('InsufficientQualified'); + + const dealers = selected.map(d => d.index); + const commitments = Array.from({ length: t2 }, (_, k) => selected.reduce((acc, d) => { + const lambda = dealers.reduce((l, j) => j === d.index ? l : Fr.mul(l, Fr.div(BigInt(j), Fr.create(BigInt(j - d.index)))), 1n); + return acc.add(mul(d.commitments[k]!, lambda)); + }, G2_ZERO)); + if (!commitments[0]!.equals(current.mpk)) fail(DEFAULT_ERROR.reshareMpk); + if (commitments[t2 - 1]!.equals(G2_ZERO)) fail('IdentityPoint'); + const publicShares = ceremony.directory.map(d => evaluate(commitments, d.index)); + if (publicShares.some(p => p.equals(G2_ZERO))) fail('IdentityPoint'); + return { threshold: t2, members: n2, dealers, excluded, commitments, mpk: commitments[0]!, publicShares, transcriptHash: hash, ceremony, initialTranscript }; +} diff --git a/tinycloud-threshold/vectors/check/schema.ts b/tinycloud-threshold/vectors/check/schema.ts index 9fc94293..80fc5ff9 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; diff --git a/tinycloud-threshold/vectors/check/transcripts.ts b/tinycloud-threshold/vectors/check/transcripts.ts new file mode 100644 index 00000000..f67e8b36 --- /dev/null +++ b/tinycloud-threshold/vectors/check/transcripts.ts @@ -0,0 +1,171 @@ +import { DST, ProtocolError, bytes, hex, type G2 } from './protocol.ts'; +import { TAG, decodeCeremony, ed25519Verifier, type Anchor, type RoundRecord, type Statement, type Verifier } from './ceremony.ts'; +import { dkgInitialTranscript, dkgVerifyTranscript, type Exclusion } from './dkg.ts'; +import { reshareInitialTranscript, reshareVerifyTranscript } from './reshare.ts'; +import { hexField, intField, invalid, literal, object, type Parsed, type Parser } from './schema.ts'; + +const hexBytes: Parser = (v, p) => typeof v === 'string' && /^(?:[0-9a-f]{2})*$/.test(v) ? v : invalid(p); +const text: Parser = (v, p) => typeof v === 'string' && v.length > 0 ? v : invalid(p); +function list(parse: Parser, min = 0, max = 0xffff): Parser { + return (v, p) => Array.isArray(v) && v.length >= min && v.length <= max ? v.map((x: unknown, i) => parse(x, `${p}[${i}]`)) : invalid(p); +} +function nullable(parse: Parser): Parser { return (v, p) => v === null ? null : parse(v, p); } +const h32 = hexField(32), h96 = hexField(96), member = intField(1, 16); +const statement = object({ body: hexBytes, signature: hexBytes }); +const rounds = list(object({ closure: statement, messages: list(statement) })); +const exclusion: Parser = (v, p) => v && typeof v === 'object' && 'round' in v + ? object({ member, reason: text, round: intField(1, 6) })(v, p) + : object({ member, reason: text })(v, p); +const shares = list(object({ member, point: h96 }), 1, 16); +const signers = list(object({ did: text, ed25519_public_key: h32 }), 1, 64); +const anchor = object({ network: text, owner: text }); +const ceremonySeeds = object({ ceremonies: list(intField(0, 255), 1) }); +const sharedDst = { + ceremony_auth: literal(TAG.ceremonyAuth), ceremony_msg: literal(TAG.ceremonyMsg), commitments: literal(TAG.commitments), + dealing: literal(TAG.dealing), round_close: literal(TAG.roundClose), transcript: literal(TAG.transcript), +}; +function exactList(expected: T): Parser { + return (v, p) => Array.isArray(v) && v.length === expected.length && v.every((x, i) => x === expected[i]) ? expected : invalid(p); +} +const encoding = literal('hex; statements are exact signed bytes; scalars 32-byte big-endian; G2 96-byte compressed'); + +/** Case records carry either `outputs` (success) or `error` (a typed refusal), never both; names are unique. */ +function cases(operation: O, inputs: Parser, outputs: Parser) { + const success = object({ name: text, operation: literal(operation), description: text, inputs, outputs }); + const failure = object({ name: text, operation: literal(operation), description: text, inputs, error: text }); + type Case = Parsed | Parsed; + const one: Parser = (v, p) => v && typeof v === 'object' && 'error' in v ? failure(v, p) : success(v, p); + const all: Parser = (v, p) => { + const parsed = list(one, 1, 256)(v, p); + if (new Set(parsed.map(c => c.name)).size !== parsed.length) invalid(`${p}: duplicate name`); + return parsed; + }; + return all; +} +const dkgFile = object({ + format_version: literal(1), + suite: literal('BLS12-381 G2 Pedersen/Feldman GJKR New-DKG; RFC 9180 base mode DHKEM(X25519, HKDF-SHA256)/HKDF-SHA256/ChaCha20-Poly1305 dealings; Ed25519 test signatures'), + encoding, spec_sections: exactList(['3.2', '3.3', '3.7', '3.10', 'S0.3 DKG appendix'] as const), + dst: object({ ...sharedDst, dkg_start: literal(TAG.dkgStart), pedersen_h: literal(DST.pedersen_h), pedersen_h_message: literal('pedersen-h') }), + anchor, seeds: ceremonySeeds, signers, + cases: cases('dkg_verify_transcript', + object({ ceremony: statement, ceremony_digest: h32, initial_transcript: h32, members: member, threshold: member, rounds }), + object({ + commitments: list(h96, 1, 16), disqualified: list(exclusion, 0, 16), mpk: h96, public_shares: shares, + qual: list(member, 1, 16), reconstructed: list(member, 0, 16), transcript_hash: h32, + })), +}); +const reshareFile = object({ + format_version: literal(1), + suite: literal('BLS12-381 G2 Feldman redistribution; RFC 9180 base mode DHKEM(X25519, HKDF-SHA256)/HKDF-SHA256/ChaCha20-Poly1305 dealings; Ed25519 test signatures'), + encoding, spec_sections: exactList(['3.2', '3.3', '3.8', '3.10', 'S0.4 redistribution appendix'] as const), + dst: object({ ...sharedDst, cert_statement: literal(TAG.certStatement), network_cert: literal(TAG.networkCert), reshare_start: literal(TAG.reshareStart) }), + anchor, seeds: ceremonySeeds, signers, + cases: cases('reshare_verify_transcript', + object({ ceremony: statement, ceremony_digest: h32, current_certificate: object({ body: hexBytes, owner_signature: hexBytes }), initial_transcript: h32, rounds }), + object({ + commitments: list(h96, 1, 16), dealers: list(member, 1, 16), excluded: list(exclusion, 0, 16), members: member, + mpk: h96, public_shares: shares, threshold: member, transcript_hash: h32, + })), +}); +export type DkgFile = Parsed; +export type ReshareFile = Parsed; +/** Schema-validated DKG or redistribution transcript document. */ +export function parseTranscriptDocument(value: unknown): DkgFile | ReshareFile { + const kind = transcriptKind(value); + if (kind === 'dkg') return dkgFile(value, 'dkg'); + if (kind === 'reshare') return reshareFile(value, 'reshare'); + return invalid('transcript document kind'); +} +export type TranscriptKind = 'dkg' | 'reshare'; + +/** Which transcript document this is, from its pinned SPEC section list; undefined for other documents. */ +export function transcriptKind(value: unknown): TranscriptKind | undefined { + if (!value || typeof value !== 'object' || !('spec_sections' in value) || !Array.isArray(value.spec_sections)) return undefined; + if (value.spec_sections.includes('S0.3 DKG appendix')) return 'dkg'; + if (value.spec_sections.includes('S0.4 redistribution appendix')) return 'reshare'; + return undefined; +} + +function same(label: string, actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${label}: observed ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}`); +} +export function statementOf(s: { body: string; signature: string }): Statement { return { body: bytes(s.body), signature: bytes(s.signature) }; } +export function recordsOf(r: { closure: { body: string; signature: string }; messages: { body: string; signature: string }[] }[]): RoundRecord[] { + return r.map(x => ({ closure: statementOf(x.closure), messages: x.messages.map(statementOf) })); +} +export function verifierOf(file: { signers: { did: string; ed25519_public_key: string }[] }): Verifier { + return ed25519Verifier(new Map(file.signers.map(s => [s.did, bytes(s.ed25519_public_key)]))); +} +/** Runs the operation and returns either its typed error code or its outputs. */ +function outcome(run: () => T): { error: string } | { value: T } { + try { return { value: run() }; } catch (e) { + if (e instanceof ProtocolError) return { error: e.code }; + throw e; + } +} +const points = (p: G2[]) => p.map(x => hex(x.toBytes())); +const indexed = (p: G2[]) => p.map((x, k) => ({ member: k + 1, point: hex(x.toBytes()) })); +const exclusions = (e: Exclusion[]) => e.map(x => ({ member: x.member, reason: x.reason, ...(x.round === undefined ? {} : { round: x.round }) })); + +export interface TranscriptReport { kind: TranscriptKind; passed: string[] } +export function checkDkg(input: unknown): TranscriptReport { + const file = dkgFile(input, 'dkg'), verify = verifierOf(file), passed: string[] = []; + const anchor: Anchor = file.anchor; + for (const c of file.cases) { + try { + const result = outcome(() => dkgVerifyTranscript(anchor, statementOf(c.inputs.ceremony), recordsOf(c.inputs.rounds), verify)); + same('computed error', 'error' in result ? result.error : null, 'error' in c ? c.error : null); + const ceremony = decodeCeremony(bytes(c.inputs.ceremony.body)); + same('ceremony digest', hex(ceremony.digest), c.inputs.ceremony_digest); + same('initial transcript', hex(dkgInitialTranscript(ceremony.digest)), c.inputs.initial_transcript); + same('ceremony members', ceremony.n, c.inputs.members); + same('ceremony threshold', ceremony.t, c.inputs.threshold); + if ('value' in result && 'outputs' in c) { + const o = result.value; + same('qual', o.qual, c.outputs.qual); + same('disqualified', exclusions(o.disqualified), c.outputs.disqualified); + same('reconstructed', o.reconstructed, c.outputs.reconstructed); + same('commitments', points(o.commitments), c.outputs.commitments); + same('mpk', hex(o.mpk.toBytes()), c.outputs.mpk); + same('public shares', indexed(o.publicShares), c.outputs.public_shares); + same('transcript hash', hex(o.transcriptHash), c.outputs.transcript_hash); + } + passed.push(c.name); + } catch (e) { throw new Error(`${c.name}: ${e instanceof Error ? e.message : String(e)}`); } + } + return { kind: 'dkg', passed }; +} +export function checkReshare(input: unknown): TranscriptReport { + const file = reshareFile(input, 'reshare'), verify = verifierOf(file), passed: string[] = []; + const anchor: Anchor = file.anchor; + for (const c of file.cases) { + try { + const certificate: Statement = { body: bytes(c.inputs.current_certificate.body), signature: bytes(c.inputs.current_certificate.owner_signature) }; + const result = outcome(() => reshareVerifyTranscript(anchor, statementOf(c.inputs.ceremony), certificate, recordsOf(c.inputs.rounds), verify)); + same('computed error', 'error' in result ? result.error : null, 'error' in c ? c.error : null); + const ceremony = decodeCeremony(bytes(c.inputs.ceremony.body)); + same('ceremony digest', hex(ceremony.digest), c.inputs.ceremony_digest); + same('initial transcript', hex(reshareInitialTranscript(ceremony.digest)), c.inputs.initial_transcript); + if ('value' in result && 'outputs' in c) { + const o = result.value; + same('threshold', o.threshold, c.outputs.threshold); + same('members', o.members, c.outputs.members); + same('dealers', o.dealers, c.outputs.dealers); + same('excluded', exclusions(o.excluded), c.outputs.excluded); + same('commitments', points(o.commitments), c.outputs.commitments); + same('mpk', hex(o.mpk.toBytes()), c.outputs.mpk); + same('public shares', indexed(o.publicShares), c.outputs.public_shares); + same('transcript hash', hex(o.transcriptHash), c.outputs.transcript_hash); + } + passed.push(c.name); + } catch (e) { throw new Error(`${c.name}: ${e instanceof Error ? e.message : String(e)}`); } + } + return { kind: 'reshare', passed }; +} +export function checkTranscripts(input: unknown): TranscriptReport { + const kind = transcriptKind(input); + if (kind === 'dkg') return checkDkg(input); + if (kind === 'reshare') return checkReshare(input); + return invalid('transcript document kind'); +} From be9926d43f740bc8f556fb6016ff2718a3ef556d Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:52:40 +0000 Subject: [PATCH 02/10] test(threshold): cover transcript rules, one-byte corruptions and vector documents; Details: TC-2223 --- tinycloud-threshold/vectors/check/dkg.test.ts | 253 ++++++++++++++++++ tinycloud-threshold/vectors/check/probes.ts | 93 +++++++ .../vectors/check/reshare.test.ts | 156 +++++++++++ tinycloud-threshold/vectors/check/testkit.ts | 236 ++++++++++++++++ .../check/transcript-corruption.test.ts | 103 +++++++ .../vectors/check/transcript-vectors.test.ts | 81 ++++++ 6 files changed, 922 insertions(+) create mode 100644 tinycloud-threshold/vectors/check/dkg.test.ts create mode 100644 tinycloud-threshold/vectors/check/probes.ts create mode 100644 tinycloud-threshold/vectors/check/reshare.test.ts create mode 100644 tinycloud-threshold/vectors/check/testkit.ts create mode 100644 tinycloud-threshold/vectors/check/transcript-corruption.test.ts create mode 100644 tinycloud-threshold/vectors/check/transcript-vectors.test.ts diff --git a/tinycloud-threshold/vectors/check/dkg.test.ts b/tinycloud-threshold/vectors/check/dkg.test.ts new file mode 100644 index 00000000..c3f06c28 --- /dev/null +++ b/tinycloud-threshold/vectors/check/dkg.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test'; +import { ed25519 } from '@noble/curves/ed25519.js'; +import { R, be, concat, tagged, type Bytes } from './protocol.ts'; +import { dkgVerifyTranscript, type DkgOutput } from './dkg.ts'; +import type { RoundRecord, Statement } from './ceremony.ts'; +import { + ANCHOR, COHORT_DEALERS, COHORT_RECIPIENTS, G2_BASE, H, alternateSignature, at, code, complaints, deal, did, dkgSetup, + SEEDS, expectedDkg, feldman, flip, g2hex, nonCanonicalR, nonCanonicalS, pair, pairs, seal, signedCeremony, times, verify, + type DkgSetup, type Draft, +} from './testkit.ts'; + +function records(s: DkgSetup, rounds: Draft[][] = s.rounds): RoundRecord[] { return seal(s.digest, s.start, rounds).records; } +function run(s: DkgSetup, rounds: Draft[][] = s.rounds, ceremony: Statement = s.ceremony): DkgOutput { + return dkgVerifyTranscript(ANCHOR, ceremony, records(s, rounds), verify); +} +const msg = (index: number, payload: Bytes): Draft => ({ cohort: COHORT_RECIPIENTS, index, payload }); +/** Replaces sender `index`'s message(s) in round `r` (1-based) with the given drafts. */ +function replace(from: DkgSetup | Draft[][], r: number, index: number, ...drafts: Draft[]): Draft[][] { + return (Array.isArray(from) ? from : from.rounds).map((round, k) => k + 1 !== r ? round : [...round.filter(d => d.index !== index), ...drafts]); +} +function expectPublic(out: DkgOutput, s: DkgSetup, qual: number[]) { + const e = expectedDkg(s.dealers, qual, s.n); + expect(out.qual).toEqual(qual); + expect(out.commitments.map(g2hex)).toEqual(e.commitments); + expect(g2hex(out.mpk)).toBe(e.commitments[0]!); + expect(out.publicShares.map(g2hex)).toEqual(e.publicShares); +} +/** Every member's R6 with its Pedersen-valid pair for each reconstructed dealer. */ +function r6(s: DkgSetup, dealers: number[], senders = s.dealers.map(d => d.index)): Draft[] { + return senders.map(j => msg(j, pairs(dealers.map(i => [i, ...pair(s.dealers[i - 1]!, j)] as const)))); +} +/** Rebuilds dealer i with explicit coefficient vectors and regenerates its R1 and R4. */ +function withDealer(s: DkgSetup, i: number, a: bigint[], b: bigint[]): DkgSetup { + const d = { index: i, a, b, C: a.map((x, k) => times(G2_BASE, x).add(times(H, b[k]!))), A: a.map(x => times(G2_BASE, x)) }; + const dealers = s.dealers.map(x => x.index === i ? d : x), members = dealers.map(x => x.index); + const next = { ...s, dealers }; + next.rounds = replace(next, 1, i, msg(i, deal(d.C, members, 64, i))); + next.rounds = replace(next, 4, i, msg(i, feldman(d.A))); + return next; +} + +describe('honest genesis', () => { + test('2-of-3 output matches scalar-domain expectation and the sealed transcript hash', () => { + const s = dkgSetup('honest-2of3', 2, 3), out = run(s); + expectPublic(out, s, [1, 2, 3]); + expect(out.disqualified).toEqual([]); + expect(out.reconstructed).toEqual([]); + expect(out.transcriptHash).toEqual(seal(s.digest, s.start, s.rounds).hash); + }); + test('byte-identical retransmissions and a second valid signature on one envelope are one message', () => { + const s = dkgSetup('retransmit', 2, 3); + const twice = s.rounds.map((round, k) => k === 1 ? [...round, round[1]!, { ...round[1]!, signature: (m: Bytes, signer: string) => alternateSignature(signer, m) }] : round); + expectPublic(run(s, twice), s, [1, 2, 3]); + expect(run(s, twice).disqualified).toEqual([]); + }); + test('individual commitment coefficients may be the identity', () => { + const base = dkgSetup('identity-coefficient', 2, 3), d = base.dealers[0]!; + const s = withDealer(base, 1, [d.a[0]!, 0n], [d.b[0]!, 0n]); + expectPublic(run(s), s, [1, 2, 3]); + }); +}); + +describe('R1–R3 and QUAL', () => { + const s = dkgSetup('qual-2of3', 2, 3), members = [1, 2, 3]; + test('conflicting R1 disqualifies with round 1 and its R4 is ignored', () => { + const out = run(s, replace(s, 1, 2, msg(2, deal(s.dealers[1]!.C, members, 64, 2)), msg(2, deal(s.dealers[1]!.C, members, 64, 9)))); + expect(out.disqualified).toEqual([{ member: 2, reason: 'Equivocated', round: 1 }]); + expectPublic(out, s, [1, 3]); + }); + test('missing R1 is MissingDeal', () => { + expect(run(s, replace(s, 1, 3)).disqualified).toEqual([{ member: 3, reason: 'MissingDeal' }]); + }); + for (const [label, payload] of [ + ['t+1 commitments', deal([...s.dealers[0]!.C, G2_BASE], members)], + ['recipients 1..2', deal(s.dealers[0]!.C, [1, 2])], + ['recipients 1..4', deal(s.dealers[0]!.C, [1, 2, 3, 4])], + ['trailing byte', concat(deal(s.dealers[0]!.C, members), be(0n, 1))], + ['version 2', flip(deal(s.dealers[0]!.C, members), 0, 3)], + ['commitment off subgroup or curve', flip(deal(s.dealers[0]!.C, members), 3 + 95)], + ['sealed dealing version 2', flip(deal(s.dealers[0]!.C, members), 3 + 2 * 96 + 2 + 2 + 4, 3)], + ] as const) { + test(`malformed R1 (${label}) is MalformedDeal`, () => { + expect(run(s, replace(s, 1, 1, msg(1, payload))).disqualified).toEqual([{ member: 1, reason: 'MalformedDeal' }]); + }); + } + test('t distinct complainers disqualify even with valid answers', () => { + const rounds = replace(replace(s, 2, 2, msg(2, complaints([1]))), 2, 3, msg(3, complaints([1]))); + const answered = rounds.map((r, k) => k === 2 ? [...r.filter(d => d.index !== 1), msg(1, pairs([[2, ...pair(s.dealers[0]!, 2)], [3, ...pair(s.dealers[0]!, 3)]]))] : r); + expect(run(s, answered).disqualified).toEqual([{ member: 1, reason: 'Complaints' }]); + }); + test('self-complaints are ignored', () => { + expect(run(s, replace(s, 2, 1, msg(1, complaints([1])))).disqualified).toEqual([]); + }); + test('a valid required answer keeps the dealer and the key', () => { + const rounds = replace(replace(s, 2, 2, msg(2, complaints([1]))), 3, 1, msg(1, pairs([[2, ...pair(s.dealers[0]!, 2)]]))); + expectPublic(run(s, rounds), s, members); + }); + test('an invalid answer is InvalidAnswer, required or not', () => { + const [x, y] = pair(s.dealers[0]!, 2); + const required = replace(replace(s, 2, 2, msg(2, complaints([1]))), 3, 1, msg(1, pairs([[2, (x + 1n) % R, y]]))); + expect(run(s, required).disqualified).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); + expect(run(s, replace(s, 3, 1, msg(1, pairs([[2, (x + 1n) % R, y]])))).disqualified).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); + }); + test('an unrequired valid answer is ignored', () => { + expect(run(s, replace(s, 3, 1, msg(1, pairs([[2, ...pair(s.dealers[0]!, 2)]])))).disqualified).toEqual([]); + }); + test('missing, omitted or malformed required answers are MissingAnswer', () => { + const complained = replace(s, 2, 2, msg(2, complaints([1]))); + const variants: Draft[][][] = [ + complained.map((r, k) => k === 2 ? r.filter(d => d.index !== 1) : r), + complained.map((r, k) => k === 2 ? [...r.filter(d => d.index !== 1), msg(1, pairs([[3, ...pair(s.dealers[0]!, 3)]]))] : r), + complained.map((r, k) => k === 2 ? [...r.filter(d => d.index !== 1), msg(1, concat(pairs([[2, ...pair(s.dealers[0]!, 2)]]), be(0n, 1)))] : r), + complained.map((r, k) => k === 2 ? [...r.filter(d => d.index !== 1), msg(1, pairs([[2, ...pair(s.dealers[0]!, 2)], [4, 1n, 1n]]))] : r), + ]; + for (const rounds of variants) expect(run(s, rounds).disqualified).toEqual([{ member: 1, reason: 'MissingAnswer' }]); + }); + test('an R3 naming a recipient above n is malformed, so its bad pair is not InvalidAnswer', () => { + expect(run(s, replace(s, 3, 1, msg(1, pairs([[2, 1n, 1n], [4, 1n, 1n]])))).disqualified).toEqual([]); + }); + test('R2 naming a dealer above n, or unsorted, supplies no complaints', () => { + expect(run(s, replace(s, 2, 2, msg(2, complaints([1, 4])))).disqualified).toEqual([]); + expect(run(s, replace(s, 2, 2, msg(2, concat(be(1n, 1), be(2n, 2), be(3n, 2), be(1n, 2))))).disqualified).toEqual([]); + }); + test('conflicting R2 disqualifies its sender and supplies no complaints', () => { + const out = run(s, replace(s, 2, 1, msg(1, complaints([])), msg(1, complaints([3])))); + expect(out.disqualified).toEqual([{ member: 1, reason: 'Equivocated', round: 2 }]); + }); + test('conflicting R3 is Equivocated round 3', () => { + expect(run(s, replace(s, 3, 2, msg(2, pairs([])), msg(2, pairs([[1, ...pair(s.dealers[1]!, 1)]])))).disqualified) + .toEqual([{ member: 2, reason: 'Equivocated', round: 3 }]); + }); + test('the first applicable reason wins', () => { + const malformedAndConflicting = replace(replace(s, 1, 1, msg(1, deal(s.dealers[0]!.C, [1, 2]))), 2, 1, msg(1, complaints([])), msg(1, complaints([2]))); + expect(run(s, malformedAndConflicting).disqualified).toEqual([{ member: 1, reason: 'MalformedDeal' }]); + const conflictingAndComplained = replace(replace(replace(s, 2, 1, msg(1, complaints([])), msg(1, complaints([2]))), 2, 2, msg(2, complaints([1]))), 2, 3, msg(3, complaints([1]))); + expect(run(s, conflictingAndComplained).disqualified).toEqual([{ member: 1, reason: 'Equivocated', round: 2 }]); + }); + test('fewer than t qualified dealers aborts only as the last record', () => { + const alone = s.rounds.map((r, k) => k === 0 ? r.filter(d => d.index === 1) : r); + expect(code(() => run(s, alone.slice(0, 3)))).toBe('InsufficientQualified'); + expect(code(() => run(s, alone))).toBe('InvalidRound'); + expect(code(() => run(s, alone.slice(0, 2)))).toBe('InvalidRound'); + }); +}); + +describe('R4–R6 extraction and reconstruction', () => { + const s = dkgSetup('extract-2of3', 2, 3), members = [1, 2, 3]; + const faults: [string, Draft[]][] = [ + ['missing', []], + ['conflicting', [msg(3, feldman(s.dealers[2]!.A)), msg(3, feldman([...s.dealers[2]!.A].reverse()))]], + ['wrong count', [msg(3, feldman(s.dealers[2]!.A.slice(0, 1)))]], + ['malformed', [msg(3, flip(feldman(s.dealers[2]!.A), 3 + 95))]], + ]; + for (const [label, drafts] of faults) { + test(`${label} R4 is an extraction fault recovered by R6; the dealer stays in QUAL`, () => { + const faulted = replace(s, 4, 3, ...drafts); + const out = run(s, [...faulted, r6(s, [3])]); + expect(out.reconstructed).toEqual([3]); + expectPublic(out, s, members); + expect(code(() => run(s, faulted))).toBe('InvalidRound'); + }); + } + test('an upheld R5 complaint forces reconstruction of the genuine contribution', () => { + const wrong = [s.dealers[2]!.A[0]!, s.dealers[2]!.A[1]!.add(G2_BASE)]; + const rounds = replace(replace(s, 4, 3, msg(3, feldman(wrong))), 5, 1, msg(1, pairs([[3, ...pair(s.dealers[2]!, 1)]]))); + const out = run(s, [...rounds, r6(s, [3])]); + expect(out.reconstructed).toEqual([3]); + expectPublic(out, s, members); + }); + test('R5 entries that are Pedersen-invalid, self-directed or Feldman-valid are ignored', () => { + const [x, y] = pair(s.dealers[2]!, 1); + for (const entry of [[3, x, (y + 1n) % R], [1, ...pair(s.dealers[0]!, 1)], [3, x, y]] as const) { + const rounds = replace(s, 5, 1, msg(1, pairs([entry]))); + expect(run(s, rounds).reconstructed).toEqual([]); + expect(code(() => run(s, [...rounds, r6(s, [3])]))).toBe('InvalidRound'); + } + }); + test('fewer than t Pedersen-valid R6 values is InsufficientRecovery', () => { + const faulted = replace(s, 4, 3); + expect(code(() => run(s, [...faulted, r6(s, [3], [1])]))).toBe('InsufficientRecovery'); + const [x, y] = pair(s.dealers[2]!, 2); + expect(code(() => run(s, [...faulted, [...r6(s, [3], [1]), msg(2, pairs([[3, x, (y + 1n) % R]]))]]))).toBe('InsufficientRecovery'); + const conflicting = [...r6(s, [3], [1, 2]), msg(2, pairs([]))]; + expect(code(() => run(s, [...faulted, conflicting]))).toBe('InsufficientRecovery'); + expectPublic(run(s, [...faulted, [...conflicting, ...r6(s, [3], [3])]]), s, members); + }); + test('3-of-5 recovers from the lowest Pedersen-valid senders despite two silent members', () => { + const big = dkgSetup('extract-3of5', 3, 5); + const out = run(big, [...replace(big, 4, 5), r6(big, [5], [1, 2, 3])]); + expect(out.reconstructed).toEqual([5]); + expectPublic(out, big, [1, 2, 3, 4, 5]); + }); +}); + +describe('identity rejections', () => { + const base = dkgSetup('identity-2of3', 2, 3), [d1, d2, d3] = base.dealers; + test('identity mpk, top aggregate coefficient or public share is IdentityPoint', () => { + const zeroMpk = withDealer(base, 3, [(2n * R - d1!.a[0]! - d2!.a[0]!) % R, d3!.a[1]!], d3!.b); + const zeroTop = withDealer(base, 3, [d3!.a[0]!, (2n * R - d1!.a[1]! - d2!.a[1]!) % R], d3!.b); + const zeroShare = withDealer(base, 3, [(3n * R - at(d1!.a, 1) - at(d2!.a, 1) - d3!.a[1]!) % R, d3!.a[1]!], d3!.b); + for (const s of [zeroMpk, zeroTop, zeroShare]) expect(code(() => run(s))).toBe('IdentityPoint'); + }); +}); + +describe('ceremony, round structure and limits', () => { + const s = dkgSetup('structure-2of3', 2, 3); + const verifyWith = (ceremony: Statement, rs: RoundRecord[]) => code(() => dkgVerifyTranscript(ANCHOR, ceremony, rs, verify)); + test('ceremony anchor, kind, budget, signature and encoding', () => { + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, network: 'tinycloud:test:other' }), [])).toBe('InvalidCeremony'); + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, owner: did(1) }, did(1)), [])).toBe('InvalidCeremony'); + expect(verifyWith(signedCeremony({ kind: 2, t: 2, n: 3, current: new Uint8Array(32), epoch: 1n }), [])).toBe('InvalidCeremony'); + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, epoch: 1n }), [])).toBe('InvalidCeremony'); + expect(verifyWith(signedCeremony({ kind: 1, t: 3, n: 4 }), [])).toBe('UnsupportedFaultBudget'); + expect(verifyWith({ ...s.ceremony, signature: flip(s.ceremony.signature, 0) }, [])).toBe('InvalidSignature'); + expect(verifyWith({ ...s.ceremony, body: concat(s.ceremony.body, be(0n, 1)) }, [])).toBe('InvalidEncoding'); + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, directory: [{ index: 1, did: did(1) }, { index: 2, did: did(1) }, { index: 3, did: did(3) }] }), [])).toBe('InvalidEncoding'); + }); + test('record count, closure binding and ordered-list commitment', () => { + const rs = records(s); + expect(code(() => run(s, [...s.rounds, r6(s, []), []]))).toBe('InvalidRound'); + expect(verifyWith(s.ceremony, rs.slice(0, 4))).toBe('InvalidRound'); + expect(code(() => dkgVerifyTranscript(ANCHOR, s.ceremony, seal(s.digest, new Uint8Array(32), s.rounds).records, verify))).toBe('InvalidRound'); + expect(verifyWith(s.ceremony, [rs[1]!, rs[0]!, ...rs.slice(2)])).toBe('InvalidRound'); + const swapped = { ...rs[1]!, messages: [rs[1]!.messages[1]!, rs[1]!.messages[0]!, rs[1]!.messages[2]!] }; + expect(verifyWith(s.ceremony, [rs[0]!, swapped, ...rs.slice(2)])).toBe('InvalidRound'); + }); + test('envelope binding, signer resolution and strict signatures', () => { + const at2 = (d: Draft) => run(s, s.rounds.map((r, k) => k === 1 ? [...r, d] : r)); + expect(code(() => at2({ ...msg(1, complaints([])), round: 3 }))).toBe('InvalidRound'); + expect(code(() => at2({ ...msg(1, complaints([])), ceremony: new Uint8Array(32) }))).toBe('InvalidRound'); + expect(code(() => at2(msg(4, complaints([]))))).toBe('InvalidIndex'); + expect(code(() => at2(msg(0, complaints([]))))).toBe('InvalidIndex'); + expect(code(() => at2({ cohort: COHORT_DEALERS, index: 1, payload: complaints([]) }))).toBe('InvalidIndex'); + expect(code(() => at2({ ...msg(1, complaints([])), signer: did(2) }))).toBe('InvalidSignature'); + expect(code(() => at2({ ...msg(1, complaints([2])), signature: (m: Bytes, signer: string) => nonCanonicalS(alternateSignature(signer, m)) }))).toBe('InvalidSignature'); + const preimage = tagged('TC-THRESHOLD-V1-CEREMONY-MSG', new Uint8Array([1, 2, 3])), zip215Only = nonCanonicalR(did(1), preimage); + expect(ed25519.verify(zip215Only, preimage, ed25519.getPublicKey(SEEDS.get(did(1))!), { zip215: true })).toBe(true); + expect(verify(did(1), preimage, zip215Only)).toBe(false); + expect(code(() => at2({ ...msg(1, complaints([2])), signature: (m: Bytes, signer: string) => nonCanonicalR(signer, m) }))).toBe('InvalidSignature'); + }); + test('per-message and signature caps', () => { + expect(code(() => run(s, replace(s, 2, 1, msg(1, new Uint8Array(65536)))))).toBe('InvalidEncoding'); + expect(code(() => run(s, replace(s, 2, 1, { ...msg(1, complaints([])), signature: () => new Uint8Array(513) })))).toBe('InvalidEncoding'); + }); + test('the 4 MiB transcript bound is checked before the ceremony and any signature', () => { + const garbage = (size: number): RoundRecord => ({ closure: { body: new Uint8Array(98), signature: new Uint8Array(64) }, messages: [{ body: new Uint8Array(size), signature: new Uint8Array(0) }] }); + const fill = (total: number) => { const per = 65536, k = Math.floor((total - 162) / per); return [...Array.from({ length: k }, () => garbage(per - 162)), garbage(total - k * (per - 162 + 162) - 162)]; }; + const bad: Statement = { body: new Uint8Array(1), signature: new Uint8Array(0) }; + expect(verifyWith(bad, fill(4194305))).toBe('LimitExceeded'); + expect(verifyWith(bad, fill(4194304))).toBe('InvalidEncoding'); + expect(verifyWith(s.ceremony, fill(4194304))).toBe('InvalidRound'); + }); +}); diff --git a/tinycloud-threshold/vectors/check/probes.ts b/tinycloud-threshold/vectors/check/probes.ts new file mode 100644 index 00000000..c2cb4e7f --- /dev/null +++ b/tinycloud-threshold/vectors/check/probes.ts @@ -0,0 +1,93 @@ +// Test-only one-byte corruption probes over committed transcript vectors. Each probe flips +// exactly one byte of one signed record and names the typed reason the verifier must return. +import { readFileSync } from 'node:fs'; +import { bytes, type Bytes } from './protocol.ts'; +import { decodeCeremony, decodeCertificate, type RoundRecord, type Statement } from './ceremony.ts'; +import { dkgVerifyTranscript } from './dkg.ts'; +import { reshareVerifyTranscript } from './reshare.ts'; +import { parseTranscriptDocument, recordsOf, statementOf, verifierOf, type DkgFile, type ReshareFile } from './transcripts.ts'; +import { code } from './testkit.ts'; + +export interface TranscriptInput { ceremony: Statement; certificate?: Statement; records: RoundRecord[] } +export interface Probe { record: string; field: string; offset: number; mask: number; expected: string; apply: (x: TranscriptInput) => void } +export type Doc = DkgFile | ReshareFile; +export type DocCase = Doc['cases'][number]; +export function loadDoc(file: string): Doc { + return parseTranscriptDocument(JSON.parse(readFileSync(new URL(`../v1/${file}`, import.meta.url), 'utf8'))); +} + +export function inputOf(c: DocCase): TranscriptInput { + const base = { ceremony: statementOf(c.inputs.ceremony), records: recordsOf(c.inputs.rounds) }; + if (!('current_certificate' in c.inputs)) return base; + const cert = c.inputs.current_certificate; + return { ...base, certificate: { body: bytes(cert.body), signature: bytes(cert.owner_signature) } }; +} +export function clone(x: TranscriptInput): TranscriptInput { + const s = (v: Statement) => ({ body: v.body.slice(), signature: v.signature.slice() }); + return { ceremony: s(x.ceremony), ...(x.certificate ? { certificate: s(x.certificate) } : {}), records: x.records.map(r => ({ closure: s(r.closure), messages: r.messages.map(s) })) }; +} +export function verifyInput(doc: Doc, x: TranscriptInput): string { + const verify = verifierOf(doc); + return code(() => x.certificate + ? reshareVerifyTranscript(doc.anchor, x.ceremony, x.certificate, x.records, verify) + : dkgVerifyTranscript(doc.anchor, x.ceremony, x.records, verify)); +} + +function at(record: string, field: string, select: (x: TranscriptInput) => Bytes, offset: number, mask: number, expected: string): Probe { + return { record, field, offset, mask, expected, apply: x => { const b = select(x); b[offset] = b[offset]! ^ mask; } }; +} +/** The fixed probe set for one case: every field class of every record kind, first and last message of each round. */ +export function probes(c: Doc['cases'][number]): Probe[] { + const x = inputOf(c), out: Probe[] = []; + const ceremony = decodeCeremony(x.ceremony.body); + const cohortSize: Record = { 1: ceremony.n, 2: x.certificate ? decodeCertificate(x.certificate.body).n : 0 }; + const cb = x.ceremony.body; + out.push( + at('ceremony', 'version', y => y.ceremony.body, 0, 1, 'InvalidEncoding'), + at('ceremony', 'kind→0', y => y.ceremony.body, 1, cb[1]!, 'InvalidEncoding'), + at('ceremony', 'ceremony id', y => y.ceremony.body, 2, 1, 'InvalidSignature'), + at('ceremony', 'network', y => y.ceremony.body, 38, 1, 'InvalidCeremony'), + at('ceremony', 'last HPKE key byte', y => y.ceremony.body, cb.length - 1, 1, 'InvalidSignature'), + at('ceremony', 'signature R', y => y.ceremony.signature, 0, 1, 'InvalidSignature'), + at('ceremony', 'signature S', y => y.ceremony.signature, 63, 1, 'InvalidSignature'), + ); + if (x.certificate) { + const len = x.certificate.body.length; + if (x.certificate.body[len - 1] !== 0 || x.certificate.body[len - 2] !== 0) throw new Error('probe expects a certificate without predecessor fields'); + out.push( + at('certificate', 'version', y => y.certificate!.body, 0, 1, 'InvalidEncoding'), + at('certificate', 'network', y => y.certificate!.body, 5, 1, 'InvalidCertificate'), + at('certificate', 'transcript hash', y => y.certificate!.body, len - 34, 1, 'InvalidSignature'), + at('certificate', 'signature', y => y.certificate!.signature, 0, 1, 'InvalidSignature'), + ); + } + for (const [k, r] of x.records.entries()) { + const name = `R${k + 1} closure`, closure = (y: TranscriptInput) => y.records[k]!.closure.body; + out.push( + at(name, 'version', closure, 0, 1, 'InvalidRound'), + at(name, 'ceremony digest', closure, 1, 1, 'InvalidRound'), + at(name, 'round', closure, 33, 1, 'InvalidRound'), + at(name, 'ordered-list hash', closure, 34, 1, 'InvalidSignature'), + at(name, 'previous hash', closure, 66, 1, 'InvalidRound'), + at(name, 'previous hash end', closure, 97, 1, 'InvalidRound'), + at(name, 'signature', y => y.records[k]!.closure.signature, 0, 1, 'InvalidSignature'), + ); + for (const m of new Set([0, r.messages.length - 1])) { + const env = r.messages[m]!.body, label = `R${k + 1} message ${m + 1}`, body = (y: TranscriptInput) => y.records[k]!.messages[m]!.body; + const flipped = ((env[35]! << 8) | env[36]!) ^ 1; + out.push( + at(label, 'version', body, 0, 1, 'InvalidEncoding'), + at(label, 'ceremony digest', body, 1, 1, 'InvalidRound'), + at(label, 'round', body, 33, 1, 'InvalidRound'), + at(label, 'cohort→0', body, 34, env[34]!, 'InvalidIndex'), + at(label, 'index', body, 36, 1, flipped >= 1 && flipped <= cohortSize[env[34]!]! ? 'InvalidSignature' : 'InvalidIndex'), + at(label, 'payload length', body, 37, 1, 'InvalidEncoding'), + at(label, 'payload first byte', body, 41, 1, 'InvalidSignature'), + at(label, 'payload last byte', body, env.length - 1, 1, 'InvalidSignature'), + at(label, 'signature R', y => y.records[k]!.messages[m]!.signature, 0, 1, 'InvalidSignature'), + at(label, 'signature S', y => y.records[k]!.messages[m]!.signature, 63, 1, 'InvalidSignature'), + ); + } + } + return out; +} diff --git a/tinycloud-threshold/vectors/check/reshare.test.ts b/tinycloud-threshold/vectors/check/reshare.test.ts new file mode 100644 index 00000000..0956624d --- /dev/null +++ b/tinycloud-threshold/vectors/check/reshare.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'bun:test'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { R, be, concat, type Bytes } from './protocol.ts'; +import { reshareVerifyTranscript, type ReshareOutput } from './reshare.ts'; +import type { RoundRecord, Statement } from './ceremony.ts'; +import { + ANCHOR, COHORT_DEALERS, COHORT_RECIPIENTS, G2_BASE, at, code, complaints, deal, did, expectedReshare, flip, g2hex, + lagrange, reshareSetup, seal, signedCertificate, times, values, verify, type CeremonySpec, type Draft, type ReshareSetup, +} from './testkit.ts'; + +function run(s: ReshareSetup, rounds: Draft[][] = s.rounds, certificate: Statement = s.certificate): ReshareOutput { + return reshareVerifyTranscript(ANCHOR, s.ceremony, certificate, seal(s.digest, s.start, rounds).records, verify); +} +const dealer = (index: number, payload: Bytes): Draft => ({ cohort: COHORT_DEALERS, index, payload }); +const recipient = (index: number, payload: Bytes): Draft => ({ cohort: COHORT_RECIPIENTS, index, payload }); +function replace(from: ReshareSetup | Draft[][], r: number, cohort: number, index: number, ...drafts: Draft[]): Draft[][] { + return (Array.isArray(from) ? from : from.rounds).map((round, k) => k + 1 !== r ? round : [...round.filter(d => d.cohort !== cohort || d.index !== index), ...drafts]); +} +const recipients = (s: ReshareSetup) => Array.from({ length: s.n2 }, (_, k) => k + 1); +const commitmentsOf = (coefficients: readonly bigint[]) => coefficients.map(c => times(G2_BASE, c)); +/** Replaces dealer i's polynomial and its R1 in place (R1 order decides D). */ +function withDealing(s: ReshareSetup, i: number, coefficients: bigint[]): ReshareSetup { + const dealings = s.dealings.map((d, k) => k + 1 === i ? coefficients : d); + const payload = deal(commitmentsOf(coefficients), recipients(s), 32, i); + return { ...s, dealings, rounds: s.rounds.map((r, k) => k !== 0 ? r : r.map(d => d.cohort === COHORT_DEALERS && d.index === i ? dealer(i, payload) : d)) }; +} +function expectOutput(out: ReshareOutput, s: ReshareSetup, dealers: number[]) { + const e = expectedReshare(s, dealers); + expect(out.dealers).toEqual(dealers); + expect(out.threshold).toBe(s.t2); + expect(out.members).toBe(s.n2); + expect(out.commitments.map(g2hex)).toEqual(e.commitments); + expect(g2hex(out.mpk)).toBe(e.mpk); + expect(out.publicShares.map(g2hex)).toEqual(e.publicShares); +} +const value = (s: ReshareSetup, i: number, j: number) => at(s.dealings[i - 1]!, j); + +describe('honest redistribution', () => { + test('2-of-3 → 3-of-4 keeps mpk; new shares match the scalar-domain expectation', () => { + const s = reshareSetup('honest-reshare', 2, 3, 3, 4), out = run(s); + expectOutput(out, s, [1, 2]); + expect(out.excluded).toEqual([]); + expect(out.transcriptHash).toEqual(seal(s.digest, s.start, s.rounds).hash); + }); + test('refresh 2-of-3 → 2-of-3 keeps mpk', () => { + const s = reshareSetup('honest-refresh', 2, 3, 2, 3, 3); + expectOutput(run(s), s, [1, 2]); + }); + test('D is the first t valid dealers in owner order of their first R1', () => { + const s = reshareSetup('order', 2, 3, 3, 4); + const reordered = s.rounds.map((r, k) => k === 0 ? [r[2]!, r[0]!, r[1]!, r[2]!] : r); + expectOutput(run(s, reordered), s, [3, 1]); + }); +}); + +describe('exclusion reasons', () => { + const s = reshareSetup('exclusions', 2, 3, 3, 4); + test('MissingDeal and Equivocated round 1', () => { + expect(run(s, replace(s, 1, COHORT_DEALERS, 1)).excluded).toEqual([{ member: 1, reason: 'MissingDeal' }]); + const twice = replace(s, 1, COHORT_DEALERS, 1, dealer(1, deal(commitmentsOf(s.dealings[0]!), recipients(s), 32, 1)), dealer(1, deal(commitmentsOf(s.dealings[0]!), recipients(s), 32, 7))); + const out = run(s, twice); + expect(out.excluded).toEqual([{ member: 1, reason: 'Equivocated', round: 1 }]); + expectOutput(out, s, [2, 3]); + }); + const g = s.dealings[0]!; + for (const [label, payload] of [ + ['B_0 is not pk_i', deal(commitmentsOf([(g[0]! + 1n) % R, g[1]!, g[2]!]), [1, 2, 3, 4])], + ['t′+1 coefficients', deal(commitmentsOf([...g, 5n]), [1, 2, 3, 4])], + ['identity top coefficient', deal(commitmentsOf([g[0]!, g[1]!, 0n]), [1, 2, 3, 4])], + ['recipients 1..3', deal(commitmentsOf(g), [1, 2, 3])], + ['trailing byte', concat(deal(commitmentsOf(g), [1, 2, 3, 4]), be(0n, 1))], + ] as const) { + test(`MalformedDeal: ${label}`, () => { + expect(run(s, replace(s, 1, COHORT_DEALERS, 1, dealer(1, payload))).excluded).toEqual([{ member: 1, reason: 'MalformedDeal' }]); + }); + } + test('Equivocated round 3', () => { + expect(run(s, replace(s, 3, COHORT_DEALERS, 2, dealer(2, values([])), dealer(2, values([[1, value(s, 2, 1)]])))).excluded) + .toEqual([{ member: 2, reason: 'Equivocated', round: 3 }]); + }); + test('t′ distinct complainers exclude, including the recipient whose index equals the dealer', () => { + let rounds = s.rounds; + for (const j of [1, 2, 3]) rounds = replace(rounds, 2, COHORT_RECIPIENTS, j, recipient(j, complaints([1]))); + expect(run(s, rounds).excluded).toEqual([{ member: 1, reason: 'Complaints' }]); + let two = s.rounds; + for (const j of [2, 3]) two = replace(two, 2, COHORT_RECIPIENTS, j, recipient(j, complaints([1]))); + expect(run(s, two).excluded).toEqual([{ member: 1, reason: 'MissingAnswer' }]); + }); + test('valid answers replace; invalid answers exclude, required or not; omissions are MissingAnswer', () => { + const complained = replace(s, 2, COHORT_RECIPIENTS, 2, recipient(2, complaints([1]))); + expectOutput(run(s, replace(complained, 3, COHORT_DEALERS, 1, dealer(1, values([[2, value(s, 1, 2)]])))), s, [1, 2]); + expect(run(s, replace(complained, 3, COHORT_DEALERS, 1, dealer(1, values([[2, (value(s, 1, 2) + 1n) % R]])))).excluded).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); + expect(run(s, replace(s, 3, COHORT_DEALERS, 1, dealer(1, values([[3, 1n]])))).excluded).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); + expect(run(s, replace(complained, 3, COHORT_DEALERS, 1)).excluded).toEqual([{ member: 1, reason: 'MissingAnswer' }]); + expect(run(s, replace(complained, 3, COHORT_DEALERS, 1, dealer(1, values([[2, value(s, 1, 2)], [5, 1n]])))).excluded).toEqual([{ member: 1, reason: 'MissingAnswer' }]); + }); + test('R2 naming a dealer above the old n supplies no complaints', () => { + expect(run(s, replace(s, 2, COHORT_RECIPIENTS, 2, recipient(2, complaints([1, 4])))).excluded).toEqual([]); + }); + test('envelopes under the other cohort are authenticated but attribute nothing', () => { + const r1AsRecipient = replace(s, 1, COHORT_DEALERS, 1, recipient(1, deal(commitmentsOf(g), recipients(s), 32, 1))); + expect(run(s, r1AsRecipient).excluded).toEqual([{ member: 1, reason: 'MissingDeal' }]); + let complaintsAsDealers = s.rounds; + for (const i of [1, 2, 3]) complaintsAsDealers = replace(complaintsAsDealers, 2, COHORT_DEALERS, i, dealer(i, complaints([1]))); + expect(run(s, complaintsAsDealers).excluded).toEqual([]); + expect(code(() => run(s, replace(s, 2, COHORT_DEALERS, 4, dealer(4, complaints([])))))).toBe('InvalidIndex'); + }); + test('fewer than t dealers is InsufficientQualified; record count must be 3', () => { + const alone = s.rounds.map((r, k) => k === 0 ? r.filter(d => d.index === 1) : r); + expect(code(() => run(s, alone))).toBe('InsufficientQualified'); + expect(code(() => run(s, s.rounds.slice(0, 2)))).toBe('InvalidRound'); + expect(code(() => run(s, [...s.rounds, []]))).toBe('InvalidRound'); + }); +}); + +describe('entry checks', () => { + test('ceremony kind, certificate binding, generation and refresh shape', () => { + const base = reshareSetup('entry', 2, 3, 3, 4); + const variant = (kind: number, spec: Partial, t2 = 3, n2 = 4) => code(() => run(reshareSetup('entry', 2, 3, t2, n2, kind, spec))); + expect(variant(1, { current: undefined, epoch: 0n })).toBe('InvalidCeremony'); + expect(variant(4, { rekey: 1n })).toBe('InvalidCeremony'); + expect(variant(2, { current: new Uint8Array(32) })).toBe('ContextMismatch'); + expect(variant(2, { current: undefined })).toBe('ContextMismatch'); + expect(variant(2, { epoch: 2n })).toBe('InvalidCeremony'); + expect(variant(2, { rekey: 1n })).toBe('InvalidCeremony'); + expect(variant(3, {}, 3, 3)).toBe('InvalidCeremony'); + expect(variant(3, { directory: [{ index: 1, did: did(1) }, { index: 2, did: did(2) }, { index: 3, did: did(9) }] }, 2, 3)).toBe('InvalidCeremony'); + expect(code(() => run(base, base.rounds, { ...base.certificate, signature: flip(base.certificate.signature, 5) }))).toBe('InvalidSignature'); + expect(code(() => run(base, base.rounds, { ...base.certificate, body: concat(base.certificate.body, be(0n, 1)) }))).toBe('InvalidEncoding'); + expect(code(() => run(base, base.rounds, signedCertificate({ t: 2, n: 3, coefficients: base.master, network: 'tinycloud:test:other' })))).toBe('InvalidCertificate'); + }); +}); + +describe('identity rejections', () => { + test('an identity new public share or top aggregate coefficient is IdentityPoint', () => { + const s = reshareSetup('identity-reshare', 2, 3, 3, 4), Fr = bls.fields.Fr, D = [1, 2]; + const l1 = lagrange(1, D), l2 = lagrange(2, D), g1 = s.dealings[0]!, s2 = s.dealings[1]![0]!; + const top = Fr.neg(Fr.mul(Fr.mul(l1, g1[2]!), Fr.inv(l2))); + expect(code(() => run(withDealing(s, 2, [s2, s.dealings[1]![1]!, top])))).toBe('IdentityPoint'); + const target = Fr.neg(Fr.mul(Fr.mul(l1, at(g1, 4)), Fr.inv(l2))), c2 = s.dealings[1]![2]!; + const c1 = Fr.mul(Fr.sub(Fr.sub(target, s2), Fr.mul(16n, c2)), Fr.inv(4n)); + const zeroShare = withDealing(s, 2, [s2, c1, c2]); + expect(code(() => run(zeroShare))).toBe('IdentityPoint'); + expect(code(() => run(zeroShare, zeroShare.rounds.map((r, k) => k === 0 ? [r[1]!, r[2]!, r[0]!] : r)))).toBe('ok'); + }); +}); + +describe('limits', () => { + test('the 4 MiB bound precedes certificate and ceremony checks', () => { + const s = reshareSetup('limits', 2, 3, 3, 4); + const big: RoundRecord[] = Array.from({ length: 65 }, () => ({ closure: { body: new Uint8Array(98), signature: new Uint8Array(64) }, messages: [{ body: new Uint8Array(64374), signature: new Uint8Array(0) }] })); + const bad: Statement = { body: new Uint8Array(1), signature: new Uint8Array(0) }; + expect(code(() => reshareVerifyTranscript(ANCHOR, bad, bad, big, verify))).toBe('LimitExceeded'); + expect(code(() => reshareVerifyTranscript(ANCHOR, s.ceremony, s.certificate, big.slice(0, 64), verify))).toBe('InvalidRound'); + }); +}); diff --git a/tinycloud-threshold/vectors/check/testkit.ts b/tinycloud-threshold/vectors/check/testkit.ts new file mode 100644 index 00000000..1c2faa7b --- /dev/null +++ b/tinycloud-threshold/vectors/check/testkit.ts @@ -0,0 +1,236 @@ +// Test-only transcript construction from SPEC byte layouts. Encoders here are written +// separately from the verifier's decoders; expected outputs are computed in the scalar +// domain (Σ coefficients, then ·g2) rather than through the verifier's point aggregation. +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { ed25519 } from '@noble/curves/ed25519.js'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { DST, PEDERSEN_H_MESSAGE, R, ProtocolError, be, bytes, concat, hex, lp, tagged, type Bytes, type G2 } from './protocol.ts'; +import { ed25519Verifier, type RoundRecord, type Statement } from './ceremony.ts'; + +const enc = new TextEncoder(); +const sha = (alg: 'sha256' | 'sha512', ...parts: Bytes[]) => new Uint8Array(createHash(alg).update(concat(...parts)).digest()); +const certCore: { seeds: { ed25519: { did: string; seed: string }[] } } = JSON.parse(readFileSync(new URL('../v1/cert-core.json', import.meta.url), 'utf8')); +/** Public test seeds published in cert-core.json; fixture-N signs with seed N. */ +export const SEEDS = new Map(certCore.seeds.ed25519.map(s => [s.did, bytes(s.seed)])); +export const did = (n: number) => `did:key:tc2222-fixture-${n}`; +export const OWNER = did(0); +export const NETWORK = 'tinycloud:test:certificates'; +export const ANCHOR = { owner: OWNER, network: NETWORK }; +export const verify = ed25519Verifier(new Map([...SEEDS].map(([d, seed]) => [d, ed25519.getPublicKey(seed)]))); +export function sign(signer: string, message: Bytes): Bytes { + const seed = SEEDS.get(signer); + if (!seed) throw new Error(`no test seed for ${signer}`); + return ed25519.sign(message, seed); +} +/** RFC 8032 group order L. */ +export const L = (1n << 252n) + 27742317777372353535851937790883648493n; +const le = (b: Bytes) => BigInt(`0x${hex(b.slice().reverse()) || '0'}`); +const toLe = (v: bigint) => be(v, 32).reverse(); +/** A second valid RFC 8032 signature with a different nonce: same signer, same message, different bytes. */ +export function alternateSignature(signer: string, message: Bytes): Bytes { + const h = sha('sha512', SEEDS.get(signer)!); + const head = h.slice(0, 32); head[0]! &= 248; head[31]! &= 127; head[31]! |= 64; + const a = le(head), A = ed25519.getPublicKey(SEEDS.get(signer)!); + const r = le(sha('sha512', h.slice(32), message, new Uint8Array([1]))) % L; + const Rb = ed25519.Point.BASE.multiply(r).toBytes(); + const k = le(sha('sha512', Rb, A, message)) % L; + return concat(Rb, toLe((r + k * a) % L)); +} +/** The same signature with S replaced by S + L (non-canonical; rejected by strict RFC 8032). */ +export function nonCanonicalS(signature: Bytes): Bytes { + return concat(signature.slice(0, 32), toLe(le(signature.slice(32)) + L)); +} +/** + * A signature valid under ZIP-215 but not strict RFC 8032: R is the identity point + * encoded non-canonically (y = 1 + p) and S = k·a, so S·B = R + k·A holds exactly. + */ +export function nonCanonicalR(signer: string, message: Bytes): Bytes { + const h = sha('sha512', SEEDS.get(signer)!); + const head = h.slice(0, 32); head[0]! &= 248; head[31]! &= 127; head[31]! |= 64; + const Rb = toLe((1n << 255n) - 18n), A = ed25519.getPublicKey(SEEDS.get(signer)!); + const k = le(sha('sha512', Rb, A, message)) % L; + return concat(Rb, toLe((k * le(head)) % L)); +} + +export function scalarOf(label: string): bigint { + const v = BigInt(`0x${hex(sha('sha256', enc.encode(label)))}`) % R; + return v === 0n ? 1n : v; +} +export function poly(label: string, degreePlusOne: number): bigint[] { + return Array.from({ length: degreePlusOne }, (_, k) => scalarOf(`${label}/${k}`)); +} +export function at(coefficients: readonly bigint[], x: number): bigint { + return coefficients.reduceRight((acc, c) => (acc * BigInt(x) + c) % R, 0n); +} +export const G2_BASE = bls.G2.Point.BASE, G2_ZERO = bls.G2.Point.ZERO; +export const H: G2 = bls.G2.hashToCurve(bytes(PEDERSEN_H_MESSAGE), { DST: DST.pedersen_h }); +export function times(p: G2, k: bigint): G2 { const s = ((k % R) + R) % R; return s === 0n ? G2_ZERO : p.multiply(s); } +export const g2hex = (p: G2) => hex(p.toBytes()); +export function lagrange(i: number, set: readonly number[]): bigint { + let num = 1n, den = 1n; + for (const j of set) if (j !== i) { num = (num * BigInt(j)) % R; den = (den * (((BigInt(j - i)) % R) + R)) % R; } + return (num * bls.fields.Fr.inv(den)) % R; +} + +// --- S0.2 statements -------------------------------------------------------------- +export interface CeremonySpec { + kind: number; id?: Bytes; network?: string; owner?: string; current?: Bytes; + t: number; n: number; rekey?: bigint; epoch?: bigint; directory?: { index: number; did: string }[]; +} +export function ceremonyBody(c: CeremonySpec): Bytes { + const directory = c.directory ?? Array.from({ length: c.n }, (_, k) => ({ index: k + 1, did: did(k + 1) })); + return concat( + be(1n, 1), be(BigInt(c.kind), 1), c.id ?? new Uint8Array(32).fill(0x5a), lp(enc.encode(c.network ?? NETWORK)), lp(enc.encode(c.owner ?? OWNER)), + c.current ? concat(be(1n, 1), c.current) : be(0n, 1), + be(BigInt(c.t), 2), be(BigInt(c.n), 2), be(c.rekey ?? 0n, 8), be(c.epoch ?? 0n, 8), be(BigInt(directory.length), 2), + ...directory.map(d => concat(be(BigInt(d.index), 2), lp(enc.encode(d.did)), new Uint8Array(32).fill(d.index))), + ); +} +export function signedCeremony(c: CeremonySpec, signer = OWNER): Statement { + const body = ceremonyBody(c); + return { body, signature: sign(signer, tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', body)) }; +} +export interface CertificateSpec { + network?: string; owner?: string; rekey?: bigint; epoch?: bigint; genesisT?: number; t: number; n: number; + coefficients: bigint[]; transcript?: Bytes; previous?: Bytes; previousMpk?: G2; +} +export function certificateBody(c: CertificateSpec): Bytes { + const commitments = c.coefficients.map(a => times(G2_BASE, a)); + return concat( + be(1n, 1), lp(enc.encode(c.network ?? NETWORK)), lp(enc.encode(c.owner ?? OWNER)), be(c.rekey ?? 0n, 8), be(c.epoch ?? 0n, 8), + be(BigInt(c.genesisT ?? c.t), 2), be(BigInt(c.t), 2), be(BigInt(c.n), 2), be(BigInt(c.n), 2), + ...Array.from({ length: c.n }, (_, k) => concat(be(BigInt(k + 1), 2), lp(enc.encode(did(k + 1))))), + commitments[0]!.toBytes(), be(BigInt(commitments.length), 2), ...commitments.map(p => p.toBytes()), + c.transcript ?? new Uint8Array(32).fill(7), + c.previous ? concat(be(1n, 1), c.previous) : be(0n, 1), + c.previousMpk ? concat(be(1n, 1), c.previousMpk.toBytes()) : be(0n, 1), + ); +} +export function signedCertificate(c: CertificateSpec, signer = OWNER): Statement & { digest: Bytes } { + const body = certificateBody(c), digest = tagged('TC-THRESHOLD-V1-NETWORK-CERT', body); + return { body, signature: sign(signer, tagged('TC-THRESHOLD-V1-CERT-STATEMENT', digest)), digest }; +} + +// --- Payloads ------------------------------------------------------------------------ +const sealed = (dealer: number, recipient: number, plaintext: number) => + concat(be(1n, 1), new Uint8Array(32).fill(dealer), lp(new Uint8Array(plaintext + 16).fill(recipient))); +export function deal(commitments: readonly G2[], recipients: readonly number[], plaintext = 64, dealer = 1): Bytes { + return concat(be(1n, 1), be(BigInt(commitments.length), 2), ...commitments.map(p => p.toBytes()), + be(BigInt(recipients.length), 2), ...recipients.map(j => concat(be(BigInt(j), 2), lp(sealed(dealer, j, plaintext))))); +} +export function complaints(dealers: readonly number[]): Bytes { + return concat(be(1n, 1), be(BigInt(dealers.length), 2), ...dealers.map(i => be(BigInt(i), 2))); +} +export function pairs(entries: readonly (readonly [number, bigint, bigint])[]): Bytes { + return concat(be(1n, 1), be(BigInt(entries.length), 2), ...entries.map(([i, s, s2]) => concat(be(BigInt(i), 2), be(s, 32), be(s2, 32)))); +} +export function values(entries: readonly (readonly [number, bigint])[]): Bytes { + return concat(be(1n, 1), be(BigInt(entries.length), 2), ...entries.map(([i, v]) => concat(be(BigInt(i), 2), be(v, 32)))); +} +export function feldman(points: readonly G2[]): Bytes { + return concat(be(1n, 1), be(BigInt(points.length), 2), ...points.map(p => p.toBytes())); +} + +// --- Rounds --------------------------------------------------------------------------- +/** One member message before envelope/signature; overrides produce adversarial envelopes. */ +export interface Draft { + cohort: number; index: number; payload: Bytes; + signer?: string; round?: number; ceremony?: Bytes; signature?: (envelope: Bytes, signer: string) => Bytes; +} +export function envelope(ceremony: Bytes, round: number, d: Draft): Bytes { + return concat(be(1n, 1), d.ceremony ?? ceremony, be(BigInt(d.round ?? round), 1), be(BigInt(d.cohort), 1), be(BigInt(d.index), 2), lp(d.payload)); +} +/** Signs envelopes and closes every round in order under the owner, chaining from `start`. */ +export function seal(ceremonyDigest: Bytes, start: Bytes, rounds: readonly Draft[][]): { records: RoundRecord[]; hash: Bytes } { + let previous = start; + const records = rounds.map((drafts, k) => { + const messages = drafts.map(d => { + const body = envelope(ceremonyDigest, k + 1, d), signer = d.signer ?? did(d.index), preimage = tagged('TC-THRESHOLD-V1-CEREMONY-MSG', body); + return { body, signature: d.signature ? d.signature(preimage, signer) : sign(signer, preimage) }; + }); + const list = concat(be(BigInt(messages.length), 2), ...messages.flatMap(m => [lp(m.body), lp(m.signature)])); + const closure = concat(be(1n, 1), ceremonyDigest, be(BigInt(k + 1), 1), tagged('TC-THRESHOLD-V1-TRANSCRIPT', list), previous); + const record = { closure: { body: closure, signature: sign(OWNER, tagged('TC-THRESHOLD-V1-ROUND-CLOSE', closure)) }, messages }; + previous = tagged('TC-THRESHOLD-V1-TRANSCRIPT', concat(previous, lp(record.closure.body), lp(record.closure.signature))); + return record; + }); + return { records, hash: previous }; +} +/** Splits a signed record back into drafts (cohort, index, payload), keeping the signer mapping. */ +export function draftsOf(record: RoundRecord): Draft[] { + return record.messages.map(m => ({ cohort: m.body[34]!, index: (m.body[35]! << 8) | m.body[36]!, payload: m.body.slice(41) })); +} + +// --- Honest scenarios ------------------------------------------------------------------- +export const COHORT_RECIPIENTS = 1, COHORT_DEALERS = 2; +export interface DkgDealer { index: number; a: bigint[]; b: bigint[]; C: G2[]; A: G2[] } +export function dkgDealers(label: string, t: number, n: number): DkgDealer[] { + return Array.from({ length: n }, (_, k) => { + const a = poly(`${label}/a/${k + 1}`, t), b = poly(`${label}/b/${k + 1}`, t); + return { index: k + 1, a, b, C: a.map((x, m) => times(G2_BASE, x).add(times(H, b[m]!))), A: a.map(x => times(G2_BASE, x)) }; + }); +} +export const pair = (d: DkgDealer, j: number) => [at(d.a, j), at(d.b, j)] as const; +export interface DkgSetup { t: number; n: number; ceremony: Statement; digest: Bytes; start: Bytes; dealers: DkgDealer[]; rounds: Draft[][] } +/** Honest 5-round genesis: everybody deals, nobody complains, empty R5. */ +export function dkgSetup(label: string, t: number, n: number, spec: Partial = {}): DkgSetup { + const ceremony = signedCeremony({ kind: 1, t, n, id: sha('sha256', enc.encode(label)), ...spec }); + const digest = tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', ceremony.body), start = tagged('TC-THRESHOLD-V1-DKG-START', digest); + const dealers = dkgDealers(label, t, n), members = dealers.map(d => d.index); + const each = (payload: (i: number) => Bytes): Draft[] => members.map(i => ({ cohort: COHORT_RECIPIENTS, index: i, payload: payload(i) })); + const rounds = [ + each(i => deal(dealers[i - 1]!.C, members, 64, i)), each(() => complaints([])), each(() => pairs([])), + each(i => feldman(dealers[i - 1]!.A)), each(() => pairs([])), + ]; + return { t, n, ceremony, digest, start, dealers, rounds }; +} +/** Expected public material for QUAL, computed from scalar sums. */ +export function expectedDkg(dealers: readonly DkgDealer[], qual: readonly number[], n: number) { + const used = dealers.filter(d => qual.includes(d.index)), t = used[0]!.a.length; + const sum = (f: (d: DkgDealer) => bigint) => used.reduce((acc, d) => (acc + f(d)) % R, 0n); + return { + commitments: Array.from({ length: t }, (_, k) => g2hex(times(G2_BASE, sum(d => d.a[k]!)))), + publicShares: Array.from({ length: n }, (_, k) => g2hex(times(G2_BASE, sum(d => at(d.a, k + 1))))), + }; +} + +export interface ReshareSetup { + t: number; n: number; t2: number; n2: number; ceremony: Statement; certificate: Statement & { digest: Bytes }; + digest: Bytes; start: Bytes; master: bigint[]; shares: bigint[]; dealings: bigint[][]; rounds: Draft[][]; +} +/** Honest redistribution from a genesis (t, n) certificate to (t2, n2); dealer i deals g_i with g_i(0) = s_i. */ +export function reshareSetup(label: string, t: number, n: number, t2: number, n2: number, kind = 2, spec: Partial = {}): ReshareSetup { + const master = poly(`${label}/master`, t), shares = Array.from({ length: n }, (_, k) => at(master, k + 1)); + const certificate = signedCertificate({ t, n, coefficients: master }); + const ceremony = signedCeremony({ kind, t: t2, n: n2, current: certificate.digest, epoch: 1n, id: sha('sha256', enc.encode(label)), ...spec }); + const digest = tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', ceremony.body), start = tagged('TC-THRESHOLD-V1-RESHARE-START', digest); + const dealings = shares.map((s, k) => [s, ...poly(`${label}/g/${k + 1}`, t2 - 1)]); + const recipients = Array.from({ length: n2 }, (_, k) => k + 1), dealersIdx = Array.from({ length: n }, (_, k) => k + 1); + const rounds: Draft[][] = [ + dealersIdx.map(i => ({ cohort: COHORT_DEALERS, index: i, payload: deal(dealings[i - 1]!.map(c => times(G2_BASE, c)), recipients, 32, i) })), + recipients.map(j => ({ cohort: COHORT_RECIPIENTS, index: j, payload: complaints([]) })), + dealersIdx.map(i => ({ cohort: COHORT_DEALERS, index: i, payload: values([]) })), + ]; + return { t, n, t2, n2, ceremony, certificate, digest, start, master, shares, dealings, rounds }; +} +export function expectedReshare(s: ReshareSetup, dealers: readonly number[]) { + const coefficient = (k: number) => dealers.reduce((acc, i) => (acc + lagrange(i, dealers) * s.dealings[i - 1]![k]!) % R, 0n); + const coefficients = Array.from({ length: s.t2 }, (_, k) => coefficient(k)); + return { + commitments: coefficients.map(c => g2hex(times(G2_BASE, c))), + publicShares: Array.from({ length: s.n2 }, (_, k) => g2hex(times(G2_BASE, at(coefficients, k + 1)))), + mpk: g2hex(times(G2_BASE, s.master[0]!)), + }; +} + +/** Typed error code of a verifier call, or 'ok'. */ +export function code(run: () => unknown): string { + try { run(); return 'ok'; } catch (e) { + if (e instanceof ProtocolError) return e.code; + throw e; + } +} +export function flip(b: Bytes, offset: number, mask = 1): Bytes { + const out = b.slice(); out[offset] = out[offset]! ^ mask; return out; +} diff --git a/tinycloud-threshold/vectors/check/transcript-corruption.test.ts b/tinycloud-threshold/vectors/check/transcript-corruption.test.ts new file mode 100644 index 00000000..69d73db7 --- /dev/null +++ b/tinycloud-threshold/vectors/check/transcript-corruption.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test'; +import { bytes, hex } from './protocol.ts'; +import { dkgVerifyTranscript } from './dkg.ts'; +import { reshareVerifyTranscript } from './reshare.ts'; +import { verifierOf } from './transcripts.ts'; +import { clone, inputOf, loadDoc, probes, verifyInput, type DocCase, type TranscriptInput } from './probes.ts'; +import { draftsOf, flip, seal, type Draft } from './testkit.ts'; + +const docs = { dkg: loadDoc('dkg-genesis.json'), reshare: loadDoc('reshare.json') }; +const cli = new URL('./cli.ts', import.meta.url).pathname; + +for (const [kind, doc] of Object.entries(docs)) { + describe(`${kind}: raw one-byte corruptions are rejected with the SPEC-ordered reason`, () => { + for (const c of doc.cases) { + test(c.name, () => { + const base = inputOf(c), mismatches: string[] = []; + for (const p of probes(c)) { + const x = clone(base); + p.apply(x); + const observed = verifyInput(doc, x); + if (observed !== p.expected) mismatches.push(`${p.record} ${p.field} @${p.offset}^${p.mask}: ${observed} != ${p.expected}`); + } + expect(mismatches).toEqual([]); + }, 60000); + } + }); +} + +/** Re-signs every member envelope and re-closes every round after mutating drafts. */ +function reseal(x: TranscriptInput, start: Uint8Array, digest: Uint8Array, mutate: (rounds: Draft[][]) => void): TranscriptInput { + const rounds = x.records.map(draftsOf); + mutate(rounds); + return { ...x, records: seal(digest, start, rounds).records }; +} +function payloadFlip(rounds: Draft[][], round: number, cohort: number, index: number, offset: (payload: Uint8Array) => number) { + const d = rounds[round - 1]!.find(m => m.cohort === cohort && m.index === index)!; + d.payload = flip(d.payload, offset(d.payload)); +} +function startOf(c: DocCase) { + return { digest: bytes(c.inputs.ceremony_digest), start: bytes(c.inputs.initial_transcript) }; +} + +describe('re-signed one-byte payload corruptions reach the rule-level reason', () => { + const dkg = (name: string) => docs.dkg.cases.find(c => c.name === name)!; + const reshare = (name: string) => docs.reshare.cases.find(c => c.name === name)!; + test('resealing unmodified drafts reproduces every committed record byte for byte', () => { + for (const c of [...docs.dkg.cases, ...docs.reshare.cases]) { + const x = inputOf(c), { digest, start } = startOf(c); + expect(reseal(x, start, digest, () => {}).records.map(r => hex(r.closure.signature))).toEqual(x.records.map(r => hex(r.closure.signature))); + } + }); + test('genesis: dealer 1 commitment byte → MalformedDeal; R2 version byte → no complaint; R4 byte → extraction fault needs R6', () => { + const c = dkg('genesis_2of3'), x = inputOf(c), { digest, start } = startOf(c), verify = verifierOf(docs.dkg); + const malformed = reseal(x, start, digest, r => payloadFlip(r, 1, 1, 1, () => 3 + 95)); + const out = dkgVerifyTranscript(docs.dkg.anchor, malformed.ceremony, malformed.records, verify); + expect(out.disqualified).toEqual([{ member: 1, reason: 'MalformedDeal' }]); + expect(out.qual).toEqual([2, 3]); + if (!('outputs' in c)) throw new Error('genesis_2of3 is a success vector'); + const quiet = reseal(x, start, digest, r => payloadFlip(r, 2, 1, 2, () => 0)); + expect(hex(dkgVerifyTranscript(docs.dkg.anchor, quiet.ceremony, quiet.records, verify).mpk.toBytes())).toBe(c.outputs.mpk); + expect(verifyInput(docs.dkg, reseal(x, start, digest, r => payloadFlip(r, 4, 1, 1, () => 3 + 95)))).toBe('InvalidRound'); + }); + test('genesis: corrupting the upheld R5 pair leaves nothing to reconstruct, so the R6 record is extra', () => { + const c = dkg('genesis_2of3_selective_extraction'), x = inputOf(c), { digest, start } = startOf(c); + expect(verifyInput(docs.dkg, reseal(x, start, digest, r => payloadFlip(r, 5, 1, 2, p => p.length - 1)))).toBe('InvalidRound'); + }); + test('reshare: B_0 byte → MalformedDeal; R3 value byte → InvalidAnswer; complaint version byte → MissingAnswer', () => { + const verify = verifierOf(docs.reshare); + const run = (x: TranscriptInput) => reshareVerifyTranscript(docs.reshare.anchor, x.ceremony, x.certificate!, x.records, verify); + let c = reshare('reshare_2of3_to_3of4'), x = inputOf(c), s = startOf(c); + let out = run(reseal(x, s.start, s.digest, r => payloadFlip(r, 1, 2, 1, () => 3 + 95))); + expect([out.excluded, out.dealers]).toEqual([[{ member: 1, reason: 'MalformedDeal' }], [2, 3]]); + c = reshare('refresh_2of3_answered_complaint'); x = inputOf(c); s = startOf(c); + out = run(reseal(x, s.start, s.digest, r => payloadFlip(r, 3, 2, 1, () => 36))); + expect([out.excluded, out.dealers]).toEqual([[{ member: 1, reason: 'InvalidAnswer' }], [2, 3]]); + c = reshare('reshare_t_prime_complaints'); x = inputOf(c); s = startOf(c); + out = run(reseal(x, s.start, s.digest, r => payloadFlip(r, 2, 1, 2, () => 0))); + expect(out.excluded).toEqual([{ member: 3, reason: 'MissingAnswer' }]); + }); + test('one appended byte over 4 MiB is LimitExceeded before any signature', () => { + for (const doc of Object.values(docs)) { + const x = inputOf(doc.cases[0]!); + const size = x.records.reduce((n, r) => n + r.closure.body.length + r.closure.signature.length + r.messages.reduce((m, s) => m + s.body.length + s.signature.length, 0), 0); + const pad = (total: number) => Array.from({ length: Math.ceil(total / 65536) }, (_, k) => ({ body: new Uint8Array(Math.min(65536, total - k * 65536)), signature: new Uint8Array(0) })); + const over = clone(x); over.records[0]!.messages.push(...pad(4194304 - size + 1)); + const exact = clone(x); exact.records[0]!.messages.push(...pad(4194304 - size)); + expect(verifyInput(doc, over)).toBe('LimitExceeded'); + expect(verifyInput(doc, exact)).toBe('InvalidEncoding'); + } + }); +}); + +describe('real CLI reports the observed reason for a corrupted committed document', () => { + for (const [kind, doc] of Object.entries(docs)) { + test(kind, () => { + const changed = structuredClone(doc), message = changed.cases[0]!.inputs.rounds[1]!.messages[0]!; + message.signature = (message.signature[0] === '0' ? '1' : '0') + message.signature.slice(1); + const result = Bun.spawnSync([process.execPath, cli, '/dev/stdin'], { env: process.env, stdin: Buffer.from(JSON.stringify(changed)), stdout: 'pipe', stderr: 'pipe', timeout: 30000 }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(`${changed.cases[0]!.name}: computed error: observed "InvalidSignature"; expected null`); + }, 30000); + } +}); diff --git a/tinycloud-threshold/vectors/check/transcript-vectors.test.ts b/tinycloud-threshold/vectors/check/transcript-vectors.test.ts new file mode 100644 index 00000000..5ecdd85c --- /dev/null +++ b/tinycloud-threshold/vectors/check/transcript-vectors.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { checkTranscripts } from './transcripts.ts'; + +const load = (file: string): unknown => JSON.parse(readFileSync(new URL(`../v1/${file}`, import.meta.url), 'utf8')); +const dkg = load('dkg-genesis.json'), reshare = load('reshare.json'); +const cli = new URL('./cli.ts', import.meta.url).pathname; +interface Outputs { + mpk: string; transcript_hash: string; commitments: string[]; public_shares: { member: number; point: string }[]; + qual: number[]; disqualified: unknown[]; reconstructed: number[]; dealers: number[]; excluded: unknown[]; threshold: number; members: number; +} +interface DocView { cases: { name: string; inputs: Record; outputs: Outputs; error?: string }[]; dst: Record } +/** Clones a committed document and applies a targeted edit to it. */ +function edit(doc: unknown, change: (d: DocView) => void): unknown { + // Unchecked: the clone of a committed vector document; checkTranscripts re-validates the edited result. + const view = structuredClone(doc) as DocView; + change(view); + return view; +} +const hexFlip = (v: unknown) => typeof v === 'string' ? (v[0] === '0' ? '1' : '0') + v.slice(1) : ''; + +test('committed transcript documents compute every named case', () => { + expect(checkTranscripts(dkg).passed).toEqual([ + 'genesis_2of3', 'genesis_2of3_selective_extraction', 'genesis_3of5_two_faulty', + 'genesis_3of5_answers_and_disqualification', 'insufficient_reconstruction_values', 'insufficient_qualified_dealers', + ]); + expect(checkTranscripts(reshare).passed).toEqual([ + 'reshare_2of3_to_3of4', 'refresh_2of3_answered_complaint', 'reshare_wrong_constant', 'reshare_t_prime_complaints', 'reshare_insufficient_dealers', + ]); +}, 30000); + +test('every expected output and declared input digest is compared with computation', () => { + const dkgFields: [string, (o: Outputs) => void][] = [ + ['mpk', o => { o.mpk = hexFlip(o.mpk); }], + ['transcript hash', o => { o.transcript_hash = hexFlip(o.transcript_hash); }], + ['commitments', o => { o.commitments[1] = hexFlip(o.commitments[1]); }], + ['public shares', o => { o.public_shares[2]!.point = hexFlip(o.public_shares[2]!.point); }], + ['qual', o => { o.qual = [1, 2]; }], + ['disqualified', o => { o.disqualified = [{ member: 4, reason: 'MissingDeal' }]; }], + ['reconstructed', o => { o.reconstructed = [2]; }], + ]; + for (const [label, change] of dkgFields) { + expect(() => checkTranscripts(edit(dkg, d => change(d.cases[1]!.outputs)))).toThrow(`genesis_2of3_selective_extraction: ${label}`); + } + const reshareFields: [string, (o: Outputs) => void][] = [ + ['dealers', o => { o.dealers = [1, 3]; }], ['excluded', o => { o.excluded = []; }], + ['threshold', o => { o.threshold = 3; }], ['members', o => { o.members = 4; }], + ]; + for (const [label, change] of reshareFields) { + expect(() => checkTranscripts(edit(reshare, d => change(d.cases[2]!.outputs)))).toThrow(`reshare_wrong_constant: ${label}`); + } + expect(() => checkTranscripts(edit(dkg, d => { d.cases[4]!.error = 'InsufficientQualified'; }))).toThrow('insufficient_reconstruction_values: computed error'); + expect(() => checkTranscripts(edit(reshare, d => { d.cases[0]!.inputs.ceremony_digest = hexFlip(d.cases[0]!.inputs.ceremony_digest); }))).toThrow('reshare_2of3_to_3of4: ceremony digest'); + expect(() => checkTranscripts(edit(dkg, d => { d.cases[0]!.inputs.initial_transcript = hexFlip(d.cases[0]!.inputs.initial_transcript); }))).toThrow('genesis_2of3: initial transcript'); + expect(() => checkTranscripts(edit(dkg, d => { d.cases[0]!.inputs.threshold = 3; }))).toThrow('genesis_2of3: ceremony threshold'); +}, 60000); + +test('schema refuses mistyped, duplicate, ambiguous and alternate-domain documents', () => { + for (const change of [ + (d: DocView) => { d.cases[1]!.name = d.cases[0]!.name; }, + (d: DocView) => { d.cases[0]!.error = 'InvalidRound'; }, + (d: DocView) => { d.cases[0]!.inputs.extra = 1; }, + (d: DocView) => { d.dst.transcript = 'TC-THRESHOLD-V1-TRANSCRIPTX'; }, + (d: DocView) => { d.cases[0]!.inputs.ceremony = { body: 'zz', signature: '' }; }, + ]) { + expect(() => checkTranscripts(edit(dkg, change))).toThrow('schema:'); + } + expect(() => checkTranscripts({})).toThrow('schema:'); +}); + +test('real CLI checks all committed documents by default and a transcript document by path', () => { + const run = (...args: string[]) => Bun.spawnSync([process.execPath, cli, ...args], { env: process.env, stdout: 'pipe', stderr: 'pipe', timeout: 60000 }); + const all = run(); + expect(all.exitCode).toBe(0); + expect(all.stdout.toString()).toContain('Primitive vectors: 18/18'); + expect(all.stdout.toString()).toContain('DKG transcripts: 6/6'); + expect(all.stdout.toString()).toContain('Reshare transcripts: 5/5'); + const one = run(new URL('../v1/reshare.json', import.meta.url).pathname); + expect(one.exitCode).toBe(0); + expect(one.stdout.toString()).not.toContain('Primitive vectors'); +}, 60000); From ee0d4d3df3a3a9b59e604fff55ceb7bcdfce4a2f Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:17:35 +0000 Subject: [PATCH 03/10] test(threshold): close review gaps in transcript checker rules and certificate checks; Details: TC-2223 --- tinycloud-threshold/vectors/check/README.md | 6 +- tinycloud-threshold/vectors/check/ceremony.ts | 40 ++++--- tinycloud-threshold/vectors/check/dkg.test.ts | 40 +++++-- tinycloud-threshold/vectors/check/dkg.ts | 5 +- .../vectors/check/reshare.test.ts | 9 ++ tinycloud-threshold/vectors/check/reshare.ts | 5 +- tinycloud-threshold/vectors/check/s02.test.ts | 100 ++++++++++++++++++ tinycloud-threshold/vectors/check/testkit.ts | 4 +- 8 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/s02.test.ts diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index d791fdbb..fa315801 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -58,8 +58,10 @@ ordered reason; re-signed flips reach the rule-level outcome. `transcript-vectors.test.ts` perturbs expected outputs and runs the real CLI. The checker does not open sealed dealings (it holds no recipient keys); a -sealed dealing is decoded by layout only, as SPEC requires of the public -verifier. +sealed dealing is decoded by layout only, per Naomi's 2026-10-30 ruling. +`s02.test.ts` runs the `../v1/cert-core.json` certificate, ceremony and +closed-round records through the same S0.2 layer and covers the §3.9 +decoded-certificate rules. ## Computations diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts index f8e947c1..9fa90864 100644 --- a/tinycloud-threshold/vectors/check/ceremony.ts +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -22,23 +22,34 @@ export const LIMIT = { members: 16, did: 256, signature: 512, message: 65536, tr /** * Typed errors for conditions SPEC describes but does not name. Every entry is * a default ruled by Naomi on 2026-10-30 (TC-2223); SPEC- and vector-named - * codes (LimitExceeded, InvalidRound for record counts, InvalidCeremony, - * ContextMismatch, IdentityPoint, InsufficientQualified, InsufficientRecovery, + * codes (LimitExceeded, InvalidRound for record counts and round transitions, + * InvalidCeremony for kind/refresh, ContextMismatch for certificate binding, + * IdentityPoint, InsufficientQualified, InsufficientRecovery, * UnsupportedFaultBudget, InvalidEncoding for decoders) are used literally. */ export const DEFAULT_ERROR = { /** Owner or member signature fails strict RFC 8032 verification, or the DID has no key. */ signature: 'InvalidSignature', - /** Closure body non-canonical, or names another ceremony/round/previous hash/ordered list. */ + /** Closure body non-canonical, or its ordered-list hash differs from the messages. */ closure: 'InvalidRound', /** Member envelope names another ceremony or round. */ envelopeBinding: 'InvalidRound', /** Envelope cohort/index does not resolve in the signed cohort. */ signer: 'InvalidIndex', + /** Envelope over 64 KiB or any signature over 512 bytes. */ + cap: 'InvalidEncoding', /** Ceremony owner/network differs from the anchor. */ ceremonyAnchor: 'InvalidCeremony', + /** Genesis ceremony with a current digest or non-zero counters. */ + genesisFields: 'InvalidCeremony', + /** Successor ceremony epoch is not the checked current epoch + 1, or the re-key counter changes. */ + successorGeneration: 'InvalidCeremony', + /** Reshare/refresh ceremony without a current-certificate digest. */ + absentCurrent: 'ContextMismatch', /** Current certificate owner/network differs from the anchor. */ certificateAnchor: 'InvalidCertificate', + /** Certificate commitment[0] ≠ mpk, genesis t outside 2..=16, or predecessor-field combination. */ + certificateFields: 'InvalidEncoding', /** Reshare B′_0 differs from the current mpk. */ reshareMpk: 'ContextMismatch', } as const; @@ -107,7 +118,7 @@ export function decodeCeremony(body: Bytes): Ceremony { export function authorizeCeremony(anchor: Anchor, statement: Statement, verify: Verifier): Ceremony { const c = decodeCeremony(statement.body); if (c.owner !== anchor.owner || c.network !== anchor.network) fail(DEFAULT_ERROR.ceremonyAnchor); - if (statement.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (statement.signature.length > LIMIT.signature) fail(DEFAULT_ERROR.cap); if (!verify(anchor.owner, c.digest, statement.signature)) fail(DEFAULT_ERROR.signature); return c; } @@ -142,13 +153,14 @@ export function decodeCertificate(body: Bytes): Omit LIMIT.members || commitmentCount !== t) fail('InvalidEncoding'); + if (commitmentCount !== t) fail('InvalidEncoding'); + if (genesisT < 2 || genesisT > LIMIT.members) fail(DEFAULT_ERROR.certificateFields); const mpk = g2(mpkBytes), commitments = commitmentBytes.map(b => g2(b, true)); - if (!commitments[0]!.equals(mpk)) fail('InvalidEncoding'); + if (!commitments[0]!.equals(mpk)) fail(DEFAULT_ERROR.certificateFields); if (commitments[t - 1]!.equals(G2_ZERO)) fail('IdentityPoint'); const previousMpk = previousMpkBytes ? g2(previousMpkBytes) : undefined; // §3.9 field combinations: genesis has neither predecessor field and zero counters; re-key has both. - if (!previous && (previousMpk || epoch !== 0n || rekey !== 0n)) fail('InvalidEncoding'); + if (!previous && (previousMpk || epoch !== 0n || rekey !== 0n)) fail(DEFAULT_ERROR.certificateFields); return { network, owner, rekey, epoch, genesisT, t, n, members, mpk, commitments, transcript, ...(previous ? { previous } : {}), ...(previousMpk ? { previousMpk } : {}), digest: tagged(TAG.networkCert, body), @@ -157,7 +169,7 @@ export function decodeCertificate(body: Bytes): Omit LIMIT.signature) fail('InvalidEncoding'); + if (statement.signature.length > LIMIT.signature) fail(DEFAULT_ERROR.cap); if (!verify(anchor.owner, tagged(TAG.certStatement, c.digest), statement.signature)) fail(DEFAULT_ERROR.signature); const publicShares = c.members.map(m => evaluate(c.commitments, m.index)); if (publicShares.some(p => p.equals(G2_ZERO))) fail('IdentityPoint'); @@ -172,12 +184,6 @@ export function orderedListHash(messages: readonly Statement[]): Bytes { export function transcriptUpdate(previous: Bytes, closure: Statement): Bytes { return tagged(TAG.transcript, concat(previous, lp(closure.body), lp(closure.signature))); } -export function encodeClosure(ceremony: Bytes, round: number, listHash: Bytes, previous: Bytes): Bytes { - return concat(be(1n, 1), ceremony, be(BigInt(round), 1), listHash, previous); -} -export function encodeEnvelope(ceremony: Bytes, round: number, cohort: number, index: number, payload: Bytes): Bytes { - return concat(be(1n, 1), ceremony, be(BigInt(round), 1), be(BigInt(cohort), 1), be(BigInt(index), 2), lp(payload)); -} export const COHORT = { recipients: 1, dealers: 2 } as const; /** An authenticated message retained in owner order: the exact envelope plus its resolved signer. */ @@ -194,14 +200,14 @@ export function verifyClosedRound(ceremony: Bytes, round: number, previous: Byte const body = record.closure.body; if (body.length !== 98 || body[0] !== 1) fail(DEFAULT_ERROR.closure); if (!equal(body.slice(1, 33), ceremony) || body[33] !== round || !equal(body.slice(66, 98), previous)) fail(DEFAULT_ERROR.closure); - if (record.closure.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (record.closure.signature.length > LIMIT.signature) fail(DEFAULT_ERROR.cap); if (!verify(owner, tagged(TAG.roundClose, body), record.closure.signature)) fail(DEFAULT_ERROR.signature); const messages = record.messages.map(m => { const r = new Reader(m.body); r.version(); const digest = r.take(32), envelopeRound = Number(r.int(1)), cohort = Number(r.int(1)), index = Number(r.int(2)); - const payload = r.field(LIMIT.message); + const payload = r.field(LIMIT.message); // the whole-envelope cap in Reader binds first r.end(); - if (m.signature.length > LIMIT.signature) fail('InvalidEncoding'); + if (m.signature.length > LIMIT.signature) fail(DEFAULT_ERROR.cap); if (!equal(digest, ceremony) || envelopeRound !== round) fail(DEFAULT_ERROR.envelopeBinding); const signer = cohorts[cohort]?.get(index); if (signer === undefined) fail(DEFAULT_ERROR.signer); diff --git a/tinycloud-threshold/vectors/check/dkg.test.ts b/tinycloud-threshold/vectors/check/dkg.test.ts index c3f06c28..026b2726 100644 --- a/tinycloud-threshold/vectors/check/dkg.test.ts +++ b/tinycloud-threshold/vectors/check/dkg.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from 'bun:test'; import { ed25519 } from '@noble/curves/ed25519.js'; import { R, be, concat, tagged, type Bytes } from './protocol.ts'; import { dkgVerifyTranscript, type DkgOutput } from './dkg.ts'; -import type { RoundRecord, Statement } from './ceremony.ts'; +import { ed25519Verifier, type RoundRecord, type Statement } from './ceremony.ts'; import { - ANCHOR, COHORT_DEALERS, COHORT_RECIPIENTS, G2_BASE, H, alternateSignature, at, code, complaints, deal, did, dkgSetup, - SEEDS, expectedDkg, feldman, flip, g2hex, nonCanonicalR, nonCanonicalS, pair, pairs, seal, signedCeremony, times, verify, - type DkgSetup, type Draft, + ANCHOR, COHORT_DEALERS, COHORT_RECIPIENTS, G2_BASE, H, NETWORK, OWNER, SEEDS, alternateSignature, at, ceremonyBody, code, + complaints, deal, did, dkgSetup, expectedDkg, feldman, flip, g2hex, nonCanonicalR, nonCanonicalS, pair, pairs, seal, sign, + signedCeremony, times, verify, type DkgSetup, type Draft, } from './testkit.ts'; function records(s: DkgSetup, rounds: Draft[][] = s.rounds): RoundRecord[] { return seal(s.digest, s.start, rounds).records; } @@ -141,6 +141,15 @@ describe('R1–R3 and QUAL', () => { expect(code(() => run(s, alone))).toBe('InvalidRound'); expect(code(() => run(s, alone.slice(0, 2)))).toBe('InvalidRound'); }); + test('rules 5–8 apply in order', () => { + const two = replace(replace(s, 2, 2, msg(2, complaints([1]))), 2, 3, msg(3, complaints([1]))); + expect(run(s, replace(two, 3, 1)).disqualified).toEqual([{ member: 1, reason: 'Complaints' }]); + expect(run(s, replace(two, 3, 1, msg(1, pairs([[2, 1n, 1n]])))).disqualified).toEqual([{ member: 1, reason: 'Complaints' }]); + expect(run(s, replace(two, 3, 1, msg(1, pairs([])), msg(1, pairs([[2, ...pair(s.dealers[0]!, 2)]])))).disqualified) + .toEqual([{ member: 1, reason: 'Equivocated', round: 3 }]); + const one = replace(s, 2, 2, msg(2, complaints([1]))); + expect(run(s, replace(one, 3, 1, msg(1, pairs([[3, 1n, 1n]])))).disqualified).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); + }); }); describe('R4–R6 extraction and reconstruction', () => { @@ -167,10 +176,14 @@ describe('R4–R6 extraction and reconstruction', () => { expect(out.reconstructed).toEqual([3]); expectPublic(out, s, members); }); - test('R5 entries that are Pedersen-invalid, self-directed or Feldman-valid are ignored', () => { + test('R5 entries that are self-directed, Pedersen-invalid or against a Feldman-valid dealer are not upheld', () => { + const wrong = replace(s, 4, 3, msg(3, feldman([s.dealers[2]!.A[0]!, s.dealers[2]!.A[1]!.add(G2_BASE)]))); const [x, y] = pair(s.dealers[2]!, 1); - for (const entry of [[3, x, (y + 1n) % R], [1, ...pair(s.dealers[0]!, 1)], [3, x, y]] as const) { - const rounds = replace(s, 5, 1, msg(1, pairs([entry]))); + for (const rounds of [ + replace(wrong, 5, 3, msg(3, pairs([[3, ...pair(s.dealers[2]!, 3)]]))), + replace(wrong, 5, 1, msg(1, pairs([[3, x, (y + 1n) % R]]))), + replace(s, 5, 1, msg(1, pairs([[3, x, y]]))), + ]) { expect(run(s, rounds).reconstructed).toEqual([]); expect(code(() => run(s, [...rounds, r6(s, [3])]))).toBe('InvalidRound'); } @@ -210,10 +223,17 @@ describe('ceremony, round structure and limits', () => { expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, owner: did(1) }, did(1)), [])).toBe('InvalidCeremony'); expect(verifyWith(signedCeremony({ kind: 2, t: 2, n: 3, current: new Uint8Array(32), epoch: 1n }), [])).toBe('InvalidCeremony'); expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, epoch: 1n }), [])).toBe('InvalidCeremony'); + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, rekey: 1n }), [])).toBe('InvalidCeremony'); + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, current: new Uint8Array(32) }), [])).toBe('InvalidCeremony'); expect(verifyWith(signedCeremony({ kind: 1, t: 3, n: 4 }), [])).toBe('UnsupportedFaultBudget'); expect(verifyWith({ ...s.ceremony, signature: flip(s.ceremony.signature, 0) }, [])).toBe('InvalidSignature'); expect(verifyWith({ ...s.ceremony, body: concat(s.ceremony.body, be(0n, 1)) }, [])).toBe('InvalidEncoding'); expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, directory: [{ index: 1, did: did(1) }, { index: 2, did: did(1) }, { index: 3, did: did(3) }] }), [])).toBe('InvalidEncoding'); + expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, directory: [{ index: 1, did: did(1) }, { index: 2, did: did(2) }, { index: 4, did: did(4) }] }), [])).toBe('InvalidEncoding'); + const marker = ceremonyBody({ kind: 1, t: 2, n: 3 }); + marker[34 + 4 + NETWORK.length + 4 + OWNER.length] = 2; + expect(verifyWith({ body: marker, signature: sign(OWNER, tagged('TC-THRESHOLD-V1-CEREMONY-AUTH', marker)) }, [])).toBe('InvalidEncoding'); + expect(verifyWith({ ...s.ceremony, signature: new Uint8Array(513) }, [])).toBe('InvalidEncoding'); }); test('record count, closure binding and ordered-list commitment', () => { const rs = records(s); @@ -237,10 +257,16 @@ describe('ceremony, round structure and limits', () => { expect(ed25519.verify(zip215Only, preimage, ed25519.getPublicKey(SEEDS.get(did(1))!), { zip215: true })).toBe(true); expect(verify(did(1), preimage, zip215Only)).toBe(false); expect(code(() => at2({ ...msg(1, complaints([2])), signature: (m: Bytes, signer: string) => nonCanonicalR(signer, m) }))).toBe('InvalidSignature'); + const identityKey = new Uint8Array(32); identityKey[0] = 1; + const smallOrder = concat(ed25519.Point.BASE.multiply(5n).toBytes(), new Uint8Array([5, ...new Uint8Array(31)])); + expect(ed25519.verify(smallOrder, preimage, identityKey, { zip215: true })).toBe(true); + expect(ed25519Verifier(new Map([['small', identityKey]]))('small', preimage, smallOrder)).toBe(false); }); test('per-message and signature caps', () => { expect(code(() => run(s, replace(s, 2, 1, msg(1, new Uint8Array(65536)))))).toBe('InvalidEncoding'); expect(code(() => run(s, replace(s, 2, 1, { ...msg(1, complaints([])), signature: () => new Uint8Array(513) })))).toBe('InvalidEncoding'); + const rs = records(s); + expect(code(() => dkgVerifyTranscript(ANCHOR, s.ceremony, [{ ...rs[0]!, closure: { ...rs[0]!.closure, signature: new Uint8Array(513) } }, ...rs.slice(1)], verify))).toBe('InvalidEncoding'); }); test('the 4 MiB transcript bound is checked before the ceremony and any signature', () => { const garbage = (size: number): RoundRecord => ({ closure: { body: new Uint8Array(98), signature: new Uint8Array(64) }, messages: [{ body: new Uint8Array(size), signature: new Uint8Array(0) }] }); diff --git a/tinycloud-threshold/vectors/check/dkg.ts b/tinycloud-threshold/vectors/check/dkg.ts index 79a65ad5..665b1b37 100644 --- a/tinycloud-threshold/vectors/check/dkg.ts +++ b/tinycloud-threshold/vectors/check/dkg.ts @@ -1,7 +1,7 @@ import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; import { DST, PEDERSEN_H_MESSAGE, ProtocolError, Reader, bytes, g2, scalar, tagged, type Bytes, type G2 } from './protocol.ts'; import { - COHORT, KIND, TAG, authorizeCeremony, checkTranscriptSize, classify, evaluate, fail, listCount, + COHORT, DEFAULT_ERROR, KIND, TAG, authorizeCeremony, checkTranscriptSize, classify, evaluate, fail, listCount, memberIndex, mul, verifyChain, type Anchor, type Authenticated, type Ceremony, type Classified, type RoundRecord, type Statement, type Verifier, } from './ceremony.ts'; @@ -96,7 +96,8 @@ export function dkgInitialTranscript(ceremonyDigest: Bytes): Bytes { return tagg /** Genesis-only ceremony rules (S0.2 appendix and §3.0 budget). */ export function authorizeGenesis(anchor: Anchor, statement: Statement, verify: Verifier): Ceremony { const c = authorizeCeremony(anchor, statement, verify); - if (c.kind !== KIND.genesis || c.current || c.rekey !== 0n || c.epoch !== 0n) fail('InvalidCeremony'); + if (c.kind !== KIND.genesis) fail('InvalidCeremony'); + if (c.current || c.rekey !== 0n || c.epoch !== 0n) fail(DEFAULT_ERROR.genesisFields); if (c.n < 2 * c.t - 1) fail('UnsupportedFaultBudget'); return c; } diff --git a/tinycloud-threshold/vectors/check/reshare.test.ts b/tinycloud-threshold/vectors/check/reshare.test.ts index 0956624d..50e60b0d 100644 --- a/tinycloud-threshold/vectors/check/reshare.test.ts +++ b/tinycloud-threshold/vectors/check/reshare.test.ts @@ -105,6 +105,15 @@ describe('exclusion reasons', () => { expect(run(s, complaintsAsDealers).excluded).toEqual([]); expect(code(() => run(s, replace(s, 2, COHORT_DEALERS, 4, dealer(4, complaints([])))))).toBe('InvalidIndex'); }); + test('rules 4–7 apply in order', () => { + let three = s.rounds; + for (const j of [1, 2, 3]) three = replace(three, 2, COHORT_RECIPIENTS, j, recipient(j, complaints([1]))); + expect(run(s, replace(three, 3, COHORT_DEALERS, 1, dealer(1, values([])), dealer(1, values([[2, value(s, 1, 2)]])))).excluded) + .toEqual([{ member: 1, reason: 'Equivocated', round: 3 }]); + expect(run(s, replace(three, 3, COHORT_DEALERS, 1, dealer(1, values([[2, 1n]])))).excluded).toEqual([{ member: 1, reason: 'Complaints' }]); + const one = replace(s, 2, COHORT_RECIPIENTS, 2, recipient(2, complaints([1]))); + expect(run(s, replace(one, 3, COHORT_DEALERS, 1, dealer(1, values([[3, 1n]])))).excluded).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); + }); test('fewer than t dealers is InsufficientQualified; record count must be 3', () => { const alone = s.rounds.map((r, k) => k === 0 ? r.filter(d => d.index === 1) : r); expect(code(() => run(s, alone))).toBe('InsufficientQualified'); diff --git a/tinycloud-threshold/vectors/check/reshare.ts b/tinycloud-threshold/vectors/check/reshare.ts index bd1082ee..b22a00c7 100644 --- a/tinycloud-threshold/vectors/check/reshare.ts +++ b/tinycloud-threshold/vectors/check/reshare.ts @@ -28,8 +28,9 @@ export function authorizeRedistribution(anchor: Anchor, statement: Statement, ce const c = authorizeCeremony(anchor, statement, verify); if (c.kind !== KIND.reshare && c.kind !== KIND.refresh) fail('InvalidCeremony'); const current = verifyCertificate(anchor, certificate, verify); - if (!c.current || !equal(c.current, current.digest)) fail('ContextMismatch'); - if (current.epoch === 0xffffffffffffffffn || c.epoch !== current.epoch + 1n || c.rekey !== current.rekey) fail('InvalidCeremony'); + if (!c.current) fail(DEFAULT_ERROR.absentCurrent); + if (!equal(c.current, current.digest)) fail('ContextMismatch'); + if (current.epoch === 0xffffffffffffffffn || c.epoch !== current.epoch + 1n || c.rekey !== current.rekey) fail(DEFAULT_ERROR.successorGeneration); if (c.kind === KIND.refresh && (c.t !== current.t || c.n !== current.n || c.directory.some((d, k) => d.did !== current.members[k]!.did))) fail('InvalidCeremony'); return { ceremony: c, current }; } diff --git a/tinycloud-threshold/vectors/check/s02.test.ts b/tinycloud-threshold/vectors/check/s02.test.ts new file mode 100644 index 00000000..ff43fde5 --- /dev/null +++ b/tinycloud-threshold/vectors/check/s02.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { bytes, hex, tagged } from './protocol.ts'; +import { authorizeCeremony, decodeCertificate, transcriptUpdate, verifyCertificate, verifyClosedRound } from './ceremony.ts'; +import { ANCHOR, G2_BASE, code, did, flip, g2hex, signedCertificate, sign, times, verify, OWNER } from './testkit.ts'; + +/** cert-core.json S0.2 records exercised by the transcript layer: field names as committed. */ +interface CertCase { name: string; operation: string; error?: string; inputs: Record; outputs?: Record } +const raw: unknown = JSON.parse(readFileSync(new URL('../v1/cert-core.json', import.meta.url), 'utf8')); +// Unchecked: committed vector document; every field read below is converted through bytes()/String() and compared. +const certCore = raw as { cases: CertCase[] }; +const str = (v: unknown) => { if (typeof v !== 'string') throw new Error('expected string'); return v; }; +const statement = (v: unknown) => { + if (!v || typeof v !== 'object' || !('body' in v) || !('signature' in v)) throw new Error('expected statement'); + return { body: bytes(str(v.body)), signature: bytes(str(v.signature)) }; +}; +const byName = (name: string) => certCore.cases.find(c => c.name === name)!; + +describe('cert-core S0.2 records agree with the transcript layer', () => { + for (const name of ['genesis-2of3', 'reshare-3of4', 'rekey-2of3']) { + test(`verify_certificate ${name}`, () => { + const c = byName(name), o = c.outputs!; + const cert = verifyCertificate({ owner: str(c.inputs.expected_owner), network: str(c.inputs.network) }, + { body: bytes(str(c.inputs.body)), signature: bytes(str(c.inputs.owner_signature)) }, verify); + expect(hex(cert.digest)).toBe(str(o.digest)); + expect(hex(tagged('TC-THRESHOLD-V1-CERT-STATEMENT', cert.digest))).toBe(str(o.owner_statement)); + expect(g2hex(cert.mpk)).toBe(str(o.mpk)); + expect(cert.commitments.map(g2hex)).toEqual(o.commitments); + expect(cert.publicShares.map((p, k) => ({ index: k + 1, point: g2hex(p) }))).toEqual(o.public_shares); + expect([Number(cert.epoch), Number(cert.rekey), cert.genesisT, cert.t, cert.n]).toEqual([o.epoch, o.rekey, o.genesis_t, o.threshold, o.members]); + if (c.inputs.pinned_predecessor !== null) expect(hex(cert.previous!)).toBe(str(c.inputs.pinned_predecessor)); + }); + } + test('decode_certificate refusals', () => { + for (const name of ['duplicate-member-did', 'trailing-certificate-byte']) { + const c = byName(name); + expect(code(() => decodeCertificate(bytes(str(c.inputs.body))))).toBe(str(c.error)); + } + }); + test('authorize_ceremony digests, signatures and pinned predecessors', () => { + for (const name of ['ceremony-epoch-0', 'ceremony-epoch-1', 'ceremony-epoch-2']) { + const c = byName(name); + const ceremony = authorizeCeremony({ owner: str(c.inputs.expected_owner), network: str(c.inputs.network) }, + { body: bytes(str(c.inputs.body)), signature: bytes(str(c.inputs.signature)) }, verify); + expect(hex(ceremony.digest)).toBe(str(c.outputs!.digest)); + expect(ceremony.current ? hex(ceremony.current) : null).toBe(c.inputs.pinned_predecessor === null ? null : str(c.inputs.pinned_predecessor)); + } + const stale = byName('stale-ceremony-predecessor'); + const ceremony = authorizeCeremony(ANCHOR, { body: bytes(str(stale.inputs.body)), signature: bytes(str(stale.inputs.signature)) }, verify); + expect(hex(ceremony.current!)).not.toBe(str(stale.inputs.pinned_predecessor)); + }); + test('verify_closed_round closure preimage and transcript update', () => { + const c = byName('closed-round-1'), ceremony = byName('ceremony-epoch-0'); + const directory = authorizeCeremony(ANCHOR, { body: bytes(str(ceremony.inputs.body)), signature: bytes(str(ceremony.inputs.signature)) }, verify).directory; + const listed: unknown = c.inputs.messages; + if (!Array.isArray(listed)) throw new Error('expected messages'); + const messages = listed.map(statement); + const closure = { body: bytes(str(c.inputs.closure)), signature: bytes(str(c.inputs.owner_signature)) }; + const previous = bytes(str(c.inputs.previous)); + const authenticated = verifyClosedRound(bytes(str(c.inputs.ceremony_digest)), Number(c.inputs.round), previous, { closure, messages }, + { 1: new Map(directory.map(d => [d.index, d.did])) }, OWNER, verify); + expect(authenticated.length).toBe(messages.length); + expect(hex(tagged('TC-THRESHOLD-V1-ROUND-CLOSE', closure.body))).toBe(str(c.outputs!.owner_preimage)); + expect(hex(transcriptUpdate(previous, closure))).toBe(str(c.outputs!.transcript_hash)); + }); +}); + +describe('§3.9 decoded-certificate rules on synthetic certificates', () => { + const coefficients = [11n, 13n]; + const verifyCert = (s: { body: Uint8Array; signature: Uint8Array }) => code(() => verifyCertificate(ANCHOR, s, verify)); + const resign = (body: Uint8Array) => ({ body, signature: sign(OWNER, tagged('TC-THRESHOLD-V1-CERT-STATEMENT', tagged('TC-THRESHOLD-V1-NETWORK-CERT', body))) }); + test('valid genesis, reshare successor and re-key certificates are accepted', () => { + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, previous: new Uint8Array(32).fill(1) }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 2n, rekey: 1n, previous: new Uint8Array(32).fill(1), previousMpk: times(G2_BASE, 5n) }))).toBe('ok'); + }); + test('constant, top coefficient and evaluated public shares', () => { + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, mpk: times(G2_BASE, 12n) }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients: [11n, 0n] }))).toBe('IdentityPoint'); + expect(verifyCert(signedCertificate({ t: 3, n: 3, coefficients: [11n, 13n, 0n] }))).toBe('IdentityPoint'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients: [11n, -11n] }))).toBe('IdentityPoint'); + }); + test('predecessor-field combinations, genesis t and option markers', () => { + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, previousMpk: times(G2_BASE, 5n) }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, rekey: 1n }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 1 }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 17 }))).toBe('InvalidEncoding'); + // Present predecessor digest with marker 02 instead of 01: the 32 digest bytes still follow, so only the marker rule rejects it. + const body = signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, previous: new Uint8Array(32).fill(1) }).body; + expect(body[body.length - 34]).toBe(1); + expect(verifyCert(resign(flip(body, body.length - 34, 3)))).toBe('InvalidEncoding'); + }); + test('anchor, signature cap and signer', () => { + const good = signedCertificate({ t: 2, n: 3, coefficients }); + expect(verifyCert({ ...good, signature: new Uint8Array(513) })).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients }, did(1)))).toBe('InvalidSignature'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, owner: did(1) }, did(1)))).toBe('InvalidCertificate'); + }); +}); diff --git a/tinycloud-threshold/vectors/check/testkit.ts b/tinycloud-threshold/vectors/check/testkit.ts index 1c2faa7b..d532b5b6 100644 --- a/tinycloud-threshold/vectors/check/testkit.ts +++ b/tinycloud-threshold/vectors/check/testkit.ts @@ -93,7 +93,7 @@ export function signedCeremony(c: CeremonySpec, signer = OWNER): Statement { } export interface CertificateSpec { network?: string; owner?: string; rekey?: bigint; epoch?: bigint; genesisT?: number; t: number; n: number; - coefficients: bigint[]; transcript?: Bytes; previous?: Bytes; previousMpk?: G2; + coefficients: bigint[]; mpk?: G2; transcript?: Bytes; previous?: Bytes; previousMpk?: G2; } export function certificateBody(c: CertificateSpec): Bytes { const commitments = c.coefficients.map(a => times(G2_BASE, a)); @@ -101,7 +101,7 @@ export function certificateBody(c: CertificateSpec): Bytes { be(1n, 1), lp(enc.encode(c.network ?? NETWORK)), lp(enc.encode(c.owner ?? OWNER)), be(c.rekey ?? 0n, 8), be(c.epoch ?? 0n, 8), be(BigInt(c.genesisT ?? c.t), 2), be(BigInt(c.t), 2), be(BigInt(c.n), 2), be(BigInt(c.n), 2), ...Array.from({ length: c.n }, (_, k) => concat(be(BigInt(k + 1), 2), lp(enc.encode(did(k + 1))))), - commitments[0]!.toBytes(), be(BigInt(commitments.length), 2), ...commitments.map(p => p.toBytes()), + (c.mpk ?? commitments[0]!).toBytes(), be(BigInt(commitments.length), 2), ...commitments.map(p => p.toBytes()), c.transcript ?? new Uint8Array(32).fill(7), c.previous ? concat(be(1n, 1), c.previous) : be(0n, 1), c.previousMpk ? concat(be(1n, 1), c.previousMpk.toBytes()) : be(0n, 1), From 3e61e74cc52c37de89fa4f4960d8faf1af784aa6 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:36:16 +0000 Subject: [PATCH 04/10] feat(threshold): apply verifier-conformance, sealed-dealing and certificate SPEC rulings to the checker; Details: TC-2223 --- tinycloud-threshold/SPEC.md | 12 ++++++---- tinycloud-threshold/vectors/check/README.md | 15 ++++++++---- tinycloud-threshold/vectors/check/ceremony.ts | 24 ++++++++++++------- tinycloud-threshold/vectors/check/dkg.test.ts | 10 ++++++++ tinycloud-threshold/vectors/check/dkg.ts | 6 +++-- .../vectors/check/reshare.test.ts | 12 ++++++++-- tinycloud-threshold/vectors/check/reshare.ts | 4 ++-- tinycloud-threshold/vectors/check/s02.test.ts | 17 +++++++++++-- tinycloud-threshold/vectors/check/testkit.ts | 9 +++---- .../check/transcript-corruption.test.ts | 2 +- 10 files changed, 81 insertions(+), 30 deletions(-) diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 07d52853..df1a58ba 100644 --- a/tinycloud-threshold/SPEC.md +++ b/tinycloud-threshold/SPEC.md @@ -160,7 +160,7 @@ Preconditions: an `AuthorizedCeremony` of kind `genesis` (§3.8 step 0 applies t **`verify_certificate(anchor, signed, verifier)` → `VerifiedCertificate`:** - owner DID = `anchor.expected_owner` and network = `anchor.network` (an otherwise valid self-signed certificate naming another owner is rejected); - the owner signature verifies through the injected verifier (principle 2's allow-list lives behind it, in tinycloud-auth); -- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0; reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk. +- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0, genesis t = t and n ≥ 2t−1 (§3.0 genesis budget); reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk, previous mpk ≠ mpk and re-key counter ≥ 1; every successor (previous digest present) ⇒ epoch ≥ 1 and re-key counter ≤ epoch. **`verify_successor(pinned: &VerifiedCertificate, next, verifier)`:** same owner and network (re-checked against the anchor); next.previous digest = pinned digest; epoch = pinned + 1 (checked); and either (reshare/refresh) the same mpk, re-key counter and genesis t, or (re-key) re-key counter + 1, previous mpk = pinned mpk, new mpk, genesis t = new t. Rollback and alternate lineages are rejected relative to the pinned certificate; which certificate is pinned as current comes from the trusted log head (gate B), not from the crate. @@ -236,6 +236,10 @@ S0.4 adds the §3.8 redistribution for reshare and refresh: dealer and recipient The dealer remains test-only. Durable storage/retirement and node-runtime integration are separate slices. The crate remains an unwired workspace member. +## Verifier conformance + +Two verifiers of the same raw transcript or certificate conform when they agree on the **outcome**: accept or reject; QUAL (genesis) or D (redistribution); the final mpk, commitments and public shares; and which dealers are disqualified or excluded. These outcomes are normative. Typed error codes, reason codes and the order in which checks run (and so which code a multiply-defective input reports) are informative; an implementation may report a different code for an input it rejects, provided it rejects it. + ## S0.2 canonical byte appendix All integers below are unsigned big-endian. `lp(x)` is `len(x):u32 || x`; strings use exact UTF-8 without normalization. `option(x)` is `00` when absent or `01 || x` when present; other markers are rejected. Symbolic tag names refer to the literal domain strings in §3.3, and `TH` is defined in §3.2. @@ -309,7 +313,7 @@ The canonical certificate body is: The certificate digest is `TH(NETWORK_CERT, body)`. The owner signs `TH(CERT_STATEMENT, certificate_digest:32)`. -Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields and zero counters. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk`, names the previous `mpk`, increments `rekey` and sets `genesis_t` to the new `t`. +Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields, zero counters, `genesis_t = t` and `n >= 2t-1`. Any certificate with a previous digest has `epoch >= 1` and `rekey <= epoch`. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk` (previous `mpk` ≠ `mpk`), names the previous `mpk`, has `rekey >= 1`, increments `rekey` and sets `genesis_t` to the new `t`. Verification uses the caller's expected owner/network. The caller's authorization-log integration selects the current pinned certificate. Candidate binding checks transcript and member index, then `scalar*g2` against the certificate's evaluated public share. @@ -395,7 +399,7 @@ R5 extraction = 01 || count:u16 || [(dealer:u16 || s:32 || s′:32)] R6 values = 01 || count:u16 || [(dealer:u16 || s:32 || s′:32)] ``` -`sealed_dealing` is the §3.10 envelope `01 || enc:32 || lp(ciphertext)`. Every count is at most 16 and is checked before allocation; index lists are strictly increasing member indices 1..=16; point coefficients are canonical G2 encodings and individual coefficients may be the identity (§3.2 role table); scalars are canonical. Trailing bytes and other versions are rejected. A payload that fails this decoding is *malformed*. +`sealed_dealing` is the §3.10 envelope `01 || enc:32 || lp(ciphertext)`; the ciphertext is at least 16 bytes (the AEAD tag), and a shorter one makes that R1 malformed. Every count is at most 16 and is checked before allocation; index lists are strictly increasing member indices 1..=16; point coefficients are canonical G2 encodings and individual coefficients may be the identity (§3.2 role table); scalars are canonical. Trailing bytes and other versions are rejected. A payload that fails this decoding is *malformed*. ### Classifying a round @@ -446,7 +450,7 @@ This appendix records the byte layouts and deterministic rules that members and ### Authorization and refresh -Every entry point (`reshare_deal`, `reshare_start_receiver`, `reshare_verify_transcript`) first requires an `AuthorizedCeremony` of kind reshare or refresh (`InvalidCeremony` otherwise) whose current-certificate digest equals the supplied `VerifiedCertificate` (`ContextMismatch` otherwise). Refresh additionally requires t′ = t, n′ = n and a directory whose index→DID map equals the current certificate's members (`InvalidCeremony` otherwise). A dealer's `KeyShare` must carry the current certificate's key context (`ContextMismatch`). A recipient's ceremony key must derive the directory key at its index (`ContextMismatch`; unknown index `InvalidIndex`). These checks precede any randomness, opening or emitted message. +Every entry point (`reshare_deal`, `reshare_start_receiver`, `reshare_verify_transcript`) first requires an `AuthorizedCeremony` of kind reshare or refresh (`InvalidCeremony` otherwise) authorized against the pinned current certificate: its current-certificate digest must be present and equal the supplied `VerifiedCertificate` (`InvalidCeremony` otherwise, for a stale or absent digest). Refresh additionally requires t′ = t, n′ = n and a directory whose index→DID map equals the current certificate's members (`InvalidCeremony` otherwise). A dealer's `KeyShare` must carry the current certificate's key context (`ContextMismatch`). A recipient's ceremony key must derive the directory key at its index (`ContextMismatch`; unknown index `InvalidIndex`). These checks precede any randomness, opening or emitted message. ### Initial link and transcript diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index fa315801..aec791a4 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -44,8 +44,11 @@ records, using `ceremony.ts` for the S0.2 layer. Check order: An abort (`InsufficientQualified`, `InsufficientRecovery`) must be the last record; missing or additional records are `InvalidRound`. -Conditions SPEC describes without naming an error use the defaults in -`DEFAULT_ERROR` (`ceremony.ts`), ruled by Naomi on 2026-10-30. +Per SPEC "Verifier conformance", outcomes (accept/reject, QUAL or D, mpk, +commitments, public shares, excluded dealers) are normative; error codes and +their precedence are informative. Conditions SPEC describes without naming an +error use the defaults in `DEFAULT_ERROR` (`ceremony.ts`), ruled by Naomi on +2026-10-30. `testkit.ts` builds signed transcripts from the published fixture seeds in `../v1/cert-core.json`, with payload encoders written separately from the @@ -53,12 +56,14 @@ decoders; expected outputs are computed in the scalar domain. `dkg.test.ts` and `reshare.test.ts` cover every disqualification/exclusion reason and its precedence, extraction and reconstruction, identity rejections, caps, entry checks and strict signatures. `probes.ts` and `transcript-corruption.test.ts` -flip one byte in each field class of every committed record and require the -ordered reason; re-signed flips reach the rule-level outcome. +flip one byte in each field class of every committed record and pin the +checker-order reason codes (informative); re-signed flips reach the rule-level +outcome. `transcript-vectors.test.ts` perturbs expected outputs and runs the real CLI. The checker does not open sealed dealings (it holds no recipient keys); a -sealed dealing is decoded by layout only, per Naomi's 2026-10-30 ruling. +sealed dealing is decoded by layout, and a ciphertext shorter than the 16-byte +AEAD tag makes that R1 malformed (SPEC S0.3 appendix). `s02.test.ts` runs the `../v1/cert-core.json` certificate, ceremony and closed-round records through the same S0.2 layer and covers the §3.9 decoded-certificate rules. diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts index 9fa90864..de44b711 100644 --- a/tinycloud-threshold/vectors/check/ceremony.ts +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -23,9 +23,10 @@ export const LIMIT = { members: 16, did: 256, signature: 512, message: 65536, tr * Typed errors for conditions SPEC describes but does not name. Every entry is * a default ruled by Naomi on 2026-10-30 (TC-2223); SPEC- and vector-named * codes (LimitExceeded, InvalidRound for record counts and round transitions, - * InvalidCeremony for kind/refresh, ContextMismatch for certificate binding, - * IdentityPoint, InsufficientQualified, InsufficientRecovery, - * UnsupportedFaultBudget, InvalidEncoding for decoders) are used literally. + * InvalidCeremony for kind/refresh/current-digest, IdentityPoint, + * InsufficientQualified, InsufficientRecovery, UnsupportedFaultBudget, + * InvalidEncoding for decoders) are used literally. Per SPEC "Verifier + * conformance", outcomes are normative and these codes are informative. */ export const DEFAULT_ERROR = { /** Owner or member signature fails strict RFC 8032 verification, or the DID has no key. */ @@ -44,11 +45,12 @@ export const DEFAULT_ERROR = { genesisFields: 'InvalidCeremony', /** Successor ceremony epoch is not the checked current epoch + 1, or the re-key counter changes. */ successorGeneration: 'InvalidCeremony', - /** Reshare/refresh ceremony without a current-certificate digest. */ - absentCurrent: 'ContextMismatch', /** Current certificate owner/network differs from the anchor. */ certificateAnchor: 'InvalidCertificate', - /** Certificate commitment[0] ≠ mpk, genesis t outside 2..=16, or predecessor-field combination. */ + /** + * Certificate commitment[0] ≠ mpk, genesis t outside 2..=16, or a §3.9 field combination: + * genesis counters/predecessors/genesis_t = t; successor epoch ≥ 1, rekey ≤ epoch; re-key mpk change, rekey ≥ 1. + */ certificateFields: 'InvalidEncoding', /** Reshare B′_0 differs from the current mpk. */ reshareMpk: 'ContextMismatch', @@ -159,8 +161,14 @@ export function decodeCertificate(body: Bytes): Omit epoch) fail(DEFAULT_ERROR.certificateFields); + if (previousMpk && (previousMpk.equals(mpk) || rekey < 1n)) fail(DEFAULT_ERROR.certificateFields); + } return { network, owner, rekey, epoch, genesisT, t, n, members, mpk, commitments, transcript, ...(previous ? { previous } : {}), ...(previousMpk ? { previousMpk } : {}), digest: tagged(TAG.networkCert, body), diff --git a/tinycloud-threshold/vectors/check/dkg.test.ts b/tinycloud-threshold/vectors/check/dkg.test.ts index 026b2726..bc050424 100644 --- a/tinycloud-threshold/vectors/check/dkg.test.ts +++ b/tinycloud-threshold/vectors/check/dkg.test.ts @@ -141,6 +141,16 @@ describe('R1–R3 and QUAL', () => { expect(code(() => run(s, alone))).toBe('InvalidRound'); expect(code(() => run(s, alone.slice(0, 2)))).toBe('InvalidRound'); }); + for (const length of [0, 15]) { + test(`a ${length}-byte sealed ciphertext makes the deal malformed and changes QUAL`, () => { + const out = run(s, replace(s, 1, 2, msg(2, deal(s.dealers[1]!.C, members, 64, 2, { 3: length })))); + expect(out.disqualified).toEqual([{ member: 2, reason: 'MalformedDeal' }]); + expectPublic(out, s, [1, 3]); + }); + } + test('a tag-only 16-byte sealed ciphertext is well formed', () => { + expect(run(s, replace(s, 1, 2, msg(2, deal(s.dealers[1]!.C, members, 64, 2, { 3: 16 })))).disqualified).toEqual([]); + }); test('rules 5–8 apply in order', () => { const two = replace(replace(s, 2, 2, msg(2, complaints([1]))), 2, 3, msg(3, complaints([1]))); expect(run(s, replace(two, 3, 1)).disqualified).toEqual([{ member: 1, reason: 'Complaints' }]); diff --git a/tinycloud-threshold/vectors/check/dkg.ts b/tinycloud-threshold/vectors/check/dkg.ts index 665b1b37..b479d27f 100644 --- a/tinycloud-threshold/vectors/check/dkg.ts +++ b/tinycloud-threshold/vectors/check/dkg.ts @@ -23,8 +23,10 @@ export function decodeDeal(payload: Bytes): { commitments: G2[]; recipients: num const recipients: number[] = []; for (let k = listCount(r), last = 0; k > 0; k--) { last = memberIndex(r, last); - // §3.10 sealed dealing envelope: 01 || enc:32 || lp(ciphertext), decoded by layout only. - const sealed = new Reader(r.field(65536)); sealed.version(); sealed.take(32); sealed.field(65536); sealed.end(); + // §3.10 sealed dealing envelope: 01 || enc:32 || lp(ciphertext); the ciphertext holds at least the 16-byte AEAD tag. + const sealed = new Reader(r.field(65536)); sealed.version(); sealed.take(32); + if (sealed.field(65536).length < 16) fail('InvalidEncoding'); + sealed.end(); recipients.push(last); } r.end(); diff --git a/tinycloud-threshold/vectors/check/reshare.test.ts b/tinycloud-threshold/vectors/check/reshare.test.ts index 50e60b0d..5034c97f 100644 --- a/tinycloud-threshold/vectors/check/reshare.test.ts +++ b/tinycloud-threshold/vectors/check/reshare.test.ts @@ -114,6 +114,13 @@ describe('exclusion reasons', () => { const one = replace(s, 2, COHORT_RECIPIENTS, 2, recipient(2, complaints([1]))); expect(run(s, replace(one, 3, COHORT_DEALERS, 1, dealer(1, values([[3, 1n]])))).excluded).toEqual([{ member: 1, reason: 'InvalidAnswer' }]); }); + for (const length of [0, 15]) { + test(`a ${length}-byte sealed ciphertext makes the deal malformed and changes D`, () => { + const out = run(s, replace(s, 1, COHORT_DEALERS, 1, dealer(1, deal(commitmentsOf(s.dealings[0]!), recipients(s), 32, 1, { 4: length })))); + expect(out.excluded).toEqual([{ member: 1, reason: 'MalformedDeal' }]); + expectOutput(out, s, [2, 3]); + }); + } test('fewer than t dealers is InsufficientQualified; record count must be 3', () => { const alone = s.rounds.map((r, k) => k === 0 ? r.filter(d => d.index === 1) : r); expect(code(() => run(s, alone))).toBe('InsufficientQualified'); @@ -128,8 +135,9 @@ describe('entry checks', () => { const variant = (kind: number, spec: Partial, t2 = 3, n2 = 4) => code(() => run(reshareSetup('entry', 2, 3, t2, n2, kind, spec))); expect(variant(1, { current: undefined, epoch: 0n })).toBe('InvalidCeremony'); expect(variant(4, { rekey: 1n })).toBe('InvalidCeremony'); - expect(variant(2, { current: new Uint8Array(32) })).toBe('ContextMismatch'); - expect(variant(2, { current: undefined })).toBe('ContextMismatch'); + // Owner ruling (TC-2223): authorized against the pinned certificate, a stale or absent digest is InvalidCeremony. + expect(variant(2, { current: new Uint8Array(32) })).toBe('InvalidCeremony'); + expect(variant(2, { current: undefined })).toBe('InvalidCeremony'); expect(variant(2, { epoch: 2n })).toBe('InvalidCeremony'); expect(variant(2, { rekey: 1n })).toBe('InvalidCeremony'); expect(variant(3, {}, 3, 3)).toBe('InvalidCeremony'); diff --git a/tinycloud-threshold/vectors/check/reshare.ts b/tinycloud-threshold/vectors/check/reshare.ts index b22a00c7..8a21d3c3 100644 --- a/tinycloud-threshold/vectors/check/reshare.ts +++ b/tinycloud-threshold/vectors/check/reshare.ts @@ -28,8 +28,8 @@ export function authorizeRedistribution(anchor: Anchor, statement: Statement, ce const c = authorizeCeremony(anchor, statement, verify); if (c.kind !== KIND.reshare && c.kind !== KIND.refresh) fail('InvalidCeremony'); const current = verifyCertificate(anchor, certificate, verify); - if (!c.current) fail(DEFAULT_ERROR.absentCurrent); - if (!equal(c.current, current.digest)) fail('ContextMismatch'); + // Authorized against the pinned current certificate: a stale or absent digest is InvalidCeremony (owner ruling, TC-2223). + if (!c.current || !equal(c.current, current.digest)) fail('InvalidCeremony'); if (current.epoch === 0xffffffffffffffffn || c.epoch !== current.epoch + 1n || c.rekey !== current.rekey) fail(DEFAULT_ERROR.successorGeneration); if (c.kind === KIND.refresh && (c.t !== current.t || c.n !== current.n || c.directory.some((d, k) => d.did !== current.members[k]!.did))) fail('InvalidCeremony'); return { ceremony: c, current }; diff --git a/tinycloud-threshold/vectors/check/s02.test.ts b/tinycloud-threshold/vectors/check/s02.test.ts index ff43fde5..cd9651f7 100644 --- a/tinycloud-threshold/vectors/check/s02.test.ts +++ b/tinycloud-threshold/vectors/check/s02.test.ts @@ -84,13 +84,26 @@ describe('§3.9 decoded-certificate rules on synthetic certificates', () => { expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, previousMpk: times(G2_BASE, 5n) }))).toBe('InvalidEncoding'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n }))).toBe('InvalidEncoding'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, rekey: 1n }))).toBe('InvalidEncoding'); - expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 1 }))).toBe('InvalidEncoding'); - expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 17 }))).toBe('InvalidEncoding'); + const successor = { epoch: 1n, previous: new Uint8Array(32).fill(1) }; + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 1, ...successor }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 17, ...successor }))).toBe('InvalidEncoding'); // Present predecessor digest with marker 02 instead of 01: the 32 digest bytes still follow, so only the marker rule rejects it. const body = signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, previous: new Uint8Array(32).fill(1) }).body; expect(body[body.length - 34]).toBe(1); expect(verifyCert(resign(flip(body, body.length - 34, 3)))).toBe('InvalidEncoding'); }); + test('owner rulings: genesis genesis_t and budget; successor epoch and counter; re-key mpk and counter', () => { + const previous = new Uint8Array(32).fill(1), rotated = times(G2_BASE, 5n); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 3 }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n] }))).toBe('UnsupportedFaultBudget'); + expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n], epoch: 1n, previous }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 0n, previous }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 2n, previous }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 2n, rekey: 2n, previous }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 1n, previous, previousMpk: times(G2_BASE, 11n) }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 0n, previous, previousMpk: rotated }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('ok'); + }); test('anchor, signature cap and signer', () => { const good = signedCertificate({ t: 2, n: 3, coefficients }); expect(verifyCert({ ...good, signature: new Uint8Array(513) })).toBe('InvalidEncoding'); diff --git a/tinycloud-threshold/vectors/check/testkit.ts b/tinycloud-threshold/vectors/check/testkit.ts index d532b5b6..ac9b9d59 100644 --- a/tinycloud-threshold/vectors/check/testkit.ts +++ b/tinycloud-threshold/vectors/check/testkit.ts @@ -113,11 +113,12 @@ export function signedCertificate(c: CertificateSpec, signer = OWNER): Statement } // --- Payloads ------------------------------------------------------------------------ -const sealed = (dealer: number, recipient: number, plaintext: number) => - concat(be(1n, 1), new Uint8Array(32).fill(dealer), lp(new Uint8Array(plaintext + 16).fill(recipient))); -export function deal(commitments: readonly G2[], recipients: readonly number[], plaintext = 64, dealer = 1): Bytes { +const sealed = (dealer: number, recipient: number, ciphertext: number) => + concat(be(1n, 1), new Uint8Array(32).fill(dealer), lp(new Uint8Array(ciphertext).fill(recipient))); +/** R1 deal; each sealed ciphertext is plaintext + 16 tag bytes unless `short` overrides recipient → ciphertext length. */ +export function deal(commitments: readonly G2[], recipients: readonly number[], plaintext = 64, dealer = 1, short: Record = {}): Bytes { return concat(be(1n, 1), be(BigInt(commitments.length), 2), ...commitments.map(p => p.toBytes()), - be(BigInt(recipients.length), 2), ...recipients.map(j => concat(be(BigInt(j), 2), lp(sealed(dealer, j, plaintext))))); + be(BigInt(recipients.length), 2), ...recipients.map(j => concat(be(BigInt(j), 2), lp(sealed(dealer, j, short[j] ?? plaintext + 16))))); } export function complaints(dealers: readonly number[]): Bytes { return concat(be(1n, 1), be(BigInt(dealers.length), 2), ...dealers.map(i => be(BigInt(i), 2))); diff --git a/tinycloud-threshold/vectors/check/transcript-corruption.test.ts b/tinycloud-threshold/vectors/check/transcript-corruption.test.ts index 69d73db7..6442e7ed 100644 --- a/tinycloud-threshold/vectors/check/transcript-corruption.test.ts +++ b/tinycloud-threshold/vectors/check/transcript-corruption.test.ts @@ -10,7 +10,7 @@ const docs = { dkg: loadDoc('dkg-genesis.json'), reshare: loadDoc('reshare.json' const cli = new URL('./cli.ts', import.meta.url).pathname; for (const [kind, doc] of Object.entries(docs)) { - describe(`${kind}: raw one-byte corruptions are rejected with the SPEC-ordered reason`, () => { + describe(`${kind}: checker-order reason codes for raw one-byte corruptions (informative; rejection is normative)`, () => { for (const c of doc.cases) { test(c.name, () => { const base = inputOf(c), mismatches: string[] = []; From 6d1883ae32bf587d4403e7ef6862536462121604 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:44:41 +0000 Subject: [PATCH 05/10] feat(threshold): require genesis t and the genesis budget on re-key certificates in the checker; Details: TC-2223 --- tinycloud-threshold/SPEC.md | 4 ++-- tinycloud-threshold/vectors/check/README.md | 2 +- tinycloud-threshold/vectors/check/ceremony.ts | 7 +++++-- tinycloud-threshold/vectors/check/s02.test.ts | 2 ++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index df1a58ba..031776c4 100644 --- a/tinycloud-threshold/SPEC.md +++ b/tinycloud-threshold/SPEC.md @@ -160,7 +160,7 @@ Preconditions: an `AuthorizedCeremony` of kind `genesis` (§3.8 step 0 applies t **`verify_certificate(anchor, signed, verifier)` → `VerifiedCertificate`:** - owner DID = `anchor.expected_owner` and network = `anchor.network` (an otherwise valid self-signed certificate naming another owner is rejected); - the owner signature verifies through the injected verifier (principle 2's allow-list lives behind it, in tinycloud-auth); -- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0, genesis t = t and n ≥ 2t−1 (§3.0 genesis budget); reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk, previous mpk ≠ mpk and re-key counter ≥ 1; every successor (previous digest present) ⇒ epoch ≥ 1 and re-key counter ≤ epoch. +- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0, genesis t = t and n ≥ 2t−1 (§3.0 genesis budget); reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk, previous mpk ≠ mpk, re-key counter ≥ 1, genesis t = t and n ≥ 2t−1 (§3.0 genesis budget); every successor (previous digest present) ⇒ epoch ≥ 1 and re-key counter ≤ epoch. **`verify_successor(pinned: &VerifiedCertificate, next, verifier)`:** same owner and network (re-checked against the anchor); next.previous digest = pinned digest; epoch = pinned + 1 (checked); and either (reshare/refresh) the same mpk, re-key counter and genesis t, or (re-key) re-key counter + 1, previous mpk = pinned mpk, new mpk, genesis t = new t. Rollback and alternate lineages are rejected relative to the pinned certificate; which certificate is pinned as current comes from the trusted log head (gate B), not from the crate. @@ -313,7 +313,7 @@ The canonical certificate body is: The certificate digest is `TH(NETWORK_CERT, body)`. The owner signs `TH(CERT_STATEMENT, certificate_digest:32)`. -Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields, zero counters, `genesis_t = t` and `n >= 2t-1`. Any certificate with a previous digest has `epoch >= 1` and `rekey <= epoch`. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk` (previous `mpk` ≠ `mpk`), names the previous `mpk`, has `rekey >= 1`, increments `rekey` and sets `genesis_t` to the new `t`. +Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields, zero counters, `genesis_t = t` and `n >= 2t-1`. Any certificate with a previous digest has `epoch >= 1` and `rekey <= epoch`. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk` (previous `mpk` ≠ `mpk`), names the previous `mpk`, has `rekey >= 1`, increments `rekey`, sets `genesis_t` to the new `t` (`genesis_t = t`) and satisfies the genesis budget `n >= 2t-1`. Verification uses the caller's expected owner/network. The caller's authorization-log integration selects the current pinned certificate. Candidate binding checks transcript and member index, then `scalar*g2` against the certificate's evaluated public share. diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index aec791a4..69bf5514 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -34,7 +34,7 @@ records, using `ceremony.ts` for the S0.2 layer. Check order: `UnsupportedFaultBudget`. 3. Redistribution only: the current certificate (decode, anchor, owner signature, evaluated public shares), then ceremony/certificate binding - (`ContextMismatch`), successor epoch/re-key and refresh shape. + (`InvalidCeremony`), successor epoch/re-key and refresh shape. 4. Each present record k as closed round k from the start link H0: closure ceremony/round/previous hash, owner closure signature, each envelope (decode, ceremony/round, signer in its signed cohort, strict RFC 8032 diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts index de44b711..9ccabce1 100644 --- a/tinycloud-threshold/vectors/check/ceremony.ts +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -49,7 +49,7 @@ export const DEFAULT_ERROR = { certificateAnchor: 'InvalidCertificate', /** * Certificate commitment[0] ≠ mpk, genesis t outside 2..=16, or a §3.9 field combination: - * genesis counters/predecessors/genesis_t = t; successor epoch ≥ 1, rekey ≤ epoch; re-key mpk change, rekey ≥ 1. + * genesis counters/predecessors/genesis_t = t; successor epoch ≥ 1, rekey ≤ epoch; re-key mpk change, rekey ≥ 1, genesis_t = t. */ certificateFields: 'InvalidEncoding', /** Reshare B′_0 differs from the current mpk. */ @@ -167,7 +167,10 @@ export function decodeCertificate(body: Bytes): Omit epoch) fail(DEFAULT_ERROR.certificateFields); - if (previousMpk && (previousMpk.equals(mpk) || rekey < 1n)) fail(DEFAULT_ERROR.certificateFields); + if (previousMpk) { + if (previousMpk.equals(mpk) || rekey < 1n || genesisT !== t) fail(DEFAULT_ERROR.certificateFields); + if (n < 2 * t - 1) fail('UnsupportedFaultBudget'); + } } return { network, owner, rekey, epoch, genesisT, t, n, members, mpk, commitments, transcript, diff --git a/tinycloud-threshold/vectors/check/s02.test.ts b/tinycloud-threshold/vectors/check/s02.test.ts index cd9651f7..5f8bd9e5 100644 --- a/tinycloud-threshold/vectors/check/s02.test.ts +++ b/tinycloud-threshold/vectors/check/s02.test.ts @@ -103,6 +103,8 @@ describe('§3.9 decoded-certificate rules on synthetic certificates', () => { expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 1n, previous, previousMpk: times(G2_BASE, 11n) }))).toBe('InvalidEncoding'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 0n, previous, previousMpk: rotated }))).toBe('InvalidEncoding'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 3, epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('InvalidEncoding'); + expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n], epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('UnsupportedFaultBudget'); }); test('anchor, signature cap and signer', () => { const good = signedCertificate({ t: 2, n: 3, coefficients }); From 2859fa2026bc9fa484a63e19e89013d2c0b78450 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:26:41 +0000 Subject: [PATCH 06/10] feat(threshold): accept 3-of-4 genesis and re-key shapes in the checker; Details: TC-2223 Genesis ceremonies and genesis/re-key certificates accept n >= 2t-1 or exactly 3-of-4, matching the crate; every other shape stays UnsupportedFaultBudget. --- tinycloud-threshold/vectors/check/README.md | 4 ++-- tinycloud-threshold/vectors/check/ceremony.ts | 8 ++++++-- tinycloud-threshold/vectors/check/dkg.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index a4ac4fb6..ea5668bc 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -30,8 +30,8 @@ records, using `ceremony.ts` for the S0.2 layer. Check order: 1. Byte-length sum of every closure body/signature and member envelope/signature above 4194304 → `LimitExceeded`, before any decoding or signature. 2. Ceremony statement: decode (`InvalidEncoding`), owner/network against the - anchor, owner signature, kind and generation rules; genesis `n < 2t−1` → - `UnsupportedFaultBudget`. + anchor, owner signature, kind and generation rules; a genesis shape other + than `n >= 2t−1` or exactly 3-of-4 → `UnsupportedFaultBudget`. 3. Redistribution only: the current certificate (decode, anchor, owner signature, evaluated public shares), then ceremony/certificate binding (`InvalidCeremony`), successor epoch/re-key and refresh shape. diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts index 9ccabce1..0c710401 100644 --- a/tinycloud-threshold/vectors/check/ceremony.ts +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -96,6 +96,10 @@ function checkDirectory(t: number, n: number, entries: { index: number; did: str if (entries.some((e, k) => e.index !== k + 1)) fail('InvalidEncoding'); if (new Set(entries.map(e => e.did)).size !== n) fail('InvalidEncoding'); } +/** §3.0 genesis budget: n >= 2t-1 (t-1 faulty members) or exactly 3-of-4 (one faulty member). */ +export function checkGenesisShape(t: number, n: number) { + if (n < 2 * t - 1 && !(t === 3 && n === 4)) fail('UnsupportedFaultBudget'); +} export const KIND = { genesis: 1, reshare: 2, refresh: 3, rekey: 4 } as const; export interface Ceremony { @@ -164,12 +168,12 @@ export function decodeCertificate(body: Bytes): Omit epoch) fail(DEFAULT_ERROR.certificateFields); if (previousMpk) { if (previousMpk.equals(mpk) || rekey < 1n || genesisT !== t) fail(DEFAULT_ERROR.certificateFields); - if (n < 2 * t - 1) fail('UnsupportedFaultBudget'); + checkGenesisShape(t, n); } } return { diff --git a/tinycloud-threshold/vectors/check/dkg.ts b/tinycloud-threshold/vectors/check/dkg.ts index b479d27f..f272d850 100644 --- a/tinycloud-threshold/vectors/check/dkg.ts +++ b/tinycloud-threshold/vectors/check/dkg.ts @@ -1,7 +1,7 @@ import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; import { DST, PEDERSEN_H_MESSAGE, ProtocolError, Reader, bytes, g2, scalar, tagged, type Bytes, type G2 } from './protocol.ts'; import { - COHORT, DEFAULT_ERROR, KIND, TAG, authorizeCeremony, checkTranscriptSize, classify, evaluate, fail, listCount, + COHORT, DEFAULT_ERROR, KIND, TAG, authorizeCeremony, checkGenesisShape, checkTranscriptSize, classify, evaluate, fail, listCount, memberIndex, mul, verifyChain, type Anchor, type Authenticated, type Ceremony, type Classified, type RoundRecord, type Statement, type Verifier, } from './ceremony.ts'; @@ -100,7 +100,7 @@ export function authorizeGenesis(anchor: Anchor, statement: Statement, verify: V const c = authorizeCeremony(anchor, statement, verify); if (c.kind !== KIND.genesis) fail('InvalidCeremony'); if (c.current || c.rekey !== 0n || c.epoch !== 0n) fail(DEFAULT_ERROR.genesisFields); - if (c.n < 2 * c.t - 1) fail('UnsupportedFaultBudget'); + checkGenesisShape(c.t, c.n); return c; } From 0e7ae8467eade825f5fc7676f0f237deea737147 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:26:41 +0000 Subject: [PATCH 07/10] test(threshold): check 3-of-4 genesis, refused shapes and the 3-of-4 DKG vector in the checker; Details: TC-2223 --- tinycloud-threshold/vectors/check/dkg.test.ts | 12 +++++++++++- tinycloud-threshold/vectors/check/s02.test.ts | 7 +++++-- .../vectors/check/transcript-vectors.test.ts | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tinycloud-threshold/vectors/check/dkg.test.ts b/tinycloud-threshold/vectors/check/dkg.test.ts index bc050424..37faa281 100644 --- a/tinycloud-threshold/vectors/check/dkg.test.ts +++ b/tinycloud-threshold/vectors/check/dkg.test.ts @@ -47,6 +47,12 @@ describe('honest genesis', () => { expect(out.reconstructed).toEqual([]); expect(out.transcriptHash).toEqual(seal(s.digest, s.start, s.rounds).hash); }); + test('3-of-4 output matches scalar-domain expectation', () => { + const s = dkgSetup('honest-3of4', 3, 4), out = run(s); + expectPublic(out, s, [1, 2, 3, 4]); + expect(out.disqualified).toEqual([]); + expect(out.reconstructed).toEqual([]); + }); test('byte-identical retransmissions and a second valid signature on one envelope are one message', () => { const s = dkgSetup('retransmit', 2, 3); const twice = s.rounds.map((round, k) => k === 1 ? [...round, round[1]!, { ...round[1]!, signature: (m: Bytes, signer: string) => alternateSignature(signer, m) }] : round); @@ -235,7 +241,11 @@ describe('ceremony, round structure and limits', () => { expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, epoch: 1n }), [])).toBe('InvalidCeremony'); expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, rekey: 1n }), [])).toBe('InvalidCeremony'); expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, current: new Uint8Array(32) }), [])).toBe('InvalidCeremony'); - expect(verifyWith(signedCeremony({ kind: 1, t: 3, n: 4 }), [])).toBe('UnsupportedFaultBudget'); + expect(verifyWith(signedCeremony({ kind: 1, t: 4, n: 5 }), [])).toBe('UnsupportedFaultBudget'); + expect(verifyWith(signedCeremony({ kind: 1, t: 3, n: 3 }), [])).toBe('UnsupportedFaultBudget'); + // 3-of-4 passes the budget and is refused only for its missing rounds, like 2-of-3. + expect(verifyWith(signedCeremony({ kind: 1, t: 3, n: 4 }), [])).toBe(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3 }), [])); + expect(verifyWith(signedCeremony({ kind: 1, t: 3, n: 4 }), [])).not.toBe('UnsupportedFaultBudget'); expect(verifyWith({ ...s.ceremony, signature: flip(s.ceremony.signature, 0) }, [])).toBe('InvalidSignature'); expect(verifyWith({ ...s.ceremony, body: concat(s.ceremony.body, be(0n, 1)) }, [])).toBe('InvalidEncoding'); expect(verifyWith(signedCeremony({ kind: 1, t: 2, n: 3, directory: [{ index: 1, did: did(1) }, { index: 2, did: did(1) }, { index: 3, did: did(3) }] }), [])).toBe('InvalidEncoding'); diff --git a/tinycloud-threshold/vectors/check/s02.test.ts b/tinycloud-threshold/vectors/check/s02.test.ts index 5f8bd9e5..23c165b7 100644 --- a/tinycloud-threshold/vectors/check/s02.test.ts +++ b/tinycloud-threshold/vectors/check/s02.test.ts @@ -95,7 +95,9 @@ describe('§3.9 decoded-certificate rules on synthetic certificates', () => { test('owner rulings: genesis genesis_t and budget; successor epoch and counter; re-key mpk and counter', () => { const previous = new Uint8Array(32).fill(1), rotated = times(G2_BASE, 5n); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 3 }))).toBe('InvalidEncoding'); - expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n] }))).toBe('UnsupportedFaultBudget'); + expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n] }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 3, n: 3, coefficients: [11n, 13n, 17n] }))).toBe('UnsupportedFaultBudget'); + expect(verifyCert(signedCertificate({ t: 4, n: 5, coefficients: [11n, 13n, 17n, 19n] }))).toBe('UnsupportedFaultBudget'); expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n], epoch: 1n, previous }))).toBe('ok'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 0n, previous }))).toBe('InvalidEncoding'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 2n, previous }))).toBe('InvalidEncoding'); @@ -104,7 +106,8 @@ describe('§3.9 decoded-certificate rules on synthetic certificates', () => { expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 0n, previous, previousMpk: rotated }))).toBe('InvalidEncoding'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('ok'); expect(verifyCert(signedCertificate({ t: 2, n: 3, coefficients, genesisT: 3, epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('InvalidEncoding'); - expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n], epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('UnsupportedFaultBudget'); + expect(verifyCert(signedCertificate({ t: 3, n: 4, coefficients: [11n, 13n, 17n], epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('ok'); + expect(verifyCert(signedCertificate({ t: 3, n: 3, coefficients: [11n, 13n, 17n], epoch: 1n, rekey: 1n, previous, previousMpk: rotated }))).toBe('UnsupportedFaultBudget'); }); test('anchor, signature cap and signer', () => { const good = signedCertificate({ t: 2, n: 3, coefficients }); diff --git a/tinycloud-threshold/vectors/check/transcript-vectors.test.ts b/tinycloud-threshold/vectors/check/transcript-vectors.test.ts index 5ecdd85c..b21c3352 100644 --- a/tinycloud-threshold/vectors/check/transcript-vectors.test.ts +++ b/tinycloud-threshold/vectors/check/transcript-vectors.test.ts @@ -23,6 +23,7 @@ test('committed transcript documents compute every named case', () => { expect(checkTranscripts(dkg).passed).toEqual([ 'genesis_2of3', 'genesis_2of3_selective_extraction', 'genesis_3of5_two_faulty', 'genesis_3of5_answers_and_disqualification', 'insufficient_reconstruction_values', 'insufficient_qualified_dealers', + 'genesis_3of4_one_faulty', ]); expect(checkTranscripts(reshare).passed).toEqual([ 'reshare_2of3_to_3of4', 'refresh_2of3_answered_complaint', 'reshare_wrong_constant', 'reshare_t_prime_complaints', 'reshare_insufficient_dealers', @@ -73,7 +74,7 @@ test('real CLI checks all committed documents by default and a transcript docume const all = run(); expect(all.exitCode).toBe(0); expect(all.stdout.toString()).toContain('Primitive vectors: 18/18'); - expect(all.stdout.toString()).toContain('DKG transcripts: 6/6'); + expect(all.stdout.toString()).toContain('DKG transcripts: 7/7'); expect(all.stdout.toString()).toContain('Reshare transcripts: 5/5'); const one = run(new URL('../v1/reshare.json', import.meta.url).pathname); expect(one.exitCode).toBe(0); From 6aedd42f561a9caf9a6f82dc0fc5a5248271e03a Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:26:41 +0000 Subject: [PATCH 08/10] docs(threshold): state the 3-of-4 genesis shape in the certificate rules; Details: TC-2223 --- tinycloud-threshold/SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 8f0651a6..031053fb 100644 --- a/tinycloud-threshold/SPEC.md +++ b/tinycloud-threshold/SPEC.md @@ -170,7 +170,7 @@ Preconditions: an `AuthorizedCeremony` of kind `genesis` (§3.8 step 0 applies t **`verify_certificate(anchor, signed, verifier)` → `VerifiedCertificate`:** - owner DID = `anchor.expected_owner` and network = `anchor.network` (an otherwise valid self-signed certificate naming another owner is rejected); - the owner signature verifies through the injected verifier (principle 2's allow-list lives behind it, in tinycloud-auth); -- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0, genesis t = t and n ≥ 2t−1 (§3.0 genesis budget); reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk, previous mpk ≠ mpk, re-key counter ≥ 1, genesis t = t and n ≥ 2t−1 (§3.0 genesis budget); every successor (previous digest present) ⇒ epoch ≥ 1 and re-key counter ≤ epoch. +- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0, genesis t = t and a §3.0 genesis shape (n ≥ 2t−1, or exactly 3-of-4); reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk, previous mpk ≠ mpk, re-key counter ≥ 1, genesis t = t and a §3.0 genesis shape; every successor (previous digest present) ⇒ epoch ≥ 1 and re-key counter ≤ epoch. **`verify_successor(pinned: &VerifiedCertificate, next, verifier)`:** same owner and network (re-checked against the anchor); next.previous digest = pinned digest; epoch = pinned + 1 (checked); and either (reshare/refresh) the same mpk, re-key counter and genesis t, or (re-key) re-key counter + 1, previous mpk = pinned mpk, new mpk, genesis t = new t. Rollback and alternate lineages are rejected relative to the pinned certificate; which certificate is pinned as current comes from the trusted log head (gate B), not from the crate. @@ -323,7 +323,7 @@ The canonical certificate body is: The certificate digest is `TH(NETWORK_CERT, body)`. The owner signs `TH(CERT_STATEMENT, certificate_digest:32)`. -Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields, zero counters, `genesis_t = t` and `n >= 2t-1`. Any certificate with a previous digest has `epoch >= 1` and `rekey <= epoch`. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk` (previous `mpk` ≠ `mpk`), names the previous `mpk`, has `rekey >= 1`, increments `rekey`, sets `genesis_t` to the new `t` (`genesis_t = t`) and satisfies the genesis budget `n >= 2t-1`. +Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields, zero counters, `genesis_t = t` and a §3.0 genesis shape (`n >= 2t-1`, or exactly `t = 3, n = 4`). Any certificate with a previous digest has `epoch >= 1` and `rekey <= epoch`. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk` (previous `mpk` ≠ `mpk`), names the previous `mpk`, has `rekey >= 1`, increments `rekey`, sets `genesis_t` to the new `t` (`genesis_t = t`) and satisfies the same genesis shape rule. Verification uses the caller's expected owner/network. The caller's authorization-log integration selects the current pinned certificate. Candidate binding checks transcript and member index, then `scalar*g2` against the certificate's evaluated public share. From aa8ebc8a2fb1642e3358ffdb37b9052d1d4a0764 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:00:52 +0000 Subject: [PATCH 09/10] fix(threshold): apply the 3-of-4 genesis shape in the certificate-layer checker; Details: TC-2223 The certificate-layer decoder and its genesis and re-key certificate and ceremony checks now share isGenesisShape with the transcript layer (n >= 2t-1 or exactly 3-of-4). Cross-family tests accept the same 3-of-4 genesis and re-key certificates and genesis ceremony in both layers and refuse 3-of-3 and 4-of-5 in both. --- tinycloud-threshold/vectors/check/ceremony.ts | 5 +- .../vectors/check/certificates.ts | 9 ++-- .../vectors/check/genesis-shape.test.ts | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/genesis-shape.test.ts diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts index 0c710401..9ec32e41 100644 --- a/tinycloud-threshold/vectors/check/ceremony.ts +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -97,8 +97,11 @@ function checkDirectory(t: number, n: number, entries: { index: number; did: str if (new Set(entries.map(e => e.did)).size !== n) fail('InvalidEncoding'); } /** §3.0 genesis budget: n >= 2t-1 (t-1 faulty members) or exactly 3-of-4 (one faulty member). */ +export function isGenesisShape(t: number, n: number): boolean { + return n >= 2 * t - 1 || (t === 3 && n === 4); +} export function checkGenesisShape(t: number, n: number) { - if (n < 2 * t - 1 && !(t === 3 && n === 4)) fail('UnsupportedFaultBudget'); + if (!isGenesisShape(t, n)) fail('UnsupportedFaultBudget'); } export const KIND = { genesis: 1, reshare: 2, refresh: 3, rekey: 4 } as const; diff --git a/tinycloud-threshold/vectors/check/certificates.ts b/tinycloud-threshold/vectors/check/certificates.ts index 1d261264..d27a8278 100644 --- a/tinycloud-threshold/vectors/check/certificates.ts +++ b/tinycloud-threshold/vectors/check/certificates.ts @@ -1,6 +1,7 @@ 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'; +import { isGenesisShape } from './ceremony.ts'; export const CERT_TAGS = { activation: 'TC-THRESHOLD-V1-ACTIVATE', ceremony: 'TC-THRESHOLD-V1-CEREMONY-AUTH', @@ -95,7 +96,7 @@ export function decodeCertificate(encoded: Bytes): CertificateBody { 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'); + if (!equal(mpk, commitments[0]!) || (!c.previous && (c.previousMpk || epoch !== 0n || rekey !== 0n || genesisThreshold !== threshold || !isGenesisShape(threshold, n)))) throw new ProtocolError('InvalidEncoding'); publicShares(c); if (!equal(encodeCertificate(c), encoded)) throw new ProtocolError('InvalidEncoding'); return c; @@ -115,7 +116,7 @@ export function verifyCertificate(encoded: Bytes, signature: Bytes, anchor: Anch 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(); + if (!equal(c.previousMpk, p.mpk) || equal(c.mpk, p.mpk) || p.rekey === MAX_U64 || c.rekey !== p.rekey + 1n || c.genesisThreshold !== c.threshold || !isGenesisShape(c.threshold, c.members.length)) 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 } } }; @@ -137,11 +138,11 @@ export function authorizeCeremony(encoded: Bytes, signature: Bytes, anchor: Anch 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(); + if (previous || c.current || c.epoch !== 0n || c.rekey !== 0n || !isGenesisShape(c.threshold, c.recipients.length)) 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(); + if (previous!.body.rekey === MAX_U64 || c.rekey !== previous!.body.rekey + 1n || !isGenesisShape(c.threshold, c.recipients.length)) invalid(); } else if (c.rekey !== previous!.body.rekey) invalid(); } const digest = tagged(CERT_TAGS.ceremony, encoded); diff --git a/tinycloud-threshold/vectors/check/genesis-shape.test.ts b/tinycloud-threshold/vectors/check/genesis-shape.test.ts new file mode 100644 index 00000000..8b96738c --- /dev/null +++ b/tinycloud-threshold/vectors/check/genesis-shape.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { verifyCertificate } from './ceremony.ts'; +import { authorizeGenesis } from './dkg.ts'; +import { authorizeCeremony as legacyAuthorize, signingKeys, verifyCertificate as legacyVerify, type SigningInput, type VerifiedCertificate } from './certificates.ts'; +import { ANCHOR, G2_BASE, code, signedCeremony, signedCertificate, times, verify } from './testkit.ts'; + +// Unchecked: committed vector document; signingKeys() validates every seed against its public key. +const certCore = JSON.parse(readFileSync(new URL('../v1/cert-core.json', import.meta.url), 'utf8')) as { seeds: { ed25519: SigningInput[] } }; +const keys = signingKeys(certCore.seeds.ed25519); +const three = [11n, 13n, 17n]; + +/** The same signed certificate through both checker families: [transcript layer, certificate layer]. */ +function both(spec: Parameters[0], previous?: VerifiedCertificate): [string, string] { + const s = signedCertificate(spec); + return [code(() => verifyCertificate(ANCHOR, s, verify)), code(() => legacyVerify(s.body, s.signature, ANCHOR, keys, previous))]; +} + +describe('§3.0 genesis shape agrees across both checker families', () => { + const genesis = signedCertificate({ t: 2, n: 3, coefficients: [11n, 13n] }); + const pinned = legacyVerify(genesis.body, genesis.signature, ANCHOR, keys); + const rekey = (t: number, n: number, coefficients: bigint[]) => + both({ t, n, coefficients, epoch: 1n, rekey: 1n, previous: pinned.digest, previousMpk: times(G2_BASE, 11n) }, pinned); + + test('3-of-4 genesis and re-key certificates are accepted by both', () => { + expect(both({ t: 3, n: 4, coefficients: three })).toEqual(['ok', 'ok']); + expect(rekey(3, 4, [5n, 7n, 9n])).toEqual(['ok', 'ok']); + }); + test('3-of-3 and 4-of-5 genesis and re-key certificates are refused by both', () => { + expect(both({ t: 3, n: 3, coefficients: three })).toEqual(['UnsupportedFaultBudget', 'InvalidEncoding']); + expect(both({ t: 4, n: 5, coefficients: [...three, 19n] })).toEqual(['UnsupportedFaultBudget', 'InvalidEncoding']); + expect(rekey(3, 3, [5n, 7n, 9n])).toEqual(['UnsupportedFaultBudget', 'InvalidCertificate']); + expect(rekey(4, 5, [5n, 7n, 9n, 19n])).toEqual(['UnsupportedFaultBudget', 'InvalidCertificate']); + }); + test('genesis ceremonies: 3-of-4 accepted, 3-of-3 and 4-of-5 refused, by both', () => { + const ceremony = (t: number, n: number) => { + const s = signedCeremony({ kind: 1, t, n }); + return [code(() => authorizeGenesis(ANCHOR, s, verify)), code(() => legacyAuthorize(s.body, s.signature, ANCHOR, keys))]; + }; + expect(ceremony(3, 4)).toEqual(['ok', 'ok']); + expect(ceremony(3, 3)).toEqual(['UnsupportedFaultBudget', 'InvalidCeremony']); + expect(ceremony(4, 5)).toEqual(['UnsupportedFaultBudget', 'InvalidCeremony']); + }); + test('certificate-layer re-key ceremonies: 3-of-4 accepted, 3-of-3 refused', () => { + const rekeyCeremony = (t: number, n: number) => { + const s = signedCeremony({ kind: 4, t, n, current: pinned.digest, epoch: 1n, rekey: 1n }); + return code(() => legacyAuthorize(s.body, s.signature, ANCHOR, keys, pinned)); + }; + expect(rekeyCeremony(3, 4)).toBe('ok'); + expect(rekeyCeremony(3, 3)).toBe('InvalidCeremony'); + }); +}); From 8a21c36b4ee78d60e0a6fb82bae9fa46759c7137 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:04:51 +0000 Subject: [PATCH 10/10] fix(threshold): authenticate and ignore opposite-cohort envelopes in certificate-layer closed rounds; Details: TC-2223 Reshare/refresh closed rounds in certificates.ts now resolve and verify an envelope in its signed cohort, then drop it from the round's role messages when it is outside the round's role (R1/R3 old dealers, R2 new recipients), as SPEC S0.4, the crate and reshare.ts do. Unresolvable or badly signed envelopes are still refused; genesis/re-key still accept recipients only. Cross-checker tests use one reshare transcript with an extra recipient-cohort R1 envelope (accepted by both), an index outside the directory and a wrong-key signature (refused by both). --- .../vectors/check/certificates.ts | 11 ++++--- .../check/closed-round-cohorts.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/closed-round-cohorts.test.ts diff --git a/tinycloud-threshold/vectors/check/certificates.ts b/tinycloud-threshold/vectors/check/certificates.ts index d27a8278..1c729150 100644 --- a/tinycloud-threshold/vectors/check/certificates.ts +++ b/tinycloud-threshold/vectors/check/certificates.ts @@ -154,19 +154,22 @@ export function verifyClosedRound(ceremony: VerifiedCeremony, closure: Bytes, ow 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 => { + // S0.4: reshare/refresh rounds authenticate both signed cohorts and ignore the one outside the round's role + // (R1/R3 old dealers, R2 new recipients). Genesis/re-key sign only as recipients. + const successor = ceremony.body.kind === 2 || ceremony.body.kind === 3; + const role = !successor ? 1 : round === 2 ? 1 : round === 1 || round === 3 ? 2 : undefined; + const authenticated = messages.flatMap(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'); + if (!successor && 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 }; + return role === undefined || cohort === role ? [{ ...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'); diff --git a/tinycloud-threshold/vectors/check/closed-round-cohorts.test.ts b/tinycloud-threshold/vectors/check/closed-round-cohorts.test.ts new file mode 100644 index 00000000..96a68486 --- /dev/null +++ b/tinycloud-threshold/vectors/check/closed-round-cohorts.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { reshareVerifyTranscript } from './reshare.ts'; +import { authorizeCeremony, signingKeys, verifyCertificate, verifyClosedRound, type SigningInput } from './certificates.ts'; +import { ANCHOR, COHORT_RECIPIENTS, code, complaints, did, reshareSetup, seal, verify, type Draft } from './testkit.ts'; + +// Unchecked: committed vector document; signingKeys() validates every seed against its public key. +const certCore = JSON.parse(readFileSync(new URL('../v1/cert-core.json', import.meta.url), 'utf8')) as { seeds: { ed25519: SigningInput[] } }; +const keys = signingKeys(certCore.seeds.ed25519); +const s = reshareSetup('closed-round-cohorts', 2, 3, 3, 4); +const pinned = verifyCertificate(s.certificate.body, s.certificate.signature, ANCHOR, keys); +const ceremony = authorizeCeremony(s.ceremony.body, s.ceremony.signature, ANCHOR, keys, pinned); + +/** The same 2-of-3 → 3-of-4 reshare with `extra` appended to closed R1: [transcript layer, certificate layer]. */ +function both(extra: Draft): [string, string] { + const rounds = s.rounds.map((round, k) => k === 0 ? [...round, extra] : round); + const { records } = seal(s.digest, s.start, rounds); + const legacy = () => records.reduce((previous, r, k) => + verifyClosedRound(ceremony, r.closure.body, r.closure.signature, k + 1, previous, r.messages, keys).transcript, s.start); + return [code(() => reshareVerifyTranscript(ANCHOR, s.ceremony, s.certificate, records, verify)), code(legacy)]; +} + +describe('S0.4 opposite-cohort envelopes agree across both checker families', () => { + test('an authorized, signed recipient-cohort R1 envelope is authenticated and ignored by both', () => { + expect(both({ cohort: COHORT_RECIPIENTS, index: 4, payload: complaints([]) })).toEqual(['ok', 'ok']); + }); + test('an extra envelope from outside the recipient directory is refused by both', () => { + expect(both({ cohort: COHORT_RECIPIENTS, index: 5, payload: complaints([]) })).toEqual(['InvalidIndex', 'InvalidCeremony']); + }); + test('an authorized index signed by another key is refused by both', () => { + expect(both({ cohort: COHORT_RECIPIENTS, index: 4, signer: did(5), payload: complaints([]) })).toEqual(['InvalidSignature', 'InvalidSignature']); + }); +});