lib/reticulum/src/destination/test.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty = @import("pretty");
  3 const reticulum = @import("../root.zig");
  4 
  5 const announce = reticulum.destination.announce;
  6 const conformance = reticulum.conformance;
  7 const destination = reticulum.destination;
  8 const identity = reticulum.identity;
  9 
 10 fn failBytes(
 11     vector_name: []const u8,
 12     field_name: []const u8,
 13     expected: []const u8,
 14     actual: []const u8,
 15 ) !void {
 16     var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
 17     defer arena.deinit();
 18     var report = try pretty.diagnostic.Report.init(
 19         arena.allocator(),
 20         "Reticulum destination conformance mismatch",
 21     );
 22     defer report.deinit();
 23     try report.field("vector", "{s}", .{vector_name});
 24     try report.field("field", "{s}", .{field_name});
 25     try report.field("expected", "{any}", .{expected});
 26     try report.field("actual", "{any}", .{actual});
 27     pretty.diagnostic.writeStderr(&report, .{ .width = 100 });
 28     return error.ConformanceMismatch;
 29 }
 30 
 31 fn expectBytes(
 32     vector_name: []const u8,
 33     field_name: []const u8,
 34     expected: []const u8,
 35     actual: []const u8,
 36 ) !void {
 37     if (!std.mem.eql(u8, expected, actual)) {
 38         return failBytes(vector_name, field_name, expected, actual);
 39     }
 40 }
 41 
 42 test "Reticulum@1.5.0 RNS/Destination.py:62-67,96-130 destination corpus" {
 43     const limits = destination.Limits{ .name_bytes_max = 512 };
 44     for (conformance.destination.vectors) |vector| {
 45         var output: [512]u8 = undefined;
 46         const identity_hash: ?[16]u8 = if (vector.identity_hash.len == 16)
 47             vector.identity_hash[0..16].*
 48         else
 49             null;
 50         const expanded = try destination.expandName(
 51             limits,
 52             vector.app_name,
 53             vector.aspects,
 54             identity_hash,
 55             &output,
 56         );
 57         try expectBytes(vector.name, "expanded_name", vector.expanded_name, expanded);
 58         const name_hash = try destination.nameHash(limits, vector.app_name, vector.aspects);
 59         try expectBytes(vector.name, "name_hash", vector.name_hash, &name_hash);
 60         const destination_hash = try destination.hash(
 61             limits,
 62             vector.app_name,
 63             vector.aspects,
 64             identity_hash,
 65         );
 66         try expectBytes(
 67             vector.name,
 68             "destination_hash",
 69             vector.destination_hash,
 70             &destination_hash,
 71         );
 72         const destination_type: destination.Type = switch (vector.destination_type) {
 73             0 => .single,
 74             1 => .group,
 75             2 => .plain,
 76             3 => .link,
 77             else => return error.ConformanceMismatch,
 78         };
 79         try std.testing.expectEqual(vector.destination_type, @backingInt(destination_type));
 80     }
 81 }
 82 
 83 test "Reticulum@1.5.0 RNS/Destination.py:62-67 destination type values" {
 84     try std.testing.expectEqual(@as(u2, 0), @backingInt(destination.Type.single));
 85     try std.testing.expectEqual(@as(u2, 1), @backingInt(destination.Type.group));
 86     try std.testing.expectEqual(@as(u2, 2), @backingInt(destination.Type.plain));
 87     try std.testing.expectEqual(@as(u2, 3), @backingInt(destination.Type.link));
 88 }
 89 
 90 fn rotatingKey(vector: conformance.announce.Vector) ?[32]u8 {
 91     if (vector.ratchet_public_key.len == 0) return null;
 92     return vector.ratchet_public_key[0..32].*;
 93 }
 94 
 95 fn checkAnnounceVector(vector: conformance.announce.Vector) !void {
 96     var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
 97     defer private.zero();
 98     var output: [announce.payload_bytes_max]u8 = undefined;
 99     const payload = try announce.build(.{
100         .destination_hash = vector.destination_hash[0..16].*,
101         .name_hash = vector.name_hash[0..10].*,
102         .random_hash = vector.random_hash[0..10].*,
103         .rotating_public_key = rotatingKey(vector),
104         .app_data = vector.app_data,
105     }, &private, &output);
106     const packet_header_bytes: usize = 19;
107     try expectBytes(vector.name, "announce_payload", vector.raw[packet_header_bytes..], payload);
108     const context_flag: u1 = if (vector.ratchet_public_key.len == 0) 0 else 1;
109     const decoded = try announce.validate(
110         vector.destination_hash[0..16].*,
111         .one,
112         context_flag,
113         payload,
114     );
115     try expectBytes(vector.name, "public_key", vector.public_key, &decoded.public_key.toBytes());
116     try expectBytes(vector.name, "name_hash", vector.name_hash, decoded.name_hash);
117     try expectBytes(vector.name, "random_hash", vector.random_hash, decoded.random_hash);
118     if (rotatingKey(vector)) |expected| {
119         try expectBytes(
120             vector.name,
121             "rotating_public_key",
122             &expected,
123             decoded.rotating_public_key orelse &.{},
124         );
125     } else if (decoded.rotating_public_key) |actual| {
126         return failBytes(vector.name, "rotating_public_key", &.{}, actual);
127     }
128     try expectBytes(vector.name, "app_data", vector.app_data, decoded.app_data);
129     try std.testing.expect(vector.validate_verdict);
130     try std.testing.expectError(
131         error.InvalidSignature,
132         announce.validate(
133             vector.destination_hash[0..16].*,
134             .one,
135             context_flag,
136             vector.corrupted_raw[packet_header_bytes..],
137         ),
138     );
139     try std.testing.expect(!vector.corrupted_validate_verdict);
140 }
141 
142 test "Reticulum@1.5.0 RNS/Destination.py:244-304 announce corpus" {
143     for (conformance.announce.vectors) |vector| try checkAnnounceVector(vector);
144 }
145 
146 test "destination name limit accepts max and rejects max plus one" {
147     const limits = destination.Limits{ .name_bytes_max = 5 };
148     var output: [5]u8 = undefined;
149     const exact = try destination.expandName(limits, "abcde", &.{}, null, &output);
150     try std.testing.expectEqualStrings("abcde", exact);
151     try std.testing.expectError(
152         error.NameTooLong,
153         destination.expandName(limits, "abcdef", &.{}, null, &output),
154     );
155     try std.testing.expectError(
156         error.DotInAppName,
157         destination.expandName(limits, "a.b", &.{}, null, &output),
158     );
159     try std.testing.expectError(
160         error.DotInAspect,
161         destination.expandName(limits, "a", &.{"b.c"}, null, &output),
162     );
163 }
164 
165 test "destination expanded-name suffix and output bounds" {
166     const identity_hash: [16]u8 = @splat(0xab);
167     const exact_limits = destination.Limits{ .name_bytes_max = 35 };
168     var exact_output: [35]u8 = undefined;
169     const exact = try destination.expandName(
170         exact_limits,
171         "ab",
172         &.{},
173         identity_hash,
174         &exact_output,
175     );
176     try std.testing.expectEqual(@as(usize, 35), exact.len);
177     const short_limits = destination.Limits{ .name_bytes_max = 34 };
178     try std.testing.expectError(error.NameTooLong, destination.expandName(
179         short_limits,
180         "ab",
181         &.{},
182         identity_hash,
183         &exact_output,
184     ));
185     var short_output: [34]u8 = undefined;
186     try std.testing.expectError(error.OutputTooSmall, destination.expandName(
187         exact_limits,
188         "ab",
189         &.{},
190         identity_hash,
191         &short_output,
192     ));
193 }
194 
195 fn announceFields(app_data: []const u8, rotating: ?[32]u8) announce.Fields {
196     return .{
197         .destination_hash = @splat(0x10),
198         .name_hash = @splat(0x20),
199         .random_hash = @splat(0x30),
200         .rotating_public_key = rotating,
201         .app_data = app_data,
202     };
203 }
204 
205 test "announce app-data limits accept max and reject max plus one" {
206     const private_bytes: identity.KeyBytes = @splat(0x42);
207     var private = identity.Private.fromBytes(private_bytes);
208     defer private.zero();
209     var output: [announce.payload_bytes_max]u8 = undefined;
210 
211     const plain_max: [announce.app_bytes_max]u8 = @splat(0x51);
212     try std.testing.expectEqual(announce.payload_bytes_max, (try announce.build(
213         announceFields(&plain_max, null),
214         &private,
215         &output,
216     )).len);
217     const plain_too_long: [announce.app_bytes_max + 1]u8 = @splat(0x52);
218     try std.testing.expectError(error.AppDataTooLong, announce.build(
219         announceFields(&plain_too_long, null),
220         &private,
221         &output,
222     ));
223 
224     const rotating: [32]u8 = @splat(0x61);
225     const rotating_max: [announce.rotating_app_bytes_max]u8 = @splat(0x62);
226     try std.testing.expectEqual(announce.payload_bytes_max, (try announce.build(
227         announceFields(&rotating_max, rotating),
228         &private,
229         &output,
230     )).len);
231     const rotating_too_long: [announce.rotating_app_bytes_max + 1]u8 = @splat(0x63);
232     try std.testing.expectError(error.AppDataTooLong, announce.build(
233         announceFields(&rotating_too_long, rotating),
234         &private,
235         &output,
236     ));
237 }
238 
239 test "announce output and payload limits reject max plus one" {
240     const private_bytes: identity.KeyBytes = @splat(0x42);
241     var private = identity.Private.fromBytes(private_bytes);
242     defer private.zero();
243     const fixed_bytes = announce.payload_bytes_max - announce.app_bytes_max;
244     var exact_output: [fixed_bytes]u8 = undefined;
245     const payload = try announce.build(announceFields(&.{}, null), &private, &exact_output);
246     try std.testing.expectEqual(@as(usize, fixed_bytes), payload.len);
247     var short_output: [fixed_bytes - 1]u8 = undefined;
248     try std.testing.expectError(
249         error.OutputTooSmall,
250         announce.build(announceFields(&.{}, null), &private, &short_output),
251     );
252     const too_long: [announce.payload_bytes_max + 1]u8 = @splat(0);
253     try std.testing.expectError(
254         error.PayloadTooLong,
255         announce.validate(@splat(0), .two, 0, &too_long),
256     );
257 }
258 
259 fn packetVector(name: []const u8) !conformance.packet.Vector {
260     for (conformance.packet.vectors) |vector| {
261         if (std.mem.eql(u8, name, vector.name)) return vector;
262     }
263     return error.MissingVector;
264 }
265 
266 test "Reticulum@1.5.0 RNS/Reticulum.py:93,151,155 announce payload and MDU bounds" {
267     try std.testing.expectEqual(
268         destination.cipher.plain_mdu + 1,
269         announce.payload_bytes_max,
270     );
271     const maximum = try packetVector("announce-single-payload-max");
272     const above = try packetVector("announce-single-payload-max-plus-one");
273     try std.testing.expectEqual(@as(u1, 1), maximum.header_type);
274     try std.testing.expectEqual(@as(u1, 1), above.header_type);
275     try std.testing.expectEqual(@as(usize, announce.payload_bytes_max), maximum.plaintext.len);
276     try std.testing.expectEqual(@as(usize, announce.payload_bytes_max + 1), above.plaintext.len);
277     try std.testing.expect(maximum.pack_verdict);
278     try std.testing.expect(!above.pack_verdict);
279 }
280 
281 test "Reticulum@1.5.0 RNS/Packet.py:236-237 announce validation bounds payload by header" {
282     const vector = try packetVector("announce-single-header1-payload-max");
283     try std.testing.expectEqual(@as(u1, 0), vector.header_type);
284     try std.testing.expect(vector.pack_verdict);
285     try std.testing.expectEqual(@as(usize, reticulum.wire.mtu), vector.raw.len);
286     try std.testing.expectEqual(
287         @as(usize, announce.received_payload_bytes_max),
288         vector.plaintext.len,
289     );
290     const hash = vector.destination_hash[0..16].*;
291     const decoded = try announce.validate(hash, .one, 0, vector.plaintext);
292     try std.testing.expectEqual(
293         @as(usize, announce.received_app_bytes_max),
294         decoded.app_data.len,
295     );
296     try std.testing.expectError(
297         error.PayloadTooLong,
298         announce.validate(hash, .two, 0, vector.plaintext),
299     );
300     const above: [announce.received_payload_bytes_max + 1]u8 = @splat(0);
301     try std.testing.expectError(error.PayloadTooLong, announce.validate(hash, .one, 0, &above));
302     try std.testing.expectEqual(announce.payload_bytes_max, announce.payloadBytesMax(.two));
303 }
304 
305 test "Reticulum@1.5.0 RNS/Destination.py:596-665 data-single-hop-0 cipher corpus" {
306     const vector = try packetVector("data-single-hop-0");
307     const packet = try reticulum.wire.decode(vector.raw);
308     var private = identity.Private.fromBytes(
309         vector.destination_private_key[0..identity.key_bytes].*,
310     );
311     defer private.zero();
312     var public = private.public();
313     defer public.zero();
314     var encrypted_storage: [reticulum.wire.mtu]u8 = undefined;
315     const encrypted = try destination.cipher.encrypt(.{ .single = .{
316         .public = &public,
317         .ratchet_public = null,
318         .ephemeral_private = @ptrCast(vector.ephemeral_private_key.ptr),
319         .iv = vector.iv[0..16].*,
320     } }, vector.plaintext, &encrypted_storage);
321     try expectBytes(vector.name, "ciphertext", packet.payload, encrypted.ciphertext);
322     try std.testing.expectEqual(@as(?[10]u8, null), encrypted.ratchet_id);
323     var plaintext_storage: [reticulum.wire.mtu]u8 = undefined;
324     const decrypted = try destination.cipher.decrypt(.{ .single = .{
325         .private = &private,
326         .ratchets = &.{},
327         .enforce_ratchets = false,
328     } }, packet.payload, &plaintext_storage);
329     try expectBytes(vector.name, "plaintext", vector.plaintext, decrypted.plaintext);
330     try std.testing.expectEqual(@as(?[10]u8, null), decrypted.ratchet_id);
331 }
332 
333 test "Reticulum@1.5.0 RNS/Destination.py:596-665 data-group-hop-1 cipher corpus" {
334     const vector = try packetVector("data-group-hop-1");
335     const packet = try reticulum.wire.decode(vector.raw);
336     const key = vector.destination_key[0..64].*;
337     var encrypted_storage: [reticulum.wire.mtu]u8 = undefined;
338     const encrypted = try destination.cipher.encrypt(.{ .group = .{
339         .key = &key,
340         .iv = vector.iv[0..16].*,
341     } }, vector.plaintext, &encrypted_storage);
342     try expectBytes(vector.name, "ciphertext", packet.payload, encrypted.ciphertext);
343     try std.testing.expectEqual(@as(?[10]u8, null), encrypted.ratchet_id);
344     var plaintext_storage: [reticulum.wire.mtu]u8 = undefined;
345     const decrypted = try destination.cipher.decrypt(
346         .{ .group = .{ .key = &key } },
347         packet.payload,
348         &plaintext_storage,
349     );
350     try expectBytes(vector.name, "plaintext", vector.plaintext, decrypted.plaintext);
351     try std.testing.expectEqual(@as(?[10]u8, null), decrypted.ratchet_id);
352 }
353 
354 test "destination cipher copies plain payloads and rejects link keys" {
355     const plaintext = "plain destination payload";
356     var output: [64]u8 = undefined;
357     const encrypted = try destination.cipher.encrypt(
358         .{ .plain = {} },
359         plaintext,
360         &output,
361     );
362     try std.testing.expectEqualStrings(plaintext, encrypted.ciphertext);
363     const decrypted = try destination.cipher.decrypt(
364         .{ .plain = {} },
365         encrypted.ciphertext,
366         &output,
367     );
368     try std.testing.expectEqualStrings(plaintext, decrypted.plaintext);
369     try std.testing.expectError(
370         error.Unsupported,
371         destination.cipher.encrypt(.{ .link = {} }, plaintext, &output),
372     );
373     try std.testing.expectError(
374         error.Unsupported,
375         destination.cipher.decrypt(.{ .link = {} }, plaintext, &output),
376     );
377 }
378 
379 test "Reticulum@1.5.0 RNS/Packet.py:106 encrypted MDU boundary" {
380     const vector = try packetVector("data-single-hop-0");
381     var private = identity.Private.fromBytes(
382         vector.destination_private_key[0..identity.key_bytes].*,
383     );
384     defer private.zero();
385     var public = private.public();
386     defer public.zero();
387     const keys = destination.cipher.EncryptKeys{ .single = .{
388         .public = &public,
389         .ratchet_public = null,
390         .ephemeral_private = @ptrCast(vector.ephemeral_private_key.ptr),
391         .iv = vector.iv[0..16].*,
392     } };
393     const fits_plaintext: [destination.cipher.encrypted_mdu]u8 = @splat(0x51);
394     const exceeds_plaintext: [destination.cipher.encrypted_mdu + 1]u8 = @splat(0x52);
395     var fits_storage: [464]u8 = undefined;
396     var exceeds_storage: [480]u8 = undefined;
397     const fits = try destination.cipher.encrypt(keys, &fits_plaintext, &fits_storage);
398     const exceeds = try destination.cipher.encrypt(keys, &exceeds_plaintext, &exceeds_storage);
399     const truncated_bits: u16 = 128;
400     const header_max: u16 = 2 + 1 + (truncated_bits / 8) * 2;
401     const reference_mdu = reticulum.wire.mtu - header_max - 1;
402     try std.testing.expectEqual(@as(u16, 464), reference_mdu);
403     try std.testing.expectEqual(@as(usize, reference_mdu), fits.ciphertext.len);
404     try std.testing.expectEqual(@as(usize, 480), exceeds.ciphertext.len);
405     try std.testing.expect(fits.ciphertext.len <= reference_mdu);
406     try std.testing.expect(exceeds.ciphertext.len > reference_mdu);
407     try std.testing.expectEqual(reference_mdu, destination.cipher.plain_mdu);
408 }