@@ -7,7 +7,20 @@ import 'package:cipherlib/cipherlib.dart';
77import 'package:cipherlib/codecs.dart' ;
88import 'package:cipherlib/hashlib.dart' show HashDigest;
99import '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;
1124import 'package:test/test.dart' ;
1225
1326import '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