Skip to main content

ados_protocol/
pairing_posture.rs

1//! Pairing posture: the data-plane auth primitives shared by the agent's
2//! native surfaces.
3//!
4//! The agent is paired-or-unpaired. Physical presence on the LAN is the gate
5//! for an UNPAIRED agent (the claim window); once paired, a data-plane caller
6//! reaching the agent from off-box must present the stored pairing key. The
7//! local operator (a loopback peer that was not relayed by a proxy or tunnel)
8//! already holds shell-level privilege that exceeds API auth and is trusted past
9//! the gate.
10//!
11//! Who the caller is gets decided ONCE per request, at the transport edge, by
12//! [`classify_caller`]: the peer address plus the request's forwarding headers
13//! give one [`CallerClass`]. Every gate (the HTTP unpaired-node gate, the
14//! dashboard-PIN first set, the pairing claim, the direct MAVLink proxies) is a
15//! function of that value and the pairing state, so no two gates can read the
16//! same caller differently.
17//!
18//! These primitives are protocol-level on purpose: more than one native surface
19//! enforces the same posture (the HTTP control surface and the direct MAVLink
20//! proxies), and a single implementation keeps them from drifting.
21//! Surface-specific concerns (request-rate limiting, the HTTP public-path
22//! exempt set, the short-TTL caching wrapper) live with each surface, not here.
23//!
24//! The pairing state is the agent's `pairing.json` (`{ "paired": bool,
25//! "api_key": "..." }`). Read it with [`load_pairing`]: an absent file, or one
26//! that is not `paired:true`, reads as [`Pairing::Unpaired`] (open). A file that
27//! exists but cannot be read or parsed, or claims `paired:true` with no key, is
28//! [`Pairing::Unreadable`] and fails closed: it may be a paired node's record on
29//! a failing card, and reading it as unpaired would open the claim to anyone.
30
31use std::net::IpAddr;
32use std::path::Path;
33
34/// Proxy / tunnel relay headers. Their presence means the request was forwarded by a
35/// reverse proxy or tunnel (e.g. a Cloudflare Tunnel terminating on 127.0.0.1) rather
36/// than originating on this host, so it must NOT qualify for on-box loopback trust.
37pub const FORWARDED_HEADERS: [&str; 4] = [
38    "x-forwarded-for",
39    "x-real-ip",
40    "forwarded",
41    "cf-connecting-ip",
42];
43
44/// The resolved pairing posture read from `pairing.json`.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Pairing {
47    /// No pairing on file: the data plane is open (LAN presence is the gate).
48    Unpaired,
49    /// Paired with this exact key required from an off-box caller.
50    Paired(String),
51    /// The file exists but could not be read or parsed. Treated as paired with
52    /// a key nobody can present: only the on-box operator is served.
53    Unreadable,
54}
55
56/// A data-plane access decision for a paired-or-unpaired agent.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Access {
59    /// Admit the connection (unpaired, or on-box, or a valid key).
60    Accept,
61    /// Reject: the agent is paired, the caller is off-box, and it presented no
62    /// key or the wrong key.
63    Unauthorized,
64}
65
66/// Decide whether a data-plane connection may be admitted, independent of any
67/// transport:
68///
69/// - **Unpaired ⇒ Accept.** A fresh agent has no key to check against. Each
70///   surface narrows the unpaired posture by [`CallerClass`] itself before it
71///   gets here (the HTTP edge PIN-gates the operator LAN and refuses remote
72///   callers; the direct MAVLink proxies admit only
73///   [`CallerClass::is_first_boot_reach`]).
74/// - **Paired + [`CallerClass::OnBox`] ⇒ Accept.** The local operator already
75///   holds shell-level privilege that exceeds API auth.
76/// - **Paired + any other caller + a valid key ⇒ Accept.** Compared in
77///   constant time.
78/// - **Paired + any other caller + a missing or wrong key ⇒ Unauthorized.**
79///
80/// `presented_key` is the key the caller supplied (e.g. an `X-ADOS-Key`
81/// header), if any.
82pub fn data_plane_access(
83    pairing: &Pairing,
84    caller: CallerClass,
85    presented_key: Option<&str>,
86) -> Access {
87    match pairing {
88        Pairing::Unpaired => Access::Accept,
89        Pairing::Unreadable if caller == CallerClass::OnBox => Access::Accept,
90        Pairing::Unreadable => Access::Unauthorized,
91        Pairing::Paired(expected) => {
92            if caller == CallerClass::OnBox {
93                return Access::Accept;
94            }
95            match presented_key {
96                Some(presented) if constant_time_eq(presented.as_bytes(), expected.as_bytes()) => {
97                    Access::Accept
98                }
99                _ => Access::Unauthorized,
100            }
101        }
102    }
103}
104
105/// Compare two byte slices in time independent of where they first differ, so
106/// the bearer-secret check leaks no timing signal about a partial match. A
107/// length mismatch is rejected up front (the length of the stored key is not
108/// itself a secret); equal-length slices are then folded together with a running
109/// difference accumulator that always visits every byte. The compiler is told
110/// via `std::hint::black_box` not to short-circuit the loop once a difference is
111/// seen.
112pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
113    if a.len() != b.len() {
114        return false;
115    }
116    let mut diff: u8 = 0;
117    for (x, y) in a.iter().zip(b.iter()) {
118        diff |= x ^ y;
119    }
120    std::hint::black_box(diff) == 0
121}
122
123/// Config subtrees no radio lane may write: the node's trust roots.
124///
125/// - `security` holds the configured API key and the HMAC secret, standing
126///   LAN credentials.
127/// - `server` names the cloud or self-hosted backend the node's key and
128///   beacon are sent to.
129/// - `pairing` names the cloud pairing endpoint.
130/// - `remote_access` configures the public tunnel, including the path its
131///   token is written to.
132///
133/// One list for both radio lanes (the relayed HTTP config write and the
134/// config tunnel), so they cannot disagree about which keys are trust roots.
135pub const RELAY_FORBIDDEN_CONFIG_KEYS: &[&str] =
136    &["security", "server", "pairing", "remote_access"];
137
138/// Whether a config write arriving over a radio lane may not touch `key` (a
139/// dotted path): the key is one of [`RELAY_FORBIDDEN_CONFIG_KEYS`] or lies
140/// beneath one.
141pub fn relay_config_key_forbidden(key: &str) -> bool {
142    let key = key.trim();
143    RELAY_FORBIDDEN_CONFIG_KEYS.iter().any(|denied| {
144        key == *denied
145            || key
146                .strip_prefix(denied)
147                .is_some_and(|rest| rest.starts_with('.'))
148    })
149}
150
151/// Who is on the other end of a request, derived once at the transport edge by
152/// [`classify_caller`].
153///
154/// An unpaired node is a unit someone powered on and has not claimed yet, in a
155/// hangar, an office or a hotel. The obvious remedy for its exposure — bind only
156/// loopback and link-local until paired — cannot be used, and the reason is
157/// worth stating so nobody reaches for it again. A headless node has exactly two
158/// operator lifelines and NEITHER is loopback or link-local: the AP hotspot on
159/// `192.168.4.1` (the primary first-boot route) and the USB gadget on
160/// `192.168.7.1`. Binding them away would leave a fresh unit reachable only from
161/// a shell the customer does not have. There is also no runtime re-bind:
162/// listeners are bound once at startup, so a bind keyed on pairing would need a
163/// service restart at the exact moment the operator is mid-claim on that socket.
164///
165/// So the gate is drawn at the caller, per request, where it follows pairing
166/// state in both directions with no restart. The honest limitation is that this
167/// is request-layer defence: the port stays open and a refused caller receives a
168/// refusal rather than finding nothing listening.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum CallerClass {
171    /// A loopback peer whose request carries none of [`FORWARDED_HEADERS`]: the
172    /// local operator (the `ados` CLI, a root-owned job), who already holds
173    /// shell-level privilege that exceeds API auth.
174    OnBox,
175    /// A first-boot lifeline: IPv4/IPv6 link-local, or a peer reached through
176    /// the agent's own AP hotspot (`192.168.4.1`) or USB gadget (`192.168.7.1`)
177    /// address. These are the surfaces a fresh unit is reached from and its PIN
178    /// is first set on. The subnet alone is not enough: a home or office LAN can
179    /// be numbered `192.168.4.0/24` too, and every host on it would otherwise be
180    /// a lifeline.
181    Lifeline,
182    /// Any other RFC1918 private-LAN peer (`10/8`, `172.16/12`, `192.168/16`):
183    /// plausibly the operator's own browser on the local network rather than a
184    /// public-WAN host. Trusted less than a lifeline: while unpaired the HTTP
185    /// surface serves it data only behind a dashboard PIN session, and the
186    /// direct MAVLink proxies do not serve it at all.
187    OperatorLan,
188    /// Everything else: a public-WAN host, ANY request that carries a
189    /// proxy-forwarding header (a reverse proxy or tunnel, including one that
190    /// terminates on this host's loopback), or a caller whose address is unknown.
191    Remote,
192}
193
194impl CallerClass {
195    /// Whether this caller may reach an UNPAIRED node's data plane with no
196    /// credential at all: the local operator and the first-boot lifelines.
197    pub fn is_first_boot_reach(self) -> bool {
198        matches!(self, CallerClass::OnBox | CallerClass::Lifeline)
199    }
200}
201
202/// The agent-owned first-boot addresses: the AP hotspot gateway and the USB
203/// gadget address. A private-LAN peer is a lifeline only when it reached one of
204/// these.
205pub const LIFELINE_LOCAL_ADDRS: [std::net::Ipv4Addr; 2] = [
206    std::net::Ipv4Addr::new(192, 168, 4, 1),
207    std::net::Ipv4Addr::new(192, 168, 7, 1),
208];
209
210/// Classify a caller from its peer address, the local address it reached, and
211/// its request headers.
212///
213/// `has_header` answers whether the request carries a header by (lowercase)
214/// name; a transport with no headers (a raw TCP or UDP socket) passes
215/// `|_| false`. A request carrying any of [`FORWARDED_HEADERS`] was relayed by a
216/// proxy or tunnel, so its socket address says nothing about where it came
217/// from: it is [`CallerClass::Remote`] whatever the peer is. That is what keeps
218/// a tunnel terminating on `127.0.0.1` from reading as the local operator, a
219/// first-boot lifeline, or a PIN claimant.
220///
221/// `local` is the address the connection arrived on (the accepted socket's
222/// local address; see [`local_addr_toward`] for a transport that has none).
223/// Lifeline trust for a private-LAN peer requires it to be one of
224/// [`LIFELINE_LOCAL_ADDRS`], so a LAN that merely shares the AP's numbering is
225/// the operator LAN, not a lifeline. `None` grants no such trust.
226///
227/// `peer` is `None` when the address could not be determined, which is
228/// [`CallerClass::Remote`]: an unidentifiable caller is exactly the one these
229/// gates exist for. An IPv4 address mapped onto IPv6 is classified as the IPv4
230/// address it carries.
231pub fn classify_caller(
232    peer: Option<IpAddr>,
233    local: Option<IpAddr>,
234    has_header: impl Fn(&str) -> bool,
235) -> CallerClass {
236    let Some(peer) = peer else {
237        return CallerClass::Remote;
238    };
239    if FORWARDED_HEADERS.iter().any(|h| has_header(h)) {
240        return CallerClass::Remote;
241    }
242    let on_lifeline_addr = matches!(
243        local.map(|l| l.to_canonical()),
244        Some(IpAddr::V4(l)) if LIFELINE_LOCAL_ADDRS.contains(&l)
245    );
246    match peer.to_canonical() {
247        ip if ip.is_loopback() => CallerClass::OnBox,
248        IpAddr::V4(v4) => {
249            let o = v4.octets();
250            if v4.is_link_local() || (on_lifeline_addr && is_rfc1918_v4(o)) {
251                CallerClass::Lifeline
252            } else if is_rfc1918_v4(o) {
253                CallerClass::OperatorLan
254            } else {
255                CallerClass::Remote
256            }
257        }
258        // fe80::/10 — IPv6 link-local unicast.
259        IpAddr::V6(v6) if (v6.segments()[0] & 0xffc0) == 0xfe80 => CallerClass::Lifeline,
260        IpAddr::V6(_) => CallerClass::Remote,
261    }
262}
263
264/// The local address this host would use to reach `peer`: the kernel's route
265/// choice, read by connecting an unbound UDP socket (no packet is sent). For a
266/// transport that has no per-connection local address (a UDP listener bound to
267/// the wildcard), this is the address a reply to the peer leaves from, which is
268/// the interface the peer sits on. `None` when no route exists.
269pub fn local_addr_toward(peer: IpAddr) -> Option<IpAddr> {
270    let peer = peer.to_canonical();
271    let bind: std::net::SocketAddr = match peer {
272        IpAddr::V4(_) => (std::net::Ipv4Addr::UNSPECIFIED, 0).into(),
273        IpAddr::V6(_) => (std::net::Ipv6Addr::UNSPECIFIED, 0).into(),
274    };
275    let socket = std::net::UdpSocket::bind(bind).ok()?;
276    socket.connect((peer, 9)).ok()?;
277    socket.local_addr().ok().map(|a| a.ip())
278}
279
280/// True when an IPv4 octet array is in an RFC1918 private range: `10.0.0.0/8`,
281/// `172.16.0.0/12`, or `192.168.0.0/16`.
282fn is_rfc1918_v4(o: [u8; 4]) -> bool {
283    match o {
284        [10, _, _, _] => true,
285        [172, b, _, _] => (16..=31).contains(&b),
286        [192, 168, _, _] => true,
287        _ => false,
288    }
289}
290
291/// Load the pairing posture from a `pairing.json`. An absent file, or a state
292/// that is not `paired:true`, is [`Pairing::Unpaired`] (open). A file that
293/// exists but cannot be read or parsed as a JSON object, or that says
294/// `paired:true` without a key, is [`Pairing::Unreadable`] (closed).
295pub fn load_pairing(path: &Path) -> Pairing {
296    let text = match std::fs::read_to_string(path) {
297        Ok(text) => text,
298        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Pairing::Unpaired,
299        Err(_) => return Pairing::Unreadable,
300    };
301    let Ok(serde_json::Value::Object(value)) = serde_json::from_str::<serde_json::Value>(&text)
302    else {
303        return Pairing::Unreadable;
304    };
305    let paired = value
306        .get("paired")
307        .and_then(|v| v.as_bool())
308        .unwrap_or(false);
309    let key = value.get("api_key").and_then(|v| v.as_str());
310    match (paired, key) {
311        (true, Some(k)) if !k.is_empty() => Pairing::Paired(k.to_string()),
312        (true, _) => Pairing::Unreadable,
313        _ => Pairing::Unpaired,
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::io::Write;
321    use std::path::PathBuf;
322
323    /// Classify a peer that reached this host on an ordinary LAN address.
324    fn class(ip: &str) -> CallerClass {
325        class_on(ip, Some("192.168.1.20"))
326    }
327
328    fn class_on(ip: &str, local: Option<&str>) -> CallerClass {
329        classify_caller(
330            Some(ip.parse().unwrap()),
331            local.map(|l| l.parse().unwrap()),
332            |_| false,
333        )
334    }
335
336    /// The local operator: a loopback peer with no relay header.
337    #[test]
338    fn a_loopback_peer_with_no_forwarding_header_is_on_box() {
339        for ip in ["127.0.0.1", "127.0.0.53", "::1", "::ffff:127.0.0.1"] {
340            assert_eq!(class(ip), CallerClass::OnBox, "{ip}");
341        }
342    }
343
344    /// A tunnel terminating on this host (a tunnel's ingress to localhost)
345    /// delivers every internet request from 127.0.0.1 with one of these headers
346    /// set. Reading that caller as on-box, as a lifeline or as a PIN claimant is
347    /// the exposure this classification exists to close.
348    #[test]
349    fn a_loopback_peer_carrying_any_forwarding_header_is_remote() {
350        let lo: IpAddr = "127.0.0.1".parse().unwrap();
351        for header in FORWARDED_HEADERS {
352            assert_eq!(
353                classify_caller(Some(lo), Some(lo), |h| h == header),
354                CallerClass::Remote,
355                "loopback + {header} is a relayed caller"
356            );
357        }
358    }
359
360    /// A reverse proxy elsewhere on the LAN is no more trustworthy than one on
361    /// this host: once a request was forwarded, its socket address no longer
362    /// says where it came from.
363    #[test]
364    fn a_forwarded_request_is_remote_from_any_peer() {
365        for ip in ["192.168.4.20", "192.168.1.50", "169.254.3.4", "fe80::1"] {
366            let peer: IpAddr = ip.parse().unwrap();
367            assert_eq!(
368                classify_caller(Some(peer), Some("192.168.4.1".parse().unwrap()), |h| {
369                    h == "x-forwarded-for"
370                }),
371                CallerClass::Remote,
372                "{ip} + x-forwarded-for"
373            );
374        }
375    }
376
377    #[test]
378    fn an_unknown_peer_is_remote() {
379        assert_eq!(classify_caller(None, None, |_| false), CallerClass::Remote);
380    }
381
382    /// The lifelines a headless unpaired unit is actually reached from. These
383    /// are the reason this is a caller filter and not a narrowed bind: a literal
384    /// loopback+link-local bind would remove the AP and USB paths and leave a
385    /// fresh device unreachable to its own operator.
386    #[test]
387    fn the_first_boot_lifelines_classify_as_lifelines() {
388        for (ip, local) in [
389            // A phone joined to the hotspot, reaching the AP address.
390            ("192.168.4.37", Some("192.168.4.1")),
391            // A laptop on the USB gadget net.
392            ("192.168.7.42", Some("192.168.7.1")),
393            // Link-local needs no particular local address.
394            ("169.254.11.9", None),
395            ("fe80::1", None),
396            // An IPv4 lifeline mapped onto v6, on a v6 listener.
397            ("::ffff:192.168.4.20", Some("::ffff:192.168.4.1")),
398        ] {
399            assert_eq!(class_on(ip, local), CallerClass::Lifeline, "{ip}");
400        }
401    }
402
403    /// A home or office LAN numbered like the AP subnet is the operator LAN,
404    /// not a lifeline: the drone is a client there, so the connection arrives
405    /// on its DHCP lease, never on the AP gateway address.
406    #[test]
407    fn a_lan_that_shares_the_ap_numbering_is_not_a_lifeline() {
408        for (ip, local) in [
409            ("192.168.4.37", Some("192.168.4.12")),
410            ("192.168.7.42", Some("192.168.7.9")),
411            ("192.168.4.37", None),
412        ] {
413            assert_eq!(
414                class_on(ip, local),
415                CallerClass::OperatorLan,
416                "{ip} via {local:?}"
417            );
418        }
419    }
420
421    /// The route-derived local address names the interface a peer sits on.
422    #[test]
423    fn local_addr_toward_loopback_is_loopback() {
424        let lo: IpAddr = "127.0.0.1".parse().unwrap();
425        assert_eq!(local_addr_toward(lo), Some(lo));
426    }
427
428    /// A browser on a private LAN is an operator-LAN caller, distinct from the
429    /// lifelines, so the MAVLink proxies can stay lifeline-only while the HTTP
430    /// gate layers the PIN on top of it.
431    #[test]
432    fn a_private_lan_peer_is_an_operator_lan_caller() {
433        for ip in [
434            "192.168.1.10",
435            "192.168.1.50",
436            "10.0.0.5",
437            "10.255.255.1",
438            "172.16.4.4",
439            "172.31.255.1",
440            "::ffff:192.168.1.50",
441            // Neighbouring subnets must not be taken for the lifelines by a
442            // sloppy prefix match.
443            "192.168.40.1",
444            "192.168.70.1",
445            "192.168.5.1",
446            "192.168.6.1",
447        ] {
448            assert_eq!(class(ip), CallerClass::OperatorLan, "{ip}");
449        }
450    }
451
452    /// Public-WAN and non-RFC1918 addresses are never operator-LAN callers.
453    #[test]
454    fn a_public_wan_peer_is_remote() {
455        for ip in [
456            "8.8.8.8",
457            "203.0.113.5",  // documentation range
458            "172.15.255.1", // just below 172.16/12
459            "172.32.0.1",   // just above
460            "2001:db8::1",
461        ] {
462            assert_eq!(class(ip), CallerClass::Remote, "{ip}");
463        }
464    }
465
466    #[test]
467    fn only_on_box_and_lifelines_reach_an_unpaired_node_without_a_credential() {
468        assert!(CallerClass::OnBox.is_first_boot_reach());
469        assert!(CallerClass::Lifeline.is_first_boot_reach());
470        assert!(!CallerClass::OperatorLan.is_first_boot_reach());
471        assert!(!CallerClass::Remote.is_first_boot_reach());
472    }
473
474    fn write_pairing(dir: &Path, body: &str) -> PathBuf {
475        let path = dir.join("pairing.json");
476        let mut f = std::fs::File::create(&path).unwrap();
477        f.write_all(body.as_bytes()).unwrap();
478        path
479    }
480
481    #[test]
482    fn constant_time_eq_matches_byte_equality() {
483        assert!(constant_time_eq(b"ados_secret", b"ados_secret"));
484        assert!(!constant_time_eq(b"ados_secret", b"ados_secre1"));
485        assert!(!constant_time_eq(b"ados_secret", b"xdos_secret"));
486        assert!(!constant_time_eq(b"ados_secret", b"ados_secret_longer"));
487        assert!(!constant_time_eq(b"ados_secret", b"short"));
488        assert!(constant_time_eq(b"", b""));
489        assert!(!constant_time_eq(b"", b"x"));
490    }
491
492    #[test]
493    fn absent_file_reads_as_unpaired() {
494        let dir = tempfile::tempdir().unwrap();
495        assert_eq!(
496            load_pairing(&dir.path().join("absent.json")),
497            Pairing::Unpaired
498        );
499    }
500
501    #[test]
502    fn paired_with_a_key_reads_as_paired() {
503        let dir = tempfile::tempdir().unwrap();
504        let path = write_pairing(dir.path(), r#"{"paired": true, "api_key": "ados_secret"}"#);
505        assert_eq!(load_pairing(&path), Pairing::Paired("ados_secret".into()));
506    }
507
508    #[test]
509    fn paired_without_a_key_is_unreadable() {
510        let dir = tempfile::tempdir().unwrap();
511        let path = write_pairing(dir.path(), r#"{"paired": true, "api_key": ""}"#);
512        assert_eq!(load_pairing(&path), Pairing::Unreadable);
513    }
514
515    /// A file that exists but cannot be parsed is not "no pairing": it may be a
516    /// paired node's record on a failing card, and unpaired opens the claim.
517    #[test]
518    fn a_malformed_file_is_unreadable_and_fails_closed() {
519        let dir = tempfile::tempdir().unwrap();
520        for body in [
521            "this is not json",
522            "[]",
523            r#"{"paired": true, "api_key": "k""#,
524        ] {
525            let path = write_pairing(dir.path(), body);
526            assert_eq!(load_pairing(&path), Pairing::Unreadable, "{body}");
527        }
528        for caller in [
529            CallerClass::Lifeline,
530            CallerClass::OperatorLan,
531            CallerClass::Remote,
532        ] {
533            assert_eq!(
534                data_plane_access(&Pairing::Unreadable, caller, Some("anything")),
535                Access::Unauthorized
536            );
537        }
538        assert_eq!(
539            data_plane_access(&Pairing::Unreadable, CallerClass::OnBox, None),
540            Access::Accept
541        );
542    }
543
544    #[test]
545    fn a_file_without_paired_true_is_unpaired() {
546        let dir = tempfile::tempdir().unwrap();
547        let path = write_pairing(dir.path(), "{}");
548        assert_eq!(load_pairing(&path), Pairing::Unpaired);
549    }
550
551    #[test]
552    fn unpaired_accepts_any_caller_at_this_layer() {
553        for caller in [CallerClass::Remote, CallerClass::OperatorLan] {
554            assert_eq!(
555                data_plane_access(&Pairing::Unpaired, caller, None),
556                Access::Accept
557            );
558            assert_eq!(
559                data_plane_access(&Pairing::Unpaired, caller, Some("anything")),
560                Access::Accept
561            );
562        }
563    }
564
565    #[test]
566    fn paired_on_box_accepts_without_a_key() {
567        let p = Pairing::Paired("k".into());
568        assert_eq!(
569            data_plane_access(&p, CallerClass::OnBox, None),
570            Access::Accept
571        );
572    }
573
574    /// Only the local operator skips the key. A lifeline or LAN caller on a
575    /// paired node is off-box like any other.
576    #[test]
577    fn paired_non_on_box_callers_need_the_key() {
578        let p = Pairing::Paired("ados_secret".into());
579        for caller in [
580            CallerClass::Lifeline,
581            CallerClass::OperatorLan,
582            CallerClass::Remote,
583        ] {
584            assert_eq!(
585                data_plane_access(&p, caller, Some("ados_secret")),
586                Access::Accept,
587                "{caller:?} with the key"
588            );
589            assert_eq!(
590                data_plane_access(&p, caller, None),
591                Access::Unauthorized,
592                "{caller:?} with no key"
593            );
594            assert_eq!(
595                data_plane_access(&p, caller, Some("wrong")),
596                Access::Unauthorized,
597                "{caller:?} with a wrong key"
598            );
599        }
600    }
601}