// The IBE of lib/src/ibe.dart against the Go reference: test/vectors/ // ibe_vectors.json, written by tool/ibe_go_vectors.go with drand/kyber // encrypt/ibe, tlock and age, the libraries of the reference implementation, // on the fixtures of testdata/. A port of ibe.test.ts of datekeys-ts. @TestOn('vm') library; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:datekeys/datekeys.dart' show concatBytes, fromHex, toHex; import 'package:datekeys/src/bls12381_curve.dart'; import 'package:datekeys/src/bls12381_pairing.dart'; import 'package:datekeys/src/bls12381_tower.dart'; import 'package:datekeys/src/ibe.dart'; import 'package:test/test.dart'; import 'tlock_support.dart'; typedef Json = Map; Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; final Json v = readJson('test/vectors/ibe_vectors.json'); List section(Json file, String name) => (file[name]! as List).cast(); String s(Json v, String key) => v[key]! as String; Uint8List h(Json v, String key) => fromHex(s(v, key)); TlockCiphertext ct(Json v) => TlockCiphertext(h(v, 'u'), h(v, 'v'), h(v, 'w')); String scalarHex(BigInt r) => r.toRadixString(16).padLeft(64, '0'); Uint8List xor(List a, List b) => Uint8List.fromList([for (var i = 0; i < a.length; i++) a[i] ^ b[i]]); final Json timeOnly = section( v, 'fixtures', ).firstWhere((f) => f['name'] == 'time_only'); const proofMessage = 'ibe: U is not r·G2: the ciphertext does not decrypt under this signature'; // Runs [body] and returns the IbeException it throws. IbeException ibeError(void Function() body, String label) { try { body(); } on IbeException catch (e) { return e; } fail('$label: no IbeException'); } // GT serialized c0 first at every level of the tower: the order of noble's // Fp12.toBytes, which H2 must not hash. Uint8List c0First(Fp12 gt) { List fp2(Fp2 a) => [...a.c0.toBytes(), ...a.c1.toBytes()]; List fp6(Fp6 a) => [...fp2(a.c0), ...fp2(a.c1), ...fp2(a.c2)]; return Uint8List.fromList([...fp6(gt.c0), ...fp6(gt.c1)]); } void main() { test('reads the vectors of the Quicknet scheme and key', () { expect(v['generator'], 'tool/ibe_go_vectors.go'); expect(v['scheme'], 'bls-unchained-g1-rfc9380'); expect( v['public_key'], readJson('testdata/vectors/profile_quicknet.json')['public_key'], ); expect(v['public_key'], quicknetPublicKey); }); test( 'serializes GT in the order of kilic, which H2 hashes, and never c0 first', () { for (final g in section(v, 'gt')) { final gt = pairing( G1Point.decode(h(g, 'g1'))!, G2Point.decode(h(g, 'g2'))!, ); final name = s(g, 'name'); expect(toHex(gtBytes(gt)), s(g, 'gt'), reason: name); expect(toHex(h2(gt, 16)), s(g, 'h2_16'), reason: name); expect(toHex(h2(gt, 32)), s(g, 'h2_32'), reason: name); expect(toHex(c0First(gt)), isNot(s(g, 'gt')), reason: name); } // The first vector is the one every implementation shares. final shared = section( readJson('testdata/vectors/tlock_ibe.json'), 'vectors', ).first; final first = section(v, 'gt').first; expect( [s(first, 'gt'), s(first, 'h2_16')], [s(shared, 'gt'), s(shared, 'h2')], ); }, ); test('computes H3 through its rejection sampling, and H4', () { for (final c in section(v, 'h3')) { final sigma = h(c, 'sigma'); final msg = h(c, 'msg'); final iterations = c['iterations']! as int; final name = s(c, 'name'); expect(scalarHex(h3(sigma, msg)), s(c, 'r'), reason: name); expect( scalarHex(h3(sigma, msg, iterations: iterations)), s(c, 'r'), reason: name, ); if (iterations > 1) { final e = ibeError( () => h3(sigma, msg, iterations: iterations - 1), name, ); expect( [e.reason, e.message], [ IbeReason.proof, 'ibe: no scalar r below the order of the group (rejection sampling ' 'failed)', ], ); } } expect([ for (final c in section(v, 'h3')) c['iterations'], ], containsAll([1, 2, 3])); for (final c in section(v, 'h4')) { expect(toHex(h4(h(c, 'sigma'), 16)), s(c, 'h4_16')); expect(toHex(h4(h(c, 'sigma'), 32)), s(c, 'h4_32')); } }); test('derives the identity of a round as drand does', () { for (final c in section(v, 'round_identities')) { final round = c['round']! as int; expect(toHex(roundIdentity(round)), s(c, 'id'), reason: '$round'); } for (final bad in [-1, maxSafeRound + 1]) { expect(() => roundIdentity(bad), throwsRangeError, reason: '$bad'); } }); test('opens the tlock stanza of every official fixture with the file key of ' 'the reference, which authenticates the age header', () { final names = [ for (final f in Directory('testdata/fixtures').listSync()) if (f.path.endsWith('.dkc')) f.uri.pathSegments.last.replaceAll('.dkc', ''), ]..sort(); final fixtures = section(v, 'fixtures'); expect([for (final f in fixtures) s(f, 'name')]..sort(), names); for (final f in fixtures) { final name = s(f, 'name'); final dkc = File('testdata/fixtures/$name.dkc').readAsBytesSync(); final record = readJson('testdata/fixtures/$name.json'); expect(record['release'], { 'round': f['round'], 'signature': f['signature'], }); final header = readAgeHeader(sealedControl(dkc)); expect(header.stanzas, hasLength(1)); final stanza = header.stanzas.single; expect( [stanza.type, stanza.args.first, toHex(stanza.body)], ['tlock', '${f['round']}', s(f, 'body')], reason: name, ); final sig = h(f, 'signature'); final c = ciphertextFromBody(stanza.body); final fileKey = decryptOnG2(sig, c); expect(toHex(fileKey), s(f, 'file_key'), reason: name); expect( ageHeaderMacValid(fileKey, header.macInput, header.mac), isTrue, reason: name, ); // The values in between, as the reference computes them. final gt = pairing(G1Point.decode(sig)!, G2Point.decode(c.u)!); expect(toHex(gtBytes(gt)), s(f, 'gt'), reason: name); final sigma = xor(c.v, h2(gt, 16)); expect(toHex(sigma), s(f, 'sigma'), reason: name); expect(xor(c.w, h4(sigma, 16)), h(f, 'file_key'), reason: name); expect(scalarHex(h3(sigma, h(f, 'file_key'))), s(f, 'r'), reason: name); expect(toHex(ciphertextToBody(c)), s(f, 'body'), reason: name); // Another file key fails the MAC. expect( ageHeaderMacValid( xor(fileKey, List.filled(16, 1)), header.macInput, header.mac, ), isFalse, ); } }); test('decrypts what kyber encrypts, for messages of 0 to 32 bytes', () { final kyber = section(v, 'kyber'); expect([for (final c in kyber) h(c, 'v').length], [0, 1, 16, 32]); for (final c in kyber) { expect( toHex(decryptOnG2(h(c, 'signature'), ct(c))), c['msg'] ?? '', reason: s(c, 'name'), ); } }); test('rejects what the reference rejects, with the reason of the first ' 'failing check', () { const want = { 'the time_only stanza': null, 'U with p added to c0': IbeReason.encoding, 'U is the point at infinity': IbeReason.identity, 'U negated': IbeReason.proof, 'V with its first bit flipped': IbeReason.proof, 'W with its last bit flipped': IbeReason.proof, 'the signature of round 1001': IbeReason.proof, 'the signature negated': IbeReason.proof, 'the signature is the point at infinity': IbeReason.identity, 'W one byte shorter than V': IbeReason.length, 'V and W of 33 bytes': IbeReason.length, 'V and W empty': IbeReason.proof, 'U is the generator of G2': IbeReason.proof, }; final cases = section(v, 'decrypt'); expect({for (final c in cases) s(c, 'name')}, want.keys.toSet()); for (final c in cases) { final name = s(c, 'name'); expect(c['go'] == 'ok', want[name] == null, reason: name); if (c['go'] == 'ok') { expect( toHex(decryptOnG2(h(c, 'signature'), ct(c))), c['msg'], reason: name, ); continue; } final e = ibeError(() => decryptOnG2(h(c, 'signature'), ct(c)), name); expect(e.reason, want[name], reason: name); if (e.reason == IbeReason.proof) { expect(e.message, proofMessage, reason: name); } } }); test('gates every encoding of the signature and of U as Go decodes it', () { final points = section( readJson('test/vectors/bls12381_vectors.json'), 'points', ); final c = ciphertextFromBody(h(timeOnly, 'body')); final sig = h(timeOnly, 'signature'); const reason = { 'invalid': IbeReason.encoding, 'identity': IbeReason.identity, 'point': IbeReason.proof, }; final seen = {}; for (final p in points) { final bytes = h(p, 'hex'); final g1 = p['group'] == 'G1'; final label = s(p, 'label'); final e = ibeError( () => g1 ? decryptOnG2(bytes, c) : decryptOnG2(sig, TlockCiphertext(bytes, c.v, c.w)), label, ); // An encoding of another length is invalid for Go too; the IBE says // so first. final want = bytes.length == (g1 ? 48 : 96) ? reason[s(p, 'go')] : IbeReason.length; expect(e.reason, want, reason: label); seen.add(e.reason); } expect(seen, IbeReason.values.toSet()); }); test('checks the lengths first, with fixed texts', () { final c = ciphertextFromBody(h(timeOnly, 'body')); final sig = h(timeOnly, 'signature'); final cases = <(String, void Function(), String)>[ ( 'a signature of 47 bytes', () => decryptOnG2(sig.sublist(1), c), 'ibe: the signature of 47 bytes, want 48', ), ( 'a signature of 49 bytes', () => decryptOnG2([...sig, 0], c), 'ibe: the signature of 49 bytes, want 48', ), ( 'U of 95 bytes', () => decryptOnG2(sig, TlockCiphertext(c.u.sublist(1), c.v, c.w)), 'ibe: U of 95 bytes, want 96', ), ( 'V longer than W', () => decryptOnG2(sig, TlockCiphertext(c.u, Uint8List(17), c.w)), 'ibe: V of 17 bytes and W of 16, want equal lengths of at most 32', ), ( 'a body of 127 bytes', () => ciphertextFromBody(Uint8List(tlockBodyLength - 1)), 'ibe: tlock stanza body of 127 bytes, want 128', ), ( 'a body of 129 bytes', () => ciphertextFromBody(Uint8List(tlockBodyLength + 1)), 'ibe: tlock stanza body of 129 bytes, want 128', ), ( 'a body with V of 15 bytes', () => ciphertextToBody(TlockCiphertext(c.u, c.v.sublist(1), c.w)), 'ibe: a tlock stanza body holds U of 96 bytes and V and W of 16', ), ( 'a body with W of 17 bytes', () => ciphertextToBody(TlockCiphertext(c.u, c.v, Uint8List(17))), 'ibe: a tlock stanza body holds U of 96 bytes and V and W of 16', ), ( 'a body with U of 95 bytes', () => ciphertextToBody(TlockCiphertext(c.u.sublist(1), c.v, c.w)), 'ibe: a tlock stanza body holds U of 96 bytes and V and W of 16', ), ]; for (final (label, body, message) in cases) { final e = ibeError(body, label); expect([e.reason, e.message], [IbeReason.length, message], reason: label); } }); test('never proves with r = 0', () { final u = G2Point.decode(h(timeOnly, 'body').sublist(0, 96))!; expect(proofHolds(BigInt.zero, u), isFalse); expect(proofHolds(BigInt.parse(s(timeOnly, 'r'), radix: 16), u), isTrue); }); test('never puts a value of the computation in an error', () { // The messages are fixed by the reason and the lengths: the file key, // sigma and r of the stanzas the edits start from appear in none. final secrets = [ s(timeOnly, 'file_key'), s(timeOnly, 'sigma'), s(timeOnly, 'r'), s(timeOnly, 'signature'), s(timeOnly, 'body').substring(0, 32), ]; for (final c in section(v, 'decrypt').where((c) => c['go'] == 'reject')) { final name = s(c, 'name'); final e = ibeError(() => decryptOnG2(h(c, 'signature'), ct(c)), name); for (final secret in secrets) { expect( e.message.toLowerCase(), isNot(contains(secret.substring(0, 16))), reason: name, ); } expect(e.message, isNot(matches(RegExp('[0-9a-f]{16}'))), reason: name); } }); test('a body splits and joins back', () { final body = h(timeOnly, 'body'); final c = ciphertextFromBody(body); expect(concatBytes([c.u, c.v, c.w]), body); expect(ciphertextToBody(c), body); }); }