diff --git a/lib/src/ibe.dart b/lib/src/ibe.dart index c8863cc..d61eceb 100644 --- a/lib/src/ibe.dart +++ b/lib/src/ibe.dart @@ -30,7 +30,6 @@ library; import 'dart:convert'; -import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; @@ -41,6 +40,7 @@ import 'bls12381_hash.dart'; import 'bls12381_pairing.dart'; import 'bls12381_tower.dart'; import 'bytes.dart'; +import 'random.dart'; /// The size of a compressed signature of Quicknet, on G1. const signatureLength = g1ByteLength; @@ -329,17 +329,20 @@ Uint8List decryptWithSignaturePoint(G1Point signature, TlockCiphertext ct) { /// Encrypts [msg], at most 32 bytes, for the identity [id] under the public /// key of a Quicknet-style scheme, a compressed point of G2, as /// EncryptCCAonG2 of kyber with the suite of tlock: Q_id = H(id) on G1 with -/// the DST of RFC 9380, a random sigma, r = H3(sigma, msg), U = r·G2, V = -/// sigma XOR H2(e(Q_id, key)^r) and W = msg XOR H4(sigma). The key passes -/// the gate of the canonical encoding first. +/// the DST of RFC 9380, a random sigma of the length of [msg], drawn from +/// [random] as kyber draws it from crypto/rand, r = H3(sigma, msg), U = +/// r·G2, V = sigma XOR H2(e(Q_id, key)^r) and W = msg XOR H4(sigma). The key +/// passes the gate of the canonical encoding first. /// /// Not constant time: sigma and r are secret (see the library comment). -TlockCiphertext encryptOnG2(List publicKey, List id, List msg) { +TlockCiphertext encryptOnG2( + List publicKey, + List id, + List msg, [ + RandomSource random = secureRandom, +]) { _checkMessage(msg); - final sigma = Uint8List(msg.length); - for (var i = 0; i < sigma.length; i++) { - sigma[i] = _random.nextInt(256); - } + final sigma = randomBytes(random, msg.length); try { return encryptOnG2WithSigma(publicKey, id, msg, sigma); } finally { @@ -384,9 +387,6 @@ TlockCiphertext encryptOnG2WithSigma( } } -// The source of sigma: the generator of the platform, created on first use. -final Random _random = Random.secure(); - void _checkMessage(List msg) { if (msg.length > maxMessageLength) { throw IbeException( diff --git a/lib/src/random.dart b/lib/src/random.dart new file mode 100644 index 0000000..3d820b0 --- /dev/null +++ b/lib/src/random.dart @@ -0,0 +1,116 @@ +/// The random values of the writers (spec §62.1 rule 5), drawn from one +/// injectable [RandomSource]: the file keys, the nonces of the STREAM and +/// the labels that age draws, the ephemeral X25519 secrets, the scrypt salts +/// and sigma of tlock; and, for the writer of a capsule, its own values and +/// the permutation of the slots of INNER_ACCESS_AGE (spec §39). +/// +/// [secureRandom], the default of every writer, draws from Random.secure of +/// dart:math, the CSPRNG of the platform. [SeededRandomSource] is +/// deterministic, for tests and vectors only: with it a writer draws the +/// values that Go draws when its crypto/rand reads the same stream, and +/// writes the same bytes. +/// +/// [randomIndex] draws an integer as Go's crypto/rand.Int, and [permute] +/// permutes a list as the permute of capsule.Encrypt in Go, so that a +/// deterministic source gives the order that Go gives. +/// +/// Internal: lib/datekeys.dart does not export it. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'chacha20poly1305.dart'; +import 'sha256.dart'; + +/// A source of random bytes. +abstract interface class RandomSource { + /// Fills [out] with random bytes. + void fill(Uint8List out); +} + +/// [n] bytes of [source]. +Uint8List randomBytes(RandomSource source, int n) { + RangeError.checkNotNegative(n, 'n'); + final out = Uint8List(n); + source.fill(out); + return out; +} + +/// The CSPRNG of the platform, Random.secure of dart:math, created on first +/// use: a platform without one throws its UnsupportedError then, not +/// before. +final class SecureRandomSource implements RandomSource { + /// The source. + const SecureRandomSource(); + + static math.Random? _random; + + @override + void fill(Uint8List out) { + final r = _random ??= math.Random.secure(); + for (var i = 0; i < out.length; i++) { + out[i] = r.nextInt(256); + } + } +} + +/// The default source of the writers: the CSPRNG of the platform. +const RandomSource secureRandom = SecureRandomSource(); + +/// A deterministic source, for tests and vectors only: the ChaCha20 +/// keystream (RFC 8439) under the key SHA-256([seed]) and a zero nonce, from +/// block 0, one fill after the other. Go draws the same bytes from a +/// crypto/rand.Reader that reads that keystream (tool/ +/// age_writer_go_vectors.go). Its values are not secret: a writer that uses +/// it gives its keys away. +final class SeededRandomSource implements RandomSource { + /// The source of [seed], any bytes. + SeededRandomSource(List seed) + : _cipher = ChaCha20(sha256(seed), Uint8List(chachaNonceSize)); + + final ChaCha20 _cipher; + + @override + void fill(Uint8List out) { + out.fillRange(0, out.length, 0); + _cipher.xorInPlace(out); + } +} + +const _two32 = 0x100000000; + +/// A uniform integer in [0, n), for 1 ≤ [n] ≤ 2^32, drawn from [source] as +/// Go's crypto/rand.Int(source, n) draws it: k bytes, the length of n - 1, +/// read big-endian with the bits above the length of n - 1 cleared, and +/// drawn again while they are n or more. For n = 1 nothing is drawn. +int randomIndex(RandomSource source, int n) { + if (n < 1 || n > _two32) throw RangeError.range(n, 1, _two32, 'n'); + final bits = (n - 1).bitLength; + if (bits == 0) return 0; + final k = (bits + 7) ~/ 8; + final top = bits % 8 == 0 ? 8 : bits % 8; + final b = Uint8List(k); + for (;;) { + source.fill(b); + b[0] &= (1 << top) - 1; + // At most four bytes, read without a shift that the web would truncate. + var v = 0; + for (final x in b) { + v = v * 256 + x; + } + if (v < n) return v; + } +} + +/// Puts [items] in a uniformly random order, in place, as the permute of +/// capsule.Encrypt in Go (spec §39): Fisher–Yates from the last position +/// down, each position swapped with [randomIndex] of its index plus one. +void permute(List items, RandomSource source) { + for (var i = items.length - 1; i > 0; i--) { + final j = randomIndex(source, i + 1); + final t = items[i]; + items[i] = items[j]; + items[j] = t; + } +} diff --git a/lib/src/tlock.dart b/lib/src/tlock.dart index 32f2aa0..2ed4c63 100644 --- a/lib/src/tlock.dart +++ b/lib/src/tlock.dart @@ -18,6 +18,7 @@ import 'dart:typed_data'; import 'bytes.dart'; import 'errors.dart'; import 'ibe.dart'; +import 'random.dart'; import 'release.dart'; String _q(String s) => goQuote(utf8Bytes(s)); @@ -96,16 +97,17 @@ Uint8List unwrapTlockStanza( /// The arguments and the body of the tlock stanza that wraps [fileKey] for /// [round] under the pinned profile [p], of the scheme of Quicknet, as Wrap -/// of Go's TimeRecipient. Its checks and texts are those of -/// NewTimeRecipient: the profile first, then the range of the round. +/// of Go's TimeRecipient, with sigma drawn from [random]. Its checks and +/// texts are those of NewTimeRecipient: the profile first, then the range +/// of the round. TimeRecipient of recipient.dart writes its stanza with it. /// -/// Encryption: not constant time, see ibe.dart. The writer, stage 6 of the -/// plan, exports it. +/// Encryption: not constant time, see ibe.dart. (List, Uint8List) wrapTlockStanza( PinnedProfile p, int round, - List fileKey, -) { + List fileKey, [ + RandomSource random = secureRandom, +]) { checkTlockProfile(p); if (round < 1 || round > p.maxRound) { throw DateKeysException( @@ -113,7 +115,7 @@ Uint8List unwrapTlockStanza( 'agewrap: round $round outside the range of ${p.id}', ); } - final ct = encryptOnG2(p.publicKey, roundIdentity(round), fileKey); + final ct = encryptOnG2(p.publicKey, roundIdentity(round), fileKey, random); return (['$round', toHex(p.chainHash)], ciphertextToBody(ct)); } diff --git a/test/random_support.dart b/test/random_support.dart new file mode 100644 index 0000000..66a254b --- /dev/null +++ b/test/random_support.dart @@ -0,0 +1,40 @@ +// Helpers of the tests of the random sources and of the age writer: the +// deterministic source of a seed named as the vectors name it, and a source +// that records the draws of another one. They read no file. + +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart' show toHex; +import 'package:datekeys/src/bytes.dart' show utf8Bytes; +import 'package:datekeys/src/random.dart'; + +typedef Json = Map; + +/// Whether the tests run compiled to JavaScript. +const isWeb = identical(0, 0.0); + +List listOf(Object? v) => (v! as List).cast(); + +/// A source that records every draw of another one: its size and bytes. +final class RecordingSource implements RandomSource { + RecordingSource(this._inner); + + final RandomSource _inner; + + /// The draws, in order. + final List draws = []; + + @override + void fill(Uint8List out) { + _inner.fill(out); + draws.add(Uint8List.fromList(out)); + } + + /// The draws as the vectors write them. + List get json => [ + for (final d in draws) {'n': d.length, 'hex': toHex(d)}, + ]; +} + +/// The deterministic source of [seed], a string, as the vectors name it. +SeededRandomSource seeded(String seed) => SeededRandomSource(utf8Bytes(seed)); diff --git a/test/random_test.dart b/test/random_test.dart new file mode 100644 index 0000000..b6dbe0e --- /dev/null +++ b/test/random_test.dart @@ -0,0 +1,145 @@ +// The random sources of the writers against test/vectors/age_writer.json +// (tool/age_writer_go_vectors.go): SeededRandomSource gives the keystream +// that Go reads as crypto/rand.Reader, across fills of any size; +// randomIndex gives what crypto/rand.Int gives over it, with the same draws; +// and permute gives the order of the permute of capsule.Encrypt. Also the +// bounds of randomIndex, the uniformity of permute and the CSPRNG of the +// platform, on the VM: compiled to JavaScript, Random.secure is not +// available to the tests. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart'; +import 'package:datekeys/src/random.dart'; +import 'package:test/test.dart'; + +import 'random_support.dart'; +import 'vectors/age_writer.g.dart'; + +final Json vectors = jsonDecode(ageWriterJson) as Json; + +// A source that replays the given bytes and fails past them. +final class Replay implements RandomSource { + Replay(List bytes) : _bytes = Uint8List.fromList(bytes); + final Uint8List _bytes; + int _at = 0; + + @override + void fill(Uint8List out) { + if (_at + out.length > _bytes.length) throw StateError('exhausted'); + out.setRange(0, out.length, _bytes, _at); + _at += out.length; + } +} + +void main() { + test('SeededRandomSource is the keystream that Go reads', () { + for (final c in listOf(vectors['seeded'])) { + final source = seeded(c['seed']! as String); + final out = BytesBuilder(); + for (final n in (c['fills']! as List).cast()) { + out.add(randomBytes(source, n)); + } + expect(toHex(out.takeBytes()), c['hex'], reason: c['seed'] as String?); + // One fill of everything gives the same bytes. + final all = randomBytes( + seeded(c['seed']! as String), + fromHex(c['hex']! as String).length, + ); + expect(toHex(all), c['hex']); + } + // fill overwrites what the buffer held. + final b = Uint8List(8)..fillRange(0, 8, 0xff); + seeded('').fill(b); + expect(b, randomBytes(seeded(''), 8)); + expect(() => randomBytes(seeded(''), -1), throwsRangeError); + }); + + test('randomIndex is crypto/rand.Int, with the same draws', () { + for (final c in listOf(vectors['rand_int'])) { + final n = c['n']! as int; + final source = RecordingSource(seeded(c['seed']! as String)); + final got = [for (var i = 0; i < 24; i++) randomIndex(source, n)]; + expect(got, c['results'], reason: '$n'); + expect(source.draws, hasLength(c['reads']), reason: '$n'); + // The stream goes on where Go's goes on. + expect(toHex(randomBytes(source, 4)), c['next'], reason: '$n'); + } + }); + + test('randomIndex draws again exactly above the largest result', () { + // n = 3: one byte, two bits kept; 3 is drawn again. + expect(randomIndex(Replay([0xff, 0xfe]), 3), 2); + expect(randomIndex(Replay([0x03, 0x01]), 3), 1); + // n = 256: one byte, all kept, none drawn again. + expect(randomIndex(Replay([0xff]), 256), 255); + // n = 257: two bytes, the first with one bit; 257 to 511 drawn again. + expect(randomIndex(Replay([0x01, 0x01, 0x01, 0x00]), 257), 256); + expect(randomIndex(Replay([0xff, 0xff, 0x00, 0x05]), 257), 5); + // n = 2^32: four bytes, any. (No shift of 32 bits: the web would + // truncate it.) + expect( + randomIndex(Replay([0xff, 0xff, 0xff, 0xff]), 0x100000000), + 0xffffffff, + ); + // n = 2^31 + 1: four bytes, 32 bits kept. + expect( + randomIndex(Replay([0x80, 0, 0, 1, 0x80, 0, 0, 0]), 0x80000001), + 0x80000000, + ); + // n = 1: nothing drawn. + expect(randomIndex(Replay(const []), 1), 0); + for (final n in [0, -1, 0x100000001]) { + expect(() => randomIndex(Replay(const []), n), throwsRangeError); + } + }); + + test('permute is the permute of capsule.Encrypt', () { + for (final c in listOf(vectors['permute'])) { + final n = c['n']! as int; + final items = List.generate(n, (i) => i); + final source = RecordingSource(seeded(c['seed']! as String)); + permute(items, source); + expect(items, c['order'], reason: c['seed'] as String?); + expect(source.draws, hasLength(c['reads'])); + } + }); + + // As TestStanzaOrderIsUniform of the Go reference and the test of + // datekeys-ts: the positions of the first and of the last of 16 items + // over many permutations, each a chi-square with 15 degrees of freedom + // under 60 (p ≈ 10⁻⁷ by chance). + test('permute puts each item in every position uniformly', () { + final source = seeded('uniform'); + const n = 16; + final rounds = isWeb ? 4000 : 32000; + final first = List.filled(n, 0); + final last = List.filled(n, 0); + for (var r = 0; r < rounds; r++) { + final items = List.generate(n, (i) => i); + permute(items, source); + first[items.indexOf(0)]++; + last[items.indexOf(n - 1)]++; + } + for (final counts in [first, last]) { + final e = rounds / n; + var chi = 0.0; + for (final o in counts) { + chi += (o - e) * (o - e) / e; + } + expect(chi, lessThan(60), reason: '$counts'); + } + }); + + test('the CSPRNG of the platform', testOn: 'vm', () { + final a = randomBytes(secureRandom, 32); + final b = randomBytes(secureRandom, 32); + expect(a, isNot(b)); + expect(a.toSet().length, greaterThan(16)); + for (var i = 0; i < 100; i++) { + final v = randomIndex(secureRandom, 16); + expect(v, inInclusiveRange(0, 15)); + } + }); +}