diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 86604ede..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; 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 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. @@ -246,6 +246,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. @@ -319,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 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 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. @@ -405,7 +409,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 @@ -456,7 +460,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 1b7ec3d0..ea5668bc 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -1,23 +1,73 @@ -# 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; 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. +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`. + +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 +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 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, 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. + ## Computations - H1 and Pedersen h use the specified RFC 9380 custom DSTs. @@ -71,9 +121,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,7 +137,7 @@ 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 ## Certificate-core checker portion diff --git a/tinycloud-threshold/vectors/check/ceremony.ts b/tinycloud-threshold/vectors/check/ceremony.ts new file mode 100644 index 00000000..9ec32e41 --- /dev/null +++ b/tinycloud-threshold/vectors/check/ceremony.ts @@ -0,0 +1,278 @@ +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 and round transitions, + * 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. */ + signature: 'InvalidSignature', + /** 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', + /** Current certificate owner/network differs from the anchor. */ + 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_t = t. + */ + certificateFields: 'InvalidEncoding', + /** 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'); +} +/** §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 (!isGenesisShape(t, n)) fail('UnsupportedFaultBudget'); +} + +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(DEFAULT_ERROR.cap); + 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 (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(DEFAULT_ERROR.certificateFields); + if (commitments[t - 1]!.equals(G2_ZERO)) fail('IdentityPoint'); + const previousMpk = previousMpkBytes ? g2(previousMpkBytes) : undefined; + // §3.9 field combinations. + if (!previous) { + if (previousMpk || epoch !== 0n || rekey !== 0n || genesisT !== t) fail(DEFAULT_ERROR.certificateFields); + checkGenesisShape(t, n); + } else { + if (epoch < 1n || rekey > epoch) fail(DEFAULT_ERROR.certificateFields); + if (previousMpk) { + if (previousMpk.equals(mpk) || rekey < 1n || genesisT !== t) fail(DEFAULT_ERROR.certificateFields); + checkGenesisShape(t, n); + } + } + 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(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'); + 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 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(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); // the whole-envelope cap in Reader binds first + r.end(); + 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); + 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/certificates.ts b/tinycloud-threshold/vectors/check/certificates.ts index 1d261264..1c729150 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); @@ -153,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/cli.ts b/tinycloud-threshold/vectors/check/cli.ts index c8667ab7..7de69636 100644 --- a/tinycloud-threshold/vectors/check/cli.ts +++ b/tinycloud-threshold/vectors/check/cli.ts @@ -1,22 +1,32 @@ import { readFileSync } from 'node:fs'; import { checkVectors } from './vectors.ts'; import { checkCertificateVectors } from './cert-vectors.ts'; +import { checkTranscripts, transcriptKind } from './transcripts.ts'; + +const defaults = ['../v1/primitives.json', '../v1/cert-core.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; + } + if (input && typeof input === 'object' && 'suite' in input && input.suite === 'TC-THRESHOLD-V1') { + const report = checkCertificateVectors(input); + for (const name of report.passed) console.log(`PASS ${name}`); + console.log(`Certificate vectors: ${report.passed.length}/${report.passed.length}; signing keys: ${report.signingKeys}`); + 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] ? [process.argv[2]] : [new URL('../v1/primitives.json', import.meta.url), new URL('../v1/cert-core.json', import.meta.url)]; - for (const path of paths) { - const input: unknown = JSON.parse(readFileSync(path, 'utf8')); - if (input && typeof input === 'object' && 'suite' in input && input.suite === 'TC-THRESHOLD-V1') { - const report = checkCertificateVectors(input); - for (const name of report.passed) console.log(`PASS ${name}`); - console.log(`Certificate vectors: ${report.passed.length}/${report.passed.length}; signing keys: ${report.signingKeys}`); - } else { - const report = checkVectors(input); - for (const name of report.passed) console.log(`PASS ${name}`); - console.log(`Primitive vectors: ${report.passed.length}/${report.passed.length}; combinations: ${report.combinations.map(m => m.join('+')).join(', ')}`); - } - } + 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/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']); + }); +}); diff --git a/tinycloud-threshold/vectors/check/dkg.test.ts b/tinycloud-threshold/vectors/check/dkg.test.ts new file mode 100644 index 00000000..37faa281 --- /dev/null +++ b/tinycloud-threshold/vectors/check/dkg.test.ts @@ -0,0 +1,299 @@ +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 { ed25519Verifier, type RoundRecord, type Statement } from './ceremony.ts'; +import { + 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; } +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('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); + 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'); + }); + 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' }]); + 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', () => { + 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 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 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'); + } + }); + 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: 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: 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'); + 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); + 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'); + 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) }] }); + 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/dkg.ts b/tinycloud-threshold/vectors/check/dkg.ts new file mode 100644 index 00000000..f272d850 --- /dev/null +++ b/tinycloud-threshold/vectors/check/dkg.ts @@ -0,0 +1,191 @@ +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, 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'; + +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); 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(); + 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) fail('InvalidCeremony'); + if (c.current || c.rekey !== 0n || c.epoch !== 0n) fail(DEFAULT_ERROR.genesisFields); + checkGenesisShape(c.t, c.n); + 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/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'); + }); +}); 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/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts index 5b796bd6..ec643689 100644 --- a/tinycloud-threshold/vectors/check/protocol.ts +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -36,7 +36,7 @@ export function be(value: bigint, length: number): Bytes { } function integer(b: Bytes): bigint { return b.length ? BigInt(`0x${hex(b)}`) : 0n; } export function lp(b: Bytes): Bytes { return concat(be(BigInt(b.length), 4), b); } -function equal(a: Bytes, b: Bytes): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } +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)); } diff --git a/tinycloud-threshold/vectors/check/reshare.test.ts b/tinycloud-threshold/vectors/check/reshare.test.ts new file mode 100644 index 00000000..5034c97f --- /dev/null +++ b/tinycloud-threshold/vectors/check/reshare.test.ts @@ -0,0 +1,173 @@ +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('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' }]); + }); + 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'); + 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'); + // 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'); + 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/reshare.ts b/tinycloud-threshold/vectors/check/reshare.ts new file mode 100644 index 00000000..8a21d3c3 --- /dev/null +++ b/tinycloud-threshold/vectors/check/reshare.ts @@ -0,0 +1,101 @@ +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); + // 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 }; +} + +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/s02.test.ts b/tinycloud-threshold/vectors/check/s02.test.ts new file mode 100644 index 00000000..23c165b7 --- /dev/null +++ b/tinycloud-threshold/vectors/check/s02.test.ts @@ -0,0 +1,118 @@ +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'); + 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('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'); + 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'); + 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('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 }); + 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 new file mode 100644 index 00000000..ac9b9d59 --- /dev/null +++ b/tinycloud-threshold/vectors/check/testkit.ts @@ -0,0 +1,237 @@ +// 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[]; mpk?: G2; 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))))), + (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), + ); +} +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, 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, 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))); +} +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..6442e7ed --- /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}: 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[] = []; + 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..b21c3352 --- /dev/null +++ b/tinycloud-threshold/vectors/check/transcript-vectors.test.ts @@ -0,0 +1,82 @@ +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', + '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', + ]); +}, 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: 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); + expect(one.stdout.toString()).not.toContain('Primitive vectors'); +}, 60000); 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'); +}