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 }