Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tinycloud-threshold/vectors/check/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
!bun.lock
88 changes: 88 additions & 0 deletions tinycloud-threshold/vectors/check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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.

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,
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
35 changes: 35 additions & 0 deletions tinycloud-threshold/vectors/check/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions tinycloud-threshold/vectors/check/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./isolate.ts"]
14 changes: 14 additions & 0 deletions tinycloud-threshold/vectors/check/cli.ts
Original file line number Diff line number Diff line change
@@ -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;
}
118 changes: 118 additions & 0 deletions tinycloud-threshold/vectors/check/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
}
86 changes: 86 additions & 0 deletions tinycloud-threshold/vectors/check/corruption.test.ts
Original file line number Diff line number Diff line change
@@ -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<VectorCase['name'], Corruption> = {
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');
});
Loading