From b58a44086e3eac7a9ec00bfea713e74ceea8b67f Mon Sep 17 00:00:00 2001 From: dryrun Date: Thu, 29 Oct 2026 16:15:25 +0000 Subject: [PATCH 1/3] feat(threshold): compute independent primitive vectors; Details: TC-2219 --- tinycloud-threshold/vectors/check/.gitignore | 2 + tinycloud-threshold/vectors/check/README.md | 81 ++++++ tinycloud-threshold/vectors/check/bun.lock | 35 +++ tinycloud-threshold/vectors/check/bunfig.toml | 2 + tinycloud-threshold/vectors/check/cli.ts | 14 + tinycloud-threshold/vectors/check/isolate.ts | 19 ++ .../vectors/check/package.json | 17 ++ .../vectors/check/protocol.test.ts | 148 +++++++++++ tinycloud-threshold/vectors/check/protocol.ts | 243 ++++++++++++++++++ tinycloud-threshold/vectors/check/schema.ts | 88 +++++++ .../vectors/check/tsconfig.json | 14 + .../vectors/check/vectors.test.ts | 79 ++++++ tinycloud-threshold/vectors/check/vectors.ts | 145 +++++++++++ 13 files changed, 887 insertions(+) create mode 100644 tinycloud-threshold/vectors/check/.gitignore create mode 100644 tinycloud-threshold/vectors/check/README.md create mode 100644 tinycloud-threshold/vectors/check/bun.lock create mode 100644 tinycloud-threshold/vectors/check/bunfig.toml create mode 100644 tinycloud-threshold/vectors/check/cli.ts create mode 100644 tinycloud-threshold/vectors/check/isolate.ts create mode 100644 tinycloud-threshold/vectors/check/package.json create mode 100644 tinycloud-threshold/vectors/check/protocol.test.ts create mode 100644 tinycloud-threshold/vectors/check/protocol.ts create mode 100644 tinycloud-threshold/vectors/check/schema.ts create mode 100644 tinycloud-threshold/vectors/check/tsconfig.json create mode 100644 tinycloud-threshold/vectors/check/vectors.test.ts create mode 100644 tinycloud-threshold/vectors/check/vectors.ts diff --git a/tinycloud-threshold/vectors/check/.gitignore b/tinycloud-threshold/vectors/check/.gitignore new file mode 100644 index 00000000..b7b50247 --- /dev/null +++ b/tinycloud-threshold/vectors/check/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +!bun.lock diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md new file mode 100644 index 00000000..f8d6cde5 --- /dev/null +++ b/tinycloud-threshold/vectors/check/README.md @@ -0,0 +1,81 @@ +# Primitive vector 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. + +```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: + +```sh +bun run vectors /absolute/path/to/primitives.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 +CLI module, not the calling directory. + +## Computations + +- H1 and Pedersen h use the specified RFC 9380 custom DSTs. +- SHA-256 XMD computes H2/H4 and H3 with modulus r, count 1, and L=48. +- Both reduced pairing values use twelve 48-byte big-endian coefficients in + SPEC §3.2 tower order. +- FullIdent recomputes the ciphertext from explicit sigma, derives the key from + the public master scalar, and unwraps the encoded tuple. +- Context, identity, request and share layouts are decoded and re-encoded. + Shares carry both digests in the amended 259-byte layout. Signing preimages + include the matching request digest explicitly. +- Each share is checked against its scalar/public share and both pairing + equations. All three 2-of-3 subsets derive the master-derived key and unwrap + the FullIdent tuple. The recorded aggregate uses the two lowest indices. +- Negative records execute their decoder, verifier or unwrap operation and + compare the computed typed error, including member attribution. + +The share randomizers are not explicit fixture inputs. The checker treats +c1/c2 as public group inputs, verifies their common-randomizer equation, and +recomputes c3 from the scalar share and transport scalar. It does not recreate +the producer RNG from the seed metadata. + +`schema.ts` implements the supplied JSON-only handoff shape, including the +current named record set, field widths, literal metadata and additional-field +rules. That shape is a vector-format contract, not a general test-count policy. +Expected cryptographic output bytes are not part of the schema. + +`protocol.test.ts` uses independently chosen identities, scalar shares, +transport keys and randomizers. It covers point/scalar encodings, request +bindings, both share equations, duplicate members, binding precedence, +zero-randomness aggregates, and FullIdent tuple/identity errors. +`vectors.test.ts` perturbs expected bytes, replaces negative inputs with valid +ones, and runs the real CLI against committed and throwaway JSON inputs. +Tests create private tool homes using `mktemp -d -p /var/tmp`, record the exact +created path, set the homes before writing, and remove that exact directory. + +## Provenance + +This checker was written in a separate implementation session from SPEC, +public JSON vectors, and the JSON-only schema/byte-layout handoff. No Rust +source, Rust tests, Rust diffs or Rust implementation traces were opened. +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 | +| [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 | +| npm registry version metadata | Version, integrity and license declarations | + +## Dependencies + +`bun.lock` freezes package versions and integrity digests. Runtime dependencies +are `@noble/curves` 2.4.0 and its `@noble/hashes` 2.4.0 dependency, both MIT. +Development dependencies are TypeScript 5.9.3 (Apache-2.0), `@types/bun` 1.3.1, +`bun-types` 1.3.1, `@types/node` 26.6.6, `undici-types` 8.9.0, +`@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 diff --git a/tinycloud-threshold/vectors/check/bun.lock b/tinycloud-threshold/vectors/check/bun.lock new file mode 100644 index 00000000..9da4d2be --- /dev/null +++ b/tinycloud-threshold/vectors/check/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "tinycloud-threshold-vector-checker", + "dependencies": { + "@noble/curves": "2.4.0", + }, + "devDependencies": { + "@types/bun": "1.3.1", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@noble/curves": ["@noble/curves@2.4.0", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/tinycloud-threshold/vectors/check/bunfig.toml b/tinycloud-threshold/vectors/check/bunfig.toml new file mode 100644 index 00000000..f0307abe --- /dev/null +++ b/tinycloud-threshold/vectors/check/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./isolate.ts"] diff --git a/tinycloud-threshold/vectors/check/cli.ts b/tinycloud-threshold/vectors/check/cli.ts new file mode 100644 index 00000000..acc0cf62 --- /dev/null +++ b/tinycloud-threshold/vectors/check/cli.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { checkVectors } from './vectors.ts'; + +try { + if (process.argv.length > 3) throw new Error('usage: bun cli.ts [primitive-vector-file]'); + const path = process.argv[2] ?? new URL('../v1/primitives.json', import.meta.url); + const input: unknown = JSON.parse(readFileSync(path, 'utf8')); + const report = checkVectors(input); + for (const name of report.passed) console.log(`PASS ${name}`); + console.log(`Primitive vectors: ${report.passed.length}/${report.passed.length}; combinations: ${report.combinations.map(m => m.join('+')).join(', ')}`); +} catch (e) { + console.error(`FAIL ${e instanceof Error ? e.message : String(e)}`); + process.exitCode = 1; +} diff --git a/tinycloud-threshold/vectors/check/isolate.ts b/tinycloud-threshold/vectors/check/isolate.ts new file mode 100644 index 00000000..ff7cd399 --- /dev/null +++ b/tinycloud-threshold/vectors/check/isolate.ts @@ -0,0 +1,19 @@ +import { afterAll } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; + +const created = spawnSync('mktemp', ['-d', '-p', '/var/tmp', 'tc-2219-checker-test.XXXXXXXX'], { encoding: 'utf8' }); +if (created.status !== 0) throw new Error('isolated tool home creation failed'); +const root = created.stdout.trim(); +if (!root.startsWith('/var/tmp/tc-2219-checker-test.')) throw new Error('unexpected tool home'); +const homes: Record = { + HOME: 'home', COURIER_HOME: 'courier', TC_HOME: 'tc', TC_CONFIG_DIR: 'tc-config', + TC_STATE_DIR: 'tc-state', TC_PROFILE_DIR: 'tc-profile', TC_SECRETS_STATE: 'tc-secrets', + BUN_INSTALL_CACHE_DIR: 'bun-cache', XDG_CONFIG_HOME: 'config', XDG_CACHE_HOME: 'cache', + XDG_DATA_HOME: 'data', XDG_STATE_HOME: 'state', +}; +for (const [key, suffix] of Object.entries(homes)) process.env[key] = `${root}/${suffix}`; +if (Object.keys(homes).some(key => !process.env[key]?.startsWith(`${root}/`))) throw new Error('tool homes not isolated'); +writeFileSync(`${root}/created-paths.txt`, `${root}\n`, { mode: 0o600 }); +for (const key of Object.keys(homes)) mkdirSync(process.env[key]!, { mode: 0o700 }); +afterAll(() => rmSync(root, { recursive: true })); diff --git a/tinycloud-threshold/vectors/check/package.json b/tinycloud-threshold/vectors/check/package.json new file mode 100644 index 00000000..22421a9d --- /dev/null +++ b/tinycloud-threshold/vectors/check/package.json @@ -0,0 +1,17 @@ +{ + "name": "tinycloud-threshold-vector-checker", + "private": true, + "type": "module", + "scripts": { + "check": "bun test --timeout 30000 && bun run typecheck && bun run vectors", + "typecheck": "tsc --noEmit", + "vectors": "bun cli.ts" + }, + "dependencies": { + "@noble/curves": "2.4.0" + }, + "devDependencies": { + "@types/bun": "1.3.1", + "typescript": "5.9.3" + } +} diff --git a/tinycloud-threshold/vectors/check/protocol.test.ts b/tinycloud-threshold/vectors/check/protocol.test.ts new file mode 100644 index 00000000..83091bb7 --- /dev/null +++ b/tinycloud-threshold/vectors/check/protocol.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from 'bun:test'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { + R, ProtocolError, bytes, hex, be, concat, scalar, g1, g2, h1, h3, + encodeIdentity, decodeIdentity, encodeContext, decodeContext, contextDigest, + encodeRequest, decodeRequest, requestDigest, encodeShare, decodeShare, + signingPreimage, verifyShare, combine, wrap, unwrap, +} from './protocol.ts'; + +const G1 = bls.G1.Point.BASE; +const G2 = bls.G2.Point.BASE; +const identity = encodeIdentity({ mode: 1, network: 'tinycloud:test:independent', path: 'records/é', version: 9n, nonce: new Uint8Array(16).fill(17) }); +const context = { network: 'tinycloud:test:independent', rekey: 2n, epoch: 7n, certificate: new Uint8Array(32).fill(41), threshold: 2, members: 3 }; +const request = encodeRequest({ ...context, context: contextDigest(context), identity, tpk: G1.multiply(11n).toBytes() }); +const keyset = { context, mpk: G2.multiply(13n), publicShares: new Map([1, 2, 3].map(i => [i, G2.multiply(13n + 5n * BigInt(i))])) }; +function share(member: number, randomness = BigInt(member + 20)) { + return encodeShare({ member, context: contextDigest(context), request: requestDigest(request), c1: G1.multiply(randomness).toBytes(), c2: G2.multiply(randomness).toBytes(), c3: h1(identity).multiply(13n + 5n * BigInt(member)).add(G1.multiply(11n * randomness)).toBytes() }); +} +function expectError(f: () => unknown, code: string, member?: number) { + try { f(); } catch (e) { + expect(e).toBeInstanceOf(ProtocolError); + expect((e as ProtocolError).code).toBe(code); + if (member !== undefined) expect((e as ProtocolError).member).toBe(member); + return; + } + throw new Error(`expected ${code}`); +} +function changed(input: Uint8Array, offset: number, value?: number) { + const result = input.slice(); + result[offset] = value ?? (result[offset]! ^ 1); + return result; +} + +describe('canonical bytes and role decoders', () => { + test('scalar accepts zero and r-1 but not r, overlong or short encodings', () => { + expect(scalar(be(0n, 32))).toBe(0n); + expect(scalar(be(R - 1n, 32))).toBe(R - 1n); + for (const b of [be(R, 32), new Uint8Array(31), new Uint8Array(33)]) expectError(() => scalar(b), 'InvalidScalar'); + }); + test('hex consumes every character', () => { + for (const value of ['0', '0g', 'aa ', '0x00', 'AA']) expectError(() => bytes(value), 'InvalidEncoding'); + }); + test('G1 and G2 reject lengths, flags, noncanonical coordinates and torsion', () => { + for (const [decode, base, size] of [[g1, G1.toBytes(), 48], [g2, G2.toBytes(), 96]] as const) { + expectError(() => decode(base.slice(1)), 'InvalidPoint'); + expectError(() => decode(changed(base, 0, base[0]! & 0x7f)), 'InvalidPoint'); + const noncanonical = new Uint8Array(size).fill(255); noncanonical[0] = 0x9f; + expectError(() => decode(noncanonical), 'InvalidPoint'); + } + const torsion = new Uint8Array(48); torsion[0] = 0x80; + expectError(() => g1(torsion), 'InvalidPoint'); + }); + test('identity is allowed only when the field permits it', () => { + expectError(() => g1(bls.G1.Point.ZERO.toBytes()), 'IdentityPoint'); + expectError(() => g2(bls.G2.Point.ZERO.toBytes()), 'IdentityPoint'); + expect(g1(bls.G1.Point.ZERO.toBytes(), true).equals(bls.G1.Point.ZERO)).toBe(true); + }); + test('identity encoding preserves UTF-8, modes, lengths and split boundaries', () => { + expect(hex(encodeIdentity(decodeIdentity(identity)))).toBe(hex(identity)); + const session = encodeIdentity({ mode: 2, network: 'a', path: '/b', version: 0n }); + expect(decodeIdentity(session).nonce).toBeUndefined(); + expect(hex(session)).not.toBe(hex(encodeIdentity({ mode: 2, network: 'a/', path: 'b', version: 0n }))); + for (const b of [identity.slice(0, -1), concat(identity, be(0n, 1)), changed(identity, 31, 3), changed(identity, 32, 255)]) expectError(() => decodeIdentity(b), 'InvalidEncoding'); + }); + test('context and request round-trip with their full bindings', () => { + expect(hex(encodeContext(decodeContext(encodeContext(context))))).toBe(hex(encodeContext(context))); + expect(hex(encodeRequest(decodeRequest(request)))).toBe(hex(request)); + expectError(() => decodeRequest(concat(request, be(0n, 1))), 'InvalidEncoding'); + const other = encodeIdentity({ ...decodeIdentity(identity), network: 'elsewhere' }); + expectError(() => decodeRequest(encodeRequest({ ...decodeRequest(request), identity: other })), 'ContextMismatch'); + expectError(() => decodeContext(encodeContext({ ...context, threshold: 1 })), 'InvalidParameters'); + }); +}); + +describe('request-bound encrypted shares', () => { + test('both pairing equations identify independently chosen member errors', () => { + const good = share(2); + expect(verifyShare(keyset, request, good)).toBe(2); + const decoded = decodeShare(good); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decoded, c1: G1.multiply(31n).toBytes() })), 'Invalid', 2); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decoded, c2: G2.multiply(31n).toBytes() })), 'Invalid', 2); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decoded, c3: g1(decoded.c3, true).add(G1).toBytes() })), 'Invalid', 2); + }); + test('identity c3 satisfies the second equation when its terms cancel', () => { + const req = encodeRequest({ ...decodeRequest(request), tpk: h1(identity).multiply(R - 18n).toBytes() }); + const encoded = encodeShare({ + member: 1, context: contextDigest(context), request: requestDigest(req), + c1: G1.toBytes(), c2: G2.toBytes(), c3: bls.G1.Point.ZERO.toBytes(), + }); + expect(verifyShare(keyset, req, encoded)).toBe(1); + }); + test('request and share binding precedes algebra and signing', () => { + const good = share(1); + for (const offset of [3, 35]) { + const bad = changed(good, offset); + bad.set(G1.multiply(3n).toBytes(), 67); + expectError(() => verifyShare(keyset, request, bad), 'ContextMismatch'); + } + for (const req of [encodeRequest({ ...decodeRequest(request), tpk: G1.multiply(17n).toBytes() }), encodeRequest({ ...decodeRequest(request), identity: encodeIdentity({ ...decodeIdentity(identity), path: 'other' }) }), encodeRequest({ ...decodeRequest(request), epoch: 8n }), encodeRequest({ ...decodeRequest(request), certificate: new Uint8Array(32) })]) { + expectError(() => verifyShare(keyset, req, good), 'ContextMismatch'); + } + expectError(() => signingPreimage(good, new Uint8Array(32), context.certificate), 'ContextMismatch'); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decodeShare(good), member: 4 })), 'ContextMismatch'); + }); + test('combine checks every binding before verifying even an unselected share', () => { + const invalidEquation = encodeShare({ ...decodeShare(share(1)), c2: G2.multiply(9n).toBytes() }); + const mixed = changed(share(3), 35); + expectError(() => combine(keyset, request, [invalidEquation, share(2), mixed], 11n), 'MixedContexts'); + }); + test('all subsets derive the master key; duplicate indices count once', () => { + const expected = hex(h1(identity).multiply(13n).toBytes()); + for (const pair of [[1, 2], [1, 3], [2, 3]]) expect(hex(combine(keyset, request, pair.map(i => share(i)), 11n).key)).toBe(expected); + expectError(() => combine(keyset, request, [share(1), share(1)], 11n), 'NotEnoughShares'); + expect(combine(keyset, request, [share(3), share(2), share(1), share(2)], 11n).selected).toEqual([1, 2]); + expectError(() => combine(keyset, request, [share(1), share(2)], 12n), 'InvalidDerivedKey'); + }); + test('zero aggregate randomness remains a valid combination', () => { + const result = combine(keyset, request, [share(1, 1n), share(2, 2n)], 11n); + expect(hex(result.aggregate.c1)).toBe(hex(bls.G1.Point.ZERO.toBytes())); + expect(hex(result.key)).toBe(hex(h1(identity).multiply(13n).toBytes())); + }); + test('amended share has 259 bytes and rejects trailing bytes and old layout', () => { + const good = share(1); + expect(good.byteLength).toBe(259); + expect(hex(encodeShare(decodeShare(good)))).toBe(hex(good)); + expectError(() => decodeShare(concat(good, be(0n, 1))), 'InvalidEncoding'); + expectError(() => decodeShare(concat(good.slice(0, 35), good.slice(67))), 'InvalidEncoding'); + }); +}); + +describe('FullIdent from independently chosen inputs', () => { + const sigma = new Uint8Array(32).fill(83); + const message = new Uint8Array(32).fill(149); + const key = h1(identity).multiply(13n).toBytes(); + test('explicit sigma wraps and unwraps; H3 is a scalar-field value', () => { + const ciphertext = wrap(G2.multiply(13n), identity, message, sigma); + expect(hex(unwrap(key, identity, identity, ciphertext))).toBe(hex(message)); + expect(h3(concat(sigma, message))).toBeLessThan(R); + }); + test('U, V and W mutations produce the same ciphertext error', () => { + const ciphertext = wrap(G2.multiply(13n), identity, message, sigma); + const u = ciphertext.slice(); u.set(G2.multiply(41n).toBytes(), 0); + for (const bad of [u, changed(ciphertext, 99), changed(ciphertext, 147), ciphertext.slice(1)]) expectError(() => unwrap(key, identity, identity, bad), 'InvalidCiphertext'); + }); + test('identity association is checked before ciphertext parsing', () => { + expectError(() => unwrap(key, identity, encodeIdentity({ ...decodeIdentity(identity), path: 'other' }), new Uint8Array()), 'WrongIdentity'); + }); +}); diff --git a/tinycloud-threshold/vectors/check/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts new file mode 100644 index 00000000..ca739404 --- /dev/null +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -0,0 +1,243 @@ +import { createHash } from 'node:crypto'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; + +export type Bytes = Uint8Array; +export type G1 = typeof bls.G1.Point.BASE; +export type G2 = typeof bls.G2.Point.BASE; +export type GT = typeof bls.fields.Fp12.ONE; +export const R = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; +export const DST = { + h1: 'TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_', + pedersen_h: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_', + h2: 'TC-THRESHOLD-V1-IBE-H2', h3: 'TC-THRESHOLD-V1-IBE-H3', h4: 'TC-THRESHOLD-V1-IBE-H4', +}; +const PREFIX = 'TC-THRESHOLD-V1-IBE-ID-ENC'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +export class ProtocolError extends Error { + constructor(readonly code: string, readonly member?: number) { super(code); } +} +export function hex(b: Bytes): string { return Buffer.from(b).toString('hex'); } +export function bytes(s: string): Bytes { + if (typeof s !== 'string' || !/^(?:[0-9a-f]{2})*$/.test(s)) throw new ProtocolError('InvalidEncoding'); + return new Uint8Array(Buffer.from(s, 'hex')); +} +export function concat(...parts: Bytes[]): Bytes { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const p of parts) { out.set(p, offset); offset += p.length; } + return out; +} +export function be(value: bigint, length: number): Bytes { + if (value < 0n || value >= 1n << BigInt(8 * length)) throw new ProtocolError('InvalidEncoding'); + return bytes(value.toString(16).padStart(length * 2, '0')); +} +function integer(b: Bytes): bigint { return b.length ? BigInt(`0x${hex(b)}`) : 0n; } +function lp(b: Bytes): Bytes { return concat(be(BigInt(b.length), 4), b); } +function equal(a: Bytes, b: Bytes): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } +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)); } + +export function xmd(message: Bytes, dst: string, length: number): Bytes { + const tag = encoder.encode(dst); + const count = Math.ceil(length / 32); + if (tag.length > 255 || length < 1 || count > 255) throw new ProtocolError('InvalidEncoding'); + const suffix = concat(tag, be(BigInt(tag.length), 1)); + const b0 = sha256(concat(new Uint8Array(64), message, be(BigInt(length), 2), be(0n, 1), suffix)); + let previous = sha256(concat(b0, be(1n, 1), suffix)); + const blocks = [previous]; + for (let i = 2; i <= count; i++) { + previous = sha256(concat(xor(b0, previous), be(BigInt(i), 1), suffix)); + blocks.push(previous); + } + return concat(...blocks).slice(0, length); +} +function xor(a: Bytes, b: Bytes): Bytes { + if (a.length !== b.length) throw new ProtocolError('InvalidEncoding'); + return a.map((v, i) => v ^ b[i]!); +} +export function scalar(b: Bytes): bigint { + if (b.length !== 32 || integer(b) >= R) throw new ProtocolError('InvalidScalar'); + return integer(b); +} +export function g1(b: Bytes, identityAllowed = false): G1 { + let p: G1; + try { + if (b.length !== 48 || !(b[0]! & 0x80)) throw new Error(); + p = bls.G1.Point.fromBytes(b); + p.assertValidity(); + if (!p.isTorsionFree() || !equal(p.toBytes(), b)) throw new Error(); + } catch { throw new ProtocolError('InvalidPoint'); } + if (!identityAllowed && p.equals(bls.G1.Point.ZERO)) throw new ProtocolError('IdentityPoint'); + return p; +} +export function g2(b: Bytes, identityAllowed = false): G2 { + let p: G2; + try { + if (b.length !== 96 || !(b[0]! & 0x80)) throw new Error(); + p = bls.G2.Point.fromBytes(b); + p.assertValidity(); + if (!p.isTorsionFree() || !equal(p.toBytes(), b)) throw new Error(); + } catch { throw new ProtocolError('InvalidPoint'); } + if (!identityAllowed && p.equals(bls.G2.Point.ZERO)) throw new ProtocolError('IdentityPoint'); + return p; +} +export function h1(identity: Bytes): G1 { return bls.G1.hashToCurve(identity, { DST: DST.h1 }); } +export function h3(message: Bytes): bigint { return integer(xmd(message, DST.h3, 48)) % R; } +export function pairing(a: G1, b: G2): GT { + if (a.equals(bls.G1.Point.ZERO) || b.equals(bls.G2.Point.ZERO)) return bls.fields.Fp12.ONE; + return bls.pairing(a, b, true); +} +export function encodeGT(gt: GT): Bytes { + const coefficients: bigint[] = []; + for (const a of [gt.c0, gt.c1]) for (const b of [a.c0, a.c1, a.c2]) coefficients.push(b.c0, b.c1); + return concat(...coefficients.map(c => be(c, 48))); +} + +class Reader { + private offset = 0; + constructor(private readonly input: Bytes) { + if (input.length > 65536) throw new ProtocolError('InvalidEncoding'); + } + take(length: number): Bytes { + if (length < 0 || length > this.input.length - this.offset) throw new ProtocolError('InvalidEncoding'); + const b = this.input.slice(this.offset, this.offset + length); this.offset += length; return b; + } + int(length: number): bigint { return integer(this.take(length)); } + field(cap: number): Bytes { + const length = Number(this.int(4)); + if (length > cap) throw new ProtocolError('InvalidEncoding'); + return this.take(length); + } + text(): string { + const b = this.field(1024); + if (b.length === 0) throw new ProtocolError('InvalidEncoding'); + try { return decoder.decode(b); } catch { throw new ProtocolError('InvalidEncoding'); } + } + version() { if (this.int(1) !== 1n) throw new ProtocolError('InvalidEncoding'); } + end() { if (this.offset !== this.input.length) throw new ProtocolError('InvalidEncoding'); } +} +export interface Identity { mode: number; network: string; path: string; version: bigint; nonce?: Bytes } +export function encodeIdentity(id: Identity): Bytes { + return concat(lp(encoder.encode(PREFIX)), be(1n, 1), be(BigInt(id.mode), 1), lp(encoder.encode(id.network)), lp(encoder.encode(id.path)), be(id.version, 8), id.mode === 1 ? (id.nonce ?? new Uint8Array()) : new Uint8Array()); +} +export function decodeIdentity(b: Bytes): Identity { + const r = new Reader(b); + if (!equal(r.field(26), encoder.encode(PREFIX))) throw new ProtocolError('InvalidEncoding'); + r.version(); + const mode = Number(r.int(1)); + if (mode !== 1 && mode !== 2) throw new ProtocolError('InvalidEncoding'); + const id: Identity = { mode, network: r.text(), path: r.text(), version: r.int(8) }; + if (mode === 1) id.nonce = r.take(16); + r.end(); return id; +} +export interface Context { network: string; rekey: bigint; epoch: bigint; certificate: Bytes; threshold: number; members: number } +export function encodeContext(c: Context): Bytes { + return concat(be(1n, 1), lp(encoder.encode(c.network)), be(c.rekey, 8), be(c.epoch, 8), c.certificate, be(BigInt(c.threshold), 2), be(BigInt(c.members), 2)); +} +export function decodeContext(b: Bytes): Context { + const r = new Reader(b); r.version(); + const c = { network: r.text(), rekey: r.int(8), epoch: r.int(8), certificate: r.take(32), threshold: Number(r.int(2)), members: Number(r.int(2)) }; + r.end(); + if (c.threshold < 2 || c.threshold > c.members || c.members > 16) throw new ProtocolError('InvalidParameters'); + return c; +} +export function contextDigest(c: Context): Bytes { return tagged('TC-THRESHOLD-V1-KEY-CONTEXT', encodeContext(c)); } +export interface Request { context: Bytes; network: string; rekey: bigint; epoch: bigint; certificate: Bytes; identity: Bytes; tpk: Bytes } +export function encodeRequest(r: Request): Bytes { + return concat(be(1n, 1), r.context, lp(encoder.encode(r.network)), be(r.rekey, 8), be(r.epoch, 8), r.certificate, lp(r.identity), r.tpk); +} +export function decodeRequest(b: Bytes): Request { + const r = new Reader(b); r.version(); + const req = { context: r.take(32), network: r.text(), rekey: r.int(8), epoch: r.int(8), certificate: r.take(32), identity: r.field(4096), tpk: r.take(48) }; + r.end(); + if (decodeIdentity(req.identity).network !== req.network) throw new ProtocolError('ContextMismatch'); + g1(req.tpk); return req; +} +export function requestDigest(b: Bytes): Bytes { return tagged('TC-THRESHOLD-V1-DECRYPT-REQUEST', b); } +export interface Share { member: number; context: Bytes; request: Bytes; c1: Bytes; c2: Bytes; c3: Bytes } +export function encodeShare(s: Share): Bytes { + return concat(be(1n, 1), be(BigInt(s.member), 2), s.context, s.request, s.c1, s.c2, s.c3); +} +export function decodeShare(b: Bytes): Share { + const r = new Reader(b); r.version(); + const s = { member: Number(r.int(2)), context: r.take(32), request: r.take(32), c1: r.take(48), c2: r.take(96), c3: r.take(48) }; + r.end(); + if (s.member < 1 || s.member > 16) throw new ProtocolError('InvalidEncoding'); + g1(s.c1); g2(s.c2); g1(s.c3, true); return s; +} +export function signingPreimage(share: Bytes, request: Bytes, certificate: Bytes): Bytes { + const s = decodeShare(share); + if (request.length !== 32 || certificate.length !== 32 || !equal(s.request, request)) throw new ProtocolError('ContextMismatch'); + return tagged('TC-THRESHOLD-V1-DECRYPT-SHARE', concat(request, certificate, be(BigInt(s.member), 2), share)); +} +export interface Keyset { context: Context; mpk: G2; publicShares: Map } +function boundRequest(keys: Keyset, encoded: Bytes): Request { + const req = decodeRequest(encoded), c = keys.context; + if (!equal(req.context, contextDigest(c)) || req.network !== c.network || req.rekey !== c.rekey || req.epoch !== c.epoch || !equal(req.certificate, c.certificate)) throw new ProtocolError('ContextMismatch'); + return req; +} +function boundShare(keys: Keyset, req: Request, digest: Bytes, s: Share): boolean { + return equal(s.context, req.context) && equal(s.request, digest) && s.member <= keys.context.members && keys.publicShares.has(s.member); +} +function equations(keys: Keyset, req: Request, s: Share) { + const c1 = g1(s.c1), c2 = g2(s.c2), c3 = g1(s.c3, true), fp = bls.fields.Fp12; + const first = fp.eql(pairing(c1, bls.G2.Point.BASE), pairing(bls.G1.Point.BASE, c2)); + const second = fp.eql(pairing(c3, bls.G2.Point.BASE), fp.mul(pairing(h1(req.identity), keys.publicShares.get(s.member)!), pairing(g1(req.tpk), c2))); + if (!first || !second) throw new ProtocolError('Invalid', s.member); +} +export function verifyShare(keys: Keyset, request: Bytes, share: Bytes): number { + const req = boundRequest(keys, request), s = decodeShare(share); + if (!boundShare(keys, req, requestDigest(request), s)) throw new ProtocolError('ContextMismatch'); + equations(keys, req, s); return s.member; +} +export function combine(keys: Keyset, request: Bytes, shares: Bytes[], transport: bigint): { key: Bytes; selected: number[]; aggregate: { c1: Bytes; c2: Bytes; c3: Bytes } } { + const req = boundRequest(keys, request), digest = requestDigest(request); + const decoded = shares.map(decodeShare); + for (const s of decoded) if (!boundShare(keys, req, digest, s)) throw new ProtocolError('MixedContexts'); + for (const s of decoded) equations(keys, req, s); + const distinct = new Map(decoded.map(s => [s.member, s])); + if (distinct.size < keys.context.threshold) throw new ProtocolError('NotEnoughShares'); + const selected = [...distinct.keys()].sort((a, b) => a - b).slice(0, keys.context.threshold); + let c1 = bls.G1.Point.ZERO, c2 = bls.G2.Point.ZERO, c3 = bls.G1.Point.ZERO; + for (const i of selected) { + let lambda = 1n; + for (const j of selected) if (i !== j) lambda = bls.fields.Fr.mul(lambda, bls.fields.Fr.div(BigInt(j), bls.fields.Fr.create(BigInt(j - i)))); + const s = distinct.get(i)!; + c1 = c1.add(g1(s.c1).multiply(lambda)); + c2 = c2.add(g2(s.c2).multiply(lambda)); + const p3 = g1(s.c3, true); + c3 = c3.add(p3.equals(bls.G1.Point.ZERO) ? p3 : p3.multiply(lambda)); + } + const k = c3.subtract(c1.equals(bls.G1.Point.ZERO) || transport === 0n ? bls.G1.Point.ZERO : c1.multiply(transport)); + if (k.equals(bls.G1.Point.ZERO) || !bls.fields.Fp12.eql(pairing(k, bls.G2.Point.BASE), pairing(h1(req.identity), keys.mpk))) throw new ProtocolError('InvalidDerivedKey'); + return { key: k.toBytes(), selected, aggregate: { + c1: (c1.equals(bls.G1.Point.ZERO) ? bls.G1.Point.ZERO : c1).toBytes(), + c2: (c2.equals(bls.G2.Point.ZERO) ? bls.G2.Point.ZERO : c2).toBytes(), + c3: (c3.equals(bls.G1.Point.ZERO) ? bls.G1.Point.ZERO : c3).toBytes(), + } }; +} +export function wrap(mpk: G2, identity: Bytes, message: Bytes, sigma: Bytes): Bytes { + decodeIdentity(identity); + if (message.length !== 32 || sigma.length !== 32) throw new ProtocolError('InvalidEncoding'); + const rho = h3(concat(sigma, message)); + if (rho === 0n) throw new ProtocolError('DegenerateRandomness'); + const u = bls.G2.Point.BASE.multiply(rho).toBytes(); + const mask = xmd(encodeGT(pairing(h1(identity).multiply(rho), mpk)), DST.h2, 32); + return concat(u, xor(sigma, mask), xor(message, xmd(sigma, DST.h4, 32))); +} +export function unwrap(key: Bytes, keyIdentity: Bytes, expectedIdentity: Bytes, ciphertext: Bytes): Bytes { + if (!equal(keyIdentity, expectedIdentity)) throw new ProtocolError('WrongIdentity'); + if (ciphertext.length !== 160) throw new ProtocolError('InvalidCiphertext'); + let u: G2; + try { u = g2(ciphertext.slice(0, 96)); } catch (e) { + if (e instanceof ProtocolError) throw new ProtocolError('InvalidCiphertext'); + throw e; + } + const sigma = xor(ciphertext.slice(96, 128), xmd(encodeGT(pairing(g1(key), u)), DST.h2, 32)); + const message = xor(ciphertext.slice(128), xmd(sigma, DST.h4, 32)); + const rho = h3(concat(sigma, message)); + if (rho === 0n || !bls.G2.Point.BASE.multiply(rho).equals(u)) throw new ProtocolError('InvalidCiphertext'); + return message; +} diff --git a/tinycloud-threshold/vectors/check/schema.ts b/tinycloud-threshold/vectors/check/schema.ts new file mode 100644 index 00000000..b88bfdb9 --- /dev/null +++ b/tinycloud-threshold/vectors/check/schema.ts @@ -0,0 +1,88 @@ +import { DST } from './protocol.ts'; + +type Parser = (value: unknown, path: string) => T; +type Parsed

= P extends Parser ? T : never; +function invalid(path: string): never { throw new Error(`schema: ${path}`); } +function literal(expected: T): Parser { + return (v, p) => v === expected ? expected : invalid(p); +} +function hexField(length: number): Parser { + return (v, p) => typeof v === 'string' && v.length === length * 2 && /^[0-9a-f]+$/.test(v) ? v : invalid(p); +} +function intField(min: number, max: number): Parser { + return (v, p) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max ? v : invalid(p); +} +function object>>(shape: S): Parser<{ [K in keyof S]: Parsed }> { + return (v, p) => { + if (!v || typeof v !== 'object' || Array.isArray(v)) return invalid(p); + const input = v as Record; + if (Object.keys(input).length !== Object.keys(shape).length || Object.keys(input).some(k => !(k in shape))) return invalid(p); + const result: Record = {}; + for (const k of Object.keys(shape)) result[k] = shape[k]!(input[k], `${p}.${k}`); + return result as { [K in keyof S]: Parsed }; + }; +} +function array(parse: Parser, length: number): Parser { + return (v, p) => { + if (!Array.isArray(v) || v.length !== length) return invalid(p); + return v.map((x: unknown, i) => parse(x, `${p}[${i}]`)); + }; +} +const h32 = hexField(32), h48 = hexField(48), h96 = hexField(96), member = intField(1, 16); +const shareOutput = object({ member, encoded: hexField(259), c1: h48, c2: h96, c3: h48, signing_preimage: h32 }); +const thresholdInputs = object({ + context: hexField(79), context_digest: h32, certificate_digest: h32, + threshold: literal(2), members: literal(3), master_scalar: h32, mpk: h96, + public_shares: array(object({ member, point: h96 }), 3), scalar_shares: array(object({ member, scalar: h32 }), 3), + transport_scalar: h32, transport_public: h48, identity: hexField(100), request: hexField(259), request_digest: h32, +}); +const cases = { + h1_custom_dst: object({ name: literal('h1_custom_dst'), operation: literal('hash_g1'), inputs: object({ message: hexField(100), dst: literal(DST.h1) }), outputs: object({ point: h48 }) }), + pedersen_h: object({ name: literal('pedersen_h'), operation: literal('hash_g2'), inputs: object({ message: hexField(10), dst: literal(DST.pedersen_h) }), outputs: object({ point: h96 }) }), + h3_scalar_r: object({ name: literal('h3_scalar_r'), operation: literal('h3'), inputs: object({ message: hexField(64) }), outputs: object({ scalar: h32 }) }), + h2_gt: object({ name: literal('h2_gt'), operation: literal('h2'), inputs: object({ message: hexField(576) }), outputs: object({ bytes: h32 }) }), + h4_sigma: object({ name: literal('h4_sigma'), operation: literal('h4'), inputs: object({ message: h32 }), outputs: object({ bytes: h32 }) }), + pairing_generators: object({ name: literal('pairing_generators'), operation: literal('pairing'), inputs: object({ g1: h48, g2: h96 }), outputs: object({ gt: hexField(576) }) }), + pairing_5_7: object({ name: literal('pairing_5_7'), operation: literal('pairing'), inputs: object({ g1: h48, g2: h96 }), outputs: object({ gt: hexField(576) }) }), + fullident_wrap_unwrap: object({ name: literal('fullident_wrap_unwrap'), operation: literal('fullident'), inputs: object({ master_scalar: h32, mpk: h96, identity: hexField(100), message: h32, sigma: h32 }), outputs: object({ ciphertext: hexField(160), derived_key: h48, unwrapped: h32 }) }), + threshold_2of3: object({ name: literal('threshold_2of3'), operation: literal('threshold'), inputs: thresholdInputs, outputs: object({ encrypted_shares: array(shareOutput, 3), selected_members: array(intField(0, 65535), 2), aggregate: object({ c1: h48, c2: h96, c3: h48 }), derived_key: h48 }) }), + noncanonical_scalar: object({ name: literal('noncanonical_scalar'), operation: literal('decode_scalar'), inputs: object({ bytes: h32 }), error: literal('InvalidScalar') }), + identity_transport: object({ name: literal('identity_transport'), operation: literal('decode_transport'), inputs: object({ bytes: h48 }), error: literal('IdentityPoint') }), + identity_mpk: object({ name: literal('identity_mpk'), operation: literal('decode_mpk'), inputs: object({ bytes: h96 }), error: literal('IdentityPoint') }), + share_c3_mutated: object({ name: literal('share_c3_mutated'), operation: literal('verify_share'), inputs: object({ reference: literal('threshold_2of3'), share: hexField(259) }), error: literal('Invalid'), member: literal(1) }), + share_context_transplanted: object({ name: literal('share_context_transplanted'), operation: literal('verify_share'), inputs: object({ reference: literal('threshold_2of3'), share: hexField(259) }), error: literal('ContextMismatch') }), + share_identity_transplanted: object({ name: literal('share_identity_transplanted'), operation: literal('verify_share'), inputs: object({ reference: literal('threshold_2of3'), share: hexField(259), request: hexField(258) }), error: literal('ContextMismatch') }), + fullident_u_mutated: object({ name: literal('fullident_u_mutated'), operation: literal('unwrap'), inputs: object({ reference: literal('fullident_wrap_unwrap'), ciphertext: hexField(160) }), error: literal('InvalidCiphertext') }), + fullident_v_mutated: object({ name: literal('fullident_v_mutated'), operation: literal('unwrap'), inputs: object({ reference: literal('fullident_wrap_unwrap'), ciphertext: hexField(160) }), error: literal('InvalidCiphertext') }), + fullident_w_mutated: object({ name: literal('fullident_w_mutated'), operation: literal('unwrap'), inputs: object({ reference: literal('fullident_wrap_unwrap'), ciphertext: hexField(160) }), error: literal('InvalidCiphertext') }), +}; +export type VectorCase = Parsed<(typeof cases)[keyof typeof cases]>; +const parseCases: Parser = (v, p) => { + if (!Array.isArray(v) || v.length !== Object.keys(cases).length) return invalid(p); + const seen = new Set(); + const result = v.map((entry: unknown, i) => { + if (!entry || typeof entry !== 'object' || !('name' in entry) || typeof entry.name !== 'string' || !Object.hasOwn(cases, entry.name) || seen.has(entry.name)) return invalid(`${p}[${i}]`); + seen.add(entry.name); + return cases[entry.name as keyof typeof cases](entry, `${p}[${i}].${entry.name}`); + }); + return result; +}; +const sections: Parser = (v, p) => { + const expected = ['3.2', '3.3', '3.4', '3.5', '3.6 + request-digest amendment']; + if (!Array.isArray(v) || v.length !== expected.length || v.some((x, i) => x !== expected[i])) return invalid(p); + return expected; +}; +const file = object({ + format_version: literal(1), suite: literal('BLS12-381 / RFC9380 SHA-256 SSWU_RO / FullIdent / encrypted threshold shares'), + encoding: literal('hex; GT coefficients in SPEC 3.2 order'), spec_sections: sections, + seeds: object({ dealer: h32, transport: h32, shares: h32, wrap: h32 }), + dst: object({ h1: literal(DST.h1), h2: literal(DST.h2), h3: literal(DST.h3), h4: literal(DST.h4), pedersen_h: literal(DST.pedersen_h) }), + cases: parseCases, +}); +export type VectorFile = Parsed; +export function parseVectors(value: unknown): VectorFile { return file(value, 'vectors'); } +export function vector(file: VectorFile, name: N): Extract { + const found = file.cases.find((c): c is Extract => c.name === name); + if (!found) return invalid(name); + return found; +} diff --git a/tinycloud-threshold/vectors/check/tsconfig.json b/tinycloud-threshold/vectors/check/tsconfig.json new file mode 100644 index 00000000..53c996af --- /dev/null +++ b/tinycloud-threshold/vectors/check/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["bun"], + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/tinycloud-threshold/vectors/check/vectors.test.ts b/tinycloud-threshold/vectors/check/vectors.test.ts new file mode 100644 index 00000000..3638d642 --- /dev/null +++ b/tinycloud-threshold/vectors/check/vectors.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from 'bun:test'; +import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { checkVectors } from './vectors.ts'; +import { parseVectors, vector, type VectorFile } from './schema.ts'; + +const raw: unknown = JSON.parse(readFileSync(new URL('../v1/primitives.json', import.meta.url), 'utf8')); +const fixture = parseVectors(raw); +const clone = () => structuredClone(fixture); +function perturb(target: object, field: string) { + const value: unknown = Reflect.get(target, field); + if (typeof value !== 'string') throw new Error(`not a byte field: ${field}`); + Reflect.set(target, field, (value[0] === '0' ? '1' : '0') + value.slice(1)); +} + +test('committed primitive vectors compute every named result', () => { + const result = checkVectors(fixture); + expect(result.passed).toEqual(fixture.cases.map((c: { name: string }) => c.name)); + expect(result.combinations).toEqual([[1, 2], [1, 3], [2, 3]]); +}); + +test('expected bytes and errors are compared with computation', () => { + for (const [name, output] of [['h1_custom_dst', 'point'], ['pedersen_h', 'point'], ['h3_scalar_r', 'scalar'], ['h2_gt', 'bytes'], ['h4_sigma', 'bytes'], ['pairing_generators', 'gt'], ['pairing_5_7', 'gt'], ['fullident_wrap_unwrap', 'ciphertext']] as const) { + const file = clone(); + perturb(vector(file, name).outputs, output); + expect(() => checkVectors(file)).toThrow(name); + } + const validNegative = clone(); + const base = vector(validNegative, 'threshold_2of3'); + vector(validNegative, 'share_c3_mutated').inputs.share = base.outputs.encrypted_shares[0]!.encoded; + expect(() => checkVectors(validNegative)).toThrow('share_c3_mutated'); + const validCiphertext = clone(); + vector(validCiphertext, 'fullident_v_mutated').inputs.ciphertext = vector(validCiphertext, 'fullident_wrap_unwrap').outputs.ciphertext; + expect(() => checkVectors(validCiphertext)).toThrow('fullident_v_mutated'); +}, 30000); + +test('share encoded bytes, signing preimages and aggregates are computed', () => { + for (const field of ['encoded', 'signing_preimage', 'c3']) { + const file = clone(); + perturb(vector(file, 'threshold_2of3').outputs.encrypted_shares[1]!, field); + expect(() => checkVectors(file)).toThrow('threshold_2of3'); + } + for (const field of ['c1', 'c2', 'c3']) { + const file = clone(); + perturb(vector(file, 'threshold_2of3').outputs.aggregate, field); + expect(() => checkVectors(file)).toThrow('threshold_2of3'); + } +}, 30000); + +test('schema refuses missing, duplicate, unknown and mistyped records', () => { + for (const mutate of [ + (f: VectorFile) => { f.cases = []; }, + (f: VectorFile) => { f.cases.pop(); }, + (f: VectorFile) => { f.cases[1] = f.cases[0]!; }, + (f: VectorFile) => { Reflect.set(f.cases[0]!, 'operation', 'unknown'); }, + (f: VectorFile) => { Reflect.set(f.cases[0]!.inputs, 'extra', true); }, + (f: VectorFile) => { vector(f, 'h1_custom_dst').outputs.point = '00'; }, + (f: VectorFile) => { f.dst.h3 = 'other'; }, + (f: VectorFile) => { Reflect.set(f, 'format_version', 2); }, + (f: VectorFile) => { Reflect.set(f.cases[0]!, 'outputs', null); }, + ]) { + const file = clone(); mutate(file); + expect(() => checkVectors(file)).toThrow(); + } +}); + +test('real CLI accepts committed JSON and exits nonzero for a perturbed file', () => { + const cli = new URL('./cli.ts', import.meta.url).pathname; + const run = (...args: string[]) => Bun.spawnSync([process.execPath, cli, ...args], { env: process.env, stdout: 'pipe', stderr: 'pipe', timeout: 30000 }); + const good = run(); + expect(good.exitCode).toBe(0); + const file = clone(); vector(file, 'h1_custom_dst').outputs.point = '00'.repeat(48); + const path = `${process.env.HOME}/perturbed.json`; + writeFileSync(path, JSON.stringify(file), { mode: 0o600 }); + try { + const bad = run(path); + expect(bad.exitCode).not.toBe(0); + expect(bad.stderr.toString()).toContain('h1_custom_dst'); + } finally { unlinkSync(path); } +}); diff --git a/tinycloud-threshold/vectors/check/vectors.ts b/tinycloud-threshold/vectors/check/vectors.ts new file mode 100644 index 00000000..b464bf4d --- /dev/null +++ b/tinycloud-threshold/vectors/check/vectors.ts @@ -0,0 +1,145 @@ +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { parseVectors, vector, type VectorCase } from './schema.ts'; +import { + ProtocolError, DST, bytes, hex, be, scalar, g1, g2, h1, h3, xmd, pairing, encodeGT, + decodeIdentity, encodeIdentity, decodeContext, encodeContext, contextDigest, + decodeRequest, encodeRequest, requestDigest, encodeShare, signingPreimage, + verifyShare, combine, wrap, unwrap, type Keyset, +} from './protocol.ts'; + +export interface CheckReport { passed: string[]; combinations: number[][] } +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)}`); +} +function checkThreshold(c: Extract): number[][] { + const i = c.inputs, o = c.outputs, context = decodeContext(bytes(i.context)); + same('context bytes', hex(encodeContext(context)), i.context); + same('context digest', hex(contextDigest(context)), i.context_digest); + same('certificate', hex(context.certificate), i.certificate_digest); + same('threshold', context.threshold, i.threshold); + same('members', context.members, i.members); + const identity = bytes(i.identity), transport = scalar(bytes(i.transport_scalar)), master = scalar(bytes(i.master_scalar)); + same('identity bytes', hex(encodeIdentity(decodeIdentity(identity))), i.identity); + same('mpk', hex(bls.G2.Point.BASE.multiply(master).toBytes()), i.mpk); + same('transport', hex(bls.G1.Point.BASE.multiply(transport).toBytes()), i.transport_public); + const request = bytes(i.request), req = decodeRequest(request); + same('request bytes', hex(encodeRequest(req)), i.request); + same('request context', hex(req.context), i.context_digest); + same('request network', req.network, context.network); + same('request rekey', req.rekey.toString(), context.rekey.toString()); + same('request epoch', req.epoch.toString(), context.epoch.toString()); + same('request certificate', hex(req.certificate), i.certificate_digest); + same('request identity', hex(req.identity), i.identity); + same('request transport', hex(req.tpk), i.transport_public); + const digest = requestDigest(request); + same('request digest', hex(digest), i.request_digest); + const indices = Array.from({ length: context.members }, (_, n) => n + 1); + same('scalar member order', i.scalar_shares.map(s => s.member), indices); + same('public member order', i.public_shares.map(s => s.member), indices); + same('encrypted member order', o.encrypted_shares.map(s => s.member), indices); + const publicShares = new Map(i.scalar_shares.map(s => [s.member, bls.G2.Point.BASE.multiply(scalar(bytes(s.scalar)))])); + for (const s of i.public_shares) same(`public share ${s.member}`, hex(publicShares.get(s.member)!.toBytes()), s.point); + const keys: Keyset = { context, mpk: g2(bytes(i.mpk)), publicShares }; + const encrypted = o.encrypted_shares.map(s => { + const c1 = g1(bytes(s.c1)), c2 = g2(bytes(s.c2)); + const si = scalar(bytes(i.scalar_shares.find(x => x.member === s.member)!.scalar)); + const c3 = h1(identity).multiply(si).add(c1.multiply(transport)).toBytes(); + same(`share ${s.member} c3`, hex(c3), s.c3); + const encoded = encodeShare({ member: s.member, context: contextDigest(context), request: digest, c1: c1.toBytes(), c2: c2.toBytes(), c3 }); + same(`share ${s.member} encoded`, hex(encoded), s.encoded); + same(`share ${s.member} signing preimage`, hex(signingPreimage(encoded, digest, context.certificate)), s.signing_preimage); + verifyShare(keys, request, encoded); + return encoded; + }); + const expected = hex(h1(identity).multiply(master).toBytes()); + same('derived key', expected, o.derived_key); + const combinations = [[1, 2], [1, 3], [2, 3]]; + for (const members of combinations) { + const result = combine(keys, request, members.map(n => encrypted[n - 1]!), transport); + same(`combine ${members.join(',')} key`, hex(result.key), expected); + if (members[0] === 1 && members[1] === 2) { + same('selected members', result.selected, o.selected_members); + same('aggregate c1', hex(result.aggregate.c1), o.aggregate.c1); + same('aggregate c2', hex(result.aggregate.c2), o.aggregate.c2); + same('aggregate c3', hex(result.aggregate.c3), o.aggregate.c3); + } + } + return combinations; +} +export function checkVectors(input: unknown): CheckReport { + const file = parseVectors(input), passed: string[] = []; + const full = vector(file, 'fullident_wrap_unwrap'), threshold = vector(file, 'threshold_2of3'); + const fi = full.inputs, ti = threshold.inputs; + const keys: Keyset = { + context: decodeContext(bytes(ti.context)), mpk: g2(bytes(ti.mpk)), + publicShares: new Map(ti.public_shares.map(s => [s.member, g2(bytes(s.point))])), + }; + const fullKey = h1(bytes(fi.identity)).multiply(scalar(bytes(fi.master_scalar))).toBytes(); + let combinations: number[][] = []; + for (const c of file.cases) { + try { + switch (c.operation) { + case 'hash_g1': + same('H1', hex(bls.G1.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + break; + case 'hash_g2': + same('Pedersen h', hex(bls.G2.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + break; + case 'h3': same('H3', hex(be(h3(bytes(c.inputs.message)), 32)), c.outputs.scalar); break; + case 'h2': same('H2', hex(xmd(bytes(c.inputs.message), DST.h2, 32)), c.outputs.bytes); break; + case 'h4': same('H4', hex(xmd(bytes(c.inputs.message), DST.h4, 32)), c.outputs.bytes); break; + case 'pairing': { + const a = c.name === 'pairing_generators' ? bls.G1.Point.BASE : bls.G1.Point.BASE.multiply(5n); + const b = c.name === 'pairing_generators' ? bls.G2.Point.BASE : bls.G2.Point.BASE.multiply(7n); + same('pairing input G1', hex(a.toBytes()), c.inputs.g1); + same('pairing input G2', hex(b.toBytes()), c.inputs.g2); + same('reduced pairing GT', hex(encodeGT(pairing(g1(bytes(c.inputs.g1)), g2(bytes(c.inputs.g2))))), c.outputs.gt); + break; + } + case 'fullident': { + same('master public key', hex(bls.G2.Point.BASE.multiply(scalar(bytes(fi.master_scalar))).toBytes()), fi.mpk); + const ciphertext = wrap(g2(bytes(fi.mpk)), bytes(fi.identity), bytes(fi.message), bytes(fi.sigma)); + same('FullIdent ciphertext', hex(ciphertext), c.outputs.ciphertext); + same('FullIdent key', hex(fullKey), c.outputs.derived_key); + const plaintext = unwrap(fullKey, bytes(fi.identity), bytes(fi.identity), bytes(c.outputs.ciphertext)); + same('FullIdent plaintext', hex(plaintext), fi.message); + same('FullIdent unwrapped', hex(plaintext), c.outputs.unwrapped); + break; + } + case 'threshold': + same('shared identity', ti.identity, fi.identity); + same('shared master', ti.master_scalar, fi.master_scalar); + same('shared mpk', ti.mpk, fi.mpk); + combinations = checkThreshold(c); + for (const members of combinations) { + const derived = combine(keys, bytes(ti.request), members.map(n => bytes(c.outputs.encrypted_shares[n - 1]!.encoded)), scalar(bytes(ti.transport_scalar))); + same(`FullIdent from subset ${members}`, hex(unwrap(derived.key, bytes(ti.identity), bytes(fi.identity), bytes(full.outputs.ciphertext))), fi.message); + } + break; + case 'decode_scalar': case 'decode_transport': case 'decode_mpk': case 'verify_share': case 'unwrap': { + let observed: { code: string; member?: number } | undefined; + try { + switch (c.operation) { + case 'decode_scalar': scalar(bytes(c.inputs.bytes)); break; + case 'decode_transport': g1(bytes(c.inputs.bytes)); break; + case 'decode_mpk': g2(bytes(c.inputs.bytes)); break; + case 'verify_share': + verifyShare(keys, bytes('request' in c.inputs ? c.inputs.request : ti.request), bytes(c.inputs.share)); break; + case 'unwrap': unwrap(fullKey, bytes(fi.identity), bytes(fi.identity), bytes(c.inputs.ciphertext)); break; + } + } catch (e) { + if (!(e instanceof ProtocolError)) throw e; + observed = { code: e.code, ...(e.member === undefined ? {} : { member: e.member }) }; + } + same('computed error', observed, { code: c.error, ...('member' in c ? { member: c.member } : {}) }); + break; + } + default: { const exhaustive: never = c; throw new Error(`unsupported case ${exhaustive}`); } + } + passed.push(c.name); + } catch (e) { + throw new Error(`${c.name}: ${e instanceof Error ? e.message : String(e)}`); + } + } + return { passed, combinations }; +} From 2bbc4cee2a3d4c6a55f1c2875859593d0ea04ff5 Mon Sep 17 00:00:00 2001 From: dryrun Date: Thu, 29 Oct 2026 16:43:24 +0000 Subject: [PATCH 2/3] test(threshold): exercise chosen vector corruptions; Details: TC-2219 --- .../vectors/check/corruption.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tinycloud-threshold/vectors/check/corruption.test.ts diff --git a/tinycloud-threshold/vectors/check/corruption.test.ts b/tinycloud-threshold/vectors/check/corruption.test.ts new file mode 100644 index 00000000..b1204781 --- /dev/null +++ b/tinycloud-threshold/vectors/check/corruption.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { parseVectors, vector, type VectorCase } from './schema.ts'; + +const input: unknown = JSON.parse(readFileSync(new URL('../v1/primitives.json', import.meta.url), 'utf8')); +const fixture = parseVectors(input); +const cli = new URL('./cli.ts', import.meta.url).pathname; + +interface Corruption { + path: (string | number)[]; + offset: number; + mask: number; + encoding: 'hex' | 'utf8'; + outcome: string; + observed?: { code: string } | 'success' | 'schema'; +} +const corruptions: Record = { + h1_custom_dst: { path: ['outputs', 'point'], offset: 47, mask: 1, encoding: 'hex', outcome: 'computed H1 bytes disagree' }, + pedersen_h: { path: ['outputs', 'point'], offset: 95, mask: 1, encoding: 'hex', outcome: 'computed Pedersen bytes disagree' }, + h3_scalar_r: { path: ['outputs', 'scalar'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed H3 scalar disagrees' }, + h2_gt: { path: ['outputs', 'bytes'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed H2 bytes disagree' }, + h4_sigma: { path: ['outputs', 'bytes'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed H4 bytes disagree' }, + pairing_generators: { path: ['outputs', 'gt'], offset: 575, mask: 1, encoding: 'hex', outcome: 'computed generator GT disagrees' }, + pairing_5_7: { path: ['outputs', 'gt'], offset: 575, mask: 1, encoding: 'hex', outcome: 'computed second GT disagrees' }, + fullident_wrap_unwrap: { path: ['outputs', 'ciphertext'], offset: 159, mask: 1, encoding: 'hex', outcome: 'computed FullIdent tuple disagrees' }, + threshold_2of3: { path: ['outputs', 'encrypted_shares', 0, 'signing_preimage'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed response signing preimage disagrees' }, + noncanonical_scalar: { path: ['inputs', 'bytes'], offset: 31, mask: 1, encoding: 'hex', outcome: 'r becomes r-1 and decodes successfully', observed: 'success' }, + identity_transport: { path: ['inputs', 'bytes'], offset: 0, mask: 128, encoding: 'hex', outcome: 'IdentityPoint becomes InvalidPoint', observed: { code: 'InvalidPoint' } }, + identity_mpk: { path: ['inputs', 'bytes'], offset: 0, mask: 128, encoding: 'hex', outcome: 'IdentityPoint becomes InvalidPoint', observed: { code: 'InvalidPoint' } }, + share_c3_mutated: { path: ['inputs', 'share'], offset: 35, mask: 1, encoding: 'hex', outcome: 'Invalid becomes ContextMismatch', observed: { code: 'ContextMismatch' } }, + share_context_transplanted: { path: ['inputs', 'share'], offset: 2, mask: 1, encoding: 'hex', outcome: 'member zero changes ContextMismatch to InvalidEncoding', observed: { code: 'InvalidEncoding' } }, + share_identity_transplanted: { path: ['inputs', 'request'], offset: 210, mask: 128, encoding: 'hex', outcome: 'transport flags change ContextMismatch to InvalidPoint', observed: { code: 'InvalidPoint' } }, + fullident_u_mutated: { path: ['error'], offset: 16, mask: 1, encoding: 'utf8', outcome: 'declared error label changes and fails schema; valid-tuple semantics checked separately', observed: 'schema' }, + fullident_v_mutated: { path: ['inputs', 'ciphertext'], offset: 96, mask: 1, encoding: 'hex', outcome: 'V restored, unwrap succeeds instead of InvalidCiphertext', observed: 'success' }, + fullident_w_mutated: { path: ['inputs', 'ciphertext'], offset: 128, mask: 1, encoding: 'hex', outcome: 'W restored, unwrap succeeds instead of InvalidCiphertext', observed: 'success' }, +}; + +function runCli(value: unknown) { + return Bun.spawnSync([process.execPath, cli, '/dev/stdin'], { + env: process.env, stdin: Buffer.from(JSON.stringify(value)), stdout: 'pipe', stderr: 'pipe', timeout: 30000, + }); +} + +test('real CLI accepts the unchanged vector document through memory input', () => { + expect(runCli(fixture).exitCode).toBe(0); +}); + +for (const original of fixture.cases) { + const mutation = corruptions[original.name]; + test(`one-byte CLI decision: ${original.name}: ${mutation.outcome}`, () => { + const changed = structuredClone(fixture); + let target: object = vector(changed, original.name); + for (const component of mutation.path.slice(0, -1)) { + const next: unknown = Reflect.get(target, component); + if (!next || typeof next !== 'object') throw new Error(`not an object: ${component}`); + target = next; + } + const field = mutation.path.at(-1)!; + const value: unknown = Reflect.get(target, field); + if (typeof value !== 'string') throw new Error(`not a byte field: ${String(field)}`); + const before = Buffer.from(value, mutation.encoding); + const after = Buffer.from(before); + if (mutation.offset >= after.length) throw new Error('corruption offset outside field'); + after[mutation.offset] = after[mutation.offset]! ^ mutation.mask; + expect(after.length).toBe(before.length); + expect(after.reduce((count, byte, index) => count + Number(byte !== before[index]), 0)).toBe(1); + Reflect.set(target, field, after.toString(mutation.encoding)); + const result = runCli(changed); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(original.name); + if (mutation.observed === 'schema') { + expect(result.stderr.toString()).toContain('FAIL schema:'); + } else if (mutation.observed !== undefined) { + const actual = mutation.observed === 'success' ? 'undefined' : JSON.stringify(mutation.observed); + expect(result.stderr.toString()).toContain(`computed error: observed ${actual};`); + } + }, 30000); +} + +test('U negative compares the actual unwrap outcome when given the valid tuple', () => { + const changed = structuredClone(fixture); + vector(changed, 'fullident_u_mutated').inputs.ciphertext = vector(changed, 'fullident_wrap_unwrap').outputs.ciphertext; + const result = runCli(changed); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain('fullident_u_mutated: computed error: observed undefined'); +}); From 730e4d7310b425ac7ce756bbcbeaa2a924f54c5b Mon Sep 17 00:00:00 2001 From: dryrun Date: Thu, 29 Oct 2026 17:42:30 +0000 Subject: [PATCH 3/3] fix(threshold): pin primitive checker constants; Details: TC-2219 --- tinycloud-threshold/vectors/check/README.md | 7 ++ .../vectors/check/constants.test.ts | 118 ++++++++++++++++++ tinycloud-threshold/vectors/check/protocol.ts | 1 + tinycloud-threshold/vectors/check/schema.ts | 4 +- tinycloud-threshold/vectors/check/vectors.ts | 8 +- 5 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/constants.test.ts diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index f8d6cde5..04588cc3 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -45,6 +45,13 @@ current named record set, field widths, literal metadata and additional-field rules. That shape is a vector-format contract, not a general test-count policy. Expected cryptographic output bytes are not part of the schema. +The Pedersen derivation message is the SPEC literal `706564657273656e2d68` +(`pedersen-h`). Hashing uses compiled SPEC DSTs, and pairing inputs are checked +against independently constructed points. `constants.test.ts` runs real-CLI +rejection cases for alternate labels with matching points, all five DSTs, +row-local DST copies, both pairing inputs, the identity prefix and the three +S0.1 tagged-hash domains, using in-memory documents. + `protocol.test.ts` uses independently chosen identities, scalar shares, transport keys and randomizers. It covers point/scalar encodings, request bindings, both share equations, duplicate members, binding precedence, diff --git a/tinycloud-threshold/vectors/check/constants.test.ts b/tinycloud-threshold/vectors/check/constants.test.ts new file mode 100644 index 00000000..e64a3ea5 --- /dev/null +++ b/tinycloud-threshold/vectors/check/constants.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { parseVectors, vector, type VectorFile } from './schema.ts'; +import { R, be, bytes, concat, encodeGT, h3, hex, scalar, tagged, xmd } from './protocol.ts'; + +const input: unknown = JSON.parse(readFileSync(new URL('../v1/primitives.json', import.meta.url), 'utf8')); +const fixture = parseVectors(input); +const cli = new URL('./cli.ts', import.meta.url).pathname; +function rejectDocument(file: VectorFile, component: string) { + const result = Bun.spawnSync([process.execPath, cli, '/dev/stdin'], { + env: process.env, stdin: Buffer.from(JSON.stringify(file)), stdout: 'pipe', stderr: 'pipe', timeout: 30000, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(component); +} + +test('Pedersen message is pedersen-h even when another label has a matching point', () => { + const file = structuredClone(fixture), row = vector(file, 'pedersen_h'); + const message = '706564657273656e2d69'; + Reflect.set(row.inputs, 'message', message); + row.outputs.point = hex(bls.G2.hashToCurve(bytes(message), { DST: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_' }).toBytes()); + rejectDocument(file, 'pedersen_h'); +}); + +const specDsts = { + h1: 'TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_', + pedersen_h: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_', + h2: 'TC-THRESHOLD-V1-IBE-H2', h3: 'TC-THRESHOLD-V1-IBE-H3', h4: 'TC-THRESHOLD-V1-IBE-H4', +}; +for (const key of ['h1', 'pedersen_h', 'h2', 'h3', 'h4'] as const) { + test(`SPEC DST ${key} stays fixed with a matching alternate-domain output`, () => { + const file = structuredClone(fixture), alternate = `${specDsts[key]}X`; + file.dst[key] = alternate; + switch (key) { + case 'h1': { + const row = vector(file, 'h1_custom_dst'); + row.inputs.dst = alternate; + row.outputs.point = hex(bls.G1.hashToCurve(bytes(row.inputs.message), { DST: alternate }).toBytes()); + break; + } + case 'pedersen_h': { + const row = vector(file, 'pedersen_h'); + row.inputs.dst = alternate; + row.outputs.point = hex(bls.G2.hashToCurve(bytes(row.inputs.message), { DST: alternate }).toBytes()); + break; + } + case 'h2': { + const row = vector(file, 'h2_gt'); + row.outputs.bytes = hex(xmd(bytes(row.inputs.message), alternate, 32)); + break; + } + case 'h3': { + const row = vector(file, 'h3_scalar_r'); + row.outputs.scalar = hex(be(BigInt(`0x${hex(xmd(bytes(row.inputs.message), alternate, 48))}`) % R, 32)); + break; + } + case 'h4': { + const row = vector(file, 'h4_sigma'); + row.outputs.bytes = hex(xmd(bytes(row.inputs.message), alternate, 32)); + break; + } + } + rejectDocument(file, `dst.${key}`); + }); +} +for (const name of ['h1_custom_dst', 'pedersen_h'] as const) { + test(`row-local ${name} DST is pinned independently of metadata`, () => { + const file = structuredClone(fixture), row = vector(file, name); + row.inputs.dst += 'X'; + row.outputs.point = hex((name === 'h1_custom_dst' ? bls.G1.hashToCurve(bytes(row.inputs.message), { DST: row.inputs.dst }) : bls.G2.hashToCurve(bytes(row.inputs.message), { DST: row.inputs.dst })).toBytes()); + rejectDocument(file, name); + }); +} + +for (const name of ['pairing_generators', 'pairing_5_7'] as const) { + for (const group of ['g1', 'g2'] as const) { + test(`${name} ${group} is fixed even with a matching alternate pairing`, () => { + const file = structuredClone(fixture), row = vector(file, name); + const a = bls.G1.Point.BASE.multiply(name === 'pairing_generators' ? (group === 'g1' ? 2n : 1n) : (group === 'g1' ? 6n : 5n)); + const b = bls.G2.Point.BASE.multiply(name === 'pairing_generators' ? (group === 'g2' ? 2n : 1n) : (group === 'g2' ? 8n : 7n)); + row.inputs.g1 = hex(a.toBytes()); row.inputs.g2 = hex(b.toBytes()); + row.outputs.gt = hex(encodeGT(bls.pairing(a, b, true))); + rejectDocument(file, name); + }); + } +} + +test('identity encoding prefix is fixed rather than selected by the document', () => { + const file = structuredClone(fixture), row = vector(file, 'fullident_wrap_unwrap'); + const identity = bytes(row.inputs.identity); + identity[29] = identity[29]! ^ 1; + row.inputs.identity = hex(identity); + const q = bls.G1.hashToCurve(identity, { DST: specDsts.h1 }); + const sigma = bytes(row.inputs.sigma), message = bytes(row.inputs.message), rho = h3(concat(sigma, message)); + const mask = xmd(encodeGT(bls.pairing(q.multiply(rho), bls.G2.Point.fromBytes(bytes(row.inputs.mpk)), true)), specDsts.h2, 32); + const pad = xmd(sigma, specDsts.h4, 32); + row.outputs.ciphertext = hex(concat(bls.G2.Point.BASE.multiply(rho).toBytes(), sigma.map((v, i) => v ^ mask[i]!), message.map((v, i) => v ^ pad[i]!))); + row.outputs.derived_key = hex(q.multiply(scalar(bytes(row.inputs.master_scalar))).toBytes()); + rejectDocument(file, 'fullident_wrap_unwrap'); +}); + +for (const tag of ['TC-THRESHOLD-V1-KEY-CONTEXT', 'TC-THRESHOLD-V1-DECRYPT-REQUEST', 'TC-THRESHOLD-V1-DECRYPT-SHARE'] as const) { + test(`SPEC tagged-hash domain ${tag} is fixed`, () => { + const file = structuredClone(fixture), row = vector(file, 'threshold_2of3'); + if (tag === 'TC-THRESHOLD-V1-KEY-CONTEXT') { + row.inputs.context_digest = hex(tagged(`${tag}X`, bytes(row.inputs.context))); + rejectDocument(file, 'threshold_2of3: context digest'); + } else if (tag === 'TC-THRESHOLD-V1-DECRYPT-REQUEST') { + row.inputs.request_digest = hex(tagged(`${tag}X`, bytes(row.inputs.request))); + rejectDocument(file, 'threshold_2of3: request digest'); + } else { + const share = row.outputs.encrypted_shares[0]!; + share.signing_preimage = hex(tagged(`${tag}X`, concat(bytes(row.inputs.request_digest), bytes(row.inputs.certificate_digest), be(BigInt(share.member), 2), bytes(share.encoded)))); + rejectDocument(file, 'threshold_2of3: share 1 signing preimage'); + } + }); +} diff --git a/tinycloud-threshold/vectors/check/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts index ca739404..04453884 100644 --- a/tinycloud-threshold/vectors/check/protocol.ts +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -11,6 +11,7 @@ export const DST = { pedersen_h: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_', h2: 'TC-THRESHOLD-V1-IBE-H2', h3: 'TC-THRESHOLD-V1-IBE-H3', h4: 'TC-THRESHOLD-V1-IBE-H4', }; +export const PEDERSEN_H_MESSAGE = '706564657273656e2d68'; const PREFIX = 'TC-THRESHOLD-V1-IBE-ID-ENC'; const encoder = new TextEncoder(); const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); diff --git a/tinycloud-threshold/vectors/check/schema.ts b/tinycloud-threshold/vectors/check/schema.ts index b88bfdb9..9fc94293 100644 --- a/tinycloud-threshold/vectors/check/schema.ts +++ b/tinycloud-threshold/vectors/check/schema.ts @@ -1,4 +1,4 @@ -import { DST } from './protocol.ts'; +import { DST, PEDERSEN_H_MESSAGE } from './protocol.ts'; type Parser = (value: unknown, path: string) => T; type Parsed

= P extends Parser ? T : never; @@ -38,7 +38,7 @@ const thresholdInputs = object({ }); const cases = { h1_custom_dst: object({ name: literal('h1_custom_dst'), operation: literal('hash_g1'), inputs: object({ message: hexField(100), dst: literal(DST.h1) }), outputs: object({ point: h48 }) }), - pedersen_h: object({ name: literal('pedersen_h'), operation: literal('hash_g2'), inputs: object({ message: hexField(10), dst: literal(DST.pedersen_h) }), outputs: object({ point: h96 }) }), + pedersen_h: object({ name: literal('pedersen_h'), operation: literal('hash_g2'), inputs: object({ message: literal(PEDERSEN_H_MESSAGE), dst: literal(DST.pedersen_h) }), outputs: object({ point: h96 }) }), h3_scalar_r: object({ name: literal('h3_scalar_r'), operation: literal('h3'), inputs: object({ message: hexField(64) }), outputs: object({ scalar: h32 }) }), h2_gt: object({ name: literal('h2_gt'), operation: literal('h2'), inputs: object({ message: hexField(576) }), outputs: object({ bytes: h32 }) }), h4_sigma: object({ name: literal('h4_sigma'), operation: literal('h4'), inputs: object({ message: h32 }), outputs: object({ bytes: h32 }) }), diff --git a/tinycloud-threshold/vectors/check/vectors.ts b/tinycloud-threshold/vectors/check/vectors.ts index b464bf4d..a5b6e9ae 100644 --- a/tinycloud-threshold/vectors/check/vectors.ts +++ b/tinycloud-threshold/vectors/check/vectors.ts @@ -1,7 +1,7 @@ import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; import { parseVectors, vector, type VectorCase } from './schema.ts'; import { - ProtocolError, DST, bytes, hex, be, scalar, g1, g2, h1, h3, xmd, pairing, encodeGT, + ProtocolError, DST, PEDERSEN_H_MESSAGE, bytes, hex, be, scalar, g1, g2, h1, h3, xmd, pairing, encodeGT, decodeIdentity, encodeIdentity, decodeContext, encodeContext, contextDigest, decodeRequest, encodeRequest, requestDigest, encodeShare, signingPreimage, verifyShare, combine, wrap, unwrap, type Keyset, @@ -80,10 +80,10 @@ export function checkVectors(input: unknown): CheckReport { try { switch (c.operation) { case 'hash_g1': - same('H1', hex(bls.G1.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + same('H1', hex(h1(bytes(c.inputs.message)).toBytes()), c.outputs.point); break; case 'hash_g2': - same('Pedersen h', hex(bls.G2.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + same('Pedersen h', hex(bls.G2.hashToCurve(bytes(PEDERSEN_H_MESSAGE), { DST: DST.pedersen_h }).toBytes()), c.outputs.point); break; case 'h3': same('H3', hex(be(h3(bytes(c.inputs.message)), 32)), c.outputs.scalar); break; case 'h2': same('H2', hex(xmd(bytes(c.inputs.message), DST.h2, 32)), c.outputs.bytes); break; @@ -93,7 +93,7 @@ export function checkVectors(input: unknown): CheckReport { const b = c.name === 'pairing_generators' ? bls.G2.Point.BASE : bls.G2.Point.BASE.multiply(7n); same('pairing input G1', hex(a.toBytes()), c.inputs.g1); same('pairing input G2', hex(b.toBytes()), c.inputs.g2); - same('reduced pairing GT', hex(encodeGT(pairing(g1(bytes(c.inputs.g1)), g2(bytes(c.inputs.g2))))), c.outputs.gt); + same('reduced pairing GT', hex(encodeGT(pairing(a, b))), c.outputs.gt); break; } case 'fullident': {