worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // The signed identity assertion the control plane hands a tenant. |
| 2 | // |
| 3 | // Why Ed25519 and not an HMAC: a tenant runs the account's own CI, which is |
| 4 | // arbitrary code. A shared symmetric key would be readable from inside that |
| 5 | // container — `/proc/self/environ`, the unit file, the process table — and could |
| 6 | // then forge any account against any tenant. The control plane signs with a private |
| 7 | // key it never distributes; tenants hold only the public half, which forges nothing. |
| 8 | // |
| 9 | // The assertion authenticates *one request*, not an identity. Without the nonce and |
| 10 | // the short window, a captured assertion is a permanent credential for that account |
| 11 | // — one log line, one proxy, one crash dump. |
| 12 | // |
| 13 | // It is deliberately NOT bound to the request body. A push is a multi-gigabyte pack |
| 14 | // that the proxy streams; hashing it would mean buffering it at the hop where there |
| 15 | // are N tenants rather than one, which is exactly what the streaming transport |
| 16 | // exists to avoid. The nonce already makes an assertion single-use, so a captured |
| 17 | // one cannot be replayed with a different body — it cannot be replayed at all. |
| 18 | |
| 19 | use crate::error::{Error, Result}; |
| 20 | use base64::Engine; |
| 21 | use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; |
| 22 | use serde::{Deserialize, Serialize}; |
| 23 | use std::collections::HashMap; |
| 24 | use std::sync::Mutex; |
| 25 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 26 | |
| 27 | /// Header carrying the assertion. |
| 28 | pub fn header() -> &'static str { |
| 29 | static NAME: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); |
| 30 | NAME.get_or_init(|| crate::brand::header_name("identity")) |
| 31 | } |
| 32 | |
| 33 | /// How long an assertion is valid for. Short: it covers one in-flight request. |
| 34 | const LIFETIME_SECS: u64 = 30; |
| 35 | |
| 36 | /// Tolerance for clock disagreement between control and tenant. |
| 37 | /// |
| 38 | /// Stated rather than implied, because "it works on my machine" and "the tenant's |
| 39 | /// clock drifted four seconds" look identical in production otherwise. |
| 40 | const SKEW_SECS: u64 = 30; |
| 41 | |
| 42 | /// What is signed. Field order is the serialization order and must not change: |
| 43 | /// the signature covers these bytes exactly. |
| 44 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] |
| 45 | pub struct Claims { |
| 46 | pub account: String, |
| 47 | pub issued_at: u64, |
| 48 | pub expires_at: u64, |
| 49 | /// Single-use, so a captured assertion cannot be replayed inside its window. |
| 50 | pub nonce: String, |
| 51 | pub method: String, |
| 52 | pub path: String, |
| 53 | } |
| 54 | |
| 55 | impl Claims { |
| 56 | pub fn new(account: &str, method: &str, path: &str) -> Self { |
| 57 | let now = now_secs(); |
| 58 | Claims { |
| 59 | account: account.to_string(), |
| 60 | issued_at: now, |
| 61 | expires_at: now + LIFETIME_SECS, |
| 62 | nonce: uuid::Uuid::new_v4().simple().to_string(), |
| 63 | method: method.to_string(), |
| 64 | path: path.to_string(), |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// The exact bytes covered by the signature. |
| 69 | /// |
| 70 | /// Serialized through serde_json with a fixed field order rather than assembled |
| 71 | /// by hand: an ambiguous canonicalization is how two implementations end up |
| 72 | /// disagreeing about what was signed. |
| 73 | fn signing_input(&self) -> Vec<u8> { |
| 74 | serde_json::to_vec(self).expect("claims always serialize") |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// Signs assertions. Held only by the control plane. |
| 79 | pub struct Signer_ { |
| 80 | key: SigningKey, |
| 81 | } |
| 82 | |
| 83 | impl Signer_ { |
| 84 | pub fn from_seed(seed: &[u8; 32]) -> Self { |
| 85 | Signer_ { |
| 86 | key: SigningKey::from_bytes(seed), |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /// Public half, base64, for a tenant's configuration. |
| 91 | pub fn public_key(&self) -> String { |
| 92 | base64::engine::general_purpose::STANDARD.encode(self.key.verifying_key().as_bytes()) |
| 93 | } |
| 94 | |
| 95 | /// Produce the header value for one request. |
| 96 | pub fn sign(&self, claims: &Claims) -> String { |
| 97 | let payload = claims.signing_input(); |
| 98 | let signature = self.key.sign(&payload); |
| 99 | format!( |
| 100 | "{}.{}", |
| 101 | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&payload), |
| 102 | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes()) |
| 103 | ) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// Verifies assertions. Held by a tenant. |
| 108 | /// |
| 109 | /// Accepts more than one public key so the control plane's key can be rotated |
| 110 | /// without a flag day: publish the new key alongside the old, switch signing, then |
| 111 | /// retire the old. |
| 112 | pub struct Verifier_ { |
| 113 | keys: Vec<VerifyingKey>, |
| 114 | /// Nonces already accepted, with the time they expire. |
| 115 | seen: Mutex<HashMap<String, u64>>, |
| 116 | } |
| 117 | |
| 118 | impl Verifier_ { |
| 119 | /// Build from base64 public keys. |
| 120 | pub fn new(encoded: &[String]) -> Result<Self> { |
| 121 | let mut keys = Vec::new(); |
| 122 | for value in encoded { |
| 123 | let bytes = base64::engine::general_purpose::STANDARD |
| 124 | .decode(value.trim()) |
| 125 | .map_err(|_| Error::invalid("invalid-key", "public key is not valid base64"))?; |
| 126 | let bytes: [u8; 32] = bytes |
| 127 | .try_into() |
| 128 | .map_err(|_| Error::invalid("invalid-key", "public key must be 32 bytes"))?; |
| 129 | keys.push( |
| 130 | VerifyingKey::from_bytes(&bytes) |
| 131 | .map_err(|_| Error::invalid("invalid-key", "not a valid Ed25519 key"))?, |
| 132 | ); |
| 133 | } |
| 134 | if keys.is_empty() { |
| 135 | return Err(Error::invalid( |
| 136 | "invalid-key", |
| 137 | "at least one public key is required", |
| 138 | )); |
| 139 | } |
| 140 | Ok(Verifier_ { |
| 141 | keys, |
| 142 | seen: Mutex::new(HashMap::new()), |
| 143 | }) |
| 144 | } |
| 145 | |
| 146 | /// Verify an assertion against the request it claims to authorize. |
| 147 | pub fn verify(&self, header: &str, method: &str, path: &str) -> Result<Claims> { |
| 148 | self.verify_at(header, method, path, now_secs()) |
| 149 | } |
| 150 | |
| 151 | fn verify_at(&self, header: &str, method: &str, path: &str, now: u64) -> Result<Claims> { |
| 152 | let reject = || Error::Unauthorized; |
| 153 | |
| 154 | let (payload_b64, signature_b64) = header.split_once('.').ok_or_else(reject)?; |
| 155 | let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD |
| 156 | .decode(payload_b64) |
| 157 | .map_err(|_| reject())?; |
| 158 | let signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD |
| 159 | .decode(signature_b64) |
| 160 | .map_err(|_| reject())?; |
| 161 | let signature_bytes: [u8; 64] = signature_bytes.try_into().map_err(|_| reject())?; |
| 162 | let signature = Signature::from_bytes(&signature_bytes); |
| 163 | |
| 164 | // Signature first: nothing else in the payload can be trusted until the |
| 165 | // bytes are known to have come from the control plane. |
| 166 | if !self |
| 167 | .keys |
| 168 | .iter() |
| 169 | .any(|key| key.verify(&payload, &signature).is_ok()) |
| 170 | { |
| 171 | return Err(reject()); |
| 172 | } |
| 173 | |
| 174 | let claims: Claims = serde_json::from_slice(&payload).map_err(|_| reject())?; |
| 175 | |
| 176 | // Re-serializing must reproduce the signed bytes exactly. Without this a |
| 177 | // payload with duplicate or reordered keys could verify and then be read |
| 178 | // differently than it was signed. |
| 179 | if claims.signing_input() != payload { |
| 180 | return Err(reject()); |
| 181 | } |
| 182 | |
| 183 | if claims.expires_at.saturating_add(SKEW_SECS) < now |
| 184 | || claims.issued_at > now.saturating_add(SKEW_SECS) |
| 185 | || claims.expires_at <= claims.issued_at |
| 186 | || claims.expires_at - claims.issued_at > LIFETIME_SECS |
| 187 | { |
| 188 | return Err(reject()); |
| 189 | } |
| 190 | |
| 191 | if claims.method != method || claims.path != path { |
| 192 | return Err(reject()); |
| 193 | } |
| 194 | |
| 195 | self.consume_nonce(&claims.nonce, claims.expires_at + SKEW_SECS, now)?; |
| 196 | Ok(claims) |
| 197 | } |
| 198 | |
| 199 | /// Record a nonce, refusing one already used. |
| 200 | fn consume_nonce(&self, nonce: &str, expires_at: u64, now: u64) -> Result<()> { |
| 201 | let mut seen = self.seen.lock().expect("nonce lock"); |
| 202 | // Expired entries cannot be replayed anyway, so they need not be kept. |
| 203 | seen.retain(|_, until| *until > now); |
| 204 | |
| 205 | if seen.insert(nonce.to_string(), expires_at).is_some() { |
| 206 | return Err(Error::Unauthorized); |
| 207 | } |
| 208 | Ok(()) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | pub fn now_secs() -> u64 { |
| 213 | SystemTime::now() |
| 214 | .duration_since(UNIX_EPOCH) |
| 215 | .map(|d| d.as_secs()) |
| 216 | .unwrap_or(0) |
| 217 | } |
| 218 | |
| 219 | #[cfg(test)] |
| 220 | mod tests { |
| 221 | use super::*; |
| 222 | |
| 223 | fn pair() -> (Signer_, Verifier_) { |
| 224 | let signer = Signer_::from_seed(&[7u8; 32]); |
| 225 | let verifier = Verifier_::new(&[signer.public_key()]).unwrap(); |
| 226 | (signer, verifier) |
| 227 | } |
| 228 | |
| 229 | #[test] |
| 230 | fn a_valid_assertion_verifies_and_yields_its_account() { |
| 231 | let (signer, verifier) = pair(); |
| 232 | let claims = Claims::new("alice", "GET", "/v1/repos"); |
| 233 | let header = signer.sign(&claims); |
| 234 | |
| 235 | let verified = verifier.verify(&header, "GET", "/v1/repos").unwrap(); |
| 236 | assert_eq!(verified.account, "alice"); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn an_assertion_is_bound_to_its_method_and_path() { |
| 241 | // A captured assertion must not authorize a different operation. |
| 242 | let (signer, verifier) = pair(); |
| 243 | let header = signer.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 244 | |
| 245 | assert!(verifier.verify(&header, "DELETE", "/v1/repos").is_err()); |
| 246 | assert!(verifier |
| 247 | .verify(&header, "GET", "/v1/repos/alice/x") |
| 248 | .is_err()); |
| 249 | } |
| 250 | |
| 251 | #[test] |
| 252 | fn an_assertion_cannot_be_replayed() { |
| 253 | let (signer, verifier) = pair(); |
| 254 | let header = signer.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 255 | |
| 256 | verifier.verify(&header, "GET", "/v1/repos").unwrap(); |
| 257 | assert!( |
| 258 | verifier.verify(&header, "GET", "/v1/repos").is_err(), |
| 259 | "a nonce must be single-use" |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | #[test] |
| 264 | fn an_expired_assertion_is_refused_even_allowing_for_clock_skew() { |
| 265 | let (signer, verifier) = pair(); |
| 266 | let claims = Claims::new("alice", "GET", "/v1/repos"); |
| 267 | let header = signer.sign(&claims); |
| 268 | |
| 269 | let just_inside = claims.expires_at + SKEW_SECS; |
| 270 | verifier |
| 271 | .verify_at(&header, "GET", "/v1/repos", just_inside) |
| 272 | .expect("still valid within the skew allowance"); |
| 273 | |
| 274 | let outside = claims.expires_at + SKEW_SECS + 1; |
| 275 | let fresh = Verifier_::new(&[signer.public_key()]).unwrap(); |
| 276 | assert!( |
| 277 | fresh |
| 278 | .verify_at(&header, "GET", "/v1/repos", outside) |
| 279 | .is_err(), |
| 280 | "an assertion must not outlive its window" |
| 281 | ); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn an_assertion_from_the_future_is_refused() { |
| 286 | let (signer, verifier) = pair(); |
| 287 | let mut claims = Claims::new("alice", "GET", "/v1/repos"); |
| 288 | claims.issued_at = now_secs() + SKEW_SECS + 60; |
| 289 | claims.expires_at = claims.issued_at + LIFETIME_SECS; |
| 290 | |
| 291 | assert!(verifier |
| 292 | .verify(&signer.sign(&claims), "GET", "/v1/repos") |
| 293 | .is_err()); |
| 294 | } |
| 295 | |
| 296 | #[test] |
| 297 | fn a_self_extended_lifetime_is_refused() { |
| 298 | // Nothing stops a compromised signer minting a long-lived assertion, but a |
| 299 | // tenant must not honour one. |
| 300 | let (signer, verifier) = pair(); |
| 301 | let mut claims = Claims::new("alice", "GET", "/v1/repos"); |
| 302 | claims.expires_at = claims.issued_at + 86_400; |
| 303 | |
| 304 | assert!(verifier |
| 305 | .verify(&signer.sign(&claims), "GET", "/v1/repos") |
| 306 | .is_err()); |
| 307 | } |
| 308 | |
| 309 | #[test] |
| 310 | fn another_keys_signature_is_refused() { |
| 311 | let attacker = Signer_::from_seed(&[9u8; 32]); |
| 312 | let verifier = Verifier_::new(&[Signer_::from_seed(&[7u8; 32]).public_key()]).unwrap(); |
| 313 | |
| 314 | let header = attacker.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 315 | assert!(verifier.verify(&header, "GET", "/v1/repos").is_err()); |
| 316 | } |
| 317 | |
| 318 | #[test] |
| 319 | fn a_tampered_payload_is_refused() { |
| 320 | let (signer, verifier) = pair(); |
| 321 | let header = signer.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 322 | |
| 323 | // Swap the account for someone else's and re-encode, keeping the signature. |
| 324 | let (payload, signature) = header.split_once('.').unwrap(); |
| 325 | let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD |
| 326 | .decode(payload) |
| 327 | .unwrap(); |
| 328 | let forged = String::from_utf8(decoded) |
| 329 | .unwrap() |
| 330 | .replace("\"alice\"", "\"bob\"\u{0}") |
| 331 | .replace('\u{0}', ""); |
| 332 | let forged_header = format!( |
| 333 | "{}.{}", |
| 334 | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(forged.as_bytes()), |
| 335 | signature |
| 336 | ); |
| 337 | |
| 338 | assert!(verifier.verify(&forged_header, "GET", "/v1/repos").is_err()); |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn rotation_accepts_both_keys_during_the_overlap() { |
| 343 | let old = Signer_::from_seed(&[1u8; 32]); |
| 344 | let new = Signer_::from_seed(&[2u8; 32]); |
| 345 | let verifier = Verifier_::new(&[old.public_key(), new.public_key()]).unwrap(); |
| 346 | |
| 347 | for signer in [&old, &new] { |
| 348 | let header = signer.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 349 | verifier.verify(&header, "GET", "/v1/repos").unwrap(); |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | #[test] |
| 354 | fn malformed_headers_are_refused_rather_than_panicking() { |
| 355 | let (_, verifier) = pair(); |
| 356 | for header in ["", ".", "notbase64.notbase64", "a.b.c", "AAAA", "AAAA.AAAA"] { |
| 357 | assert!( |
| 358 | verifier.verify(header, "GET", "/v1/repos").is_err(), |
| 359 | "{header:?} must be refused" |
| 360 | ); |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | #[test] |
| 365 | fn a_verifier_needs_at_least_one_key() { |
| 366 | assert!(Verifier_::new(&[]).is_err()); |
| 367 | assert!(Verifier_::new(&["not base64!".into()]).is_err()); |
| 368 | assert!(Verifier_::new(&["c2hvcnQ=".into()]).is_err()); |
| 369 | } |
| 370 | |
| 371 | #[test] |
| 372 | fn expired_nonces_do_not_accumulate() { |
| 373 | let (signer, verifier) = pair(); |
| 374 | let start = now_secs(); |
| 375 | |
| 376 | for _ in 0..50 { |
| 377 | let header = signer.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 378 | verifier |
| 379 | .verify_at(&header, "GET", "/v1/repos", start) |
| 380 | .unwrap(); |
| 381 | } |
| 382 | assert_eq!(verifier.seen.lock().unwrap().len(), 50); |
| 383 | |
| 384 | // Long after they all expired, one more verification sweeps them. |
| 385 | let header = signer.sign(&Claims::new("alice", "GET", "/v1/repos")); |
| 386 | let later = start + LIFETIME_SECS + SKEW_SECS + 1; |
| 387 | let mut fresh = Claims::new("alice", "GET", "/v1/repos"); |
| 388 | fresh.issued_at = later; |
| 389 | fresh.expires_at = later + LIFETIME_SECS; |
| 390 | let _ = header; |
| 391 | |
| 392 | verifier |
| 393 | .verify_at(&signer.sign(&fresh), "GET", "/v1/repos", later) |
| 394 | .unwrap(); |
| 395 | assert_eq!( |
| 396 | verifier.seen.lock().unwrap().len(), |
| 397 | 1, |
| 398 | "spent nonces must not grow without bound" |
| 399 | ); |
| 400 | } |
| 401 | } |