1use hmac::{Hmac, KeyInit, Mac};
68use sha2::{Digest, Sha256};
69use thiserror::Error;
70
71use crate::nonce_cache::NonceCache;
72
73type HmacSha256 = Hmac<Sha256>;
74
75pub const RELAY_KEY_LABEL: &[u8] = b"ados-relay-ticket-v2";
81
82pub const RELAY_SECRET_PATH: &str = "/etc/ados/secrets/relay-peer-secret";
86
87pub const RELAY_SECRET_LEN: usize = 32;
89
90pub const TICKET_LIFETIME_SECONDS: i64 = 30;
97
98pub const CLOCK_SKEW_TOLERANCE_SECONDS: i64 = 300;
102
103pub const REPLAY_RETENTION_SECONDS: i64 = 600;
105
106pub const REPLAY_CACHE_CAPACITY: usize = 4096;
108
109pub const SCOPE_RELAY: &str = "relay.http";
111
112pub const RELAY_REFUSAL_CODE: &str = "E_RELAY_TICKET";
114
115pub const TUNNEL_BINDING_METHOD: &str = "TUNNEL";
118
119pub const TUNNEL_BINDING_PATH: &str = "/api/config";
122
123#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
124pub enum RelayTicketError {
125 #[error("no relay peer secret on file")]
130 NoSecret,
131 #[error("malformed relay ticket")]
133 Malformed,
134 #[error("relay ticket signature does not verify")]
136 BadSignature,
137 #[error("relay ticket is bound to a different request")]
140 BindingMismatch,
141 #[error("relay ticket expired")]
143 Expired,
144 #[error("relay ticket issued in the future beyond the clock tolerance")]
146 ClockSkew,
147 #[error("relay ticket already used")]
149 Replayed,
150}
151
152impl RelayTicketError {
153 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
166pub const SECRET_CONFLICT_REASON: &str = "secret_conflict";
169
170pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct RequestBinding<'a> {
183 pub method: &'a str,
185 pub path: &'a str,
188 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
198pub 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#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct VerifiedRelayTicket {
218 pub nonce: String,
219 pub valid_until: i64,
220}
221
222impl VerifiedRelayTicket {
223 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
234pub const fn new_replay_cache() -> NonceCache {
236 NonceCache::new(REPLAY_CACHE_CAPACITY, REPLAY_RETENTION_SECONDS)
237}
238
239#[derive(Clone)]
241pub struct RelayTicketIssuer {
242 key: Vec<u8>,
243}
244
245impl RelayTicketIssuer {
246 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 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 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 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 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
338struct 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 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
391fn body_digest(body: &[u8]) -> String {
393 hex::encode(Sha256::digest(body))
394}
395
396fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum AcceptDecision {
416 Accept,
418 AlreadyHeld,
421 RefusedWouldOverwrite,
423 RefusedMalformed,
425}
426
427pub 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
458pub 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
471static RELAY_SECRET_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
475
476fn 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
490fn 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
505fn 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
517fn 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 let _ = std::fs::remove_file(&tmp);
551 if matches!(outcome, Ok(AcceptDecision::Accept)) {
552 sync_parent(path)?;
553 }
554 outcome
555}
556
557pub 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 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 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 assert_eq!(
617 decide_accept(Some(HEX32), HEX32),
618 AcceptDecision::AlreadyHeld
619 );
620 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 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 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 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 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 let ahead = mint(now + 200);
736 assert!(issuer().verify(&ahead, DRONE, &status_get(), now).is_ok());
737 let far_ahead = mint(now + 400);
739 assert_eq!(
740 issuer().verify(&far_ahead, DRONE, &status_get(), now),
741 Err(RelayTicketError::ClockSkew)
742 );
743 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 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 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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
958 assert!(load_secret_at(&dir.path().join("nope")).is_none());
959 }
960}