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}