Skip to main content

clawft_kernel/licence/
steward.rs

1//! The STEWARD verification profile for a binding (ADR-106 section 3,
2//! phase 1d), plugged into the store through [`BindingExtraCheck`].
3//!
4//! It runs after the member checks (operator signature, canonical payload,
5//! `mesh_id` equal to the local id) and adds what only the steward can know:
6//! the age window, the live Seed identity (read by the caller, because the
7//! hook is synchronous), the grant key fingerprint the operator confirmed
8//! over the USB link, that this node is the steward the record names, and a
9//! `seq` strictly above the one held.
10
11use super::{BindState, BindingExtraCheck, BindingRecord, LicenceError, key_id};
12use crate::workload_pkg::codec::hex_decode_exact;
13
14/// Inputs of the steward checks.
15#[derive(Debug, Clone)]
16pub struct StewardCheck {
17    /// Now, unix seconds.
18    pub now: u64,
19    /// Oldest `bound_at` accepted, in seconds before `now` (600).
20    pub max_age_secs: u64,
21    /// Newest `bound_at` accepted, in seconds after `now`.
22    pub skew_secs: u64,
23    /// The grant key fingerprint (`ed25519:` plus 16 hex) the operator
24    /// compared at `weft-licence init` time.
25    pub confirmed_fingerprint: String,
26    /// This node's id: the steward the record must name.
27    pub steward_node_id: String,
28    /// This node's request-signing key, 64 hex chars.
29    pub steward_pubkey: String,
30    /// The `seq` of the binding held now, if any.
31    pub held_seq: Option<u64>,
32    /// The Seed's live `(device_id, public_key)`. `None` skips the identity
33    /// match (the pre-check that runs before the Seed is contacted).
34    pub seed_identity: Option<(String, String)>,
35}
36
37fn fail(code: &str) -> LicenceError {
38    LicenceError::CheckFailed(code.into())
39}
40
41impl BindingExtraCheck for StewardCheck {
42    fn check(&self, r: &BindingRecord) -> Result<(), LicenceError> {
43        if r.state != BindState::Bound {
44            return Err(fail("not_a_bind"));
45        }
46        if r.steward_node_id != self.steward_node_id || r.steward_pubkey != self.steward_pubkey {
47            return Err(fail("steward_mismatch"));
48        }
49        let gk = hex_decode_exact::<32>(&r.grant_pubkey).ok_or_else(|| fail("grant_key"))?;
50        if key_id(&gk) != self.confirmed_fingerprint {
51            return Err(fail("fingerprint_mismatch"));
52        }
53        if r.bound_at > self.now.saturating_add(self.skew_secs)
54            || self.now.saturating_sub(r.bound_at) > self.max_age_secs
55        {
56            return Err(fail("expired"));
57        }
58        if self.held_seq.is_some_and(|h| r.seq <= h) {
59            return Err(fail("seq_not_greater"));
60        }
61        if let Some((id, key)) = &self.seed_identity {
62            if *id != r.device_id {
63                return Err(fail("identity_mismatch_device_id"));
64            }
65            if *key != r.device_pubkey {
66                return Err(fail("identity_mismatch_device_key"));
67            }
68        }
69        Ok(())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::super::tests_common::*;
76    use super::*;
77
78    fn rec() -> BindingRecord {
79        binding_rec(2, BindState::Bound, &grant_key(), &mesh())
80    }
81
82    fn check() -> StewardCheck {
83        StewardCheck {
84            now: T0,
85            max_age_secs: 600,
86            skew_secs: 60,
87            confirmed_fingerprint: key_id(&grant_key().verifying_key().to_bytes()),
88            steward_node_id: "node-steward".into(),
89            steward_pubkey: pk_hex(&sk(21)),
90            held_seq: Some(1),
91            seed_identity: Some(("seed-test".into(), pk_hex(&sk(20)))),
92        }
93    }
94
95    fn refused(c: &StewardCheck, r: &BindingRecord) -> String {
96        match c.check(r) {
97            Err(LicenceError::CheckFailed(code)) => code,
98            other => panic!("expected a refusal, got {other:?}"),
99        }
100    }
101
102    #[test]
103    fn a_matching_record_passes() {
104        assert_eq!(check().check(&rec()), Ok(()));
105    }
106
107    #[test]
108    fn each_check_refuses_with_its_own_code() {
109        let mut c = check();
110        c.confirmed_fingerprint = key_id(&sk(99).verifying_key().to_bytes());
111        assert_eq!(refused(&c, &rec()), "fingerprint_mismatch");
112
113        let mut c = check();
114        c.steward_node_id = "other".into();
115        assert_eq!(refused(&c, &rec()), "steward_mismatch");
116
117        let mut c = check();
118        c.now = T0 + 601;
119        assert_eq!(refused(&c, &rec()), "expired");
120        c.now = T0 - 61;
121        assert_eq!(refused(&c, &rec()), "expired");
122        c.now = T0 + 600;
123        assert_eq!(c.check(&rec()), Ok(()), "600 s is inside the window");
124
125        let mut c = check();
126        c.held_seq = Some(2);
127        assert_eq!(refused(&c, &rec()), "seq_not_greater");
128
129        let mut c = check();
130        c.seed_identity = Some(("someone-else".into(), pk_hex(&sk(20))));
131        assert_eq!(refused(&c, &rec()), "identity_mismatch_device_id");
132        c.seed_identity = Some(("seed-test".into(), pk_hex(&sk(77))));
133        assert_eq!(refused(&c, &rec()), "identity_mismatch_device_key");
134
135        let unbind = binding_rec(2, BindState::Unbound, &grant_key(), &mesh());
136        assert_eq!(refused(&check(), &unbind), "not_a_bind");
137    }
138
139    #[test]
140    fn the_precheck_without_a_seed_skips_only_the_identity_match() {
141        let mut c = check();
142        c.seed_identity = None;
143        assert_eq!(c.check(&rec()), Ok(()));
144        c.confirmed_fingerprint = "ed25519:0000000000000000".into();
145        assert_eq!(refused(&c, &rec()), "fingerprint_mismatch");
146    }
147}