Skip to main content

ados_protocol/
relay_ticket.rs

1//! A per-pair credential for relayed requests.
2//!
3//! ## What this closes
4//!
5//! A request that crosses the radio relay arrives on the drone's loopback and
6//! is therefore treated as on-box — the highest trust level the agent has.
7//! `ados_control::serve` says so in its own words: a fleet shares one radio key
8//! and distributes no per-node credential, so the relay has nothing to present.
9//! Radio range consequently carries a node's full authority, bounded only by
10//! the `relay_forbidden` path denylist.
11//!
12//! This gives the relay something to present.
13//!
14//! ## Why the fleet key cannot be the key
15//!
16//! The obvious shortcut — derive the ticket key from the shared 64-byte fleet
17//! keypair both ends already hold — is theatre. Every member of the fleet holds
18//! that key; it is what *makes* them a member. A token derived from it proves
19//! only that the caller is on the radio, which is exactly what the caller
20//! already demonstrated by being on the radio. It would authenticate nothing
21//! and would read, on a status page, as though it did.
22//!
23//! So the key is a secret generated **per pairing** by the ground station and
24//! delivered to that one drone. A second ground station holding the fleet radio
25//! key cannot mint a ticket the drone accepts, because it does not hold the
26//! per-pair secret.
27//!
28//! ## Bootstrap, stated plainly
29//!
30//! The secret is delivered over the same relay it will later protect, at pair
31//! time. That is trust-on-first-use: an attacker already positioned on the
32//! radio at the moment of pairing can observe the delivery. It is a real
33//! limitation and not a hidden one — the alternative is an out-of-band channel
34//! that does not exist on a headless aircraft, and the window is one exchange
35//! at pair time rather than every request forever, which is the situation
36//! today.
37//!
38//! ## Shape
39//!
40//! An HMAC over the request it accompanies, keyed from the per-pair secret and
41//! domain-separated by its own label so a ticket minted for one purpose can
42//! never verify as a WebSocket ticket even though both derive from
43//! HMAC-SHA256. The signed payload is
44//!
45//! ```text
46//! v2|{scope}|{target}|{issued_at}|{expires_at}|{nonce}|{METHOD}|{path_with_query}|{sha256_hex(body)}
47//! ```
48//!
49//! and the token on the wire is that payload followed by `|{hmac_hex}`.
50//!
51//! - **Bound to the request.** Every fleet member hears every ticket, because
52//!   the uplink is a broadcast. A ticket that named only its target could be
53//!   lifted off the air and replayed with any method, path and body against
54//!   the same aircraft; binding all three makes a captured ticket good for the
55//!   one request it was minted for and nothing else.
56//! - **Single use.** The 16-byte random nonce is remembered by the verifier
57//!   (see [`crate::nonce_cache`]) for at least [`REPLAY_RETENTION_SECONDS`],
58//!   so the same request cannot be played twice either.
59//! - **Tolerant of an unsynchronised clock.** A radio-only aircraft has no
60//!   time source but its own RTC, which can be minutes off after a reboot. The
61//!   lifetime is checked against the ticket's own `issued_at` (never longer
62//!   than [`TICKET_LIFETIME_SECONDS`]), and the verifier's clock may disagree
63//!   with the minter's by up to [`CLOCK_SKEW_TOLERANCE_SECONDS`] either way.
64//!   The nonce cache, not the clock, is what stops a replay inside that
65//!   window.
66
67use hmac::{Hmac, KeyInit, Mac};
68use sha2::{Digest, Sha256};
69use thiserror::Error;
70
71use crate::nonce_cache::NonceCache;
72
73type HmacSha256 = Hmac<Sha256>;
74
75/// Domain-separation label mixed into the per-pair secret.
76///
77/// Distinct from the WS ticket's label on purpose: the two credentials protect
78/// different surfaces, and a token minted for one must not verify against the
79/// other even if a caller obtains it.
80pub const RELAY_KEY_LABEL: &[u8] = b"ados-relay-ticket-v2";
81
82/// Where the drone keeps the secret its ground station gave it. 0600, beside
83/// the plugin token secret, which is the established home for material like
84/// this.
85pub const RELAY_SECRET_PATH: &str = "/etc/ados/secrets/relay-peer-secret";
86
87/// Secret length in bytes.
88pub const RELAY_SECRET_LEN: usize = 32;
89
90/// Ticket lifetime, `expires_at - issued_at`. A verifier refuses a ticket that
91/// claims a longer one.
92///
93/// Short because a relayed request is a round trip over a radio, not a session:
94/// the ticket only has to outlive the call it accompanies, including the
95/// retransmit schedule on a lossy lane.
96pub const TICKET_LIFETIME_SECONDS: i64 = 30;
97
98/// How far the verifier's wall clock may disagree with the minter's, in either
99/// direction, before a ticket is refused as `clock_skew` (issued too far in
100/// the future) or `expired`.
101pub const CLOCK_SKEW_TOLERANCE_SECONDS: i64 = 300;
102
103/// How long a spent nonce is remembered, at minimum.
104pub const REPLAY_RETENTION_SECONDS: i64 = 600;
105
106/// How many spent nonces a verifier remembers before evicting the oldest.
107pub const REPLAY_CACHE_CAPACITY: usize = 4096;
108
109/// The scope a relayed HTTP call carries.
110pub const SCOPE_RELAY: &str = "relay.http";
111
112/// The `error` code every relay-ticket refusal answers with.
113pub const RELAY_REFUSAL_CODE: &str = "E_RELAY_TICKET";
114
115/// The method a config-tunnel ticket is bound to. Not an HTTP verb, so a
116/// tunnel ticket can never authorize a relayed HTTP request or the reverse.
117pub const TUNNEL_BINDING_METHOD: &str = "TUNNEL";
118
119/// The path a config-tunnel ticket is bound to: the one surface the tunnel
120/// reaches.
121pub const TUNNEL_BINDING_PATH: &str = "/api/config";
122
123#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
124pub enum RelayTicketError {
125    /// This node holds no per-pair secret, so it cannot verify any ticket and
126    /// has nothing to distinguish its own ground station from anything else
127    /// holding the shared fleet radio key. Refusing is the only safe answer:
128    /// admitting would hand radio range this node's full on-box authority.
129    #[error("no relay peer secret on file")]
130    NoSecret,
131    /// Not a ticket of this version's shape.
132    #[error("malformed relay ticket")]
133    Malformed,
134    /// The HMAC does not verify under this node's secret.
135    #[error("relay ticket signature does not verify")]
136    BadSignature,
137    /// Authentic, but minted for a different scope, target, method, path or
138    /// body than the request it arrived with.
139    #[error("relay ticket is bound to a different request")]
140    BindingMismatch,
141    /// Its expiry is further in the past than the clock tolerance allows.
142    #[error("relay ticket expired")]
143    Expired,
144    /// Issued further in the future than the clock tolerance allows.
145    #[error("relay ticket issued in the future beyond the clock tolerance")]
146    ClockSkew,
147    /// Its nonce has already been spent.
148    #[error("relay ticket already used")]
149    Replayed,
150}
151
152impl RelayTicketError {
153    /// The `reason` string a refusal body carries.
154    pub fn reason(self) -> &'static str {
155        match self {
156            Self::NoSecret => "no_secret",
157            Self::Malformed | Self::BadSignature => "bad_signature",
158            Self::BindingMismatch => "binding_mismatch",
159            Self::Expired => "expired",
160            Self::ClockSkew => "clock_skew",
161            Self::Replayed => "replayed",
162        }
163    }
164}
165
166/// The reason a ground station refuses to relay at all because the drone holds
167/// a secret issued by a different ground station.
168pub const SECRET_CONFLICT_REASON: &str = "secret_conflict";
169
170/// The JSON body of a relay-ticket refusal:
171/// `{"error":"E_RELAY_TICKET","reason":"<reason>"}`. Answered with HTTP 401.
172pub fn refusal_body(reason: &str) -> Vec<u8> {
173    serde_json::to_vec(&serde_json::json!({
174        "error": RELAY_REFUSAL_CODE,
175        "reason": reason,
176    }))
177    .unwrap_or_default()
178}
179
180/// The request a ticket is bound to.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct RequestBinding<'a> {
183    /// Upper-case HTTP method, or [`TUNNEL_BINDING_METHOD`].
184    pub method: &'a str,
185    /// The path exactly as the verifier will see it, including any query
186    /// string and still percent-encoded.
187    pub path: &'a str,
188    /// The request body bytes.
189    pub body: &'a [u8],
190}
191
192impl<'a> RequestBinding<'a> {
193    pub fn new(method: &'a str, path: &'a str, body: &'a [u8]) -> Self {
194        Self { method, path, body }
195    }
196}
197
198/// The body a config-tunnel request's ticket binds: the op, key and value of
199/// the request object, ignoring the `ticket` field itself.
200///
201/// Built from the parsed JSON rather than the raw bytes so the minting side
202/// (which inserts the ticket into the object) and the verifying side (which
203/// receives it there) agree without either depending on key order.
204pub fn tunnel_binding_body(request: &serde_json::Value) -> Vec<u8> {
205    let field = |name: &str| -> String {
206        match request.get(name) {
207            None | Some(serde_json::Value::Null) => String::new(),
208            Some(v) => v.to_string(),
209        }
210    };
211    format!("{}\n{}\n{}", field("op"), field("key"), field("value")).into_bytes()
212}
213
214/// What a verified ticket tells its verifier: the nonce to record as spent and
215/// the last second at which the ticket could still have verified.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct VerifiedRelayTicket {
218    pub nonce: String,
219    pub valid_until: i64,
220}
221
222impl VerifiedRelayTicket {
223    /// Spend the nonce in `cache`. A second use of the same ticket is
224    /// [`RelayTicketError::Replayed`].
225    pub fn spend(&self, cache: &NonceCache, now: i64) -> Result<(), RelayTicketError> {
226        if cache.admit(&self.nonce, now, self.valid_until) {
227            Ok(())
228        } else {
229            Err(RelayTicketError::Replayed)
230        }
231    }
232}
233
234/// A replay cache sized for relay tickets.
235pub const fn new_replay_cache() -> NonceCache {
236    NonceCache::new(REPLAY_CACHE_CAPACITY, REPLAY_RETENTION_SECONDS)
237}
238
239/// Mints and verifies relay tickets from a per-pair secret.
240#[derive(Clone)]
241pub struct RelayTicketIssuer {
242    key: Vec<u8>,
243}
244
245impl RelayTicketIssuer {
246    /// Derive the ticket key from the per-pair secret under the relay label.
247    ///
248    /// Takes bytes rather than a string: the secret is random material, not
249    /// text, and hex-decoding it at the boundary keeps that explicit.
250    pub fn from_secret(secret: &[u8]) -> Self {
251        let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
252        mac.update(RELAY_KEY_LABEL);
253        Self {
254            key: mac.finalize().into_bytes().to_vec(),
255        }
256    }
257
258    /// Mint a ticket for `request` addressed to `target`, issued at `now`,
259    /// with a fresh random nonce. `None` only when the system cannot supply
260    /// randomness.
261    pub fn mint(&self, target: &str, request: &RequestBinding<'_>, now: i64) -> Option<String> {
262        let nonce = crate::nonce_cache::random_nonce_hex()?;
263        Some(self.mint_with_nonce(target, request, now, &nonce))
264    }
265
266    /// Deterministic mint core (explicit nonce) for tests.
267    pub fn mint_with_nonce(
268        &self,
269        target: &str,
270        request: &RequestBinding<'_>,
271        now: i64,
272        nonce: &str,
273    ) -> String {
274        let expires_at = now.saturating_add(TICKET_LIFETIME_SECONDS);
275        let payload = sign_payload(SCOPE_RELAY, target, now, expires_at, nonce, request);
276        let signature = self.sign(&payload);
277        format!("{payload}|{signature}")
278    }
279
280    /// Verify a ticket against the request it arrived with: authenticity
281    /// first, then what it is bound to, then time.
282    ///
283    /// Order matters. Checking the binding or the clock before the HMAC would
284    /// answer questions about a string nobody has shown to be ours.
285    ///
286    /// This does NOT spend the nonce; the caller does that with
287    /// [`VerifiedRelayTicket::spend`] at the point where a second arrival
288    /// would re-execute the request.
289    pub fn verify(
290        &self,
291        token: &str,
292        expected_target: &str,
293        request: &RequestBinding<'_>,
294        now: i64,
295    ) -> Result<VerifiedRelayTicket, RelayTicketError> {
296        let parsed = ParsedTicket::parse(token)?;
297        let sig = hex::decode(parsed.signature).map_err(|_| RelayTicketError::BadSignature)?;
298        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
299        mac.update(parsed.payload.as_bytes());
300        // Constant-time.
301        mac.verify_slice(&sig)
302            .map_err(|_| RelayTicketError::BadSignature)?;
303
304        if parsed.scope != SCOPE_RELAY
305            || parsed.target != expected_target
306            || parsed.method != request.method
307            || parsed.path != request.path
308            || parsed.body_sha256 != body_digest(request.body)
309        {
310            return Err(RelayTicketError::BindingMismatch);
311        }
312        let lifetime = parsed.expires_at.saturating_sub(parsed.issued_at);
313        if !(1..=TICKET_LIFETIME_SECONDS).contains(&lifetime) {
314            return Err(RelayTicketError::Malformed);
315        }
316        if parsed.issued_at > now.saturating_add(CLOCK_SKEW_TOLERANCE_SECONDS) {
317            return Err(RelayTicketError::ClockSkew);
318        }
319        let valid_until = parsed
320            .expires_at
321            .saturating_add(CLOCK_SKEW_TOLERANCE_SECONDS);
322        if now > valid_until {
323            return Err(RelayTicketError::Expired);
324        }
325        Ok(VerifiedRelayTicket {
326            nonce: parsed.nonce.to_owned(),
327            valid_until,
328        })
329    }
330
331    fn sign(&self, payload: &str) -> String {
332        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
333        mac.update(payload.as_bytes());
334        hex::encode(mac.finalize().into_bytes())
335    }
336}
337
338/// The fields of a token, borrowed from it.
339struct ParsedTicket<'a> {
340    payload: &'a str,
341    signature: &'a str,
342    scope: &'a str,
343    target: &'a str,
344    issued_at: i64,
345    expires_at: i64,
346    nonce: &'a str,
347    method: &'a str,
348    path: &'a str,
349    body_sha256: &'a str,
350}
351
352impl<'a> ParsedTicket<'a> {
353    /// Split a token. The path is the one field that may itself contain `|`
354    /// (an unencoded query string can), so the fixed fields are taken from
355    /// both ends and the path is whatever lies between.
356    fn parse(token: &'a str) -> Result<Self, RelayTicketError> {
357        let (payload, signature) = token.rsplit_once('|').ok_or(RelayTicketError::Malformed)?;
358        let (head, body_sha256) = payload
359            .rsplit_once('|')
360            .ok_or(RelayTicketError::Malformed)?;
361        let mut fields = head.splitn(8, '|');
362        let mut next = || fields.next().ok_or(RelayTicketError::Malformed);
363        let version = next()?;
364        let scope = next()?;
365        let target = next()?;
366        let issued_at = next()?;
367        let expires_at = next()?;
368        let nonce = next()?;
369        let method = next()?;
370        let path = next()?;
371        if version != "v2" || nonce.len() != 32 || !nonce.bytes().all(|b| b.is_ascii_hexdigit()) {
372            return Err(RelayTicketError::Malformed);
373        }
374        Ok(Self {
375            payload,
376            signature,
377            scope,
378            target,
379            issued_at: issued_at.parse().map_err(|_| RelayTicketError::Malformed)?,
380            expires_at: expires_at
381                .parse()
382                .map_err(|_| RelayTicketError::Malformed)?,
383            nonce,
384            method,
385            path,
386            body_sha256,
387        })
388    }
389}
390
391/// Lowercase hex SHA-256 of a request body.
392fn body_digest(body: &[u8]) -> String {
393    hex::encode(Sha256::digest(body))
394}
395
396/// The signed substring.
397fn sign_payload(
398    scope: &str,
399    target: &str,
400    issued_at: i64,
401    expires_at: i64,
402    nonce: &str,
403    request: &RequestBinding<'_>,
404) -> String {
405    format!(
406        "v2|{scope}|{target}|{issued_at}|{expires_at}|{nonce}|{}|{}|{}",
407        request.method,
408        request.path,
409        body_digest(request.body)
410    )
411}
412
413/// Why a secret may or may not be accepted from the relay.
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum AcceptDecision {
416    /// No secret held: take this one. Trust-on-first-use.
417    Accept,
418    /// Already holding this exact secret. A no-op, not a conflict — the ground
419    /// station restates it on every reconcile tick.
420    AlreadyHeld,
421    /// Holding a DIFFERENT secret. Refused.
422    RefusedWouldOverwrite,
423    /// The offered value is not a usable secret.
424    RefusedMalformed,
425}
426
427/// Decide whether to accept a relay secret offered over the relay itself.
428///
429/// **This is the security decision in the whole scheme, so it is a pure
430/// function with its own tests rather than a branch inside an HTTP handler.**
431///
432/// The delivery necessarily rides the very channel the credential will later
433/// protect, which today is unauthenticated. If the drone simply took whatever
434/// it was handed, anyone within radio range could overwrite the secret with
435/// their own and then mint tickets the drone accepts — leaving a credential
436/// that looks like protection on every status surface while granting exactly
437/// the access it was built to deny. That is strictly worse than having none.
438///
439/// So: **first write wins.** An unset drone takes the first secret it is
440/// offered; a drone already holding one refuses to replace it over the relay.
441/// The exposure is the pairing moment rather than every request forever, which
442/// is the honest statement of what trust-on-first-use buys.
443///
444/// Replacing a secret deliberately — a re-pair to a different ground station —
445/// goes through unpair, which clears it locally, rather than through a write
446/// that a stranger could also make.
447pub fn decide_accept(held: Option<&str>, offered: &str) -> AcceptDecision {
448    if offered.len() != RELAY_SECRET_LEN * 2 || !offered.chars().all(|c| c.is_ascii_hexdigit()) {
449        return AcceptDecision::RefusedMalformed;
450    }
451    match held {
452        None => AcceptDecision::Accept,
453        Some(existing) if existing.eq_ignore_ascii_case(offered) => AcceptDecision::AlreadyHeld,
454        Some(_) => AcceptDecision::RefusedWouldOverwrite,
455    }
456}
457
458/// The secret this drone currently holds, or `None` when it holds none.
459///
460/// A missing file and an unreadable one are both "none", and "none" denies:
461/// the drone has no credential to verify against, so the only relayed call it
462/// will serve is the one that delivers the credential. An unreadable secret
463/// therefore closes the relay lane rather than opening it — the opposite of a
464/// permissions slip silently restoring radio-range full authority.
465pub fn load_secret_at(path: &std::path::Path) -> Option<String> {
466    let raw = std::fs::read_to_string(path).ok()?;
467    let trimmed = raw.trim().to_string();
468    (!trimmed.is_empty()).then_some(trimmed)
469}
470
471/// Serialises every offer this process applies, so two concurrent offers
472/// cannot both read "none held". The hard link below is the cross-process
473/// guard; this one keeps the read-decide-link sequence whole in-process.
474static RELAY_SECRET_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
475
476/// Create the secret's directory, owner-only.
477fn prepare_secret_dir(path: &std::path::Path) -> std::io::Result<()> {
478    let Some(parent) = path.parent() else {
479        return Ok(());
480    };
481    std::fs::create_dir_all(parent)?;
482    #[cfg(unix)]
483    {
484        use std::os::unix::fs::PermissionsExt;
485        let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
486    }
487    Ok(())
488}
489
490/// Write `secret` to a fresh owner-only file at `tmp` and flush it to disk.
491fn write_private_file(tmp: &std::path::Path, secret: &str) -> std::io::Result<()> {
492    use std::io::Write;
493    let mut opts = std::fs::OpenOptions::new();
494    opts.write(true).create_new(true);
495    #[cfg(unix)]
496    {
497        use std::os::unix::fs::OpenOptionsExt;
498        opts.mode(0o600);
499    }
500    let mut f = opts.open(tmp)?;
501    f.write_all(secret.as_bytes())?;
502    f.sync_all()
503}
504
505/// Flush the directory entry for `path`, so a power cut after an accept
506/// cannot lose the link that made the secret visible.
507fn sync_parent(path: &std::path::Path) -> std::io::Result<()> {
508    #[cfg(unix)]
509    if let Some(parent) = path.parent() {
510        std::fs::File::open(parent)?.sync_all()?;
511    }
512    #[cfg(not(unix))]
513    let _ = path;
514    Ok(())
515}
516
517/// Make `offered` the held secret unless one already exists.
518///
519/// The secret is written whole to a temp sibling first and then hard-linked
520/// into place: the link either creates the file with its full content or
521/// fails because a file is already there, so no reader ever sees a partial or
522/// empty secret and two writers cannot both win. On `AlreadyExists` the held
523/// value decides the answer; an existing file that holds nothing (a leftover
524/// from an older non-atomic write) is replaced atomically.
525fn install_secret(path: &std::path::Path, offered: &str) -> std::io::Result<AcceptDecision> {
526    prepare_secret_dir(path)?;
527    let name = path
528        .file_name()
529        .map(|n| n.to_string_lossy().into_owned())
530        .unwrap_or_default();
531    let nanos = std::time::SystemTime::now()
532        .duration_since(std::time::UNIX_EPOCH)
533        .map(|d| d.as_nanos())
534        .unwrap_or(0);
535    let tmp = path.with_file_name(format!(".{name}.{}.{nanos}.tmp", std::process::id()));
536    if let Err(e) = write_private_file(&tmp, offered) {
537        let _ = std::fs::remove_file(&tmp);
538        return Err(e);
539    }
540    let outcome = match std::fs::hard_link(&tmp, path) {
541        Ok(()) => Ok(AcceptDecision::Accept),
542        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => match load_secret_at(path) {
543            Some(held) => Ok(decide_accept(Some(&held), offered)),
544            None => std::fs::rename(&tmp, path).map(|()| AcceptDecision::Accept),
545        },
546        Err(e) => Err(e),
547    };
548    // Gone already after a rename; otherwise the link (or the refusal) made it
549    // redundant.
550    let _ = std::fs::remove_file(&tmp);
551    if matches!(outcome, Ok(AcceptDecision::Accept)) {
552        sync_parent(path)?;
553    }
554    outcome
555}
556
557/// Apply an offered secret under the [`decide_accept`] rule, storing it only on
558/// [`AcceptDecision::Accept`].
559///
560/// The decision and the write are joined here so no caller can reach the write
561/// without going through the rule -- the whole scheme rests on first-write-wins,
562/// and a handler that stored first and asked afterwards would defeat it. The
563/// write itself is atomic and exclusive (see `install_secret`), and offers
564/// are serialised, so concurrent offers yield exactly one `Accept`. Blocking
565/// file IO: an async caller runs it in `spawn_blocking`.
566pub fn apply_offered_secret(
567    path: &std::path::Path,
568    offered: &str,
569) -> Result<AcceptDecision, std::io::Error> {
570    let _guard = RELAY_SECRET_LOCK.lock().unwrap_or_else(|p| p.into_inner());
571    let decision = decide_accept(load_secret_at(path).as_deref(), offered);
572    if decision != AcceptDecision::Accept {
573        return Ok(decision);
574    }
575    install_secret(path, offered)
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    const SECRET: &[u8] = b"0123456789abcdef0123456789abcdef";
583    const DRONE: &str = "40bb1a5a";
584
585    fn issuer() -> RelayTicketIssuer {
586        RelayTicketIssuer::from_secret(SECRET)
587    }
588
589    const HEX32: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
590
591    #[test]
592    fn an_unset_drone_takes_the_first_secret_it_is_offered() {
593        // Trust-on-first-use: at pair time there is nothing better available on
594        // a headless aircraft.
595        assert_eq!(decide_accept(None, HEX32), AcceptDecision::Accept);
596    }
597
598    #[test]
599    fn a_drone_already_holding_a_secret_refuses_to_be_re_keyed_over_the_air() {
600        // The delivery rides the channel the credential protects, and that
601        // channel is unauthenticated. Without this, anyone in radio range
602        // overwrites the secret and then mints tickets the drone accepts — a
603        // credential that reads as protection while granting the access it
604        // exists to deny. Deliberate replacement goes through unpair.
605        let other = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
606        assert_eq!(
607            decide_accept(Some(HEX32), other),
608            AcceptDecision::RefusedWouldOverwrite
609        );
610    }
611
612    #[test]
613    fn restating_the_same_secret_is_a_no_op_not_a_conflict() {
614        // The ground station restates it on every reconcile tick, so the
615        // steady state must not look like an attack.
616        assert_eq!(
617            decide_accept(Some(HEX32), HEX32),
618            AcceptDecision::AlreadyHeld
619        );
620        // Case differences in hex are the same secret.
621        assert_eq!(
622            decide_accept(Some(&HEX32.to_uppercase()), HEX32),
623            AcceptDecision::AlreadyHeld
624        );
625    }
626
627    #[test]
628    fn a_malformed_offer_is_refused_before_anything_is_stored() {
629        // Storing a short or non-hex value would leave the drone holding
630        // something credential-shaped that authenticates nothing, and would
631        // then block the real secret via first-write-wins.
632        for bad in ["", "abc", "zzzz", &"a".repeat(63), &"a".repeat(65)] {
633            assert_eq!(
634                decide_accept(None, bad),
635                AcceptDecision::RefusedMalformed,
636                "{bad:?}"
637            );
638        }
639    }
640
641    #[test]
642    fn a_malformed_offer_cannot_displace_a_held_secret() {
643        assert_eq!(
644            decide_accept(Some(HEX32), "nope"),
645            AcceptDecision::RefusedMalformed
646        );
647    }
648
649    const NONCE: &str = "00112233445566778899aabbccddeeff";
650
651    fn status_get() -> RequestBinding<'static> {
652        RequestBinding::new("GET", "/api/status", b"")
653    }
654
655    fn mint(now: i64) -> String {
656        issuer().mint_with_nonce(DRONE, &status_get(), now, NONCE)
657    }
658
659    #[test]
660    fn a_freshly_minted_ticket_verifies_for_its_target_and_request() {
661        let t = mint(1_000);
662        let v = issuer().verify(&t, DRONE, &status_get(), 1_005).unwrap();
663        assert_eq!(v.nonce, NONCE);
664        assert_eq!(
665            v.valid_until,
666            1_000 + TICKET_LIFETIME_SECONDS + CLOCK_SKEW_TOLERANCE_SECONDS
667        );
668    }
669
670    #[test]
671    fn a_ticket_presented_with_another_request_is_a_binding_mismatch() {
672        // A ticket lifted off the broadcast uplink must not authorize any
673        // request but the one it was minted for.
674        let t = mint(1_000);
675        for other in [
676            RequestBinding::new("POST", "/api/command", b"{}"),
677            RequestBinding::new("POST", "/api/status", b""),
678            RequestBinding::new("GET", "/api/status?x=1", b""),
679            RequestBinding::new("GET", "/api/status", b"x"),
680        ] {
681            assert_eq!(
682                issuer().verify(&t, DRONE, &other, 1_005),
683                Err(RelayTicketError::BindingMismatch),
684                "{other:?}"
685            );
686        }
687        assert_eq!(
688            RelayTicketError::BindingMismatch.reason(),
689            "binding_mismatch"
690        );
691    }
692
693    #[test]
694    fn a_ticket_for_one_drone_does_not_verify_at_another() {
695        let t = mint(1_000);
696        assert_eq!(
697            issuer().verify(&t, "f6aa0aa4", &status_get(), 1_005),
698            Err(RelayTicketError::BindingMismatch)
699        );
700    }
701
702    #[test]
703    fn a_different_pair_secret_does_not_verify() {
704        // A second ground station holding the shared fleet radio key still
705        // cannot mint a ticket this drone accepts.
706        let t = mint(1_000);
707        let other = RelayTicketIssuer::from_secret(b"ffffffffffffffffffffffffffffffff");
708        assert_eq!(
709            other.verify(&t, DRONE, &status_get(), 1_005),
710            Err(RelayTicketError::BadSignature)
711        );
712    }
713
714    #[test]
715    fn a_ws_ticket_key_derivation_does_not_verify_a_relay_ticket() {
716        // Domain separation: only the label keeps one credential from being
717        // replayed as the other.
718        let t = mint(1_000);
719        let mut mac = HmacSha256::new_from_slice(SECRET).unwrap();
720        mac.update(crate::ws_ticket::TICKET_KEY_LABEL);
721        let ws_keyed = RelayTicketIssuer {
722            key: mac.finalize().into_bytes().to_vec(),
723        };
724        assert_eq!(
725            ws_keyed.verify(&t, DRONE, &status_get(), 1_005),
726            Err(RelayTicketError::BadSignature)
727        );
728        assert_ne!(RELAY_KEY_LABEL, crate::ws_ticket::TICKET_KEY_LABEL);
729    }
730
731    #[test]
732    fn clock_skew_inside_the_tolerance_is_accepted_and_beyond_it_is_refused() {
733        let now = 10_000;
734        // Minter's clock 200 s ahead of ours: inside the tolerance.
735        let ahead = mint(now + 200);
736        assert!(issuer().verify(&ahead, DRONE, &status_get(), now).is_ok());
737        // 400 s ahead: refused, and said why.
738        let far_ahead = mint(now + 400);
739        assert_eq!(
740            issuer().verify(&far_ahead, DRONE, &status_get(), now),
741            Err(RelayTicketError::ClockSkew)
742        );
743        // Minter's clock behind ours: accepted until the expiry is more than
744        // the tolerance in the past.
745        let behind = mint(now - 300);
746        assert!(issuer().verify(&behind, DRONE, &status_get(), now).is_ok());
747        let stale = mint(now - 400);
748        assert_eq!(
749            issuer().verify(&stale, DRONE, &status_get(), now),
750            Err(RelayTicketError::Expired)
751        );
752    }
753
754    #[test]
755    fn a_ticket_is_spent_once() {
756        let cache = new_replay_cache();
757        let t = mint(1_000);
758        let v = issuer().verify(&t, DRONE, &status_get(), 1_001).unwrap();
759        assert_eq!(v.spend(&cache, 1_001), Ok(()));
760        let again = issuer().verify(&t, DRONE, &status_get(), 1_002).unwrap();
761        assert_eq!(again.spend(&cache, 1_002), Err(RelayTicketError::Replayed));
762        assert_eq!(RelayTicketError::Replayed.reason(), "replayed");
763    }
764
765    #[test]
766    fn every_mint_carries_a_fresh_nonce() {
767        let a = issuer().mint(DRONE, &status_get(), 1_000).unwrap();
768        let b = issuer().mint(DRONE, &status_get(), 1_000).unwrap();
769        assert_ne!(a, b);
770    }
771
772    #[test]
773    fn a_tampered_field_is_refused_before_anything_else_is_read() {
774        let t = mint(1_000);
775        // Push the expiry out. Without an HMAC over the exact substring this
776        // would simply extend the ticket's life.
777        let forged = t.replacen("|1030|", "|99999999999|", 1);
778        assert_ne!(forged, t);
779        assert_eq!(
780            issuer().verify(&forged, DRONE, &status_get(), 1_005),
781            Err(RelayTicketError::BadSignature)
782        );
783    }
784
785    #[test]
786    fn a_path_containing_a_pipe_still_binds_exactly() {
787        let req = RequestBinding::new("GET", "/api/logs?q=a|b", b"");
788        let t = issuer().mint_with_nonce(DRONE, &req, 1_000, NONCE);
789        assert!(issuer().verify(&t, DRONE, &req, 1_001).is_ok());
790        let other = RequestBinding::new("GET", "/api/logs?q=a", b"");
791        assert_eq!(
792            issuer().verify(&t, DRONE, &other, 1_001),
793            Err(RelayTicketError::BindingMismatch)
794        );
795    }
796
797    #[test]
798    fn a_malformed_token_is_refused_rather_than_panicking() {
799        for bad in [
800            "",
801            "v2",
802            "v1|relay.http|d|1|2|ff",
803            "not-a-ticket",
804            "v2|relay.http|d|1|2|short|GET|/x|ab|ff",
805            "v2|relay.http|d|x|2|00112233445566778899aabbccddeeff|GET|/x|ab|ff",
806        ] {
807            assert!(
808                issuer().verify(bad, DRONE, &status_get(), 1_000).is_err(),
809                "{bad}"
810            );
811        }
812    }
813
814    #[test]
815    fn a_refusal_body_names_the_code_and_reason() {
816        let body: serde_json::Value = serde_json::from_slice(&refusal_body("clock_skew")).unwrap();
817        assert_eq!(body["error"], RELAY_REFUSAL_CODE);
818        assert_eq!(body["reason"], "clock_skew");
819    }
820
821    #[test]
822    fn the_tunnel_binding_ignores_the_ticket_and_key_order() {
823        let a: serde_json::Value =
824            serde_json::from_str(r#"{"op":"put","key":"video.bitrate","value":4}"#).unwrap();
825        let b: serde_json::Value = serde_json::from_str(
826            r#"{"value":4,"ticket":"anything","key":"video.bitrate","op":"put"}"#,
827        )
828        .unwrap();
829        assert_eq!(tunnel_binding_body(&a), tunnel_binding_body(&b));
830        let c: serde_json::Value =
831            serde_json::from_str(r#"{"op":"put","key":"video.bitrate","value":5}"#).unwrap();
832        assert_ne!(tunnel_binding_body(&a), tunnel_binding_body(&c));
833    }
834
835    // Checked at compile time: a shorter secret would weaken every ticket,
836    // and a runtime assertion on a constant is not a test.
837    const _: () = assert!(RELAY_SECRET_LEN >= 32);
838
839    #[test]
840    fn applying_an_offer_to_an_unset_drone_stores_it() {
841        let dir = tempfile::tempdir().unwrap();
842        let path = dir.path().join("secrets/relay-peer-secret");
843
844        assert_eq!(
845            apply_offered_secret(&path, HEX32).unwrap(),
846            AcceptDecision::Accept
847        );
848        assert_eq!(load_secret_at(&path).as_deref(), Some(HEX32));
849    }
850
851    #[test]
852    fn applying_a_restated_secret_leaves_the_stored_value_alone() {
853        // The ground station restates on every reconcile tick, so the ordinary
854        // steady state must not read as a refusal.
855        let dir = tempfile::tempdir().unwrap();
856        let path = dir.path().join("secrets/relay-peer-secret");
857        apply_offered_secret(&path, HEX32).unwrap();
858
859        assert_eq!(
860            apply_offered_secret(&path, HEX32).unwrap(),
861            AcceptDecision::AlreadyHeld
862        );
863    }
864
865    #[test]
866    fn applying_a_stranger_offer_leaves_the_stored_secret_intact() {
867        // The attack the first-write-wins rule exists to stop: anyone in radio
868        // range replacing the credential with their own would leave something
869        // that reads as protection while granting exactly what it denies.
870        let dir = tempfile::tempdir().unwrap();
871        let path = dir.path().join("secrets/relay-peer-secret");
872        apply_offered_secret(&path, HEX32).unwrap();
873
874        let attacker = "f".repeat(64);
875        assert_eq!(
876            apply_offered_secret(&path, &attacker).unwrap(),
877            AcceptDecision::RefusedWouldOverwrite
878        );
879        assert_eq!(
880            load_secret_at(&path).as_deref(),
881            Some(HEX32),
882            "the held secret is untouched"
883        );
884    }
885
886    #[test]
887    fn applying_a_malformed_offer_reaches_no_file() {
888        let dir = tempfile::tempdir().unwrap();
889        let path = dir.path().join("secrets/relay-peer-secret");
890
891        assert_eq!(
892            apply_offered_secret(&path, "nonsense").unwrap(),
893            AcceptDecision::RefusedMalformed
894        );
895        assert!(load_secret_at(&path).is_none());
896    }
897
898    #[cfg(unix)]
899    #[test]
900    fn an_empty_leftover_file_is_replaced_owner_only() {
901        use std::os::unix::fs::PermissionsExt;
902        let dir = tempfile::tempdir().unwrap();
903        let path = dir.path().join("relay-peer-secret");
904        // What a crash between truncate and write used to leave behind, with
905        // the looser mode an earlier build could have set.
906        std::fs::write(&path, "").unwrap();
907        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
908
909        assert_eq!(
910            apply_offered_secret(&path, HEX32).unwrap(),
911            AcceptDecision::Accept
912        );
913        assert_eq!(load_secret_at(&path).as_deref(), Some(HEX32));
914        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
915        assert_eq!(mode, 0o600, "got {mode:o}");
916    }
917
918    #[test]
919    fn concurrent_offers_accept_exactly_one() {
920        let dir = tempfile::tempdir().unwrap();
921        let path = dir.path().join("secrets/relay-peer-secret");
922        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
923        let offers = [HEX32.to_string(), "f".repeat(64)];
924        let handles: Vec<_> = offers
925            .iter()
926            .cloned()
927            .map(|offer| {
928                let path = path.clone();
929                let barrier = barrier.clone();
930                std::thread::spawn(move || {
931                    barrier.wait();
932                    (offer.clone(), apply_offered_secret(&path, &offer).unwrap())
933                })
934            })
935            .collect();
936        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
937        let winners: Vec<_> = results
938            .iter()
939            .filter(|(_, d)| *d == AcceptDecision::Accept)
940            .collect();
941        assert_eq!(winners.len(), 1, "{results:?}");
942        assert!(results
943            .iter()
944            .any(|(_, d)| *d == AcceptDecision::RefusedWouldOverwrite));
945        assert_eq!(
946            load_secret_at(&path).as_deref(),
947            Some(winners[0].0.as_str())
948        );
949        // No temp sibling is left behind.
950        let leftovers = std::fs::read_dir(path.parent().unwrap()).unwrap().count();
951        assert_eq!(leftovers, 1);
952    }
953
954    #[test]
955    fn an_absent_secret_reads_as_none_rather_than_an_error() {
956        // Which is what keeps the drone inert until one is delivered.
957        let dir = tempfile::tempdir().unwrap();
958        assert!(load_secret_at(&dir.path().join("nope")).is_none());
959    }
960}