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.
382 lines
13 KiB
382 lines
13 KiB
|
2 days ago
|
// 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<String, Object?>;
|
||
|
|
|
||
|
|
Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json;
|
||
|
|
|
||
|
|
final Json v = readJson('test/vectors/ibe_vectors.json');
|
||
|
|
|
||
|
|
List<Json> section(Json file, String name) =>
|
||
|
|
(file[name]! as List).cast<Json>();
|
||
|
|
|
||
|
|
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<int> a, List<int> 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<int> fp2(Fp2 a) => [...a.c0.toBytes(), ...a.c1.toBytes()];
|
||
|
|
List<int> 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 = <IbeReason>{};
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
}
|