clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

main
devsite / crates / devsite-daemon / tests / authz.rs
authz.rs 12.6K
1//! The authorization matrix, exercised over real Iroh against real local TCP services.2//!3//! These run the same `devsite-client` code the CLI runs. Capabilities are minted directly4//! by a test issuer rather than by the5//! control plane, which keeps the tests focused on what the *daemon* enforces — the6//! control plane's own "may this account have a capability at all" rules are covered by the7//! policy tests in devsite-server.8//!9//! Negative cases assert `ConnectError::Denied` specifically, never merely "an error". A10//! relay timeout is also an error, and a test that accepted one would report a working11//! authorization check on a daemon that was simply unreachable.12//!13//! Endpoints are shared across cases: reaching a relay is slow, and the public relays14//! rate-limit, so binding one endpoint per assertion makes the suite flaky rather than15//! thorough.1617use std::sync::Arc;1819use devsite_client::{ClientEndpoint, ConnectError, ServiceStream};20use devsite_daemon::config::{DaemonConfig, HostedService, Visibility};21use devsite_daemon::Daemon;22use devsite_proto::capability::{23 CapabilityClaims, KeyBytes, Permission, SignedCapability, DEFAULT_LIFETIME_SECS,24};25use devsite_proto::{AccountId, ResourceId};26use ed25519_dalek::SigningKey;27use iroh::{EndpointAddr, RelayUrl, SecretKey};28use tokio::io::{AsyncReadExt, AsyncWriteExt};29use tokio::net::TcpListener;3031const HERMES: &str = "hermes";32const AGENT: &str = "agent";3334async fn spawn_service(name: &'static str) -> std::net::SocketAddr {35 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();36 let target = listener.local_addr().unwrap();37 tokio::spawn(async move {38 loop {39 let Ok((mut stream, _)) = listener.accept().await else {40 return;41 };42 tokio::spawn(async move {43 let mut buf = Vec::new();44 let _ = stream.read_to_end(&mut buf).await;45 let response = format!("{name}:{}", String::from_utf8_lossy(&buf));46 let _ = stream.write_all(response.as_bytes()).await;47 });48 }49 });50 target51}5253struct Harness {54 daemon: Arc<Daemon>,55 daemon_key: KeyBytes,56 relay: RelayUrl,57 signing_key: SigningKey,58 hermes: ResourceId,59 agent: ResourceId,60 unknown: ResourceId,61 /// The legitimate client.62 client: ClientEndpoint,63 /// A second client, for anything about one client using another's grant.64 mallory: ClientEndpoint,65}6667impl Harness {68 async fn start() -> Self {69 let signing_key = SigningKey::from_bytes(&[42; 32]);70 let hermes = ResourceId::generate();71 let agent = ResourceId::generate();7273 let config = DaemonConfig {74 server_url: Some("https://dev.site".into()),75 machine_credential: Some("test".into()),76 control_plane_key: Some(77 data_encoding::HEXLOWER.encode(signing_key.verifying_key().as_bytes()),78 ),79 resources: vec![80 HostedService {81 resource_id: hermes,82 name: "Hermes".into(),83 target: spawn_service(HERMES).await,84 visibility: Visibility::Private,85 },86 HostedService {87 resource_id: agent,88 name: "Agent".into(),89 target: spawn_service(AGENT).await,90 visibility: Visibility::Shared,91 },92 ],93 };9495 let daemon = Arc::new(Daemon::bind(SecretKey::generate(), config).await.unwrap());96 let daemon_key = daemon.endpoint_key();97 let relay = daemon98 .endpoint()99 .addr()100 .relay_urls()101 .next()102 .expect("daemon should have a relay")103 .clone();104105 tokio::spawn(Arc::clone(&daemon).serve());106107 let (client, mallory) = tokio::join!(ClientEndpoint::create(), ClientEndpoint::create());108109 Self {110 daemon,111 daemon_key,112 relay,113 signing_key,114 hermes,115 agent,116 unknown: ResourceId::generate(),117 client: client.unwrap(),118 mallory: mallory.unwrap(),119 }120 }121122 fn now(&self) -> u64 {123 devsite_daemon::now_secs()124 }125126 /// Mint a capability with every field under the test's control.127 fn mint(128 &self,129 key: &SigningKey,130 resource: ResourceId,131 audience: KeyBytes,132 client_key: KeyBytes,133 issued_at: u64,134 expires_at: u64,135 ) -> SignedCapability {136 let mut nonce = [0u8; 16];137 getrandom::fill(&mut nonce).unwrap();138 SignedCapability::sign(139 &CapabilityClaims {140 issuer: "https://dev.site".into(),141 viewer: AccountId::generate(),142 resource,143 audience,144 client_key,145 permission: Permission::TcpConnect,146 issued_at,147 expires_at,148 nonce,149 },150 key,151 )152 .unwrap()153 }154155 /// An ordinary, valid capability for `resource`, bound to the legitimate client.156 fn valid(&self, resource: ResourceId) -> SignedCapability {157 let now = self.now();158 self.mint(159 &self.signing_key,160 resource,161 self.daemon_key,162 *self.client.endpoint_id().as_bytes(),163 now,164 now + DEFAULT_LIFETIME_SECS,165 )166 }167168 async fn connect_as(169 &self,170 who: &ClientEndpoint,171 capability: SignedCapability,172 input: &str,173 ) -> Result<String, ConnectError> {174 // The relay is pinned rather than looked up. In the browser a bare175 // endpoint id is enough — the daemon publishes its address and the client176 // resolves it — but that path depends on a third-party lookup service,177 // and these tests are about what the daemon enforces, not about how it178 // was found. Handing the address over directly keeps a denial a denial179 // and never a lookup that was slow.180 let addr =181 EndpointAddr::from(self.daemon.endpoint().id()).with_relay_url(self.relay.clone());182 let ServiceStream { mut send, mut recv } = who.connect(addr, capability).await?;183 send.write_all(input.as_bytes())184 .await185 .map_err(|err| ConnectError::Transport(err.into()))?;186 send.finish()187 .map_err(|err| ConnectError::Transport(err.into()))?;188 let output = recv189 .read_to_end(1024 * 1024)190 .await191 .map_err(|err| ConnectError::Transport(err.into()))?;192 Ok(String::from_utf8_lossy(&output).into_owned())193 }194195 async fn connect(&self, capability: SignedCapability) -> Result<String, ConnectError> {196 self.connect_as(&self.client, capability, "hello").await197 }198199 /// Assert the daemon reached a verdict and refused, rather than being unreachable.200 async fn assert_denied(&self, capability: SignedCapability, case: &str) {201 match self.connect(capability).await {202 Err(ConnectError::Denied) => {}203 Err(ConnectError::Transport(err)) => {204 panic!("{case}: never reached a verdict — {err:#}")205 }206 Err(other) => panic!("{case}: expected a denial, got {other}"),207 Ok(body) => panic!("{case}: expected a denial, but the service answered: {body}"),208 }209 }210}211212/// The whole matrix runs in one test, on one runtime.213///214/// Separate `#[tokio::test]` functions each get their own runtime, and the daemon's accept215/// loop lives on whichever one built the shared harness first. Once that runtime shuts216/// down the daemon stops answering and every later case hangs — so the cases are sequenced217/// here instead, which is also faster and easier on the public relays.218#[tokio::test]219async fn authorization_matrix() {220 let h = Harness::start().await;221222 valid_capability_opens_a_stream(&h).await;223 each_resource_maps_to_its_own_service(&h).await;224 capability_bound_to_another_client_is_refused(&h).await;225 capabilities_that_fail_verification_are_refused(&h).await;226 a_capability_cannot_be_redeemed_twice(&h).await;227 denials_reveal_nothing_about_why(&h).await;228 revoking_an_active_pair_closes_its_stream(&h).await;229}230231async fn valid_capability_opens_a_stream(h: &Harness) {232 let response = h233 .connect(h.valid(h.hermes))234 .await235 .expect("a valid capability should be honoured");236 assert_eq!(response, "hermes:hello");237}238239async fn each_resource_maps_to_its_own_service(h: &Harness) {240 // Proves the daemon routes by resource id rather than serving one default target —241 // otherwise "Bob can reach Agent" would quietly also mean "Bob can reach Hermes".242 let response = h.connect(h.valid(h.agent)).await.unwrap();243 assert_eq!(response, "agent:hello");244}245246async fn capability_bound_to_another_client_is_refused(h: &Harness) {247 // The core claim: a leaked or forwarded capability is useless without the private key248 // it was bound to. Mallory presents the client's capability from her own endpoint.249 let stolen = h.valid(h.agent);250 match h.connect_as(&h.mallory, stolen, "stolen").await {251 Err(ConnectError::Denied) => {}252 Err(ConnectError::Transport(err)) => panic!("never reached a verdict — {err:#}"),253 Err(other) => panic!("expected a denial, got {other}"),254 Ok(body) => panic!("a rebound capability was honoured: {body}"),255 }256}257258async fn capabilities_that_fail_verification_are_refused(h: &Harness) {259 let now = h.now();260 let mine = *h.client.endpoint_id().as_bytes();261262 // Signed by someone who is not the control plane.263 h.assert_denied(264 h.mint(265 &SigningKey::from_bytes(&[7; 32]),266 h.hermes,267 h.daemon_key,268 mine,269 now,270 now + DEFAULT_LIFETIME_SECS,271 ),272 "forged signature",273 )274 .await;275276 // Expired.277 h.assert_denied(278 h.mint(279 &h.signing_key,280 h.hermes,281 h.daemon_key,282 mine,283 now - 600,284 now - 300,285 ),286 "expired",287 )288 .await;289290 // Addressed to a different daemon.291 h.assert_denied(292 h.mint(293 &h.signing_key,294 h.hermes,295 [99; 32],296 mine,297 now,298 now + DEFAULT_LIFETIME_SECS,299 ),300 "wrong audience",301 )302 .await;303304 // Correctly signed and bound, but names a resource this daemon does not serve.305 h.assert_denied(h.valid(h.unknown), "unknown resource")306 .await;307}308309async fn a_capability_cannot_be_redeemed_twice(h: &Harness) {310 let capability = h.valid(h.hermes);311 assert!(h.connect(capability.clone()).await.is_ok());312 h.assert_denied(capability, "replayed capability").await;313}314315async fn denials_reveal_nothing_about_why(h: &Harness) {316 // A peer must not be able to tell "no such resource" from "not signed correctly" —317 // that difference is enough to enumerate a profile's private services.318 let now = h.now();319 let mine = *h.client.endpoint_id().as_bytes();320321 let unknown = h.connect(h.valid(h.unknown)).await.unwrap_err();322 let forged = h323 .connect(h.mint(324 &SigningKey::from_bytes(&[7; 32]),325 h.hermes,326 h.daemon_key,327 mine,328 now,329 now + DEFAULT_LIFETIME_SECS,330 ))331 .await332 .unwrap_err();333334 assert!(matches!(unknown, ConnectError::Denied));335 assert!(matches!(forged, ConnectError::Denied));336 assert_eq!(337 unknown.to_string(),338 forged.to_string(),339 "denial messages must be indistinguishable"340 );341}342343async fn revoking_an_active_pair_closes_its_stream(h: &Harness) {344 let addr = EndpointAddr::from(h.daemon.endpoint().id()).with_relay_url(h.relay.clone());345 let ServiceStream { mut send, mut recv } = h346 .client347 .connect(addr, h.valid(h.agent))348 .await349 .expect("the stream should open before revocation");350 send.write_all(b"held-open").await.unwrap();351352 let revoked = h353 .daemon354 .replace_authorizations(std::collections::HashSet::new())355 .await;356 assert_eq!(357 revoked, 1,358 "the active stream should be selected for revocation"359 );360361 tokio::time::timeout(std::time::Duration::from_secs(2), recv.read_to_end(1024))362 .await363 .expect("the revoked stream should close promptly")364 .ok();365}366367#[test]368fn resource_ids_do_not_collide() {369 let mut seen = std::collections::HashSet::new();370 for _ in 0..10_000 {371 assert!(372 seen.insert(ResourceId::generate()),373 "generated a duplicate id"374 );375 }376}