1//! Deciding whether to honour a request.2//!3//! Everything here is deliberately paranoid and deliberately quiet: the caller learns only4//! that it was denied, never *why*. A peer that could distinguish "no such resource" from5//! "not shared with you" could enumerate a profile's private services.67use std::collections::HashMap;8use std::net::SocketAddr;910use devsite_proto::capability::{CapabilityClaims, KeyBytes, Permission, SignedCapability};11use devsite_proto::ResourceId;12use ed25519_dalek::VerifyingKey;1314/// A request that passed every check, carrying the local target it resolved to.1516pub struct Authorized {17 pub claims: CapabilityClaims,18 pub target: SocketAddr,19}202122pub enum Denied {23 /// Signature, audience, binding, or expiry failed.24 Capability,25 /// Well-formed and correctly signed, but names a resource this daemon does not serve.26 UnknownResource,27 /// This exact capability has already been used.28 Replayed,29}3031/// Remembers recently used capability nonces so a grant cannot be redeemed twice.32///33/// Bounded by expiry rather than by count: entries are pruned once the capability they34/// describe could no longer be valid anyway, so memory tracks issuance rate, not uptime.3536pub struct ReplayGuard {37 seen: HashMap<[u8; 16], u64>,38}3940impl ReplayGuard {41 /// Record a nonce. Returns false if it had already been used.42 pub fn admit(&mut self, nonce: [u8; 16], expires_at: u64, now: u64) -> bool {43 self.seen.retain(|_, expiry| *expiry > now);44 if self.seen.contains_key(&nonce) {45 return false;46 }47 self.seen.insert(nonce, expires_at);48 true49 }5051 pub fn len(&self) -> usize {52 self.seen.len()53 }5455 pub fn is_empty(&self) -> bool {56 self.seen.is_empty()57 }58}5960/// The full check, in the order that leaks the least.61///62/// `peer` must come from the connection's authenticated identity — iroh's63/// `Connection::remote_id()` — and never from anything inside the request frame.64pub fn authorize(65 capability: &SignedCapability,66 control_plane_key: &VerifyingKey,67 my_endpoint: &KeyBytes,68 peer: &KeyBytes,69 targets: &HashMap<ResourceId, SocketAddr>,70 replay: &mut ReplayGuard,71 now: u64,72) -> Result<Authorized, Denied> {73 // Cryptography first: signature, audience, client binding, expiry. Until this passes74 // the claims are just bytes a stranger sent us.75 let claims = capability76 .verify_for(control_plane_key, my_endpoint, peer, now)77 .map_err(|_| Denied::Capability)?;7879 if claims.permission != Permission::TcpConnect {80 return Err(Denied::Capability);81 }8283 let target = targets84 .get(&claims.resource)85 .ok_or(Denied::UnknownResource)?86 .to_owned();8788 // Last, because consuming the nonce has a side effect: a request that would have been89 // refused anyway must not burn a legitimate capability.90 if !replay.admit(claims.nonce, claims.expires_at, now) {91 return Err(Denied::Replayed);92 }9394 Ok(Authorized { claims, target })95}969798mod tests {99 use devsite_proto::capability::DEFAULT_LIFETIME_SECS;100 use devsite_proto::{AccountId, ResourceId};101 use ed25519_dalek::SigningKey;102103 use super::*;104105 const NOW: u64 = 1_760_000_000;106 const DAEMON: KeyBytes = [7; 32];107 const BROWSER: KeyBytes = [8; 32];108109 fn resource() -> ResourceId {110 ResourceId::from_bytes([2; 16])111 }112113 fn targets() -> HashMap<ResourceId, SocketAddr> {114 HashMap::from([(resource(), "127.0.0.1:4101".parse().unwrap())])115 }116117 fn claims_with(nonce: [u8; 16], res: ResourceId) -> CapabilityClaims {118 CapabilityClaims {119 issuer: "https://dev.site".to_string(),120 viewer: AccountId::from_bytes([1; 16]),121 resource: res,122 audience: DAEMON,123 client_key: BROWSER,124 permission: Permission::TcpConnect,125 issued_at: NOW,126 expires_at: NOW + DEFAULT_LIFETIME_SECS,127 nonce,128 }129 }130131 fn run(132 key: &SigningKey,133 claims: &CapabilityClaims,134 peer: &KeyBytes,135 replay: &mut ReplayGuard,136 ) -> Result<Authorized, Denied> {137 let cap = SignedCapability::sign(claims, key).unwrap();138 authorize(139 &cap,140 &key.verifying_key(),141 &DAEMON,142 peer,143 &targets(),144 replay,145 NOW,146 )147 }148149 150 fn allows_a_valid_request() {151 let key = SigningKey::from_bytes(&[9; 32]);152 let mut replay = ReplayGuard::default();153 let ok = run(154 &key,155 &claims_with([1; 16], resource()),156 &BROWSER,157 &mut replay,158 )159 .unwrap();160 assert_eq!(ok.target, "127.0.0.1:4101".parse().unwrap());161 }162163 164 fn denies_a_capability_bound_to_another_client() {165 let key = SigningKey::from_bytes(&[9; 32]);166 let mut replay = ReplayGuard::default();167 let denied = run(168 &key,169 &claims_with([1; 16], resource()),170 &[99; 32],171 &mut replay,172 )173 .unwrap_err();174 assert_eq!(denied, Denied::Capability);175 }176177 178 fn denies_a_resource_this_daemon_does_not_serve() {179 // Correctly signed and correctly bound, but for a resource id that is not in the180 // local map — e.g. a capability meant for a service the owner has since removed.181 let key = SigningKey::from_bytes(&[9; 32]);182 let mut replay = ReplayGuard::default();183 let stranger = ResourceId::from_bytes([44; 16]);184 let denied = run(&key, &claims_with([1; 16], stranger), &BROWSER, &mut replay).unwrap_err();185 assert_eq!(denied, Denied::UnknownResource);186 }187188 189 fn denies_a_second_use_of_the_same_capability() {190 let key = SigningKey::from_bytes(&[9; 32]);191 let mut replay = ReplayGuard::default();192 let claims = claims_with([1; 16], resource());193 assert!(run(&key, &claims, &BROWSER, &mut replay).is_ok());194 assert_eq!(195 run(&key, &claims, &BROWSER, &mut replay).unwrap_err(),196 Denied::Replayed197 );198 }199200 201 fn a_denied_request_does_not_consume_the_nonce() {202 // If a wrong-peer attempt burned the nonce, an attacker who merely observed a203 // capability could deny the legitimate client its one use of it.204 let key = SigningKey::from_bytes(&[9; 32]);205 let mut replay = ReplayGuard::default();206 let claims = claims_with([1; 16], resource());207208 assert!(run(&key, &claims, &[99; 32], &mut replay).is_err());209 assert!(210 replay.is_empty(),211 "a failed attempt must not record a nonce"212 );213 assert!(run(&key, &claims, &BROWSER, &mut replay).is_ok());214 }215216 217 fn replay_guard_forgets_expired_nonces() {218 let mut replay = ReplayGuard::default();219 assert!(replay.admit([1; 16], NOW + 60, NOW));220 assert!(!replay.admit([1; 16], NOW + 60, NOW));221 // Once every recorded nonce has expired the table drains rather than growing222 // without bound.223 assert!(replay.admit([2; 16], NOW + 3600, NOW + 61));224 assert_eq!(replay.len(), 1);225 }226}