Skip to main content

clawft_kernel/workload_runtime/seed_bind/
v2.rs

1//! The licence form of binding (ADR-106 phase 1d): the STEWARD profile run
2//! over an operator-signed `licence::BindingRecord` v2.
3//!
4//! Order, so an untrusted record costs the Seed and the replay state nothing:
5//! admission posture, pinned link, member checks (operator signature,
6//! canonical payload, `mesh_id`), the steward checks that need no Seed, then
7//! the live `GET /api/v1/identity` match, then the replay memory (persisted
8//! before anything is accepted), then the store. A refusal is chained as
9//! `workload.refuse`; a bind as `workload.node.bind`.
10//!
11//! If the store refuses after the replay memory was persisted (it cannot,
12//! after the `seq` pre-check, except on a disk error), that `bound_at` is
13//! spent and the operator signs a newer record.
14
15use serde_json::json;
16use sha2::{Digest, Sha256};
17
18use super::{
19    BIND_CHAIN_SOURCE, BindError, SeedBinder, write_state, MAX_BOUND_DEVICES,
20};
21use crate::chain::{EVENT_KIND_WORKLOAD_NODE_BIND, EVENT_KIND_WORKLOAD_NODE_UNBIND};
22use crate::licence::{
23    AdmissionPosture, BindState as LicState, BindingExtraCheck, BindingRecord, CheckoutGrantStore, LicenceError,
24    NoExtraChecks, Outcome, SignedBinding, StewardCheck, key_id,
25};
26use crate::workload_pkg::codec::hex_decode_exact;
27use crate::workload_runtime::seed::SeedApiRuntime;
28use crate::workload_runtime::types::WorkloadRuntime;
29
30/// Stable chain code of a licence refusal.
31pub(super) fn licence_code(e: &LicenceError) -> &'static str {
32    match e {
33        LicenceError::Malformed(_) | LicenceError::TooLarge => "malformed",
34        LicenceError::BadSignature => "bad_signature",
35        LicenceError::UntrustedKey => "untrusted_operator",
36        LicenceError::WrongMesh => "wrong_mesh",
37        LicenceError::NoLocalMesh => "no_local_mesh",
38        LicenceError::NoBinding => "no_binding",
39        LicenceError::Conflict(_) => "binding_conflict",
40        LicenceError::BindingRefused(_) => "binding_refused",
41        LicenceError::Poisoned(_) => "store_poisoned",
42        LicenceError::Persist(_) => "state_unwritable",
43        LicenceError::CheckFailed(c) => match c.as_str() {
44            "not_a_bind" => "not_a_bind",
45            "not_an_unbind" => "not_an_unbind",
46            "steward_mismatch" => "steward_mismatch",
47            "fingerprint_mismatch" => "fingerprint_mismatch",
48            "expired" => "expired",
49            "seq_not_greater" => "seq_not_greater",
50            "device_mismatch" => "device_mismatch",
51            "identity_mismatch_device_id" => "identity_mismatch",
52            "identity_mismatch_device_key" => "identity_mismatch",
53            _ => "check_failed",
54        },
55        _ => "licence_refused",
56    }
57}
58
59/// Everything one steward bind needs.
60pub struct StewardBind<'a> {
61    /// The operator-signed v2 record.
62    pub signed: &'a SignedBinding,
63    /// The Seed's adapter runtime (its link must be pinned).
64    pub rt: &'a SeedApiRuntime,
65    /// The node's checkout store (holds the live local mesh id).
66    pub store: &'a CheckoutGrantStore,
67    /// The node's admission state.
68    pub posture: AdmissionPosture,
69    /// The grant key fingerprint the operator confirmed at `init` time.
70    pub confirmed_fingerprint: &'a str,
71    /// This node's id (the steward the record must name).
72    pub steward_node_id: &'a str,
73    /// This node's request-signing key, 64 hex chars.
74    pub steward_pubkey: &'a str,
75    /// Now, unix seconds.
76    pub now: u64,
77}
78
79fn lic(e: LicenceError) -> BindError {
80    BindError::Licence(e)
81}
82
83impl SeedBinder {
84    /// Steward-profile bind of a v2 record. See the module docs.
85    pub async fn bind_v2(&self, b: &StewardBind<'_>) -> Result<BindingRecord, BindError> {
86        match self.verify_v2(b).await {
87            Ok(r) => Ok(r),
88            Err(e) => {
89                self.refuse_record(&b.signed.payload, &e);
90                Err(e)
91            }
92        }
93    }
94
95    fn steward_check(&self, b: &StewardBind<'_>, held: Option<u64>) -> StewardCheck {
96        StewardCheck {
97            now: b.now,
98            max_age_secs: self.max_age_secs,
99            skew_secs: super::BIND_CLOCK_SKEW_SECS,
100            confirmed_fingerprint: b.confirmed_fingerprint.to_owned(),
101            steward_node_id: b.steward_node_id.to_owned(),
102            steward_pubkey: b.steward_pubkey.to_owned(),
103            held_seq: held,
104            seed_identity: None,
105        }
106    }
107
108    async fn verify_v2(&self, b: &StewardBind<'_>) -> Result<BindingRecord, BindError> {
109        let poisoned = || BindError::Malformed("binder poisoned".into());
110        b.posture.check().map_err(lic)?;
111        // Before anything is read from the Seed: an identity read over an
112        // unauthenticated link proves nothing.
113        b.rt.link_security()
114            .require_pinned(b.rt.node_id())
115            .map_err(BindError::UnpinnedTransport)?;
116        let rec = b.store.verify_member(b.signed).map_err(lic)?;
117        // seed_bound_elsewhere comes from the store, the one source of truth:
118        // the same device held Bound for another mesh id. (A copy kept here
119        // could drift and lock a Seed out for good.)
120        let held_rec = b.store.held_binding();
121        if let Some(h) = &held_rec
122            && h.device_id == rec.device_id
123            && h.state == LicState::Bound
124            && h.mesh_id != rec.mesh_id
125        {
126            return Err(BindError::SeedBoundElsewhere { device_id: rec.device_id });
127        }
128        let held = held_rec.map(|h| h.seq);
129        let mut check = self.steward_check(b, held);
130        check.check(&rec).map_err(lic)?;
131        // The Seed is asked last, so an untrusted record costs it nothing.
132        let (device_id, device_key) = b
133            .rt
134            .identity()
135            .await
136            .map_err(|e| BindError::Seed(e.to_string()))?;
137        check.seed_identity = Some((device_id, device_key));
138        check.check(&rec).map_err(lic)?;
139
140        let digest: [u8; 32] = Sha256::digest(
141            [b.signed.payload.as_bytes(), b.signed.signature.as_bytes()].concat(),
142        )
143        .into();
144        let mut seen = self.seen.lock().map_err(|_| poisoned())?;
145        let mut bound = self.bound.lock().map_err(|_| poisoned())?;
146        if seen.contains(&digest) {
147            return Err(BindError::Replayed);
148        }
149        if bound.get(&rec.device_id).is_some_and(|(at, _)| rec.bound_at <= *at) {
150            return Err(BindError::Replayed);
151        }
152        if !bound.contains_key(&rec.device_id) && bound.len() >= MAX_BOUND_DEVICES {
153            return Err(BindError::State("too many bound devices".into()));
154        }
155        let node_id = b.rt.node_id().to_owned();
156        let mut next = bound.clone();
157        next.insert(rec.device_id.clone(), (rec.bound_at, node_id));
158        // Saved before it is accepted: if the replay memory cannot be
159        // persisted, nothing is bound.
160        if let Some(path) = &self.state_file {
161            write_state(path, &next).map_err(BindError::State)?;
162        }
163        *bound = next;
164        seen.insert(digest);
165        drop((seen, bound));
166        match b.store.accept_binding(b.signed, b.posture, &check).map_err(lic)? {
167            Outcome::Applied | Outcome::AppliedUnsaved => {}
168            Outcome::Duplicate | Outcome::Ignored => return Err(BindError::Replayed),
169        }
170        self.chain.append(
171            BIND_CHAIN_SOURCE,
172            EVENT_KIND_WORKLOAD_NODE_BIND,
173            Some(json!({
174                "v": 2,
175                "device_id": rec.device_id, "device_pubkey": rec.device_pubkey,
176                "mesh_id": rec.mesh_id, "seq": rec.seq, "bound_at": rec.bound_at,
177                "grant_fingerprint": b.confirmed_fingerprint,
178                "steward_node_id": rec.steward_node_id,
179                "operator_key": b.signed.public_key,
180                "record_hash": crate::workload_pkg::codec::hex_encode(&Sha256::digest(
181                    b.signed.payload.as_bytes())),
182            })),
183        );
184        Ok(rec)
185    }
186
187    /// Withdraw the binding with an operator-signed `unbound` record. Needs
188    /// no Seed (it may be gone). Any operator-Admin node may do it. If the
189    /// store could not save it, the unbind is still applied in memory (it only
190    /// restricts), the event is chained, and the outcome says the save is
191    /// pending (the store's tick retries it).
192    pub fn unbind_v2(
193        &self,
194        signed: &SignedBinding,
195        store: &CheckoutGrantStore,
196    ) -> Result<UnbindOutcome, BindError> {
197        match self.unbind_inner(signed, store) {
198            Ok(r) => Ok(r),
199            Err(e) => {
200                self.refuse_record(&signed.payload, &e);
201                Err(e)
202            }
203        }
204    }
205
206    fn unbind_inner(
207        &self,
208        signed: &SignedBinding,
209        store: &CheckoutGrantStore,
210    ) -> Result<UnbindOutcome, BindError> {
211        let rec = store.verify_member(signed).map_err(lic)?;
212        if rec.state != LicState::Unbound {
213            return Err(lic(LicenceError::CheckFailed("not_an_unbind".into())));
214        }
215        let held = store.held_binding().ok_or(lic(LicenceError::NoBinding))?;
216        if held.device_id != rec.device_id {
217            return Err(lic(LicenceError::CheckFailed("device_mismatch".into())));
218        }
219        if rec.seq <= held.seq {
220            return Err(lic(LicenceError::CheckFailed("seq_not_greater".into())));
221        }
222        // Turning the licence off must work under any admission posture.
223        let off = AdmissionPosture {
224            enforce: true,
225            verdict_source_bound: true,
226            open_membership: false,
227        };
228        let save_pending = match store.accept_binding(signed, off, &NoExtraChecks) {
229            Ok(Outcome::AppliedUnsaved) => true,
230            Ok(_) => false,
231            // Older store behaviour: applied in memory, the disk write failed.
232            Err(LicenceError::Persist(_)) => true,
233            Err(e) => return Err(lic(e)),
234        };
235        self.chain.append(
236            BIND_CHAIN_SOURCE,
237            EVENT_KIND_WORKLOAD_NODE_UNBIND,
238            Some(json!({
239                "v": 2, "device_id": rec.device_id, "mesh_id": rec.mesh_id, "seq": rec.seq,
240                "save_pending": save_pending,
241                "operator_key": signed.public_key,
242                "record_hash": crate::workload_pkg::codec::hex_encode(&Sha256::digest(
243                    signed.payload.as_bytes())),
244            })),
245        );
246        Ok(UnbindOutcome { record: rec, save_pending })
247    }
248}
249
250/// What an unbind did.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct UnbindOutcome {
253    /// The accepted `unbound` record.
254    pub record: BindingRecord,
255    /// The unbind is in effect but could not be saved yet.
256    pub save_pending: bool,
257}
258
259/// The fingerprint of a grant key in hex form, for display and the CLI's
260/// confirmation (`ed25519:` plus 16 hex).
261pub fn grant_fingerprint(grant_pubkey_hex: &str) -> Option<String> {
262    hex_decode_exact::<32>(grant_pubkey_hex).map(|k| key_id(&k))
263}