zuka
zuka/src/account/assertion.rs

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
zuka/src/account/assertion.rs
RSassertion.rs14.3 KBDownload
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
19use crate::error::{Error, Result};
20use base64::Engine;
21use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::Mutex;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27/// Header carrying the assertion.
28pub 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.
34const 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.
40const 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)]
45pub 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
55impl 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.
79pub struct Signer_ {
80 key: SigningKey,
81}
82
83impl 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.
112pub struct Verifier_ {
113 keys: Vec<VerifyingKey>,
114 /// Nonces already accepted, with the time they expire.
115 seen: Mutex<HashMap<String, u64>>,
116}
117
118impl 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
212pub 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)]
220mod 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}