clone

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

main
devsite / crates / devsite-server / src / issuer.rs
issuer.rs 8.5K
1//! The control plane's capability-signing key.2//!3//! This key is the root of trust for every daemon. Daemons pin its public half at login4//! and refuse anything not signed by it.56use std::path::{Path, PathBuf};78use anyhow::{Context, Result};9use devsite_proto::access_plan::{ServiceGrantPlanClaims, SignedServiceGrantPlan};10use devsite_proto::capability::{11 CapabilityClaims, KeyBytes, Permission, SignedCapability, DEFAULT_LIFETIME_SECS,12};13use devsite_proto::{AccountId, ResourceId};14use ed25519_dalek::{SigningKey, VerifyingKey};1516pub struct Issuer {17 issuer_name: String,18 key: SigningKey,19}2021impl Issuer {22 /// Load a signing key supplied directly, as 64 hex characters.23 ///24 /// For hosts where the filesystem is the wrong place to keep it. On Fly the25 /// key is a secret: encrypted at rest, injected into the machine's26 /// environment, and — the part that matters — not tied to the survival of one27 /// volume on one host. Losing this key is the single unrecoverable failure in28 /// the system, because every daemon pinned its public half at `devsite login`29 /// and refuses capabilities signed by anything else.30 ///31 /// An unusable value is an error, never a fallback to generating a fresh key:32 /// that would break every daemon in the field while looking perfectly healthy33 /// from here.34 pub fn from_hex(hex: &str, issuer_name: &str) -> Result<Self> {35 let raw = data_encoding::HEXLOWER_PERMISSIVE36 .decode(hex.trim().as_bytes())37 .context("the signing key is not hex")?;38 let bytes: [u8; 32] = raw39 .as_slice()40 .try_into()41 .map_err(|_| anyhow::anyhow!("the signing key is not 32 bytes"))?;42 Ok(Self {43 issuer_name: issuer_name.to_string(),44 key: SigningKey::from_bytes(&bytes),45 })46 }4748 /// Load the signing key from the state directory, generating it on first run.49 pub fn load_or_create(state_dir: &Path, issuer_name: &str) -> Result<Self> {50 let path = state_dir.join("capability_signing.key");51 let key = if path.exists() {52 let raw =53 std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;54 let bytes: [u8; 32] = raw55 .as_slice()56 .try_into()57 .map_err(|_| anyhow::anyhow!("{} is not a 32 byte key", path.display()))?;58 SigningKey::from_bytes(&bytes)59 } else {60 std::fs::create_dir_all(state_dir)?;61 let key = SigningKey::generate(&mut rand::rngs::OsRng);62 write_private(&path, &key.to_bytes())?;63 tracing::info!(64 "generated a new capability signing key at {}",65 path.display()66 );67 key68 };69 Ok(Self {70 issuer_name: issuer_name.to_string(),71 key,72 })73 }7475 pub fn verifying_key(&self) -> VerifyingKey {76 self.key.verifying_key()77 }7879 /// Hex-encoded public key, as daemons pin it.80 pub fn public_key_hex(&self) -> String {81 data_encoding::HEXLOWER.encode(self.verifying_key().as_bytes())82 }8384 pub fn issue_access_plan(&self, claims: &ServiceGrantPlanClaims) -> Result<String> {85 Ok(SignedServiceGrantPlan::sign(claims, &self.key)?.to_token())86 }8788 pub fn verify_access_plan(&self, token: &str) -> Result<ServiceGrantPlanClaims> {89 Ok(SignedServiceGrantPlan::from_token(token)?.verify(&self.verifying_key())?)90 }9192 /// Mint a grant. Every field is supplied by the caller from verified state — nothing93 /// here comes from the client except `client_key`, which is exactly what the daemon94 /// will check the connection against.95 pub fn issue(96 &self,97 viewer: AccountId,98 resource: ResourceId,99 audience: KeyBytes,100 client_key: KeyBytes,101 now: u64,102 ) -> Result<SignedCapability> {103 let mut nonce = [0u8; 16];104 {105 use rand::RngCore;106 rand::rngs::OsRng.fill_bytes(&mut nonce);107 }108 let claims = CapabilityClaims {109 issuer: self.issuer_name.clone(),110 viewer,111 resource,112 audience,113 client_key,114 permission: Permission::TcpConnect,115 issued_at: now,116 expires_at: now + DEFAULT_LIFETIME_SECS,117 nonce,118 };119 SignedCapability::sign(&claims, &self.key).map_err(|err| anyhow::anyhow!("{err}"))120 }121}122123fn write_private(path: &PathBuf, contents: &[u8]) -> Result<()> {124 #[cfg(unix)]125 {126 use std::io::Write;127 use std::os::unix::fs::OpenOptionsExt;128 let mut file = std::fs::OpenOptions::new()129 .write(true)130 .create(true)131 .truncate(true)132 .mode(0o600)133 .open(path)?;134 file.write_all(contents)?;135 Ok(())136 }137 #[cfg(not(unix))]138 {139 std::fs::write(path, contents)?;140 Ok(())141 }142}143144#[cfg(test)]145mod tests {146 use super::*;147148 #[test]149 fn issued_capabilities_verify_and_expire() {150 let dir = std::env::temp_dir().join(format!("devsite-issuer-{}", std::process::id()));151 let issuer = Issuer::load_or_create(&dir, "https://dev.site").unwrap();152153 let cap = issuer154 .issue(155 AccountId::from_bytes([1; 16]),156 ResourceId::from_bytes([2; 16]),157 [7; 32],158 [8; 32],159 1000,160 )161 .unwrap();162163 assert!(cap164 .verify_for(&issuer.verifying_key(), &[7; 32], &[8; 32], 1000)165 .is_ok());166 assert!(cap167 .verify_for(168 &issuer.verifying_key(),169 &[7; 32],170 &[8; 32],171 1000 + DEFAULT_LIFETIME_SECS + 1172 )173 .is_err());174175 std::fs::remove_dir_all(&dir).ok();176 }177178 #[test]179 fn a_supplied_key_is_used_verbatim() {180 // The same 32 bytes must always yield the same public half, whether they181 // arrived from a file or from a secret: a deployment that moves hosts182 // keeps its identity only if this holds.183 let hex = data_encoding::HEXLOWER.encode(&[9u8; 32]);184 let from_secret = Issuer::from_hex(&hex, "https://dev.site").unwrap();185 let expected = SigningKey::from_bytes(&[9; 32]).verifying_key();186 assert_eq!(187 from_secret.public_key_hex(),188 data_encoding::HEXLOWER.encode(expected.as_bytes())189 );190 // Uppercase and stray whitespace are what a shell pipeline produces.191 assert_eq!(192 Issuer::from_hex(&format!(" {}\n", hex.to_uppercase()), "https://dev.site")193 .unwrap()194 .public_key_hex(),195 from_secret.public_key_hex()196 );197 }198199 #[test]200 fn an_unusable_supplied_key_is_refused_rather_than_replaced() {201 for bad in ["", "not-hex", &"ab".repeat(31), &"ab".repeat(33)] {202 assert!(203 Issuer::from_hex(bad, "https://dev.site").is_err(),204 "{bad:?} should be refused"205 );206 }207 }208209 #[test]210 fn the_key_survives_a_restart() {211 let dir = std::env::temp_dir().join(format!("devsite-issuer-p-{}", std::process::id()));212 std::fs::remove_dir_all(&dir).ok();213214 let first = Issuer::load_or_create(&dir, "https://dev.site").unwrap();215 let second = Issuer::load_or_create(&dir, "https://dev.site").unwrap();216 // Daemons pin this key. If it changed on restart, every daemon in the world would217 // start rejecting valid capabilities.218 assert_eq!(first.public_key_hex(), second.public_key_hex());219220 std::fs::remove_dir_all(&dir).ok();221 }222223 #[test]224 fn each_capability_gets_a_distinct_nonce() {225 let dir = std::env::temp_dir().join(format!("devsite-issuer-n-{}", std::process::id()));226 let issuer = Issuer::load_or_create(&dir, "https://dev.site").unwrap();227 let mint = || {228 issuer229 .issue(230 AccountId::from_bytes([1; 16]),231 ResourceId::from_bytes([2; 16]),232 [7; 32],233 [8; 32],234 1000,235 )236 .unwrap()237 .verify(&issuer.verifying_key())238 .unwrap()239 .nonce240 };241 // Identical inputs must still produce distinct grants, or the daemon's replay242 // guard would reject a viewer's second legitimate visit.243 assert_ne!(mint(), mint());244 std::fs::remove_dir_all(&dir).ok();245 }246}