//! Signed requests and the proofs events carry (spec §8.5, §9.5). //! //! A principal signs `"spectra1 request " || method || 0x00 || BLAKE3(body) //! || time (8 bytes) || nonce (16 bytes)` with its key, through `oxy-core`'s //! challenge signing. The signature, with what it covered, is the proof //! kept on every event the request causes, so a verifier can check later, //! from the ledger alone, which key asked for it. //! //! The CLI, writing to the ledger itself, signs with `method = "cli"` and //! `body` = the event's canonical payload, so its proof also binds the //! event's content. use crate::cbor::{self, Value}; use crate::identity::{Identity, Signature}; use crate::keys::KeyPair; use rand::RngCore; /// How far a request's time may be from the server's clock (spec §8.5). pub const WINDOW_MS: u64 = 5 * 60 * 1000; /// A signature with everything it covered except the body itself. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Proof { pub sig: Signature, pub method: String, pub body_hash: [u8; 32], pub time_ms: u64, pub nonce: [u8; 16], } impl Proof { /// Sign `method` and `body` now with `pair`. pub fn sign(pair: &KeyPair, method: &str, body: &[u8]) -> Proof { let mut nonce = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut nonce); Proof::sign_at(pair, method, body, crate::ids::now_ms(), nonce) } pub fn sign_at(pair: &KeyPair, method: &str, body: &[u8], time_ms: u64, nonce: [u8; 16]) -> Proof { let body_hash = *blake3::hash(body).as_bytes(); let sig = pair.sign(&message(method, &body_hash, time_ms, &nonce)); Proof { sig, method: method.into(), body_hash, time_ms, nonce } } /// The signing key's KeyId. pub fn key(&self) -> [u8; 32] { self.sig.key.0 } /// Check the signature against `identity` and, when given, the body. pub fn verify(&self, identity: &Identity, body: Option<&[u8]>) -> Result<(), String> { if let Some(b) = body { if *blake3::hash(b).as_bytes() != self.body_hash { return Err("the signature covers a different body".into()); } } let msg = message(&self.method, &self.body_hash, self.time_ms, &self.nonce); crate::identity::verify(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}")) } pub fn encode(&self) -> Vec { cbor::encode(&Value::map([ (0, self.sig.to_value()), (1, Value::Text(self.method.clone())), (2, Value::Bytes(self.body_hash.to_vec())), (3, Value::Uint(self.time_ms)), (4, Value::Bytes(self.nonce.to_vec())), ])) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b).map_err(|e| format!("malformed proof: {e}"))?; let m = v.as_map().ok_or("malformed proof")?; let get = |k: u64| m.get(&k).ok_or_else(|| format!("proof lacks field {k}")); Ok(Proof { sig: Signature::from_value(get(0)?).map_err(|e| format!("malformed signature: {e}"))?, method: get(1)?.as_text().ok_or("proof method")?.to_string(), body_hash: get(2)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof body hash")?, time_ms: get(3)?.as_uint().ok_or("proof time")?, nonce: get(4)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof nonce")?, }) } } /// `"spectra1 request " || method || 0 || body_hash || time || nonce`. pub fn message(method: &str, body_hash: &[u8; 32], time_ms: u64, nonce: &[u8; 16]) -> Vec { let mut m = b"spectra1 request ".to_vec(); m.extend_from_slice(method.as_bytes()); m.push(0); m.extend_from_slice(body_hash); m.extend_from_slice(&time_ms.to_be_bytes()); m.extend_from_slice(nonce); m } #[cfg(test)] mod tests { use super::*; #[test] fn a_proof_round_trips_and_binds_key_method_body_and_time() { let a = KeyPair::generate("a", ""); let b = KeyPair::generate("b", ""); let p = Proof::sign(&a, "POST /mcp", b"{\"x\":1}"); let q = Proof::decode(&p.encode()).unwrap(); assert_eq!(p, q); q.verify(&a.identity, Some(b"{\"x\":1}")).unwrap(); assert!(q.verify(&a.identity, Some(b"{\"x\":2}")).is_err(), "another body"); assert!(q.verify(&b.identity, None).is_err(), "another key"); let mut r = q.clone(); r.method = "POST /api".into(); assert!(r.verify(&a.identity, None).is_err(), "another method"); let mut t = q.clone(); t.time_ms += 1; assert!(t.verify(&a.identity, None).is_err(), "another time"); } }