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 }