use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::nonce_cache::NonceCache;
type HmacSha256 = Hmac<Sha256>;
pub const RELAY_KEY_LABEL: &[u8] = b"ados-relay-ticket-v2";
pub const RELAY_SECRET_PATH: &str = "/etc/ados/secrets/relay-peer-secret";
pub const RELAY_SECRET_LEN: usize = 32;
pub const TICKET_LIFETIME_SECONDS: i64 = 30;
pub const CLOCK_SKEW_TOLERANCE_SECONDS: i64 = 300;
pub const REPLAY_RETENTION_SECONDS: i64 = 600;
pub const REPLAY_CACHE_CAPACITY: usize = 4096;
pub const SCOPE_RELAY: &str = "relay.http";
pub const RELAY_REFUSAL_CODE: &str = "E_RELAY_TICKET";
pub const TUNNEL_BINDING_METHOD: &str = "TUNNEL";
pub const TUNNEL_BINDING_PATH: &str = "/api/config";
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum RelayTicketError {
#[error("no relay peer secret on file")]
NoSecret,
#[error("malformed relay ticket")]
Malformed,
#[error("relay ticket signature does not verify")]
BadSignature,
#[error("relay ticket is bound to a different request")]
BindingMismatch,
#[error("relay ticket expired")]
Expired,
#[error("relay ticket issued in the future beyond the clock tolerance")]
ClockSkew,
#[error("relay ticket already used")]
Replayed,
}
impl RelayTicketError {
pub fn reason(self) -> &'static str {
match self {
Self::NoSecret => "no_secret",
Self::Malformed | Self::BadSignature => "bad_signature",
Self::BindingMismatch => "binding_mismatch",
Self::Expired => "expired",
Self::ClockSkew => "clock_skew",
Self::Replayed => "replayed",
}
}
}
pub const SECRET_CONFLICT_REASON: &str = "secret_conflict";
pub fn refusal_body(reason: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"error": RELAY_REFUSAL_CODE,
"reason": reason,
}))
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequestBinding<'a> {
pub method: &'a str,
pub path: &'a str,
pub body: &'a [u8],
}
impl<'a> RequestBinding<'a> {
pub fn new(method: &'a str, path: &'a str, body: &'a [u8]) -> Self {
Self { method, path, body }
}
}
pub fn tunnel_binding_body(request: &serde_json::Value) -> Vec<u8> {
let field = |name: &str| -> String {
match request.get(name) {
None | Some(serde_json::Value::Null) => String::new(),
Some(v) => v.to_string(),
}
};
format!("{}\n{}\n{}", field("op"), field("key"), field("value")).into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRelayTicket {
pub nonce: String,
pub valid_until: i64,
}
impl VerifiedRelayTicket {
pub fn spend(&self, cache: &NonceCache, now: i64) -> Result<(), RelayTicketError> {
if cache.admit(&self.nonce, now, self.valid_until) {
Ok(())
} else {
Err(RelayTicketError::Replayed)
}
}
}
pub const fn new_replay_cache() -> NonceCache {
NonceCache::new(REPLAY_CACHE_CAPACITY, REPLAY_RETENTION_SECONDS)
}
#[derive(Clone)]
pub struct RelayTicketIssuer {
key: Vec<u8>,
}
impl RelayTicketIssuer {
pub fn from_secret(secret: &[u8]) -> Self {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(RELAY_KEY_LABEL);
Self {
key: mac.finalize().into_bytes().to_vec(),
}
}
pub fn mint(&self, target: &str, request: &RequestBinding<'_>, now: i64) -> Option<String> {
let nonce = crate::nonce_cache::random_nonce_hex()?;
Some(self.mint_with_nonce(target, request, now, &nonce))
}
pub fn mint_with_nonce(
&self,
target: &str,
request: &RequestBinding<'_>,
now: i64,
nonce: &str,
) -> String {
let expires_at = now.saturating_add(TICKET_LIFETIME_SECONDS);
let payload = sign_payload(SCOPE_RELAY, target, now, expires_at, nonce, request);
let signature = self.sign(&payload);
format!("{payload}|{signature}")
}
pub fn verify(
&self,
token: &str,
expected_target: &str,
request: &RequestBinding<'_>,
now: i64,
) -> Result<VerifiedRelayTicket, RelayTicketError> {
let parsed = ParsedTicket::parse(token)?;
let sig = hex::decode(parsed.signature).map_err(|_| RelayTicketError::BadSignature)?;
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(parsed.payload.as_bytes());
mac.verify_slice(&sig)
.map_err(|_| RelayTicketError::BadSignature)?;
if parsed.scope != SCOPE_RELAY
|| parsed.target != expected_target
|| parsed.method != request.method
|| parsed.path != request.path
|| parsed.body_sha256 != body_digest(request.body)
{
return Err(RelayTicketError::BindingMismatch);
}
let lifetime = parsed.expires_at.saturating_sub(parsed.issued_at);
if !(1..=TICKET_LIFETIME_SECONDS).contains(&lifetime) {
return Err(RelayTicketError::Malformed);
}
if parsed.issued_at > now.saturating_add(CLOCK_SKEW_TOLERANCE_SECONDS) {
return Err(RelayTicketError::ClockSkew);
}
let valid_until = parsed
.expires_at
.saturating_add(CLOCK_SKEW_TOLERANCE_SECONDS);
if now > valid_until {
return Err(RelayTicketError::Expired);
}
Ok(VerifiedRelayTicket {
nonce: parsed.nonce.to_owned(),
valid_until,
})
}
fn sign(&self, payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(payload.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}
struct ParsedTicket<'a> {
payload: &'a str,
signature: &'a str,
scope: &'a str,
target: &'a str,
issued_at: i64,
expires_at: i64,
nonce: &'a str,
method: &'a str,
path: &'a str,
body_sha256: &'a str,
}
impl<'a> ParsedTicket<'a> {
fn parse(token: &'a str) -> Result<Self, RelayTicketError> {
let (payload, signature) = token.rsplit_once('|').ok_or(RelayTicketError::Malformed)?;
let (head, body_sha256) = payload
.rsplit_once('|')
.ok_or(RelayTicketError::Malformed)?;
let mut fields = head.splitn(8, '|');
let mut next = || fields.next().ok_or(RelayTicketError::Malformed);
let version = next()?;
let scope = next()?;
let target = next()?;
let issued_at = next()?;
let expires_at = next()?;
let nonce = next()?;
let method = next()?;
let path = next()?;
if version != "v2" || nonce.len() != 32 || !nonce.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(RelayTicketError::Malformed);
}
Ok(Self {
payload,
signature,
scope,
target,
issued_at: issued_at.parse().map_err(|_| RelayTicketError::Malformed)?,
expires_at: expires_at
.parse()
.map_err(|_| RelayTicketError::Malformed)?,
nonce,
method,
path,
body_sha256,
})
}
}
fn body_digest(body: &[u8]) -> String {
hex::encode(Sha256::digest(body))
}
fn sign_payload(
scope: &str,
target: &str,
issued_at: i64,
expires_at: i64,
nonce: &str,
request: &RequestBinding<'_>,
) -> String {
format!(
"v2|{scope}|{target}|{issued_at}|{expires_at}|{nonce}|{}|{}|{}",
request.method,
request.path,
body_digest(request.body)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcceptDecision {
Accept,
AlreadyHeld,
RefusedWouldOverwrite,
RefusedMalformed,
}
pub fn decide_accept(held: Option<&str>, offered: &str) -> AcceptDecision {
if offered.len() != RELAY_SECRET_LEN * 2 || !offered.chars().all(|c| c.is_ascii_hexdigit()) {
return AcceptDecision::RefusedMalformed;
}
match held {
None => AcceptDecision::Accept,
Some(existing) if existing.eq_ignore_ascii_case(offered) => AcceptDecision::AlreadyHeld,
Some(_) => AcceptDecision::RefusedWouldOverwrite,
}
}
pub fn load_secret_at(path: &std::path::Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let trimmed = raw.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
static RELAY_SECRET_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare_secret_dir(path: &std::path::Path) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn write_private_file(tmp: &std::path::Path, secret: &str) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(tmp)?;
f.write_all(secret.as_bytes())?;
f.sync_all()
}
fn sync_parent(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
if let Some(parent) = path.parent() {
std::fs::File::open(parent)?.sync_all()?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
fn install_secret(path: &std::path::Path, offered: &str) -> std::io::Result<AcceptDecision> {
prepare_secret_dir(path)?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = path.with_file_name(format!(".{name}.{}.{nanos}.tmp", std::process::id()));
if let Err(e) = write_private_file(&tmp, offered) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
let outcome = match std::fs::hard_link(&tmp, path) {
Ok(()) => Ok(AcceptDecision::Accept),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => match load_secret_at(path) {
Some(held) => Ok(decide_accept(Some(&held), offered)),
None => std::fs::rename(&tmp, path).map(|()| AcceptDecision::Accept),
},
Err(e) => Err(e),
};
let _ = std::fs::remove_file(&tmp);
if matches!(outcome, Ok(AcceptDecision::Accept)) {
sync_parent(path)?;
}
outcome
}
pub fn apply_offered_secret(
path: &std::path::Path,
offered: &str,
) -> Result<AcceptDecision, std::io::Error> {
let _guard = RELAY_SECRET_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let decision = decide_accept(load_secret_at(path).as_deref(), offered);
if decision != AcceptDecision::Accept {
return Ok(decision);
}
install_secret(path, offered)
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &[u8] = b"0123456789abcdef0123456789abcdef";
const DRONE: &str = "40bb1a5a";
fn issuer() -> RelayTicketIssuer {
RelayTicketIssuer::from_secret(SECRET)
}
const HEX32: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn an_unset_drone_takes_the_first_secret_it_is_offered() {
assert_eq!(decide_accept(None, HEX32), AcceptDecision::Accept);
}
#[test]
fn a_drone_already_holding_a_secret_refuses_to_be_re_keyed_over_the_air() {
let other = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
assert_eq!(
decide_accept(Some(HEX32), other),
AcceptDecision::RefusedWouldOverwrite
);
}
#[test]
fn restating_the_same_secret_is_a_no_op_not_a_conflict() {
assert_eq!(
decide_accept(Some(HEX32), HEX32),
AcceptDecision::AlreadyHeld
);
assert_eq!(
decide_accept(Some(&HEX32.to_uppercase()), HEX32),
AcceptDecision::AlreadyHeld
);
}
#[test]
fn a_malformed_offer_is_refused_before_anything_is_stored() {
for bad in ["", "abc", "zzzz", &"a".repeat(63), &"a".repeat(65)] {
assert_eq!(
decide_accept(None, bad),
AcceptDecision::RefusedMalformed,
"{bad:?}"
);
}
}
#[test]
fn a_malformed_offer_cannot_displace_a_held_secret() {
assert_eq!(
decide_accept(Some(HEX32), "nope"),
AcceptDecision::RefusedMalformed
);
}
const NONCE: &str = "00112233445566778899aabbccddeeff";
fn status_get() -> RequestBinding<'static> {
RequestBinding::new("GET", "/api/status", b"")
}
fn mint(now: i64) -> String {
issuer().mint_with_nonce(DRONE, &status_get(), now, NONCE)
}
#[test]
fn a_freshly_minted_ticket_verifies_for_its_target_and_request() {
let t = mint(1_000);
let v = issuer().verify(&t, DRONE, &status_get(), 1_005).unwrap();
assert_eq!(v.nonce, NONCE);
assert_eq!(
v.valid_until,
1_000 + TICKET_LIFETIME_SECONDS + CLOCK_SKEW_TOLERANCE_SECONDS
);
}
#[test]
fn a_ticket_presented_with_another_request_is_a_binding_mismatch() {
let t = mint(1_000);
for other in [
RequestBinding::new("POST", "/api/command", b"{}"),
RequestBinding::new("POST", "/api/status", b""),
RequestBinding::new("GET", "/api/status?x=1", b""),
RequestBinding::new("GET", "/api/status", b"x"),
] {
assert_eq!(
issuer().verify(&t, DRONE, &other, 1_005),
Err(RelayTicketError::BindingMismatch),
"{other:?}"
);
}
assert_eq!(
RelayTicketError::BindingMismatch.reason(),
"binding_mismatch"
);
}
#[test]
fn a_ticket_for_one_drone_does_not_verify_at_another() {
let t = mint(1_000);
assert_eq!(
issuer().verify(&t, "f6aa0aa4", &status_get(), 1_005),
Err(RelayTicketError::BindingMismatch)
);
}
#[test]
fn a_different_pair_secret_does_not_verify() {
let t = mint(1_000);
let other = RelayTicketIssuer::from_secret(b"ffffffffffffffffffffffffffffffff");
assert_eq!(
other.verify(&t, DRONE, &status_get(), 1_005),
Err(RelayTicketError::BadSignature)
);
}
#[test]
fn a_ws_ticket_key_derivation_does_not_verify_a_relay_ticket() {
let t = mint(1_000);
let mut mac = HmacSha256::new_from_slice(SECRET).unwrap();
mac.update(crate::ws_ticket::TICKET_KEY_LABEL);
let ws_keyed = RelayTicketIssuer {
key: mac.finalize().into_bytes().to_vec(),
};
assert_eq!(
ws_keyed.verify(&t, DRONE, &status_get(), 1_005),
Err(RelayTicketError::BadSignature)
);
assert_ne!(RELAY_KEY_LABEL, crate::ws_ticket::TICKET_KEY_LABEL);
}
#[test]
fn clock_skew_inside_the_tolerance_is_accepted_and_beyond_it_is_refused() {
let now = 10_000;
let ahead = mint(now + 200);
assert!(issuer().verify(&ahead, DRONE, &status_get(), now).is_ok());
let far_ahead = mint(now + 400);
assert_eq!(
issuer().verify(&far_ahead, DRONE, &status_get(), now),
Err(RelayTicketError::ClockSkew)
);
let behind = mint(now - 300);
assert!(issuer().verify(&behind, DRONE, &status_get(), now).is_ok());
let stale = mint(now - 400);
assert_eq!(
issuer().verify(&stale, DRONE, &status_get(), now),
Err(RelayTicketError::Expired)
);
}
#[test]
fn a_ticket_is_spent_once() {
let cache = new_replay_cache();
let t = mint(1_000);
let v = issuer().verify(&t, DRONE, &status_get(), 1_001).unwrap();
assert_eq!(v.spend(&cache, 1_001), Ok(()));
let again = issuer().verify(&t, DRONE, &status_get(), 1_002).unwrap();
assert_eq!(again.spend(&cache, 1_002), Err(RelayTicketError::Replayed));
assert_eq!(RelayTicketError::Replayed.reason(), "replayed");
}
#[test]
fn every_mint_carries_a_fresh_nonce() {
let a = issuer().mint(DRONE, &status_get(), 1_000).unwrap();
let b = issuer().mint(DRONE, &status_get(), 1_000).unwrap();
assert_ne!(a, b);
}
#[test]
fn a_tampered_field_is_refused_before_anything_else_is_read() {
let t = mint(1_000);
let forged = t.replacen("|1030|", "|99999999999|", 1);
assert_ne!(forged, t);
assert_eq!(
issuer().verify(&forged, DRONE, &status_get(), 1_005),
Err(RelayTicketError::BadSignature)
);
}
#[test]
fn a_path_containing_a_pipe_still_binds_exactly() {
let req = RequestBinding::new("GET", "/api/logs?q=a|b", b"");
let t = issuer().mint_with_nonce(DRONE, &req, 1_000, NONCE);
assert!(issuer().verify(&t, DRONE, &req, 1_001).is_ok());
let other = RequestBinding::new("GET", "/api/logs?q=a", b"");
assert_eq!(
issuer().verify(&t, DRONE, &other, 1_001),
Err(RelayTicketError::BindingMismatch)
);
}
#[test]
fn a_malformed_token_is_refused_rather_than_panicking() {
for bad in [
"",
"v2",
"v1|relay.http|d|1|2|ff",
"not-a-ticket",
"v2|relay.http|d|1|2|short|GET|/x|ab|ff",
"v2|relay.http|d|x|2|00112233445566778899aabbccddeeff|GET|/x|ab|ff",
] {
assert!(
issuer().verify(bad, DRONE, &status_get(), 1_000).is_err(),
"{bad}"
);
}
}
#[test]
fn a_refusal_body_names_the_code_and_reason() {
let body: serde_json::Value = serde_json::from_slice(&refusal_body("clock_skew")).unwrap();
assert_eq!(body["error"], RELAY_REFUSAL_CODE);
assert_eq!(body["reason"], "clock_skew");
}
#[test]
fn the_tunnel_binding_ignores_the_ticket_and_key_order() {
let a: serde_json::Value =
serde_json::from_str(r#"{"op":"put","key":"video.bitrate","value":4}"#).unwrap();
let b: serde_json::Value = serde_json::from_str(
r#"{"value":4,"ticket":"anything","key":"video.bitrate","op":"put"}"#,
)
.unwrap();
assert_eq!(tunnel_binding_body(&a), tunnel_binding_body(&b));
let c: serde_json::Value =
serde_json::from_str(r#"{"op":"put","key":"video.bitrate","value":5}"#).unwrap();
assert_ne!(tunnel_binding_body(&a), tunnel_binding_body(&c));
}
const _: () = assert!(RELAY_SECRET_LEN >= 32);
#[test]
fn applying_an_offer_to_an_unset_drone_stores_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets/relay-peer-secret");
assert_eq!(
apply_offered_secret(&path, HEX32).unwrap(),
AcceptDecision::Accept
);
assert_eq!(load_secret_at(&path).as_deref(), Some(HEX32));
}
#[test]
fn applying_a_restated_secret_leaves_the_stored_value_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets/relay-peer-secret");
apply_offered_secret(&path, HEX32).unwrap();
assert_eq!(
apply_offered_secret(&path, HEX32).unwrap(),
AcceptDecision::AlreadyHeld
);
}
#[test]
fn applying_a_stranger_offer_leaves_the_stored_secret_intact() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets/relay-peer-secret");
apply_offered_secret(&path, HEX32).unwrap();
let attacker = "f".repeat(64);
assert_eq!(
apply_offered_secret(&path, &attacker).unwrap(),
AcceptDecision::RefusedWouldOverwrite
);
assert_eq!(
load_secret_at(&path).as_deref(),
Some(HEX32),
"the held secret is untouched"
);
}
#[test]
fn applying_a_malformed_offer_reaches_no_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets/relay-peer-secret");
assert_eq!(
apply_offered_secret(&path, "nonsense").unwrap(),
AcceptDecision::RefusedMalformed
);
assert!(load_secret_at(&path).is_none());
}
#[cfg(unix)]
#[test]
fn an_empty_leftover_file_is_replaced_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("relay-peer-secret");
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(
apply_offered_secret(&path, HEX32).unwrap(),
AcceptDecision::Accept
);
assert_eq!(load_secret_at(&path).as_deref(), Some(HEX32));
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "got {mode:o}");
}
#[test]
fn concurrent_offers_accept_exactly_one() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets/relay-peer-secret");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let offers = [HEX32.to_string(), "f".repeat(64)];
let handles: Vec<_> = offers
.iter()
.cloned()
.map(|offer| {
let path = path.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
(offer.clone(), apply_offered_secret(&path, &offer).unwrap())
})
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let winners: Vec<_> = results
.iter()
.filter(|(_, d)| *d == AcceptDecision::Accept)
.collect();
assert_eq!(winners.len(), 1, "{results:?}");
assert!(results
.iter()
.any(|(_, d)| *d == AcceptDecision::RefusedWouldOverwrite));
assert_eq!(
load_secret_at(&path).as_deref(),
Some(winners[0].0.as_str())
);
let leftovers = std::fs::read_dir(path.parent().unwrap()).unwrap().count();
assert_eq!(leftovers, 1);
}
#[test]
fn an_absent_secret_reads_as_none_rather_than_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(load_secret_at(&dir.path().join("nope")).is_none());
}
}