clone

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

main
devsite / crates / devsite-proto / src / capability.rs
capability.rs 9.5K
1//! Short-lived, signed grants issued by the control plane and verified by a daemon.2//!3//! A capability is the only thing that persuades a daemon to talk to a local service. It4//! is deliberately narrow: one account, one resource, one daemon, one client key, one5//! permission, a few minutes.6//!7//! Public keys are carried as raw bytes rather than iroh types so this crate stays free of8//! iroh (and therefore trivially portable). The daemon converts its `EndpointId` with9//! `as_bytes()` when comparing.1011use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};12use serde::{Deserialize, Serialize};1314use crate::{AccountId, ResourceId};1516/// Raw Ed25519/iroh public key bytes.17pub type KeyBytes = [u8; 32];1819/// How long a freshly issued capability remains valid. Short enough that a leaked token is20/// of little use, long enough to survive slow network setup.21pub const DEFAULT_LIFETIME_SECS: u64 = 180;2223/// Tolerance for clock skew between the control plane and a daemon.24const MAX_CLOCK_SKEW_SECS: u64 = 30;2526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]27pub enum Permission {28 /// Open one bidirectional byte stream to the resource's configured TCP target.29 TcpConnect,30}3132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]33pub struct CapabilityClaims {34 pub issuer: String,35 /// Who is being granted access, by immutable account id — never by handle.36 pub viewer: AccountId,37 pub resource: ResourceId,38 /// The daemon this grant is addressed to. A daemon rejects capabilities minted for a39 /// different audience, so one daemon cannot replay a grant against another.40 pub audience: KeyBytes,41 /// The client endpoint key this grant is bound to. The daemon checks it against the42 /// authenticated peer of the connection, which is what makes a stolen capability43 /// useless without the matching private key.44 pub client_key: KeyBytes,45 pub permission: Permission,46 pub issued_at: u64,47 pub expires_at: u64,48 /// Unique per issuance, so a daemon can refuse to serve the same grant twice.49 pub nonce: [u8; 16],50}5152/// A capability plus its signature.53///54/// `claims` holds the exact bytes that were signed. Verification checks the signature55/// against *these* bytes and only then deserializes them — re-encoding before verifying56/// would make the result depend on serializer stability.57#[derive(Debug, Clone, Serialize, Deserialize)]58pub struct SignedCapability {59 claims: Vec<u8>,60 /// 64 raw signature bytes. Held as a `Vec` because serde has no impl for `[u8; 64]`;61 /// the length is checked during verification rather than trusted.62 signature: Vec<u8>,63}6465#[derive(Debug, thiserror::Error, PartialEq, Eq)]66pub enum CapabilityError {67 #[error("signature does not verify")]68 BadSignature,69 #[error("capability is malformed")]70 Malformed,71 #[error("capability has expired")]72 Expired,73 #[error("capability is not valid yet")]74 NotYetValid,75 #[error("capability was issued for a different daemon")]76 WrongAudience,77 #[error("capability is bound to a different client key")]78 WrongClientKey,79}8081impl SignedCapability {82 pub fn sign(claims: &CapabilityClaims, key: &SigningKey) -> Result<Self, CapabilityError> {83 let bytes = postcard::to_allocvec(claims).map_err(|_| CapabilityError::Malformed)?;84 let signature = key.sign(&bytes);85 Ok(Self {86 claims: bytes,87 signature: signature.to_bytes().to_vec(),88 })89 }9091 /// Verify the signature and return the claims.92 ///93 /// This checks only what the signature attests to. Context-dependent checks — audience,94 /// expiry, client binding — live in [`Self::verify_for`], which callers should prefer.95 pub fn verify(&self, key: &VerifyingKey) -> Result<CapabilityClaims, CapabilityError> {96 let signature: [u8; 64] = self97 .signature98 .as_slice()99 .try_into()100 .map_err(|_| CapabilityError::Malformed)?;101 key.verify(&self.claims, &Signature::from_bytes(&signature))102 .map_err(|_| CapabilityError::BadSignature)?;103 postcard::from_bytes(&self.claims).map_err(|_| CapabilityError::Malformed)104 }105106 /// Full verification as a daemon must perform it.107 ///108 /// `peer` is the authenticated remote endpoint of the connection the capability109 /// arrived on — not anything the peer told us about itself.110 pub fn verify_for(111 &self,112 key: &VerifyingKey,113 audience: &KeyBytes,114 peer: &KeyBytes,115 now: u64,116 ) -> Result<CapabilityClaims, CapabilityError> {117 let claims = self.verify(key)?;118119 if &claims.audience != audience {120 return Err(CapabilityError::WrongAudience);121 }122 // The check the whole design rests on: the capability names a client key, and the123 // connection proves possession of one. They must be the same key.124 if &claims.client_key != peer {125 return Err(CapabilityError::WrongClientKey);126 }127 if now > claims.expires_at {128 return Err(CapabilityError::Expired);129 }130 if claims.issued_at > now.saturating_add(MAX_CLOCK_SKEW_SECS) {131 return Err(CapabilityError::NotYetValid);132 }133 Ok(claims)134 }135136 pub fn to_bytes(&self) -> Vec<u8> {137 postcard::to_allocvec(self).expect("a signed capability always serializes")138 }139140 pub fn from_bytes(bytes: &[u8]) -> Result<Self, CapabilityError> {141 postcard::from_bytes(bytes).map_err(|_| CapabilityError::Malformed)142 }143}144145#[cfg(test)]146mod tests {147 use super::*;148149 const NOW: u64 = 1_760_000_000;150151 fn signing_key(seed: u8) -> SigningKey {152 SigningKey::from_bytes(&[seed; 32])153 }154155 fn claims(audience: KeyBytes, client_key: KeyBytes) -> CapabilityClaims {156 CapabilityClaims {157 issuer: "https://dev.site".to_string(),158 viewer: AccountId::from_bytes([1; 16]),159 resource: ResourceId::from_bytes([2; 16]),160 audience,161 client_key,162 permission: Permission::TcpConnect,163 issued_at: NOW,164 expires_at: NOW + DEFAULT_LIFETIME_SECS,165 nonce: [3; 16],166 }167 }168169 #[test]170 fn a_well_formed_capability_verifies() {171 let key = signing_key(9);172 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();173 let verified = cap174 .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW + 1)175 .unwrap();176 assert_eq!(verified.permission, Permission::TcpConnect);177 }178179 #[test]180 fn survives_a_round_trip_through_bytes() {181 let key = signing_key(9);182 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();183 let restored = SignedCapability::from_bytes(&cap.to_bytes()).unwrap();184 assert!(restored185 .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW)186 .is_ok());187 }188189 #[test]190 fn rejects_a_capability_signed_by_someone_else() {191 let attacker = signing_key(1);192 let real = signing_key(9);193 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &attacker).unwrap();194 assert_eq!(195 cap.verify_for(&real.verifying_key(), &[7; 32], &[8; 32], NOW),196 Err(CapabilityError::BadSignature)197 );198 }199200 #[test]201 fn rejects_use_from_a_different_client_key() {202 // Bob hands his capability to someone else, or it leaks from the network. The203 // thief connects with their own endpoint key, and the binding fails.204 let key = signing_key(9);205 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();206 assert_eq!(207 cap.verify_for(&key.verifying_key(), &[7; 32], &[99; 32], NOW),208 Err(CapabilityError::WrongClientKey)209 );210 }211212 #[test]213 fn rejects_replay_against_a_different_daemon() {214 let key = signing_key(9);215 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();216 assert_eq!(217 cap.verify_for(&key.verifying_key(), &[123; 32], &[8; 32], NOW),218 Err(CapabilityError::WrongAudience)219 );220 }221222 #[test]223 fn rejects_expired_capabilities() {224 let key = signing_key(9);225 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();226 assert_eq!(227 cap.verify_for(228 &key.verifying_key(),229 &[7; 32],230 &[8; 32],231 NOW + DEFAULT_LIFETIME_SECS + 1232 ),233 Err(CapabilityError::Expired)234 );235 }236237 #[test]238 fn rejects_capabilities_from_the_future_beyond_skew() {239 let key = signing_key(9);240 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();241 // Within tolerance: fine.242 assert!(cap243 .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW - 10)244 .is_ok());245 // Well beyond it: refuse.246 assert_eq!(247 cap.verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW - 600),248 Err(CapabilityError::NotYetValid)249 );250 }251252 #[test]253 fn rejects_tampered_claims() {254 let key = signing_key(9);255 let mut cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();256 // Flip a byte in the signed payload; the signature must no longer match.257 cap.claims[4] ^= 0xff;258 assert_eq!(259 cap.verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW),260 Err(CapabilityError::BadSignature)261 );262 }263}