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 }