OXYGENHUB

Coverage / crates/spectra-core/src/proof.rs 95.9%

70 of 73 lines run, 21 of 29 functions · run #22 on main · run missed

1
//! Signed requests and the proofs events carry (spec §8.5, §9.5).
2
//!
3
//! A principal signs `"spectra1 request " || method || 0x00 || BLAKE3(body)
4
//! || time (8 bytes) || nonce (16 bytes)` with its key, through `oxy-core`'s
5
//! challenge signing. The signature, with what it covered, is the proof
6
//! kept on every event the request causes, so a verifier can check later,
7
//! from the ledger alone, which key asked for it.
8
//!
9
//! The CLI, writing to the ledger itself, signs with `method = "cli"` and
10
//! `body` = the event's canonical payload, so its proof also binds the
11
//! event's content.
12
13
use crate::keys::KeyPair;
14
use oxy_core::cbor::{self, Value};
15
use oxy_core::identity::{Identity, Signature};
16
use rand::RngCore;
17
18
/// How far a request's time may be from the server's clock (spec §8.5).
19
pub const WINDOW_MS: u64 = 5 * 60 * 1000;
20
21
/// A signature with everything it covered except the body itself.
22
#[derive(Clone, Debug, PartialEq, Eq)]
23
pub struct Proof {
24
    pub sig: Signature,
25
    pub method: String,
26
    pub body_hash: [u8; 32],
27
    pub time_ms: u64,
28
    pub nonce: [u8; 16],
29
}
30
31
impl Proof {
32
    /// Sign `method` and `body` now with `pair`.
33329×
    pub fn sign(pair: &KeyPair, method: &str, body: &[u8]) -> Proof {
34329×
        let mut nonce = [0u8; 16];
35329×
        rand::rngs::OsRng.fill_bytes(&mut nonce);
36329×
        Proof::sign_at(pair, method, body, crate::ids::now_ms(), nonce)
37329×
    }
38
39330×
    pub fn sign_at(pair: &KeyPair, method: &str, body: &[u8], time_ms: u64, nonce: [u8; 16]) -> Proof {
40330×
        let body_hash = *blake3::hash(body).as_bytes();
41330×
        let sig = pair.sign(&message(method, &body_hash, time_ms, &nonce));
42330×
        Proof { sig, method: method.into(), body_hash, time_ms, nonce }
43330×
    }
44
45
    /// The signing key's KeyId.
46985×
    pub fn key(&self) -> [u8; 32] {
47985×
        self.sig.key.0
48985×
    }
49
50
    /// Check the signature against `identity` and, when given, the body.
51883×
    pub fn verify(&self, identity: &Identity, body: Option<&[u8]>) -> Result<(), String> {
52883×
        if let Some(b) = body {
53837×
            if *blake3::hash(b).as_bytes() != self.body_hash {
543×
                return Err("the signature covers a different body".into());
55834×
            }
5646×
        }
57880×
        let msg = message(&self.method, &self.body_hash, self.time_ms, &self.nonce);
58880×
        oxy_core::identity::verify_challenge(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}"))
59883×
    }
60
61331×
    pub fn encode(&self) -> Vec<u8> {
62331×
        cbor::encode(&Value::map([
63331×
            (0, self.sig.to_value()),
64331×
            (1, Value::Text(self.method.clone())),
65331×
            (2, Value::Bytes(self.body_hash.to_vec())),
66331×
            (3, Value::Uint(self.time_ms)),
67331×
            (4, Value::Bytes(self.nonce.to_vec())),
68331×
        ]))
69331×
    }
70
71769×
    pub fn decode(b: &[u8]) -> Result<Proof, String> {
72769×
        let v = cbor::decode(b).map_err(|e| format!("malformed proof: {e}"))?;
73769×
        let m = v.as_map().ok_or("malformed proof")?;
743845×
        let get = |k: u64| m.get(&k).ok_or_else(|| format!("proof lacks field {k}"));
75
        Ok(Proof {
76769×
            sig: Signature::from_value(get(0)?).map_err(|e| format!("malformed signature: {e}"))?,
77769×
            method: get(1)?.as_text().ok_or("proof method")?.to_string(),
78769×
            body_hash: get(2)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof body hash")?,
79769×
            time_ms: get(3)?.as_uint().ok_or("proof time")?,
80769×
            nonce: get(4)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof nonce")?,
81
        })
82769×
    }
83
}
84
85
/// `"spectra1 request " || method || 0 || body_hash || time || nonce`.
861210×
pub fn message(method: &str, body_hash: &[u8; 32], time_ms: u64, nonce: &[u8; 16]) -> Vec<u8> {
871210×
    let mut m = b"spectra1 request ".to_vec();
881210×
    m.extend_from_slice(method.as_bytes());
891210×
    m.push(0);
901210×
    m.extend_from_slice(body_hash);
911210×
    m.extend_from_slice(&time_ms.to_be_bytes());
921210×
    m.extend_from_slice(nonce);
931210×
    m
941210×
}
95
96
#[cfg(test)]
97
mod tests {
98
    use super::*;
99
100
    #[test]
1011×
    fn a_proof_round_trips_and_binds_key_method_body_and_time() {
1021×
        let a = KeyPair::generate("a", "");
1031×
        let b = KeyPair::generate("b", "");
1041×
        let p = Proof::sign(&a, "POST /mcp", b"{\"x\":1}");
1051×
        let q = Proof::decode(&p.encode()).unwrap();
1061×
        assert_eq!(p, q);
1071×
        q.verify(&a.identity, Some(b"{\"x\":1}")).unwrap();
1081×
        assert!(q.verify(&a.identity, Some(b"{\"x\":2}")).is_err(), "another body");
1091×
        assert!(q.verify(&b.identity, None).is_err(), "another key");
1101×
        let mut r = q.clone();
1111×
        r.method = "POST /api".into();
1121×
        assert!(r.verify(&a.identity, None).is_err(), "another method");
1131×
        let mut t = q.clone();
1141×
        t.time_ms += 1;
1151×
        assert!(t.verify(&a.identity, None).is_err(), "another time");
1161×
    }
117
}