lib/reticulum/src/node/link.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! The two ends of an encrypted session between two programs on a mesh network run inside one
2 //! node's state machine, from the request that opens the session through its data, the messages
3 //! that confirm each end is still there, and its close. Two programs that found each other by
4 //! broadcast want a channel whose keys belong to this conversation alone, so a key that leaks later
5 //! reveals nothing said before. Each end has to learn that the other is still there, and has to let
6 //! go of a session whose other end has gone quiet. A sender over the session wants to know that its
7 //! packet arrived. Fresh keys need randomness, and a state machine that draws its own gives a
8 //! different answer on every run. Both ends have to agree on the session's name before either has
9 //! heard from the other, and the request packet is all they share.
10 //!
11 //! *Reticulum 1.5.0*, the reference implementation this package is a port of, runs these sessions
12 //! in RNS/Link.py, and this module follows it at Reticulum@1.5.0
13 //! RNS/Link.py:186-227,274-322,348-375,378-389,391-438,516-538,657-683,722-766,948-968,1130-1135.
14 //! The package generates reference bytes and clocks for these sessions from that release (a
15 //! *conformance corpus*), and the node tests replay them, so each claim about the reference is
16 //! checkable from this tree. The node's clock is a whole second, and Reticulum 1.5.0 computes
17 //! session deadlines in fractional seconds: this difference is the invariant behind the ninth,
18 //! tenth and eleventh departures below.
19 //!
20 //! Each transition derives a key, an initialization vector, or a tag for an encrypted session
21 //! between two endpoints, a *link*, with HKDF-SHA256 from the 32 random bytes its input carried.
22 //! That derivation takes the 16-byte truncated hash of the opening request that names the link at
23 //! every node it passes, the *link id*, as the salt and one of four names as the label:
24 //! `reticulum-link-responder-key`, `reticulum-link-rtt-iv`, `reticulum-link-close-iv`,
25 //! `reticulum-link-rediscovery-tag`. Those four names belong to this port. This port departs from
26 //! the reference in eleven places.
27 //!
28 //! First, a link stays pending through a confirmation whose signature fails to verify (a *proof*, a
29 //! packet carrying a signature over an earlier packet's hash, sent back so the sender learns the
30 //! packet arrived) and through a proof naming a mode this port refuses. A proof that arrives later
31 //! and does verify brings the link up at the end that sent the request, the *initiator*.
32 //! Reticulum@1.5.0 RNS/Link.py:350,409 advances the link to HANDSHAKE ahead of the signature check,
33 //! Reticulum@1.5.0 RNS/Link.py:446 lets a failed check pass in silence, and Reticulum@1.5.0
34 //! RNS/Link.py:393 admits a proof for a PENDING link alone, which strands the link. On a mode it
35 //! refuses, Reticulum@1.5.0 RNS/Link.py:398,448-449 closes the link.
36 //!
37 //! Second, a round trip packet counts only on the end the request was sent to, the *responder*, for
38 //! a link in handshake. That packet carries the seconds the link measures between its request and
39 //! the answer, the *round trip time*, which sets its deadlines for checking the other end and for
40 //! letting go of it. Reticulum@1.5.0 RNS/Link.py:938,1022-1024 takes one on every responder link
41 //! that remains open.
42 //!
43 //! Third, a round trip plaintext is nine bytes of msgpack float64, and any other plaintext tears
44 //! the link down. Reticulum@1.5.0 RNS/Link.py:521-522,536-538 takes whatever msgpack decodes as
45 //! long as the value stands in comparison to a float, and ends the link when the decode or that
46 //! comparison raises.
47 //!
48 //! Fourth, an X25519 exchange that lands on an all-zero shared secret fails here: a request that
49 //! reaches it is invalid, and a proof that reaches it is refused even with a signature that
50 //! verifies, which holds the initiator link at pending. Reticulum@1.5.0
51 //! RNS/Cryptography/X25519.py:139-145 returns that secret as it stands and Reticulum@1.5.0
52 //! RNS/Link.py:348-361 derives the link key from it, so the responder there answers such a request
53 //! with a proof, as Reticulum@1.5.0 RNS/Link.py:209-211 does, and the initiator there brings the
54 //! link up on such a proof once the signature validates, as Reticulum@1.5.0
55 //! RNS/Link.py:405-409,415-424 does.
56 //!
57 //! Fifth, a request that derives the id of a link the node already holds is a duplicate and draws
58 //! the named report the node hands its caller when it drops a packet, giving the reason (a
59 //! *diagnostic*). Reticulum@1.5.0 RNS/Transport.py:2864-2869 records a second link under that id.
60 //!
61 //! Sixth, the network interfaces this port drives declare no hardware MTU, and a responder here
62 //! names its link by the id the initiator computed. On an interface of that kind Reticulum@1.5.0
63 //! RNS/Transport.py:2471-2474 takes the three bytes a link request or proof may carry past its
64 //! fixed body, the *signalling bytes*, off the request data, and Reticulum@1.5.0
65 //! RNS/Link.py:336-342 with Reticulum@1.5.0 RNS/Packet.py:353-358 folds those same bytes into the
66 //! hash that names the link, because the hash covers the raw packet.
67 //!
68 //! Seventh, an application send returns `error.LinkNotEstablished` until the link has activated.
69 //! Reticulum@1.5.0 RNS/Packet.py:290-300 withholds a packet on a closed link alone and puts one on
70 //! the wire for a link still pending or in handshake, before the link key exists.
71 //!
72 //! Eighth, four refusals reach the caller here as named diagnostics: a close packet whose plaintext
73 //! differs from the link id draws `link_close_invalid`, a request that arrives at an initiator
74 //! asking whether the link is still alive draws `link_state_mismatch`, data the link key fails to
75 //! open draws `decryption_failed`, and a proof of link data matching no record the node still holds
76 //! of a packet it sent draws `proof_rejected`. Reticulum@1.5.0 RNS/Link.py:674-683,938-946,948-953
77 //! and Reticulum@1.5.0 RNS/Transport.py:2693-2697 pass over all four in silence.
78 //!
79 //! Ninth, an instant stored on a link moves to the later of the value it already holds and the
80 //! clock the transition carries, which keeps every deadline in place when a transition arrives with
81 //! an earlier clock. Reticulum@1.5.0 RNS/Link.py:652-655,938-946 writes the wall clock into those
82 //! fields as it finds it.
83 //!
84 //! Tenth, a link *timer* (a deadline the node holds to the whole second) arriving ahead of the
85 //! deadline it carries is armed again for that deadline or for one second past the current instant,
86 //! whichever falls later. Reticulum@1.5.0 RNS/Link.py:757-759,775 sleeps out the remaining time
87 //! under a ceiling of five seconds on each sleep.
88 //!
89 //! Eleventh, a link turns *stale* when it hears nothing for twice the quiet interval its round trip
90 //! time sets. Five seconds pass between turning stale and its close. Reticulum@1.5.0
91 //! RNS/Link.py:84,88,99-106,754,775 arrives at the same five seconds, and its `STALE_TIME`
92 //! documentation describes a delay under no ceiling, `rtt` times `KEEPALIVE_TIMEOUT_FACTOR` plus
93 //! `STALE_GRACE`, that the ceiling on each watchdog sleep removes.
94 //!
95 //! - *node*: the state machine that holds one Reticulum node's tables, one packet of scratch space,
96 //! and its effect list.
97 //! - *step*: one transition, taking one event and returning the effects it produced.
98 //! - *event*: the one input a step takes, either a frame that arrived on a carrier, a timer that
99 //! came due, an application request, or the completion of a caller's storage write.
100 //! - *effect*: a record the node appends in place of doing input or output itself, such as a frame
101 //! to send on a carrier or a timer to arm.
102 //! - *step entropy*: the 32 bytes the caller draws fresh for each carrier frame and each timer
103 //! event, which the step expands with HKDF-SHA256 whenever it needs a key or an initialization
104 //! vector.
105 //! - *carrier*: one network interface a node sends and receives frames over, named by a byte index.
106 //! - *frame*: the bytes handed to one carrier, one packet plus at most a 64-byte signature.
107 //! - *packet*: one Reticulum datagram, at most 500 bytes, carrying a flags byte, a hop count, an
108 //! optional transport id, a destination hash, a context byte, and a payload.
109 //! - *link request*: the packet an initiator sends to open a link, carrying its ephemeral public
110 //! keys and three signalling bytes.
111 //! - *link data*: an application payload sent over a link, encrypted with the link key and capped
112 //! at 431 bytes of plaintext.
113 //! - *LINKCLOSE*: the packet an end sends to close a link, whose plaintext is the link id.
114 //! - *keepalive*: the packet an initiator sends to hold an otherwise quiet link open, and the
115 //! answer a responder returns.
116 //! - *keepalive period*: how long an active link may stay quiet before its initiator sends a
117 //! keepalive, between 5 and 360 seconds and derived from the link's round trip time.
118 //! - *receipt*: the record of one packet the node sent, holding its hash, its destination, the
119 //! instant it went out, and how long it may wait for a proof.
120
121 const std = @import("std");
122 const reticulum = @import("../root.zig");
123
124 const crypto = reticulum.crypto;
125 const destination = reticulum.destination;
126 const identity = reticulum.identity;
127 const node = reticulum.node;
128 const packet = reticulum.packet;
129 const wire = reticulum.wire;
130
131 const links = node.transport.links;
132 const relay = node.transport.link.relay;
133 const Ed25519 = std.crypto.sign.Ed25519;
134 const X25519 = std.crypto.dh.X25519;
135
136 const per_hop_timeout: node.Seconds = 6;
137 const keepalive_max: node.Seconds = 360;
138
139 const keepalive_max_seconds: f64 = 360;
140 const keepalive_min_seconds: f64 = 5;
141 const keepalive_max_rtt: f64 = 1.75;
142 const stale_factor: f64 = 2;
143 const stale_grace: node.Seconds = 5;
144 const traffic_timeout_factor: f64 = 6;
145 const traffic_timeout_min: f64 = 5.0 / 1000.0;
146 const seconds_limit: f64 = 18446744073709551616.0;
147
148 const responder_key_label = "reticulum-link-responder-key";
149 const rtt_iv_label = "reticulum-link-rtt-iv";
150 const close_iv_label = "reticulum-link-close-iv";
151 const rediscovery_tag_label = "reticulum-link-rediscovery-tag";
152
153 fn expand(entropy: *const [32]u8, link_id: *const [16]u8, label: []const u8, out: []u8) void {
154 std.debug.assert(out.len > 0);
155 std.debug.assert(out.len <= 64);
156 const length: u17 = @intCast(out.len);
157 const derived = crypto.hkdf.derive(length, entropy, link_id[0..], label, out) catch
158 unreachable;
159 std.debug.assert(derived.len == out.len);
160 }
161
162 const Handshake = struct {
163 public: [32]u8,
164 derived: [64]u8,
165 };
166
167 fn handshake(private: *const [32]u8, peer_public: [32]u8, link_id: *const [16]u8) ?Handshake {
168 var shared = X25519.scalarmult(private.*, peer_public) catch return null;
169 defer std.crypto.secureZero(u8, &shared);
170 var result = Handshake{
171 .public = X25519.recoverPublicKey(private.*) catch unreachable,
172 .derived = undefined,
173 };
174 const derived = crypto.hkdf.derive(64, &shared, link_id[0..], null, &result.derived) catch
175 unreachable;
176 std.debug.assert(derived.len == result.derived.len);
177 return result;
178 }
179
180 fn linkPacket(
181 link_id: [16]u8,
182 packet_type: wire.PacketType,
183 context: wire.Context,
184 payload: []const u8,
185 ) wire.Packet {
186 return .{
187 .ifac = 0,
188 .header = .one,
189 .context_flag = 0,
190 .transport = .broadcast,
191 .destination_type = .link,
192 .packet_type = packet_type,
193 .hops = 0,
194 .transport_id = null,
195 .destination = link_id,
196 .context = context,
197 .payload = payload,
198 };
199 }
200
201 fn requestFrame(
202 node_owner: *node.Node,
203 destination_hash: [16]u8,
204 public: identity.KeyBytes,
205 ) []const u8 {
206 const value = wire.link.Request{
207 .encryption_public = public[0..32].*,
208 .signing_public = public[32..64].*,
209 .signalling = wire.link.default_signalling,
210 };
211 const payload = value.encode(node_owner.scratch[wire.header_one_bytes..]) catch unreachable;
212 const raw = wire.encode(.{
213 .ifac = 0,
214 .header = .one,
215 .context_flag = 0,
216 .transport = .broadcast,
217 .destination_type = .single,
218 .packet_type = .link_request,
219 .hops = 0,
220 .transport_id = null,
221 .destination = destination_hash,
222 .context = .none,
223 .payload = payload,
224 }, node_owner.scratch) catch unreachable;
225 std.debug.assert(raw.len == wire.header_one_bytes + wire.link.signalled_request_bytes);
226 return raw;
227 }
228
229 fn proofFrame(
230 node_owner: *node.Node,
231 link_id: [16]u8,
232 responder_public: [32]u8,
233 private: *const identity.Private,
234 ) []const u8 {
235 const signalling = wire.link.default_signalling;
236 const destination_public = private.publicBytes();
237 var signed: [wire.link.signed_proof_bytes_max]u8 = undefined;
238 const message = wire.link.signedProof(
239 link_id,
240 responder_public,
241 destination_public[32..64].*,
242 signalling,
243 &signed,
244 );
245 const value = wire.link.Proof{
246 .signature = private.sign(message),
247 .encryption_public = responder_public,
248 .signalling = signalling,
249 };
250 const payload = value.encode(node_owner.scratch[wire.header_one_bytes..]) catch unreachable;
251 const proof_packet = linkPacket(link_id, .proof, .lrproof, payload);
252 const raw = wire.encode(proof_packet, node_owner.scratch) catch unreachable;
253 std.debug.assert(raw.len == wire.header_one_bytes + wire.link.signalled_proof_bytes);
254 return raw;
255 }
256
257 fn encryptedFrame(
258 node_owner: *node.Node,
259 entry: *const links.Entry,
260 context: wire.Context,
261 iv: [crypto.token.iv_length]u8,
262 plaintext: []const u8,
263 ) error{PacketTooLarge}![]const u8 {
264 std.debug.assert(entry.status != .pending);
265 const token = crypto.token.Token.init(&entry.derived_key) catch unreachable;
266 const payload = token.encrypt(iv, plaintext, node_owner.scratch[wire.header_one_bytes..]) catch
267 return error.PacketTooLarge;
268 return wire.encode(linkPacket(entry.id, .data, context, payload), node_owner.scratch) catch
269 return error.PacketTooLarge;
270 }
271
272 fn plainFrame(
273 node_owner: *node.Node,
274 link_id: [16]u8,
275 packet_type: wire.PacketType,
276 context: wire.Context,
277 payload: []const u8,
278 ) []const u8 {
279 std.debug.assert(payload.len > 0);
280 std.debug.assert(payload.len <= wire.mtu - wire.header_one_bytes);
281 const value = linkPacket(link_id, packet_type, context, payload);
282 const raw = wire.encode(value, node_owner.scratch) catch unreachable;
283 std.debug.assert(raw.len == wire.header_one_bytes + payload.len);
284 return raw;
285 }
286
287 fn schedule(node_owner: *node.Node, link_id: [16]u8, at: node.Seconds) node.StepError!void {
288 const timer_id = node.TimerId{ .link = link_id };
289 node_owner.timers.schedule(timer_id, at) catch return error.TimerFull;
290 node_owner.effects.push(.{ .schedule_timer = .{ .id = timer_id, .at = at } }) catch
291 return error.EffectsFull;
292 }
293
294 fn sendOnLink(
295 node_owner: *node.Node,
296 entry: *const links.Entry,
297 frame: []const u8,
298 ) node.StepError!void {
299 std.debug.assert(entry.status != .pending);
300 const fanout: node.outbound.Fanout = .{ .one = entry.attached };
301 if (node.outbound.frameCount(node_owner, fanout) == 0) return;
302 _ = node_owner.duplicate_hashes.insert(wire.hash.full(frame) catch unreachable);
303 try node.outbound.transmit(node_owner, fanout, frame);
304 }
305
306 fn release(node_owner: *node.Node, link_id: [16]u8, reason: node.LinkCloseReason) void {
307 const entry = node_owner.transport.links.find(link_id) orelse unreachable;
308 const destination_hash = entry.destination;
309 _ = node_owner.timers.cancel(.{ .link = link_id });
310 const removed = node_owner.transport.links.remove(link_id);
311 std.debug.assert(removed);
312 node_owner.effects.push(.{ .link_closed = .{
313 .link_id = link_id,
314 .destination = destination_hash,
315 .reason = reason,
316 } }) catch unreachable;
317 std.debug.assert(node_owner.transport.links.find(link_id) == null);
318 }
319
320 fn closeReason(role: links.Role) node.LinkCloseReason {
321 return switch (role) {
322 .initiator => .initiator_closed,
323 .responder => .destination_closed,
324 };
325 }
326
327 fn secondsAtOrAfter(value: f64) node.Seconds {
328 std.debug.assert(!std.math.isNan(value));
329 const rounded = @ceil(value);
330 if (rounded <= 0) return 0;
331 if (rounded >= seconds_limit) return std.math.maxInt(node.Seconds);
332 return @intFromFloat(rounded);
333 }
334
335 fn floatSeconds(seconds: node.Seconds) f64 {
336 return @floatFromInt(seconds);
337 }
338
339 fn keepalivePeriod(entry: *const links.Entry) f64 {
340 std.debug.assert(std.math.isFinite(entry.rtt));
341 std.debug.assert(entry.rtt >= 0);
342 const period = switch (entry.status) {
343 .pending, .handshake => keepalive_max_seconds,
344 .active, .stale => @max(
345 @min(entry.rtt * (keepalive_max_seconds / keepalive_max_rtt), keepalive_max_seconds),
346 keepalive_min_seconds,
347 ),
348 };
349 std.debug.assert(period >= keepalive_min_seconds);
350 std.debug.assert(period <= keepalive_max_seconds);
351 return period;
352 }
353
354 fn quietSince(entry: *const links.Entry) node.Seconds {
355 return @max(@max(entry.last_inbound, entry.last_proof), entry.activated_at);
356 }
357
358 fn keepaliveDue(entry: *const links.Entry) node.Seconds {
359 std.debug.assert(entry.role == .initiator);
360 const period = keepalivePeriod(entry);
361 const quiet = @min(
362 secondsAtOrAfter(floatSeconds(quietSince(entry)) + period),
363 secondsAtOrAfter(floatSeconds(entry.last_outbound) + period),
364 );
365 return @max(quiet, secondsAtOrAfter(floatSeconds(entry.last_keepalive) + period));
366 }
367
368 fn staleDue(entry: *const links.Entry) node.Seconds {
369 const stale_time = keepalivePeriod(entry) * stale_factor;
370 return secondsAtOrAfter(floatSeconds(quietSince(entry)) + stale_time);
371 }
372
373 /// Answers with the whole second on which a link that has turned stale emits its LINKCLOSE and
374 /// closes under reason `timeout`, five seconds past the moment it turned, so the stale watchdog
375 /// knows when to send the close packet. Reticulum@1.5.0 RNS/Link.py:754 sets its own delay to `rtt`
376 /// times `KEEPALIVE_TIMEOUT_FACTOR` plus `STALE_GRACE`, whose values are 4 and 5. Reticulum@1.5.0
377 /// RNS/Link.py:775 holds each watchdog sleep under `WATCHDOG_MAX_SLEEP`, whose value is 5. A round
378 /// trip time is at least zero, so `rtt` times 4 plus 5 is at least 5, the cap always wins, and a
379 /// reference link waits exactly five seconds.
380 fn staleCloseAt(entry: *const links.Entry) node.Seconds {
381 return staleDue(entry) +| stale_grace;
382 }
383
384 fn answersKeepalive(entry: *const links.Entry, now: node.Seconds) bool {
385 std.debug.assert(entry.role == .responder);
386 return now >= secondsAtOrAfter(floatSeconds(entry.last_outbound) + keepalivePeriod(entry));
387 }
388
389 fn receiptTimeout(sent_at: node.Seconds, rtt: f64) node.Seconds {
390 std.debug.assert(std.math.isFinite(rtt));
391 const timeout = @max(rtt * traffic_timeout_factor, traffic_timeout_min);
392 const last_quiet = @floor(floatSeconds(sent_at) + timeout);
393 if (last_quiet >= seconds_limit) return std.math.maxInt(node.Seconds) - sent_at;
394 const quiet_until: node.Seconds = @intFromFloat(last_quiet);
395 return quiet_until -| sent_at;
396 }
397
398 fn nextDeadline(entry: *const links.Entry) node.Seconds {
399 return switch (entry.status) {
400 .pending, .handshake => entry.request_time +| entry.establishment_timeout,
401 .active => switch (entry.role) {
402 .initiator => @min(keepaliveDue(entry), staleDue(entry)),
403 .responder => staleDue(entry),
404 },
405 .stale => entry.close_at,
406 };
407 }
408
409 fn markInbound(entry: *links.Entry, now: node.Seconds) void {
410 std.debug.assert(entry.status != .pending);
411 entry.last_inbound = @max(entry.last_inbound, now);
412 if (entry.status == .stale) entry.status = .active;
413 }
414
415 fn markOutbound(entry: *links.Entry, now: node.Seconds) void {
416 entry.last_outbound = @max(entry.last_outbound, now);
417 }
418
419 fn markKeepalive(entry: *links.Entry, now: node.Seconds) void {
420 markOutbound(entry, now);
421 entry.last_keepalive = @max(entry.last_keepalive, now);
422 }
423
424 fn initiatorEntry(
425 value: node.ApplicationLinkOpen,
426 link_id: [16]u8,
427 destination_public: *const identity.KeyBytes,
428 hops: u8,
429 timeout: node.Seconds,
430 ) links.Entry {
431 std.debug.assert(timeout >= node.outbound.first_hop_timeout + per_hop_timeout);
432 return .{
433 .id = link_id,
434 .destination = value.destination,
435 .encryption_private = value.encryption_private,
436 .signing_private = value.signing_private,
437 .peer_signing_public = destination_public[32..64].*,
438 .derived_key = @splat(0),
439 .request_time = value.now,
440 .establishment_timeout = timeout,
441 .activated_at = 0,
442 .last_inbound = 0,
443 .last_outbound = value.now,
444 .last_keepalive = 0,
445 .last_proof = 0,
446 .close_at = 0,
447 .rtt = 0,
448 .expected_hops = hops,
449 .attached = 0,
450 .role = .initiator,
451 .status = .pending,
452 .rebalanced = false,
453 .proof_strategy = value.proof_strategy,
454 };
455 }
456
457 /// Opens an encrypted session for the caller so the link id learned here names everything after.
458 /// The call puts a link request on the wire toward a destination the node has an identity for, and
459 /// records a pending initiator link carrying one timer set at the establishment deadline. That
460 /// request holds the two public keys matching the private keys the caller supplied, and its
461 /// signalling bytes announce AES-256-CBC with an MTU of 500, as Reticulum@1.5.0 RNS/Link.py:304-322
462 /// sends it. Reticulum@1.5.0 RNS/Link.py:281-283 fixes that deadline at 6 seconds plus 6 seconds
463 /// for each hop, counting one hop at the least and counting 128 hops when no path is known. A
464 /// request routed across more than one hop leaves with a transport header, as Reticulum@1.5.0
465 /// RNS/Transport.py:1345-1356 adds it. A request with no path to follow leaves on every carrier,
466 /// and the node keeps its packet hash, as Reticulum@1.5.0 RNS/Transport.py:1393,1536-1539
467 /// broadcasts it. An identity the node does not recall returns `error.UnknownDestination`, a full
468 /// link pool returns `error.LinksFull`, and keys that give the id of an open link return
469 /// `error.DuplicateLink`. Every one of those errors returns before the node changes anything.
470 pub fn open(node_owner: *node.Node, value: node.ApplicationLinkOpen) node.StepError!void {
471 const known = node_owner.known_identities.recall(value.destination) orelse
472 return error.UnknownDestination;
473 if (node_owner.transport.links.full()) return error.LinksFull;
474 var private = identity.Private.fromBytes(value.encryption_private ++ value.signing_private);
475 defer private.zero();
476 const raw = requestFrame(node_owner, value.destination, private.publicBytes());
477 const link_id = wire.link.linkId(raw) catch unreachable;
478 if (node_owner.transport.links.find(link_id) != null) return error.DuplicateLink;
479 const hash = wire.hash.full(raw) catch unreachable;
480 const path = node_owner.transport.paths.find(value.destination, value.now);
481 const fanout: node.outbound.Fanout = if (path) |entry| .{ .one = entry.carrier } else .all;
482 const carriers = node.outbound.frameCount(node_owner, fanout);
483 if (carriers == 0) return error.NoOutgoingCarrier;
484 std.debug.assert(carriers <= node_owner.interfaces.len);
485 if (!node_owner.timers.canScheduleAfterCancel(.{ .link = link_id }, null)) {
486 return error.TimerFull;
487 }
488 const hops = if (path) |entry| entry.hops else wire.pathfinder_hops;
489 const inserts = path != null and hops > 1;
490 const frame = if (inserts)
491 try node.transport.rewrite.insert(raw, path.?.next_hop, node_owner.scratch)
492 else
493 raw;
494 try node.outbound.reserve(node_owner, carriers + 2, carriers);
495 const per_hop = per_hop_timeout * @as(node.Seconds, @max(1, hops));
496 const timeout = node.outbound.first_hop_timeout + per_hop;
497 const entry = initiatorEntry(value, link_id, &known.public_key, hops, timeout);
498 _ = node_owner.transport.links.insert(entry) catch unreachable;
499 std.debug.assert(node_owner.transport.links.find(link_id) != null);
500 node_owner.effects.push(.{ .link_requested = .{
501 .link_id = link_id,
502 .destination = value.destination,
503 } }) catch unreachable;
504 try schedule(node_owner, link_id, value.now +| timeout);
505 if (path == null) _ = node_owner.duplicate_hashes.insert(hash);
506 if (inserts) path.?.timestamp = value.now;
507 try node.outbound.transmit(node_owner, fanout, frame);
508 }
509
510 /// Transmits application bytes across an open link for the caller. The call encrypts the plaintext
511 /// the caller passed under the link key with the initialization vector the caller passed, and puts
512 /// one data packet on the carrier that link uses, as Reticulum@1.5.0 RNS/Link.py:73,
513 /// Reticulum@1.5.0 RNS/Packet.py:290-300,419-420, and Reticulum@1.5.0 RNS/Transport.py:1307-1318
514 /// send it. With a receipt asked for, a receipt that stays in the table fails max(rtt * 6, 0.005)
515 /// seconds after the send when no proof has arrived, and when a later send that asks for a receipt
516 /// finds the receipt table full, the node culls the oldest receipt and reports it to the
517 /// application as a `receipt_update` effect with status `culled` and no round trip time. An unheld
518 /// link id returns `error.UnknownLink`, a link that has yet to activate returns
519 /// `error.LinkNotEstablished`, a carrier that carries no outgoing traffic returns
520 /// `error.NoOutgoingCarrier`, and plaintext over 431 bytes returns `error.PacketTooLarge`. Each of
521 /// those errors returns before the node changes anything.
522 pub fn send(node_owner: *node.Node, value: node.ApplicationLinkSend) node.StepError!void {
523 const entry = node_owner.transport.links.find(value.link_id) orelse return error.UnknownLink;
524 switch (entry.status) {
525 .pending, .handshake => return error.LinkNotEstablished,
526 .active, .stale => {},
527 }
528 const fanout: node.outbound.Fanout = .{ .one = entry.attached };
529 const carriers = node.outbound.frameCount(node_owner, fanout);
530 if (carriers == 0) return error.NoOutgoingCarrier;
531 const frame = try encryptedFrame(node_owner, entry, .none, value.iv, value.plaintext);
532 const hash = wire.hash.full(frame) catch unreachable;
533 const culls = value.create_receipt and node_owner.receipts.cullCandidate(hash) != null;
534 const effects = carriers + @intFromBool(value.create_receipt) + @intFromBool(culls);
535 try node.outbound.reserve(node_owner, effects, carriers);
536 if (value.create_receipt) {
537 const timeout = receiptTimeout(value.now, entry.rtt);
538 try node.outbound.insertReceipt(node_owner, entry.id, hash, value.now, timeout);
539 }
540 markOutbound(entry, value.now);
541 try sendOnLink(node_owner, entry, frame);
542 }
543
544 /// Closes a link for the caller so the other end hears the close. The call emits a LINKCLOSE
545 /// carrying the link id as its plaintext, releases the link, and reports the close to the caller,
546 /// as Reticulum@1.5.0 RNS/Link.py:657-672 closes it. An initiator gives the reason
547 /// `initiator_closed` and a responder gives `destination_closed`. A link that has yet to activate
548 /// closes with no packet sent. An unheld link id returns `error.UnknownLink`.
549 pub fn close(node_owner: *node.Node, value: node.ApplicationLinkClose) node.StepError!void {
550 const entry = node_owner.transport.links.find(value.link_id) orelse return error.UnknownLink;
551 const reason = closeReason(entry.role);
552 if (entry.status == .pending) {
553 try node.outbound.reserve(node_owner, 1, 0);
554 return release(node_owner, value.link_id, reason);
555 }
556 const carriers = node.outbound.frameCount(node_owner, .{ .one = entry.attached });
557 try node.outbound.reserve(node_owner, carriers + 1, carriers);
558 try teardown(node_owner, entry, value.iv, reason);
559 }
560
561 /// Answers a link delivery for an application under the `.app` proof strategy. The call puts a
562 /// proof of one link packet on the link as plaintext, carrying that packet hash together with a
563 /// signature over it, as Reticulum@1.5.0 RNS/Link.py:378-389 and Reticulum@1.5.0
564 /// RNS/Transport.py:2527-2530 send it. The signing key is the ephemeral key on an initiator and the
565 /// destination identity on a responder. An unheld link id returns `error.UnknownLink`, a link that
566 /// is still pending returns `error.LinkNotEstablished`, a packet the node has already forgotten
567 /// returns `error.ProofUnavailable`, and a carrier that carries no outgoing traffic returns
568 /// `error.NoOutgoingCarrier`.
569 pub fn prove(node_owner: *node.Node, value: node.ApplicationLinkProve) node.StepError!void {
570 const entry = node_owner.transport.links.find(value.link_id) orelse return error.UnknownLink;
571 if (entry.status == .pending) return error.LinkNotEstablished;
572 if (!node_owner.duplicate_hashes.contains(value.packet_hash)) return error.ProofUnavailable;
573 const carriers = node.outbound.frameCount(node_owner, .{ .one = entry.attached });
574 if (carriers == 0) return error.NoOutgoingCarrier;
575 try node.outbound.reserve(node_owner, carriers, carriers);
576 try proveOnLink(node_owner, entry, value.packet_hash, value.now);
577 }
578
579 /// Handles one arriving packet that belongs to a link and reports whether it did, so the frame
580 /// handler sends link traffic here and keeps the rest. A link request naming a local single
581 /// destination draws a proof and leaves a responder link in handshake, as Reticulum@1.5.0
582 /// RNS/Transport.py:2456-2487 and Reticulum@1.5.0 RNS/Link.py:186-215,366-375 answer it. A request
583 /// proof arriving for a pending initiator link reaches that link. A proof that verifies with a hop
584 /// count differing from the one the link expects corrects the expectation once, as Reticulum@1.5.0
585 /// RNS/Transport.py:2605-2650 corrects it. A proof that verifies and whose X25519 key lands on an
586 /// all-zero shared secret draws `proof_rejected` and holds the link at pending, and a proof that
587 /// verifies later at the expected hops brings the link up. Every other proof that verifies at the
588 /// expected hops brings the link up and sends the round trip packet, as Reticulum@1.5.0
589 /// RNS/Link.py:391-438 does. A round trip packet activates a responder link, as Reticulum@1.5.0
590 /// RNS/Link.py:516-538 activates it. Link data arriving on a carrier the link does not use drops
591 /// that packet hash from the current generation of duplicate hashes and draws
592 /// `link_wrong_interface`, as Reticulum@1.5.0 RNS/Transport.py:2515-2516 checks it. A data packet
593 /// that carries no context arrives on a link that has activated, and under the `.all` proof
594 /// strategy the node decrypts it, hands it to the application, and proves it. A keepalive request
595 /// arriving at a responder draws an answer once one keepalive period has passed since that
596 /// responder last sent a packet. A LINKCLOSE carrying the link id as plaintext closes the link, and
597 /// one carrying any other plaintext draws `link_close_invalid`, as Reticulum@1.5.0
598 /// RNS/Link.py:674-683,948-968,1130-1135 handles them. A request, proof, or packet naming a link
599 /// the node does not hold goes to a transport node's relay table, and `node.transport.link.relay`
600 /// carries it onward, as Reticulum@1.5.0 RNS/Transport.py:1968-2010,2030-2077,2535-2600 relays it.
601 /// Link traffic this code carries no support for reports `links_unsupported`.
602 pub fn receive(
603 node_owner: *node.Node,
604 value: wire.Packet,
605 raw: []const u8,
606 frame: node.CarrierFrame,
607 hash: packet.Hash,
608 ) node.StepError!bool {
609 std.debug.assert(value.hops >= 1);
610 if (value.packet_type == .link_request) {
611 try routeRequest(node_owner, value, raw, frame, hash);
612 return true;
613 }
614 if (value.packet_type == .proof and value.context == .lrproof) {
615 try routeProof(node_owner, value, raw, frame, hash);
616 return true;
617 }
618 if (value.destination_type == .link) {
619 try routeLinkPacket(node_owner, value, raw, frame, hash);
620 return true;
621 }
622 if (value.context.encode() < wire.Context.linkidentify.encode()) return false;
623 node.inbound.diagnostic(node_owner, .links_unsupported, hash);
624 return true;
625 }
626
627 fn routeRequest(
628 node_owner: *node.Node,
629 value: wire.Packet,
630 raw: []const u8,
631 frame: node.CarrierFrame,
632 hash: packet.Hash,
633 ) node.StepError!void {
634 std.debug.assert(value.packet_type == .link_request);
635 if (node_owner.destinations.find(value.destination)) |entry| {
636 return answerRequest(node_owner, entry, value, raw, frame, hash);
637 }
638 if (relay.appliesToRequest(node_owner, value)) {
639 return relay.request(node_owner, value, raw, frame, hash);
640 }
641 node.inbound.diagnostic(node_owner, .unknown_destination, hash);
642 }
643
644 fn routeProof(
645 node_owner: *node.Node,
646 value: wire.Packet,
647 raw: []const u8,
648 frame: node.CarrierFrame,
649 hash: packet.Hash,
650 ) node.StepError!void {
651 std.debug.assert(value.context == .lrproof);
652 if (node_owner.transport.links.find(value.destination)) |entry| {
653 return acceptProof(node_owner, entry, value, frame, hash);
654 }
655 if (relay.find(node_owner, value.destination)) |entry| {
656 return relay.proof(node_owner, entry, value, raw, frame, hash);
657 }
658 node.inbound.diagnostic(node_owner, .unknown_link, hash);
659 }
660
661 fn routeLinkPacket(
662 node_owner: *node.Node,
663 value: wire.Packet,
664 raw: []const u8,
665 frame: node.CarrierFrame,
666 hash: packet.Hash,
667 ) node.StepError!void {
668 std.debug.assert(value.destination_type == .link);
669 if (value.packet_type == .announce) {
670 return node.inbound.diagnostic(node_owner, .links_unsupported, hash);
671 }
672 const entry = node_owner.transport.links.find(value.destination) orelse {
673 if (relay.find(node_owner, value.destination)) |relayed| {
674 return relay.traffic(node_owner, relayed, value, raw, frame, hash);
675 }
676 return node.inbound.diagnostic(node_owner, .unknown_link, hash);
677 };
678 if (entry.status == .pending) return node.inbound.diagnostic(node_owner, .unknown_link, hash);
679 if (value.packet_type == .proof) return acceptLinkProof(node_owner, entry, value, frame, hash);
680 if (value.packet_type != .data) {
681 return node.inbound.diagnostic(node_owner, .links_unsupported, hash);
682 }
683 if (entry.attached != frame.interface) {
684 _ = node_owner.duplicate_hashes.removeCurrent(hash);
685 return node.inbound.diagnostic(node_owner, .link_wrong_interface, hash);
686 }
687 return receiveTraffic(node_owner, entry, value, frame, hash);
688 }
689
690 fn receiveTraffic(
691 node_owner: *node.Node,
692 entry: *links.Entry,
693 value: wire.Packet,
694 frame: node.CarrierFrame,
695 hash: packet.Hash,
696 ) node.StepError!void {
697 std.debug.assert(value.packet_type == .data);
698 std.debug.assert(entry.attached == frame.interface);
699 switch (value.context) {
700 .none => return deliver(node_owner, entry, value, frame, hash),
701 .lrrtt => return acceptRtt(node_owner, entry, value, frame, hash),
702 .linkclose => return acceptClose(node_owner, entry, value, frame, hash),
703 .keepalive => return acceptKeepalive(node_owner, entry, value, frame, hash),
704 else => {
705 markInbound(entry, frame.now);
706 return node.inbound.diagnostic(node_owner, .links_unsupported, hash);
707 },
708 }
709 }
710
711 fn deliver(
712 node_owner: *node.Node,
713 entry: *links.Entry,
714 value: wire.Packet,
715 frame: node.CarrierFrame,
716 hash: packet.Hash,
717 ) node.StepError!void {
718 std.debug.assert(value.context == .none);
719 const proves = entry.proof_strategy == .all;
720 const fanout: node.outbound.Fanout = .{ .one = entry.attached };
721 const carriers = if (proves) node.outbound.frameCount(node_owner, fanout) else 0;
722 try node.outbound.reserve(node_owner, 1 + carriers, carriers);
723 markInbound(entry, frame.now);
724 const token = crypto.token.Token.init(&entry.derived_key) catch unreachable;
725 const plaintext = token.decrypt(value.payload, node_owner.scratch) catch
726 return node.inbound.diagnostic(node_owner, .decryption_failed, hash);
727 node_owner.effects.push(.{ .link_delivery = .{
728 .link_id = entry.id,
729 .packet_hash = hash,
730 .plaintext = plaintext,
731 .proof_requested = entry.proof_strategy == .app,
732 } }) catch unreachable;
733 if (proves) try proveOnLink(node_owner, entry, hash, frame.now);
734 }
735
736 fn acceptKeepalive(
737 node_owner: *node.Node,
738 entry: *links.Entry,
739 value: wire.Packet,
740 frame: node.CarrierFrame,
741 hash: packet.Hash,
742 ) node.StepError!void {
743 std.debug.assert(value.context == .keepalive);
744 const request = value.payload.len == 1 and value.payload[0] == wire.link.keepalive_request;
745 if (entry.role == .initiator and request) {
746 return node.inbound.diagnostic(node_owner, .link_state_mismatch, hash);
747 }
748 const carriers = node.outbound.frameCount(node_owner, .{ .one = entry.attached });
749 try node.outbound.reserve(node_owner, carriers, carriers);
750 markInbound(entry, frame.now);
751 if (entry.role == .initiator or !request) return;
752 if (!answersKeepalive(entry, frame.now)) return;
753 const answer = [_]u8{wire.link.keepalive_answer};
754 const answer_frame = plainFrame(node_owner, entry.id, .data, .keepalive, &answer);
755 try sendOnLink(node_owner, entry, answer_frame);
756 markKeepalive(entry, frame.now);
757 }
758
759 fn acceptClose(
760 node_owner: *node.Node,
761 entry: *links.Entry,
762 value: wire.Packet,
763 frame: node.CarrierFrame,
764 hash: packet.Hash,
765 ) node.StepError!void {
766 std.debug.assert(value.context == .linkclose);
767 try node.outbound.reserve(node_owner, 1, 0);
768 markInbound(entry, frame.now);
769 const token = crypto.token.Token.init(&entry.derived_key) catch unreachable;
770 const plaintext = token.decrypt(value.payload, node_owner.scratch) catch
771 return node.inbound.diagnostic(node_owner, .decryption_failed, hash);
772 if (!wire.link.closes(plaintext, entry.id)) {
773 return node.inbound.diagnostic(node_owner, .link_close_invalid, hash);
774 }
775 const reason: node.LinkCloseReason = switch (entry.role) {
776 .initiator => .destination_closed,
777 .responder => .initiator_closed,
778 };
779 release(node_owner, entry.id, reason);
780 }
781
782 fn proofSignature(
783 node_owner: *node.Node,
784 entry: *const links.Entry,
785 packet_hash: *const [32]u8,
786 ) [64]u8 {
787 switch (entry.role) {
788 .initiator => {
789 const empty: [32]u8 = @splat(0);
790 var private = identity.Private.fromBytes(empty ++ entry.signing_private);
791 defer private.zero();
792 return private.sign(packet_hash);
793 },
794 .responder => {
795 const registered = node_owner.destinations.find(entry.destination) orelse
796 unreachable;
797 const private = requestIdentity(node_owner, registered) orelse unreachable;
798 return private.sign(packet_hash);
799 },
800 }
801 }
802
803 fn proveOnLink(
804 node_owner: *node.Node,
805 entry: *links.Entry,
806 packet_hash: [32]u8,
807 now: node.Seconds,
808 ) node.StepError!void {
809 std.debug.assert(entry.status != .pending);
810 var payload: [wire.proof.explicit_bytes]u8 = undefined;
811 payload[0..wire.proof.packet_hash_bytes].* = packet_hash;
812 payload[wire.proof.packet_hash_bytes..].* = proofSignature(node_owner, entry, &packet_hash);
813 const proof = plainFrame(node_owner, entry.id, .proof, .none, &payload);
814 try sendOnLink(node_owner, entry, proof);
815 markOutbound(entry, now);
816 }
817
818 fn provedReceipt(
819 node_owner: *node.Node,
820 entry: *const links.Entry,
821 payload: []const u8,
822 ) ?packet.Hash {
823 if (payload.len < wire.proof.explicit_bytes) return null;
824 const proved: packet.Hash = payload[0..wire.proof.packet_hash_bytes].*;
825 const receipt = node_owner.receipts.find(proved) orelse return null;
826 if (receipt.status != .sent) return null;
827 const signature = payload[wire.proof.packet_hash_bytes..wire.proof.explicit_bytes].*;
828 const signer = Ed25519.PublicKey.fromBytes(entry.peer_signing_public) catch return null;
829 Ed25519.Signature.fromBytes(signature).verify(&proved, signer) catch return null;
830 return proved;
831 }
832
833 fn acceptLinkProof(
834 node_owner: *node.Node,
835 entry: *links.Entry,
836 value: wire.Packet,
837 frame: node.CarrierFrame,
838 hash: packet.Hash,
839 ) node.StepError!void {
840 std.debug.assert(value.packet_type == .proof);
841 std.debug.assert(entry.status != .pending);
842 if (value.context == .resource_prf) {
843 return node.inbound.diagnostic(node_owner, .links_unsupported, hash);
844 }
845 const proved = provedReceipt(node_owner, entry, value.payload) orelse
846 return node.inbound.diagnostic(node_owner, .proof_rejected, hash);
847 try node.outbound.reserve(node_owner, 1, 0);
848 entry.last_proof = @max(entry.last_proof, frame.now);
849 node.inbound.concludeReceipt(node_owner, proved, frame.now);
850 }
851
852 fn requestIdentity(
853 node_owner: *node.Node,
854 entry: *const destination.registry.Entry,
855 ) ?*const identity.Private {
856 if (entry.kind != .single) return null;
857 const index = entry.identity_index orelse return null;
858 return node_owner.identityAt(index);
859 }
860
861 fn acceptsSignalling(signalling: ?wire.link.Signalling) bool {
862 const value = signalling orelse return true;
863 if (value.mtu != 0) return true;
864 return value.mode == wire.link.default_mode;
865 }
866
867 fn responderEntry(
868 value: wire.Packet,
869 frame: node.CarrierFrame,
870 link_id: [16]u8,
871 peer_signing_public: [32]u8,
872 derived_key: *const [64]u8,
873 timeout: node.Seconds,
874 proof_strategy: destination.registry.ProofStrategy,
875 ) links.Entry {
876 std.debug.assert(timeout > keepalive_max);
877 return .{
878 .id = link_id,
879 .destination = value.destination,
880 .encryption_private = @splat(0),
881 .signing_private = @splat(0),
882 .peer_signing_public = peer_signing_public,
883 .derived_key = derived_key.*,
884 .request_time = frame.now,
885 .establishment_timeout = timeout,
886 .activated_at = 0,
887 .last_inbound = frame.now,
888 .last_outbound = frame.now,
889 .last_keepalive = 0,
890 .last_proof = 0,
891 .close_at = 0,
892 .rtt = 0,
893 .expected_hops = 0,
894 .attached = frame.interface,
895 .role = .responder,
896 .status = .handshake,
897 .rebalanced = false,
898 .proof_strategy = proof_strategy,
899 };
900 }
901
902 fn answerRequest(
903 node_owner: *node.Node,
904 entry: *const destination.registry.Entry,
905 value: wire.Packet,
906 raw: []const u8,
907 frame: node.CarrierFrame,
908 hash: packet.Hash,
909 ) node.StepError!void {
910 std.debug.assert(value.packet_type == .link_request);
911 if (@backingInt(entry.kind) != @backingInt(value.destination_type)) {
912 return node.inbound.diagnostic(node_owner, .destination_type_mismatch, hash);
913 }
914 const private = requestIdentity(node_owner, entry) orelse
915 return node.inbound.diagnostic(node_owner, .link_request_invalid, hash);
916 const decoded = wire.link.Request.decode(value.payload) catch
917 return node.inbound.diagnostic(node_owner, .link_request_invalid, hash);
918 if (!acceptsSignalling(decoded.signalling)) {
919 return node.inbound.diagnostic(node_owner, .link_request_invalid, hash);
920 }
921 const link_id = wire.link.linkId(raw) catch unreachable;
922 if (node_owner.transport.links.find(link_id) != null) {
923 return node.inbound.diagnostic(node_owner, .link_request_duplicate, hash);
924 }
925 if (node_owner.transport.links.full()) {
926 return node.inbound.diagnostic(node_owner, .links_full, hash);
927 }
928 var responder_private: [32]u8 = undefined;
929 defer std.crypto.secureZero(u8, &responder_private);
930 expand(&frame.entropy, &link_id, responder_key_label, &responder_private);
931 var keys = handshake(&responder_private, decoded.encryption_public, &link_id) orelse
932 return node.inbound.diagnostic(node_owner, .link_request_invalid, hash);
933 defer std.crypto.secureZero(u8, &keys.derived);
934 if (!node_owner.timers.canScheduleAfterCancel(.{ .link = link_id }, null)) {
935 return error.TimerFull;
936 }
937 const carriers = node.outbound.frameCount(node_owner, .{ .one = frame.interface });
938 try node.outbound.reserve(node_owner, carriers + 1, carriers);
939 const proof = proofFrame(node_owner, link_id, keys.public, private);
940 const timeout = per_hop_timeout * @as(node.Seconds, @max(1, value.hops)) + keepalive_max;
941 const responder = responderEntry(
942 value,
943 frame,
944 link_id,
945 decoded.signing_public,
946 &keys.derived,
947 timeout,
948 entry.proof_strategy,
949 );
950 const inserted = node_owner.transport.links.insert(responder) catch unreachable;
951 std.debug.assert(inserted.status == .handshake);
952 try schedule(node_owner, link_id, frame.now +| timeout);
953 try sendOnLink(node_owner, inserted, proof);
954 }
955
956 fn verifyProof(entry: *const links.Entry, payload: []const u8) ?[32]u8 {
957 if (wire.link.proofMode(payload) != wire.link.default_mode) return null;
958 const decoded = wire.link.Proof.decode(payload) catch return null;
959 var signed: [wire.link.signed_proof_bytes_max]u8 = undefined;
960 const message = wire.link.signedProof(
961 entry.id,
962 decoded.encryption_public,
963 entry.peer_signing_public,
964 decoded.signalling,
965 &signed,
966 );
967 const signer = Ed25519.PublicKey.fromBytes(entry.peer_signing_public) catch return null;
968 Ed25519.Signature.fromBytes(decoded.signature).verify(message, signer) catch return null;
969 return decoded.encryption_public;
970 }
971
972 fn rebalance(node_owner: *node.Node, entry: *links.Entry, hops: u8, now: node.Seconds) void {
973 std.debug.assert(entry.status == .pending);
974 std.debug.assert(!entry.rebalanced);
975 entry.rebalanced = true;
976 entry.expected_hops = hops;
977 const path = node_owner.transport.paths.find(entry.destination, now) orelse return;
978 path.hops = hops;
979 }
980
981 fn acceptProof(
982 node_owner: *node.Node,
983 entry: *links.Entry,
984 value: wire.Packet,
985 frame: node.CarrierFrame,
986 hash: packet.Hash,
987 ) node.StepError!void {
988 std.debug.assert(value.context == .lrproof);
989 if (entry.role != .initiator or entry.status != .pending) {
990 return node.inbound.diagnostic(node_owner, .link_state_mismatch, hash);
991 }
992 if (!node_owner.timers.canScheduleAfterCancel(.{ .link = entry.id }, null)) {
993 return error.TimerFull;
994 }
995 const carriers = node.outbound.frameCount(node_owner, .{ .one = frame.interface });
996 try node.outbound.reserve(node_owner, carriers + 2, carriers);
997 const responder_public = verifyProof(entry, value.payload);
998 const unbalanced = value.hops != entry.expected_hops;
999 if (responder_public != null and unbalanced and !entry.rebalanced) {
1000 rebalance(node_owner, entry, value.hops, frame.now);
1001 }
1002 if (value.hops != entry.expected_hops) {
1003 return node.inbound.diagnostic(node_owner, .proof_rejected, hash);
1004 }
1005 _ = node_owner.duplicate_hashes.insert(hash);
1006 const peer_public = responder_public orelse
1007 return node.inbound.diagnostic(node_owner, .proof_rejected, hash);
1008 var keys = handshake(&entry.encryption_private, peer_public, &entry.id) orelse
1009 return node.inbound.diagnostic(node_owner, .proof_rejected, hash);
1010 defer std.crypto.secureZero(u8, &keys.derived);
1011 try establishInitiator(node_owner, entry, &keys.derived, frame);
1012 }
1013
1014 fn establishInitiator(
1015 node_owner: *node.Node,
1016 entry: *links.Entry,
1017 derived_key: *const [64]u8,
1018 frame: node.CarrierFrame,
1019 ) node.StepError!void {
1020 std.debug.assert(entry.role == .initiator);
1021 std.debug.assert(entry.status == .pending);
1022 const rtt: f64 = @floatFromInt(frame.now -| entry.request_time);
1023 std.debug.assert(rtt >= 0);
1024 var iv: [crypto.token.iv_length]u8 = undefined;
1025 expand(&frame.entropy, &entry.id, rtt_iv_label, &iv);
1026 entry.derived_key = derived_key.*;
1027 std.crypto.secureZero(u8, &entry.encryption_private);
1028 entry.attached = frame.interface;
1029 entry.status = .active;
1030 entry.activated_at = frame.now;
1031 entry.last_proof = frame.now;
1032 entry.rtt = rtt;
1033 node_owner.effects.push(.{ .link_established = .{
1034 .link_id = entry.id,
1035 .destination = entry.destination,
1036 .role = .initiator,
1037 .rtt = rtt,
1038 .interface = frame.interface,
1039 } }) catch unreachable;
1040 const plaintext = wire.link.encodeRtt(rtt);
1041 const rtt_frame = encryptedFrame(node_owner, entry, .lrrtt, iv, &plaintext) catch
1042 unreachable;
1043 try sendOnLink(node_owner, entry, rtt_frame);
1044 markOutbound(entry, frame.now);
1045 try schedule(node_owner, entry.id, nextDeadline(entry));
1046 }
1047
1048 fn acceptRtt(
1049 node_owner: *node.Node,
1050 entry: *links.Entry,
1051 value: wire.Packet,
1052 frame: node.CarrierFrame,
1053 hash: packet.Hash,
1054 ) node.StepError!void {
1055 std.debug.assert(value.context == .lrrtt);
1056 std.debug.assert(entry.attached == frame.interface);
1057 const activates = entry.role == .responder and entry.status == .handshake;
1058 if (activates and !node_owner.timers.canScheduleAfterCancel(.{ .link = entry.id }, null)) {
1059 return error.TimerFull;
1060 }
1061 const carriers = node.outbound.frameCount(node_owner, .{ .one = entry.attached });
1062 try node.outbound.reserve(node_owner, carriers + 2, carriers);
1063 markInbound(entry, frame.now);
1064 if (!activates) return node.inbound.diagnostic(node_owner, .link_state_mismatch, hash);
1065 const token = crypto.token.Token.init(&entry.derived_key) catch unreachable;
1066 const plaintext = token.decrypt(value.payload, node_owner.scratch) catch
1067 return node.inbound.diagnostic(node_owner, .decryption_failed, hash);
1068 const received = wire.link.decodeRtt(plaintext) catch {
1069 var iv: [crypto.token.iv_length]u8 = undefined;
1070 expand(&frame.entropy, &entry.id, close_iv_label, &iv);
1071 return teardown(node_owner, entry, iv, closeReason(entry.role));
1072 };
1073 const measured: f64 = @floatFromInt(frame.now -| entry.request_time);
1074 entry.rtt = @max(measured, received);
1075 std.debug.assert(entry.rtt >= measured);
1076 entry.status = .active;
1077 entry.activated_at = frame.now;
1078 entry.expected_hops = value.hops;
1079 node_owner.effects.push(.{ .link_established = .{
1080 .link_id = entry.id,
1081 .destination = entry.destination,
1082 .role = .responder,
1083 .rtt = entry.rtt,
1084 .interface = entry.attached,
1085 } }) catch unreachable;
1086 try schedule(node_owner, entry.id, nextDeadline(entry));
1087 }
1088
1089 fn teardown(
1090 node_owner: *node.Node,
1091 entry: *links.Entry,
1092 iv: [crypto.token.iv_length]u8,
1093 reason: node.LinkCloseReason,
1094 ) node.StepError!void {
1095 std.debug.assert(entry.status != .pending);
1096 const link_id = entry.id;
1097 const close_frame = encryptedFrame(node_owner, entry, .linkclose, iv, &entry.id) catch
1098 unreachable;
1099 try sendOnLink(node_owner, entry, close_frame);
1100 release(node_owner, link_id, reason);
1101 }
1102
1103 /// Handles every deadline a link holds for the node. The call closes a link still pending or in
1104 /// handshake once its establishment deadline arrives, under reason `timeout`, as Reticulum@1.5.0
1105 /// RNS/Link.py:722-738,744-766 times it out. On a link that has activated the same call drives the
1106 /// keepalive and stale watchdog: an initiator sends a keepalive request after one quiet keepalive
1107 /// period, and either end turns stale after two. A stale link sends a LINKCLOSE packet and closes
1108 /// with reason `timeout` at its close deadline. A timer arriving ahead of the deadline it carries
1109 /// is armed again for that deadline or for one second past the current instant, whichever falls
1110 /// later. A link id the node has already dropped cancels the timer.
1111 pub fn timerExpired(
1112 node_owner: *node.Node,
1113 link_id: [16]u8,
1114 value: node.TimerExpired,
1115 ) node.StepError!void {
1116 std.debug.assert(value.id == .link);
1117 const entry = node_owner.transport.links.find(link_id) orelse {
1118 _ = node_owner.timers.cancel(value.id);
1119 return;
1120 };
1121 switch (entry.status) {
1122 .pending, .handshake => {
1123 const deadline = entry.request_time +| entry.establishment_timeout;
1124 if (value.now < deadline) {
1125 try node.outbound.reserve(node_owner, 1, 0);
1126 return schedule(node_owner, link_id, deadline);
1127 }
1128 try expireUnactivated(node_owner, entry, value);
1129 },
1130 .active => try watchActive(node_owner, entry, value),
1131 .stale => try watchStale(node_owner, entry, value),
1132 }
1133 }
1134
1135 /// Discovers a fresh path for a link attempt that timed out so the node can replace a path that may
1136 /// have gone. The call closes a link that has yet to activate and rediscovers its path, as
1137 /// Reticulum@1.5.0 RNS/Transport.py:674-697 rediscovers it. A node carrying no traffic for others
1138 /// forgets the path its initiator took and asks every carrier for a fresh one, at most once in 20
1139 /// seconds for a given destination. A node carrying traffic for others holds its path, because it
1140 /// answers path requests on its own.
1141 fn expireUnactivated(
1142 node_owner: *node.Node,
1143 entry: *links.Entry,
1144 value: node.TimerExpired,
1145 ) node.StepError!void {
1146 std.debug.assert(entry.status != .active);
1147 std.debug.assert(entry.status != .stale);
1148 const link_id = entry.id;
1149 const destination_hash = entry.destination;
1150 const rediscovers = entry.role == .initiator and !node_owner.transport.enabled;
1151 const frames = if (rediscovers)
1152 node.transport.requests.rediscoveryFrames(node_owner, destination_hash, value.now)
1153 else
1154 0;
1155 try node.outbound.reserve(node_owner, frames + 1, frames);
1156 release(node_owner, link_id, .timeout);
1157 if (!rediscovers) return;
1158 var tag: [16]u8 = undefined;
1159 expand(&value.entropy, &link_id, rediscovery_tag_label, &tag);
1160 try node.transport.requests.rediscover(node_owner, destination_hash, &tag, value.now);
1161 }
1162
1163 fn watchActive(
1164 node_owner: *node.Node,
1165 entry: *links.Entry,
1166 value: node.TimerExpired,
1167 ) node.StepError!void {
1168 std.debug.assert(entry.status == .active);
1169 const carriers = node.outbound.frameCount(node_owner, .{ .one = entry.attached });
1170 try node.outbound.reserve(node_owner, carriers + 1, carriers);
1171 if (!node_owner.timers.canScheduleAfterCancel(value.id, null)) return error.TimerFull;
1172 if (entry.role == .initiator and value.now >= keepaliveDue(entry)) {
1173 const request = [_]u8{wire.link.keepalive_request};
1174 const keepalive = plainFrame(node_owner, entry.id, .data, .keepalive, &request);
1175 try sendOnLink(node_owner, entry, keepalive);
1176 markKeepalive(entry, value.now);
1177 }
1178 if (value.now >= staleDue(entry)) {
1179 entry.status = .stale;
1180 entry.close_at = staleCloseAt(entry);
1181 }
1182 try schedule(node_owner, entry.id, @max(nextDeadline(entry), value.now +| 1));
1183 }
1184
1185 fn watchStale(
1186 node_owner: *node.Node,
1187 entry: *links.Entry,
1188 value: node.TimerExpired,
1189 ) node.StepError!void {
1190 std.debug.assert(entry.status == .stale);
1191 if (value.now < entry.close_at) {
1192 try node.outbound.reserve(node_owner, 1, 0);
1193 return schedule(node_owner, entry.id, entry.close_at);
1194 }
1195 const carriers = node.outbound.frameCount(node_owner, .{ .one = entry.attached });
1196 try node.outbound.reserve(node_owner, carriers + 1, carriers);
1197 var iv: [crypto.token.iv_length]u8 = undefined;
1198 expand(&value.entropy, &entry.id, close_iv_label, &iv);
1199 try teardown(node_owner, entry, iv, .timeout);
1200 }