Skip to content

Commit e851b6a

Browse files
committed
Add ML-KEM serialization round-trip tests
1 parent 9a96469 commit e851b6a

1 file changed

Lines changed: 102 additions & 1 deletion

File tree

‎test/mlkem_test.dart‎

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@ import 'package:cipherlib/cipherlib.dart';
77
import 'package:cipherlib/codecs.dart';
88
import 'package:cipherlib/hashlib.dart' show HashDigest;
99
import 'package:cipherlib/src/algorithms/mlkem/mlkem_core.dart'
10-
show $rejUniform;
10+
show
11+
$polyCompress4,
12+
$polyCompress5,
13+
$polyDecompress4,
14+
$polyDecompress5,
15+
$polyFromBytes,
16+
$polyFromMsg,
17+
$polyToBytes,
18+
$polyToMsg,
19+
$rejUniform,
20+
$vecCompress10,
21+
$vecCompress11,
22+
$vecDecompress10,
23+
$vecDecompress11;
1124
import 'package:test/test.dart';
1225

1326
import 'fixtures/mlkem_decaps_vectors.dart';
@@ -232,6 +245,94 @@ void main() {
232245
});
233246
});
234247

248+
group('serialization', () {
249+
// Circular (mod q) distance between two coefficients.
250+
int cdist(int a, int b) {
251+
final d = ((a - b) % 3329 + 3329) % 3329;
252+
return d < 3329 - d ? d : 3329 - d;
253+
}
254+
255+
// Sweeps every residue 0..q-1 through a 256-coefficient pack/unpack round
256+
// trip and checks the FIPS 203 (Section 4.2.1) compression error bound:
257+
// Decompress(Compress_d(x)) differs from x by at most ceil(q / 2^(d+1)).
258+
// A [bound] of 0 means the transform must be lossless. Exercises the
259+
// compress/decompress twins (arith_64bit / arith_32bit) directly, so it
260+
// runs on both the VM and node.
261+
void sweep(
262+
int byteLength,
263+
void Function(Int16List orig, Uint8List bytes) pack,
264+
void Function(Uint8List bytes, Int16List out) unpack,
265+
int bound,
266+
) {
267+
final orig = Int16List(256);
268+
final out = Int16List(256);
269+
final bytes = Uint8List(byteLength);
270+
var maxErr = 0;
271+
for (var base = 0; base < 3329; base += 256) {
272+
for (var i = 0; i < 256; ++i) {
273+
orig[i] = (base + i) % 3329;
274+
}
275+
pack(orig, bytes);
276+
unpack(bytes, out);
277+
for (var i = 0; i < 256; ++i) {
278+
final e = cdist(orig[i], out[i]);
279+
if (e > maxErr) maxErr = e;
280+
expect(e, lessThanOrEqualTo(bound),
281+
reason: 'x=${orig[i]} -> ${out[i]}');
282+
}
283+
}
284+
// Guard against a transform that is trivially lossless (a broken
285+
// compressor acting as identity): a lossy variant must actually lose
286+
// precision and reach its bound.
287+
if (bound == 0) {
288+
expect(maxErr, 0);
289+
} else {
290+
expect(maxErr, inInclusiveRange(1, bound));
291+
}
292+
}
293+
294+
test('ByteEncode12/ByteDecode12 is lossless over 0..q-1', () {
295+
sweep(384, (o, b) => $polyToBytes(b, 0, o, 0),
296+
(b, out) => $polyFromBytes(out, 0, b, 0), 0);
297+
});
298+
test('Compress/Decompress d=4 within the error bound', () {
299+
// ceil(3329 / 2^5) = 105
300+
sweep(128, (o, b) => $polyCompress4(b, 0, o, 0),
301+
(b, out) => $polyDecompress4(out, 0, b, 0), 105);
302+
});
303+
test('Compress/Decompress d=5 within the error bound', () {
304+
// ceil(3329 / 2^6) = 53
305+
sweep(160, (o, b) => $polyCompress5(b, 0, o, 0),
306+
(b, out) => $polyDecompress5(out, 0, b, 0), 53);
307+
});
308+
test('Compress/Decompress d=10 within the error bound', () {
309+
// ceil(3329 / 2^11) = 2
310+
sweep(320, (o, b) => $vecCompress10(b, 0, o, 1),
311+
(b, out) => $vecDecompress10(out, b, 0, 1), 2);
312+
});
313+
test('Compress/Decompress d=11 within the error bound', () {
314+
// ceil(3329 / 2^12) = 1
315+
sweep(352, (o, b) => $vecCompress11(b, 0, o, 1),
316+
(b, out) => $vecDecompress11(out, b, 0, 1), 1);
317+
});
318+
test('poly message encode/decode round trip', () {
319+
// FIPS 203 Compress_1 / Decompress_1: a bit maps to 0 or 1665 and back.
320+
final poly = Int16List(256);
321+
final out = Uint8List(32);
322+
for (final msg in <List<int>>[
323+
List.filled(32, 0),
324+
List.filled(32, 0xFF),
325+
List.generate(32, (i) => (i * 37) & 0xFF),
326+
List.generate(32, (i) => (i * i * 91 + 13) & 0xFF),
327+
]) {
328+
final m = Uint8List.fromList(msg);
329+
$polyFromMsg(poly, 0, m, 0);
330+
$polyToMsg(out, 0, poly, 0);
331+
expect(out, equals(m), reason: toHex(m));
332+
}
333+
});
334+
});
335+
235336
group('correctness', () {
236337
test('rejection sampling continues across any number of XOF blocks', () {
237338
// SampleNTT needs more than the usual 3 XOF blocks with probability

0 commit comments

Comments
 (0)