You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
172 lines
9.4 KiB
172 lines
9.4 KiB
// Tests of release.ts: the cases of provider.Verify in the Go reference
|
|
// (provider/provider_test.go) and every release of the mutation corpus that
|
|
// fails at step 10.
|
|
|
|
import { bls12_381 } from '@noble/curves/bls12-381.js';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { DateKeysError } from './errors.ts';
|
|
import { roundIdentity } from './ibe.ts';
|
|
import { inspect } from './inspect.ts';
|
|
import { maxRound, quicknet, type Profile } from './profile.ts';
|
|
import { QUICKNET_DST, suppliedRelease, verifyRelease, type Release } from './release.ts';
|
|
import { expectCode, h, readBytes, readJSON } from './testing/testdata.ts';
|
|
import { applyEdits, edits } from './testing/vectors.ts';
|
|
|
|
const { G1, G2, fields, shortSignatures } = bls12_381;
|
|
|
|
// The published releases of the fixtures (rounds 1000, 1001 and 2000).
|
|
const published = new Map<number, Uint8Array>();
|
|
for (const name of ['time_only', 'empty_payload', 'time_only_extensions']) {
|
|
const r = readJSON<{ release: { round: number; signature: string } }>(`fixtures/${name}.json`).release;
|
|
published.set(r.round, h(r.signature));
|
|
}
|
|
const sig1000 = published.get(1000)!;
|
|
const sig1001 = published.get(1001)!;
|
|
const rel = (round: number, signature: Uint8Array): Release => ({ round, signature });
|
|
const INVALID = (round: number): string =>
|
|
`provider: the signature is not a canonical point encoding, or does not verify as the BLS signature of round ${round} under datekeys:quicknet:v1: ERR_RELEASE_INVALID`;
|
|
|
|
// The mutation corpus, as far as step 10 needs it.
|
|
type CorpusCase = { name: string; dkc: { base?: string; edits: unknown }; release: { round: number; signature: string } | null; error: string; step: number };
|
|
const corpus = readJSON<{ cases: CorpusCase[] }>('vectors/mutations.json').cases;
|
|
|
|
// The same signature with x + p: possible only when x + p stays below 2^381,
|
|
// as for round 1004 (spec §76, amendment), whose case gives the encoding.
|
|
const xPlusP = corpus.find((c) => c.name === 'release signature re-encoded with x + p')!.release!;
|
|
|
|
// The x + p encoding with p subtracted again, flags kept: the canonical one.
|
|
function minusP(b: Uint8Array): Uint8Array {
|
|
const flags = b[0]! & 0xe0;
|
|
let x = 0n;
|
|
for (const [i, v] of b.entries()) x = (x << 8n) | BigInt(i === 0 ? v & 0x1f : v);
|
|
x -= fields.Fp.ORDER;
|
|
const out = new Uint8Array(48);
|
|
for (let i = 47; i >= 0; i--, x >>= 8n) out[i] = Number(x & 0xffn);
|
|
out[0]! |= flags;
|
|
return out;
|
|
}
|
|
const flip = (b: Uint8Array, i: number, mask: number): Uint8Array => {
|
|
const c = new Uint8Array(b);
|
|
c[i]! ^= mask;
|
|
return c;
|
|
};
|
|
const infinity = (flags = 0xc0): Uint8Array => {
|
|
const b = new Uint8Array(48);
|
|
b[0] = flags;
|
|
return b;
|
|
};
|
|
|
|
describe('verifyRelease', () => {
|
|
it('accepts the published releases', () => {
|
|
published.set(1004, minusP(h(xPlusP.signature)));
|
|
expect([...published.keys()].sort()).toEqual([1000, 1001, 1004, 2000]);
|
|
for (const [round, signature] of published) verifyRelease(quicknet(), round, rel(round, signature));
|
|
});
|
|
|
|
it('rejects what provider.Verify rejects, in its order and with its texts', () => {
|
|
const p = quicknet();
|
|
const cases: [string, number, Release, string, string?][] = [
|
|
// Spec §17: a valid signature of another round is not enough.
|
|
['valid release of another round', 1000, rel(1001, sig1001), 'ERR_ROUND_MISMATCH', 'provider: release for round 1001, expected 1000: ERR_ROUND_MISMATCH'],
|
|
// Spec §63 step 10: the round is compared before the signature.
|
|
['another round and a short signature', 1000, rel(1001, sig1001.subarray(0, 47)), 'ERR_ROUND_MISMATCH'],
|
|
['signature of another round relabelled', 1000, rel(1000, sig1001), 'ERR_RELEASE_INVALID', INVALID(1000)],
|
|
['a signature of round 1000 for round 999', 999, rel(999, sig1000), 'ERR_RELEASE_INVALID', INVALID(999)],
|
|
['all-zero signature', 1000, rel(1000, new Uint8Array(48)), 'ERR_RELEASE_INVALID'],
|
|
['flipped bit', 1000, rel(1000, flip(sig1000, 47, 1)), 'ERR_RELEASE_INVALID'],
|
|
['short signature', 1000, rel(1000, sig1000.subarray(0, 47)), 'ERR_RELEASE_INVALID', 'provider: signature is 47 bytes, bls-unchained-g1-rfc9380 uses 48: ERR_RELEASE_INVALID'],
|
|
['G2-sized signature', 1000, rel(1000, new Uint8Array([...sig1000, ...sig1000])), 'ERR_RELEASE_INVALID', 'provider: signature is 96 bytes, bls-unchained-g1-rfc9380 uses 48: ERR_RELEASE_INVALID'],
|
|
// Spec §12.2: the canonical encoding of a point of G1 other than the
|
|
// point at infinity. A decoder that reduces x modulo p would accept
|
|
// x + p as the published signature of round 1004.
|
|
['signature re-encoded with x + p', 1004, rel(1004, h(xPlusP.signature)), 'ERR_RELEASE_INVALID', INVALID(1004)],
|
|
['signature the point at infinity', 1000, rel(1000, infinity()), 'ERR_RELEASE_INVALID'],
|
|
['infinity flag and a payload', 1000, rel(1000, flip(sig1000, 0, 0x40)), 'ERR_RELEASE_INVALID'],
|
|
['point at infinity with the sort flag', 1000, rel(1000, infinity(0xe0)), 'ERR_RELEASE_INVALID'],
|
|
['compression flag cleared', 1000, rel(1000, flip(sig1000, 0, 0x80)), 'ERR_RELEASE_INVALID'],
|
|
['negated signature', 1000, rel(1000, flip(sig1000, 0, 0x20)), 'ERR_RELEASE_INVALID'],
|
|
['round zero', 0, rel(0, sig1000), 'ERR_DATEKEY_INVALID', 'provider: round 0 outside the range of datekeys:quicknet:v1: ERR_DATEKEY_INVALID'],
|
|
['round beyond the profile', maxRound(p) + 1, rel(maxRound(p) + 1, sig1000), 'ERR_DATEKEY_INVALID'],
|
|
['a round that is not an integer', 1000.5, rel(1000.5, sig1000), 'ERR_DATEKEY_INVALID'],
|
|
];
|
|
for (const [name, round, release, code, message] of cases) {
|
|
expectCode(() => verifyRelease(p, round, release), code, message === undefined ? undefined : new RegExp(`^${escape(message)}$`), name);
|
|
}
|
|
});
|
|
|
|
it('uses the pinned key only, and only for the scheme of Quicknet', () => {
|
|
const q = quicknet();
|
|
const withKey = (publicKey: Uint8Array): Profile => ({ ...q, publicKey });
|
|
const g2 = G2.Point.BASE.toBytes();
|
|
// Another valid key does not verify the genuine signature.
|
|
expectCode(() => verifyRelease(withKey(g2), 1000, rel(1000, sig1000)), 'ERR_RELEASE_INVALID');
|
|
// kyber decodes the point at infinity; the verification refuses it.
|
|
const inf = new Uint8Array(96);
|
|
inf[0] = 0xc0;
|
|
expectCode(() => verifyRelease(withKey(inf), 1000, rel(1000, sig1000)), 'ERR_RELEASE_INVALID');
|
|
expectCode(
|
|
() => verifyRelease(withKey(flip(q.publicKey, 0, 0x80)), 1000, rel(1000, sig1000)),
|
|
'ERR_UNKNOWN_PROFILE',
|
|
/^provider: pinned public key of datekeys:quicknet:v1 is not the canonical encoding of a point of the key group: ERR_UNKNOWN_PROFILE$/,
|
|
);
|
|
const other: Profile = { ...q, scheme: 'pedersen-bls-unchained' };
|
|
expectCode(
|
|
() => verifyRelease(other, 1000, rel(1000, sig1000)),
|
|
'ERR_UNKNOWN_PROFILE',
|
|
/^provider: profile datekeys:quicknet:v1 uses scheme pedersen-bls-unchained; only bls-unchained-g1-rfc9380 releases are verified here: ERR_UNKNOWN_PROFILE$/,
|
|
);
|
|
// The round checks come first, as in the reference.
|
|
expectCode(() => verifyRelease(other, 1000, rel(1001, sig1001)), 'ERR_ROUND_MISMATCH');
|
|
});
|
|
|
|
it('hashes the round with the DST of RFC 9380 for G1, not the one of G2 that bls-unchained-on-g1 uses', () => {
|
|
const key = G2.Point.fromBytes(quicknet().publicKey);
|
|
const signature = G1.Point.fromBytes(sig1000);
|
|
expect(QUICKNET_DST).toBe('BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_');
|
|
expect(shortSignatures.verify(signature, shortSignatures.hash(roundIdentity(1000), QUICKNET_DST), key)).toBe(true);
|
|
expect(shortSignatures.verify(signature, shortSignatures.hash(roundIdentity(1000), 'BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_'), key)).toBe(false);
|
|
});
|
|
|
|
it('gives the code of every release of the mutation corpus that fails at step 10', async () => {
|
|
const at10 = corpus.filter((c) => c.step === 10);
|
|
// The same seven in each format; formats 2 and 3 name them "format 2: …"
|
|
// and "format 3: …".
|
|
const names = [
|
|
'DateKey A + release of round B',
|
|
'release of another round',
|
|
'release signature is the point at infinity',
|
|
'release signature negated',
|
|
'release signature re-encoded with x + p',
|
|
'release signature with the infinity flag and a payload',
|
|
'negated release signature and U re-encoded with c0 + p',
|
|
];
|
|
expect(at10.map((c) => c.name).sort()).toEqual([...names, ...names.map((n) => `format 2: ${n}`), ...names.map((n) => `format 3: ${n}`)].sort());
|
|
for (const c of at10) {
|
|
const dkc = applyEdits(c.dkc.base === undefined ? new Uint8Array(0) : readBytes(`fixtures/${c.dkc.base}`), edits(c.dkc.edits, c.name));
|
|
const inspection = await inspect(dkc);
|
|
expect(inspection.error, c.name).toBeUndefined();
|
|
const round = inspection.header!.dateKey.round;
|
|
expectCode(() => verifyRelease(quicknet(), round, rel(c.release!.round, h(c.release!.signature))), c.error, undefined, c.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('suppliedRelease', () => {
|
|
it('hands the caller release over for any round, unverified, and has none without one', async () => {
|
|
const r = rel(1001, sig1001);
|
|
expect(await suppliedRelease(r).fetch(quicknet(), 1000)).toBe(r);
|
|
const err = await suppliedRelease()
|
|
.fetch(quicknet(), 1000)
|
|
.then(
|
|
() => undefined,
|
|
(e: unknown) => e,
|
|
);
|
|
expect(err).toBeInstanceOf(DateKeysError);
|
|
expect((err as DateKeysError).message).toBe('release: no release supplied for round 1000: ERR_RELEASE_UNAVAILABLE');
|
|
});
|
|
});
|
|
|
|
function escape(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|