lib/reticulum/src/wire/link.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const flags = @import("flags.zig");
3 const hash = @import("hash.zig");
4 const header = @import("header.zig");
5
6 /// Sixty-four bytes, the payload of an opening packet (*link request*) with no
7 /// signalling: two 32-byte keys back to back, the initiator's encryption key
8 /// and then its signing key, for sizing or checking the payload an initiator
9 /// sends to open an encrypted session (*link*), following Reticulum@1.5.0
10 /// RNS/Link.py:70,189.
11 pub const request_bytes: u8 = 64;
12 /// Three bytes, which an opening packet (*link request*) or a proof packet
13 /// (*link request proof*) may carry after its fixed body to state the link MTU
14 /// and the cipher, for working out how much longer a request or a proof gets
15 /// once it states an MTU and a cipher, following Reticulum@1.5.0
16 /// RNS/Link.py:80,148-151,312,371.
17 pub const signalling_bytes: u8 = 3;
18 /// Sixty-seven bytes, a link request payload with signalling, which together
19 /// with 64 makes the pair of lengths the receiving end (*responder*) accepts,
20 /// for checking an arriving request against the second of the two lengths a
21 /// responder takes, following Reticulum@1.5.0 RNS/Link.py:187.
22 pub const signalled_request_bytes: u8 = request_bytes + signalling_bytes;
23 /// Ninety-six bytes, a link request proof payload with no signalling: a 64-byte
24 /// Ed25519 signature and then 32 bytes of the responder's encryption key, for
25 /// sizing or checking the payload the receiving end (*responder*) sends back to
26 /// accept an encrypted session (*link*), following Reticulum@1.5.0
27 /// RNS/Link.py:405-406.
28 pub const proof_bytes: u8 = 96;
29 /// Ninety-nine bytes, a link request proof payload with signalling, and the one
30 /// length from which an initiator reads a confirmed MTU, so the opening end
31 /// (*initiator*) can check whether the proof it received settles the link MTU,
32 /// following Reticulum@1.5.0 RNS/Link.py:399-402.
33 pub const signalled_proof_bytes: u8 = proof_bytes + signalling_bytes;
34 /// Eighty-three bytes, the widest signing input a link request proof ever has,
35 /// which runs the 16-byte session identifier (*link id*), then responder
36 /// encryption key, then the signing key of the destination's identity, then
37 /// whatever signalling rode along, for sizing the buffer a caller builds a
38 /// proof's signing message in, following Reticulum@1.5.0 RNS/Link.py:368,412.
39 pub const signed_proof_bytes_max: u8 = header.truncated_hash_bytes + 32 + 32 + signalling_bytes;
40 /// Nine bytes, what the opening end (*initiator*) sends to report its round
41 /// trip time: a leading 0xcb, which is how MessagePack introduces a double, and
42 /// then that double's eight bytes, most significant first, for sizing the
43 /// plaintext that reports a round trip time over a new encrypted session
44 /// (*link*), following Reticulum@1.5.0 RNS/vendor/umsgpack.py:329.
45 pub const rtt_bytes: u8 = 9;
46 /// 0xff, the one-byte payload an initiator alone sends to keep a quiet link
47 /// alive, which the responder recognizes, following Reticulum@1.5.0
48 /// RNS/Link.py:750,800. An initiator that hears one back reports a link state
49 /// mismatch.
50 pub const keepalive_request: u8 = 0xff;
51 /// 0xfe, the one-byte payload a responder returns once a keepalive request
52 /// reaches it, so an initiator recognizes its keepalive was heard, following
53 /// Reticulum@1.5.0 RNS/Link.py:1131-1133.
54 pub const keepalive_answer: u8 = 0xfe;
55
56 const float64_marker: u8 = 0xcb;
57
58 /// The three-bit cipher mode a link runs under, for a caller reading the cipher
59 /// out of three signalled bits. The reference names eight values and enables
60 /// AES-256-CBC alone, following Reticulum@1.5.0 RNS/Link.py:125-133. No
61 /// three-bit value is left without a name here, so decoding one always lands on
62 /// a cipher.
63 pub const Mode = enum(u3) {
64 aes128_cbc = 0,
65 aes256_cbc = 1,
66 aes256_gcm = 2,
67 otp_reserved = 3,
68 pq_reserved_1 = 4,
69 pq_reserved_2 = 5,
70 pq_reserved_3 = 6,
71 pq_reserved_4 = 7,
72 };
73
74 /// AES-256-CBC, the cipher that a request or proof carrying no signalling runs
75 /// under and the single cipher the reference turns on, for working out which
76 /// cipher applies when a request or proof signalled nothing, following
77 /// Reticulum@1.5.0 RNS/Link.py:133-134,176,183.
78 pub const default_mode: Mode = .aes256_cbc;
79
80 /// The link MTU and cipher a request or proof states, for a caller reading or
81 /// stating what an encrypted session (*link*) will run under. The three bytes
82 /// make one 24-bit value, most significant byte first, whose three highest bits
83 /// carry the cipher and whose remaining 21 carry the MTU, following
84 /// Reticulum@1.5.0 RNS/Link.py:144-151.
85 pub const Signalling = struct {
86 mtu: u21,
87 mode: Mode,
88
89 /// Returns the three bytes with the cipher in the three highest bits and
90 /// the MTU in the 21 below them, most significant byte first, for a caller
91 /// appending its signalling to the request or proof it is about to send,
92 /// following Reticulum@1.5.0 RNS/Link.py:148-151.
93 pub fn encode(self: Signalling) [signalling_bytes]u8 {
94 const value = (@as(u24, @backingInt(self.mode)) << 21) | self.mtu;
95 var bytes: [signalling_bytes]u8 = undefined;
96 std.mem.writeInt(u24, &bytes, value, .big);
97 return bytes;
98 }
99
100 /// Pulls the MTU out of the 21 lowest bits and the cipher out of the three
101 /// highest for a caller reading the values an arriving request or proof
102 /// carried, following Reticulum@1.5.0 RNS/Link.py:154-164,172-183. Requests
103 /// and proofs signal in the same layout, so one reader serves both.
104 pub fn decode(bytes: [signalling_bytes]u8) Signalling {
105 const value = std.mem.readInt(u24, &bytes, .big);
106 return .{
107 .mtu = @truncate(value),
108 .mode = @fromBackingInt(@as(u3, @truncate(value >> 21))),
109 };
110 }
111 };
112
113 /// The MTU 500 with AES-256-CBC that an initiator sends on an interface
114 /// reporting no hardware MTU of its own, and that the responder confirms. The
115 /// reference signals MTU 500 when the interface toward the next hop reports no
116 /// hardware MTU of its own, following Reticulum@1.5.0 RNS/Link.py:307-310.
117 pub const default_signalling: Signalling = .{ .mtu = header.mtu, .mode = default_mode };
118
119 pub const DecodeError = error{InvalidLength};
120 pub const EncodeError = error{OutputTooSmall};
121 pub const RttError = error{InvalidRtt};
122
123 /// The three fields of a link request payload to open a session: the
124 /// initiator's encryption key, its signing key, and, once the payload runs long
125 /// enough, the signalling, so a responder reads these three fields out of the
126 /// request that reached it, following Reticulum@1.5.0 RNS/Link.py:312.
127 pub const Request = struct {
128 encryption_public: [32]u8,
129 signing_public: [32]u8,
130 signalling: ?Signalling,
131
132 /// Splits a link request payload of 64 or 67 bytes to open a session into
133 /// the two keys and any signalling for a responder, following
134 /// Reticulum@1.5.0 RNS/Link.py:187-189. The call returns
135 /// `error.InvalidLength` for every other length.
136 pub fn decode(payload: []const u8) DecodeError!Request {
137 const signalling: ?Signalling = switch (payload.len) {
138 request_bytes => null,
139 signalled_request_bytes => Signalling.decode(
140 payload[request_bytes..][0..signalling_bytes].*,
141 ),
142 else => return error.InvalidLength,
143 };
144 return .{
145 .encryption_public = payload[0..32].*,
146 .signing_public = payload[32..request_bytes].*,
147 .signalling = signalling,
148 };
149 }
150
151 pub fn encodedLength(self: Request) u8 {
152 return if (self.signalling == null) request_bytes else signalled_request_bytes;
153 }
154
155 /// Puts both keys at the start of `out`, follows them with the signalling
156 /// when there is any, and gives back what it wrote, so the initiator
157 /// sending the request writes it into its outgoing buffer, following
158 /// Reticulum@1.5.0 RNS/Link.py:312. The call returns `error.OutputTooSmall`
159 /// when `out` is shorter than the payload.
160 pub fn encode(self: Request, out: []u8) EncodeError![]u8 {
161 const length: usize = self.encodedLength();
162 if (out.len < length) return error.OutputTooSmall;
163 out[0..32].* = self.encryption_public;
164 out[32..request_bytes].* = self.signing_public;
165 if (self.signalling) |value| out[request_bytes..][0..signalling_bytes].* = value.encode();
166 return out[0..length];
167 }
168
169 /// Returns the cipher mode that the request signalled, and AES-256-CBC when
170 /// it signalled none, so a responder learns which cipher the initiator
171 /// asked for, following Reticulum@1.5.0 RNS/Link.py:172-176.
172 pub fn mode(self: Request) Mode {
173 const value = self.signalling orelse return default_mode;
174 return value.mode;
175 }
176 };
177
178 /// The three fields of a link request proof payload confirming acceptance: the
179 /// signature made by the destination's identity, the responder's encryption
180 /// key, and, once the payload runs long enough, the signalling, so an initiator
181 /// reads these three fields out of the proof that came back, following
182 /// Reticulum@1.5.0 RNS/Link.py:369-371.
183 pub const Proof = struct {
184 signature: [64]u8,
185 encryption_public: [32]u8,
186 signalling: ?Signalling,
187
188 /// Splits a link request proof payload of 96 or 99 bytes confirming
189 /// acceptance into the signature, the key, and any signalling for an
190 /// initiator, following Reticulum@1.5.0 RNS/Link.py:399-406. The call
191 /// returns `error.InvalidLength` for every other length.
192 pub fn decode(payload: []const u8) DecodeError!Proof {
193 const signalling: ?Signalling = switch (payload.len) {
194 proof_bytes => null,
195 signalled_proof_bytes => Signalling.decode(
196 payload[proof_bytes..][0..signalling_bytes].*,
197 ),
198 else => return error.InvalidLength,
199 };
200 return .{
201 .signature = payload[0..64].*,
202 .encryption_public = payload[64..proof_bytes].*,
203 .signalling = signalling,
204 };
205 }
206
207 pub fn encodedLength(self: Proof) u8 {
208 return if (self.signalling == null) proof_bytes else signalled_proof_bytes;
209 }
210
211 /// Puts the signature at the start of `out`, follows it with the encryption
212 /// key and then the signalling when there is any, and gives back what it
213 /// wrote, so the responder confirming the link writes the proof into its
214 /// outgoing buffer, following Reticulum@1.5.0 RNS/Link.py:371. The call
215 /// returns `error.OutputTooSmall` when `out` is shorter than the payload.
216 pub fn encode(self: Proof, out: []u8) EncodeError![]u8 {
217 const length: usize = self.encodedLength();
218 if (out.len < length) return error.OutputTooSmall;
219 out[0..64].* = self.signature;
220 out[64..proof_bytes].* = self.encryption_public;
221 if (self.signalling) |value| out[proof_bytes..][0..signalling_bytes].* = value.encode();
222 return out[0..length];
223 }
224 };
225
226 /// Reads the cipher mode out of the top three bits of byte 96 once a proof
227 /// payload runs past 96 bytes, and gives AES-256-CBC for a payload of 96 bytes
228 /// or fewer, so a caller reads a proof's cipher without splitting the whole
229 /// payload first, following Reticulum@1.5.0 RNS/Link.py:179-183,396-398.
230 pub fn proofMode(payload: []const u8) Mode {
231 if (payload.len <= proof_bytes) return default_mode;
232 return @fromBackingInt(@as(u3, @truncate(payload[proof_bytes] >> 5)));
233 }
234
235 /// Builds in `out` the bytes a link request proof puts its signature over, and
236 /// returns them, running the 16-byte link id naming the session, then responder
237 /// encryption key, then the signing key of the destination's identity, then
238 /// whatever signalling rode along, so a responder builds the message it signs
239 /// and an initiator builds the same message to check that signature, following
240 /// Reticulum@1.5.0 RNS/Link.py:368,412. A proof with no signalling signs 80
241 /// bytes, and one with signalling signs 83.
242 pub fn signedProof(
243 link_id: [header.truncated_hash_bytes]u8,
244 encryption_public: [32]u8,
245 signing_public: [32]u8,
246 signalling: ?Signalling,
247 out: *[signed_proof_bytes_max]u8,
248 ) []const u8 {
249 out[0..16].* = link_id;
250 out[16..48].* = encryption_public;
251 out[48..80].* = signing_public;
252 const value = signalling orelse return out[0..80];
253 out[80..signed_proof_bytes_max].* = value.encode();
254 return out[0..signed_proof_bytes_max];
255 }
256
257 /// Returns the 16-byte truncated hash that names an encrypted session between
258 /// two endpoints (*link*), taken over the request packet's hashable part with
259 /// everything past the 64 key bytes left off, following Reticulum@1.5.0
260 /// RNS/Link.py:335-342. Trimming that way keeps the name the same with or
261 /// without three signalling bytes, and with or without the sixteen bytes a
262 /// carrying node inserts, so a relay and the two ends all arrive at one link
263 /// id. The function returns `error.PacketTooShort` when `raw` is shorter than
264 /// the header its flags byte claims.
265 pub fn linkId(raw: []const u8) hash.HashError![header.truncated_hash_bytes]u8 {
266 if (raw.len < 1) return error.PacketTooShort;
267 const payload_start: usize = header.headerLength(flags.Flags.decode(raw[0]).header);
268 if (raw.len < payload_start) return error.PacketTooShort;
269 const excess = (raw.len - payload_start) -| request_bytes;
270 return hash.truncated(raw[0 .. raw.len - excess]);
271 }
272
273 /// Returns the nine bytes that report a round trip time in seconds: the 0xcb
274 /// marker and then the double's bits, most significant byte first, following
275 /// Reticulum@1.5.0 RNS/vendor/umsgpack.py:325-333. The call asserts that the
276 /// seconds it was handed are finite and that their sign bit is clear.
277 pub fn encodeRtt(seconds: f64) [rtt_bytes]u8 {
278 std.debug.assert(std.math.isFinite(seconds));
279 std.debug.assert(!std.math.signbit(seconds));
280 var bytes: [rtt_bytes]u8 = undefined;
281 bytes[0] = float64_marker;
282 std.mem.writeInt(u64, bytes[1..rtt_bytes], @bitCast(seconds), .big);
283 return bytes;
284 }
285
286 /// Returns the round trip time carried by a nine-byte plaintext whose first
287 /// byte is 0xcb and whose remaining eight hold a double that is finite with its
288 /// sign bit clear, so a responder reads what the initiator reported to bring
289 /// the link up, the form Reticulum@1.5.0 RNS/vendor/umsgpack.py:768-769
290 /// accepts. The function returns `error.InvalidRtt` for every other plaintext,
291 /// negative zero, negative values, the infinities, and NaN among them. The
292 /// reference reads more forms than this: it unpacks any MessagePack value at
293 /// Reticulum@1.5.0 RNS/Link.py:521 and hands it to `max` at Reticulum@1.5.0
294 /// RNS/Link.py:522, so a 32-bit float under the 0xca marker, an integer, or any
295 /// other floating value reaches it as well. The reference drops the link at
296 /// Reticulum@1.5.0 RNS/Link.py:536-538 whenever either that unpacking or that
297 /// comparison throws.
298 pub fn decodeRtt(plaintext: []const u8) RttError!f64 {
299 if (plaintext.len != rtt_bytes) return error.InvalidRtt;
300 if (plaintext[0] != float64_marker) return error.InvalidRtt;
301 const seconds: f64 = @bitCast(std.mem.readInt(u64, plaintext[1..rtt_bytes], .big));
302 if (!std.math.isFinite(seconds)) return error.InvalidRtt;
303 if (std.math.signbit(seconds)) return error.InvalidRtt;
304 return seconds;
305 }
306
307 /// Returns whether the decrypted plaintext is exactly the link id, the payload
308 /// carried by the packet an end sends to close the link, so a caller decides
309 /// whether that plaintext closes the link, following Reticulum@1.5.0
310 /// RNS/Link.py:674-678. No other plaintext closes a link.
311 pub fn closes(plaintext: []const u8, link_id: [header.truncated_hash_bytes]u8) bool {
312 return std.mem.eql(u8, plaintext, &link_id);
313 }
314
315 fn patternedKey(salt: u8) [32]u8 {
316 var bytes: [32]u8 = undefined;
317 for (&bytes, 0..) |*byte, index| byte.* = @intCast((index * 37 + salt) & 0xff);
318 return bytes;
319 }
320
321 fn requestFrame(
322 payload: []const u8,
323 transport_id: ?[16]u8,
324 hops: u8,
325 out: *[header.mtu]u8,
326 ) ![]u8 {
327 return header.encode(.{
328 .ifac = 0,
329 .header = if (transport_id == null) .one else .two,
330 .context_flag = 0,
331 .transport = if (transport_id == null) .broadcast else .transport,
332 .destination_type = .single,
333 .packet_type = .link_request,
334 .hops = hops,
335 .transport_id = transport_id,
336 .destination = @splat(0x5d),
337 .context = .none,
338 .payload = payload,
339 }, out);
340 }
341
342 test "Reticulum@1.5.0 RNS/Link.py:148-151 signals MTU 500 and AES-256-CBC as 2001f4" {
343 const bytes = default_signalling.encode();
344 try std.testing.expectEqualSlices(u8, &.{ 0x20, 0x01, 0xf4 }, &bytes);
345 try std.testing.expectEqual(default_signalling, Signalling.decode(bytes));
346 }
347
348 test "Reticulum@1.5.0 RNS/Link.py:144-183 signalling keeps every mode and 21 MTU bits" {
349 const mtus = [_]u21{ 0, 1, header.mtu, std.math.maxInt(u21) };
350 for (std.enums.values(Mode)) |value| {
351 for (mtus) |mtu| {
352 const signalling = Signalling{ .mtu = mtu, .mode = value };
353 const bytes = signalling.encode();
354 try std.testing.expectEqual(@as(u8, @backingInt(value)), bytes[0] >> 5);
355 try std.testing.expectEqual(signalling, Signalling.decode(bytes));
356 }
357 }
358 }
359
360 test "Reticulum@1.5.0 RNS/Link.py:187 decodes only 64 and 67 request bytes" {
361 var payload: [header.mtu]u8 = undefined;
362 for (&payload, 0..) |*byte, index| byte.* = @intCast(index & 0xff);
363 for (0..payload.len + 1) |length| {
364 const decoded = Request.decode(payload[0..length]) catch |err| {
365 try std.testing.expectEqual(error.InvalidLength, err);
366 try std.testing.expect(length != request_bytes and length != signalled_request_bytes);
367 continue;
368 };
369 try std.testing.expect(length == request_bytes or length == signalled_request_bytes);
370 try std.testing.expectEqual(length == signalled_request_bytes, decoded.signalling != null);
371 var out: [signalled_request_bytes]u8 = undefined;
372 try std.testing.expectEqualSlices(u8, payload[0..length], try decoded.encode(&out));
373 }
374 }
375
376 test "Reticulum@1.5.0 RNS/Link.py:172-176 uses the default request mode without signalling" {
377 const unsignalled = Request{
378 .encryption_public = patternedKey(1),
379 .signing_public = patternedKey(2),
380 .signalling = null,
381 };
382 try std.testing.expectEqual(default_mode, unsignalled.mode());
383 var signalled = unsignalled;
384 signalled.signalling = .{ .mtu = header.mtu, .mode = .aes128_cbc };
385 try std.testing.expectEqual(Mode.aes128_cbc, signalled.mode());
386 var out: [signalled_request_bytes - 1]u8 = undefined;
387 try std.testing.expectError(error.OutputTooSmall, signalled.encode(&out));
388 }
389
390 test "Reticulum@1.5.0 RNS/Link.py:399-406 decodes only 96 and 99 proof bytes" {
391 var payload: [header.mtu]u8 = undefined;
392 for (&payload, 0..) |*byte, index| byte.* = @intCast((index * 7) & 0xff);
393 for (0..payload.len + 1) |length| {
394 const decoded = Proof.decode(payload[0..length]) catch |err| {
395 try std.testing.expectEqual(error.InvalidLength, err);
396 try std.testing.expect(length != proof_bytes and length != signalled_proof_bytes);
397 continue;
398 };
399 try std.testing.expect(length == proof_bytes or length == signalled_proof_bytes);
400 var out: [signalled_proof_bytes]u8 = undefined;
401 try std.testing.expectEqualSlices(u8, payload[0..length], try decoded.encode(&out));
402 }
403 }
404
405 test "Reticulum@1.5.0 RNS/Link.py:179-183 reads a proof mode past byte 95" {
406 var payload: [signalled_proof_bytes + 1]u8 = @splat(0);
407 try std.testing.expectEqual(default_mode, proofMode(payload[0..proof_bytes]));
408 payload[proof_bytes] = 0x40;
409 try std.testing.expectEqual(Mode.aes256_gcm, proofMode(payload[0 .. proof_bytes + 1]));
410 try std.testing.expectEqual(Mode.aes256_gcm, proofMode(&payload));
411 payload[proof_bytes] = 0xff;
412 try std.testing.expectEqual(Mode.pq_reserved_4, proofMode(payload[0..signalled_proof_bytes]));
413 }
414
415 test "Reticulum@1.5.0 RNS/Link.py:368,412 signs signalling only when a proof carries it" {
416 var out: [signed_proof_bytes_max]u8 = undefined;
417 const link_id: [16]u8 = @splat(0x11);
418 const unsignalled = signedProof(link_id, patternedKey(3), patternedKey(4), null, &out);
419 try std.testing.expectEqual(@as(usize, 80), unsignalled.len);
420 try std.testing.expectEqualSlices(u8, &link_id, unsignalled[0..16]);
421 try std.testing.expectEqualSlices(u8, &patternedKey(3), unsignalled[16..48]);
422 try std.testing.expectEqualSlices(u8, &patternedKey(4), unsignalled[48..80]);
423 const signalled = signedProof(
424 link_id,
425 patternedKey(3),
426 patternedKey(4),
427 default_signalling,
428 &out,
429 );
430 try std.testing.expectEqual(@as(usize, signed_proof_bytes_max), signalled.len);
431 try std.testing.expectEqualSlices(u8, &.{ 0x20, 0x01, 0xf4 }, signalled[80..]);
432 }
433
434 test "Reticulum@1.5.0 RNS/Link.py:335-342 link ids ignore signalling and transport headers" {
435 const request = Request{
436 .encryption_public = patternedKey(5),
437 .signing_public = patternedKey(6),
438 .signalling = default_signalling,
439 };
440 var payload: [signalled_request_bytes]u8 = undefined;
441 const signalled = try request.encode(&payload);
442 var frames: [4][header.mtu]u8 = undefined;
443 const direct = try requestFrame(signalled, null, 0, &frames[0]);
444 const stripped = try requestFrame(signalled[0..request_bytes], null, 1, &frames[1]);
445 const inserted = try requestFrame(signalled, @splat(0x77), 2, &frames[2]);
446 const expected = try hash.truncated(stripped);
447 try std.testing.expectEqualSlices(u8, &expected, &try linkId(direct));
448 try std.testing.expectEqualSlices(u8, &expected, &try linkId(stripped));
449 try std.testing.expectEqualSlices(u8, &expected, &try linkId(inserted));
450 const shorter = try requestFrame(signalled[0 .. request_bytes - 1], null, 0, &frames[3]);
451 try std.testing.expectEqualSlices(u8, &try hash.truncated(shorter), &try linkId(shorter));
452 try std.testing.expectError(error.PacketTooShort, linkId(&.{}));
453 const partial_header = direct[0 .. header.header_one_bytes - 1];
454 try std.testing.expectError(error.PacketTooShort, linkId(partial_header));
455 }
456
457 test "Reticulum@1.5.0 RNS/vendor/umsgpack.py:329 encodes an RTT of 0.25 as cb3fd0000000000000" {
458 const bytes = encodeRtt(0.25);
459 const expected = [_]u8{ 0xcb, 0x3f, 0xd0, 0, 0, 0, 0, 0, 0 };
460 try std.testing.expectEqualSlices(u8, &expected, &bytes);
461 try std.testing.expectEqual(@as(f64, 0.25), try decodeRtt(&bytes));
462 try std.testing.expectEqual(@as(f64, 0), try decodeRtt(&encodeRtt(0)));
463 }
464
465 test "RTT decoding rejects every first byte other than 0xcb" {
466 var plaintext = encodeRtt(1.0);
467 for (0..256) |byte| {
468 plaintext[0] = @intCast(byte);
469 if (byte == float64_marker) {
470 try std.testing.expectEqual(@as(f64, 1.0), try decodeRtt(&plaintext));
471 } else {
472 try std.testing.expectError(error.InvalidRtt, decodeRtt(&plaintext));
473 }
474 }
475 }
476
477 test "RTT decoding rejects other lengths, non-finite values, and set sign bits" {
478 var long: [rtt_bytes + 1]u8 = @splat(0);
479 long[0] = float64_marker;
480 for (0..long.len + 1) |length| {
481 if (length == rtt_bytes) continue;
482 try std.testing.expectError(error.InvalidRtt, decodeRtt(long[0..length]));
483 }
484 const rejected = [_]f64{
485 -0.0,
486 -1.0,
487 std.math.inf(f64),
488 -std.math.inf(f64),
489 std.math.nan(f64),
490 };
491 for (rejected) |seconds| {
492 var plaintext: [rtt_bytes]u8 = undefined;
493 plaintext[0] = float64_marker;
494 std.mem.writeInt(u64, plaintext[1..rtt_bytes], @bitCast(seconds), .big);
495 try std.testing.expectError(error.InvalidRtt, decodeRtt(&plaintext));
496 }
497 const largest = encodeRtt(std.math.floatMax(f64));
498 try std.testing.expectEqual(std.math.floatMax(f64), try decodeRtt(&largest));
499 }
500
501 test "Reticulum@1.5.0 RNS/Link.py:674-683 closes only on the link id plaintext" {
502 const link_id: [16]u8 = @splat(0x3c);
503 try std.testing.expect(closes(&link_id, link_id));
504 var changed = link_id;
505 changed[15] ^= 1;
506 try std.testing.expect(!closes(&changed, link_id));
507 try std.testing.expect(!closes(link_id[0..15], link_id));
508 try std.testing.expect(!closes(&.{}, link_id));
509 }