Skip to main content

clawft_kernel/workload_runtime/
seed_bind.rs

1//! Fleet identity binding for Cognitum Seeds (card mesh-placement-22; ADR-100
2//! section 5; `docs/research/mesh-placement/fleet-compat.md` section 2c).
3//!
4//! A Seed has a device identity (`GET /api/v1/identity`: a device id and an
5//! Ed25519 public key) but no WeftOS node key. The operator binds the two
6//! with a signed record `{device_id, device_pubkey, node_id, bound_at}`;
7//! [`SeedBinder::bind`] verifies it against the pinned operator keys and
8//! against the Seed itself, then chains `workload.node.bind`. Anything
9//! else (an unpinned signer, a changed record, a key that is not the
10//! device's, a record for another node, an expired or replayed record) is
11//! refused with a chained `workload.refuse`.
12//!
13//! The node id of a Seed is the id derived from a per-Seed *adapter key*
14//! held by WeftOS (never by the Seed): the Seed cannot sign for itself, so
15//! the adapter signs the Seed's facts ([`attest_seed_facts`]) and every
16//! capability carries provenance `claimed`, since the device's own report
17//! is not something WeftOS measured.
18//!
19//! Bearer tokens never reach a record, an error or a chain event: the only
20//! Seed data used is the (public) identity.
21//!
22//! ADR-106 phase 1d adds the licence form: [`SeedBinder::bind_v2`] verifies an
23//! operator-signed `licence::BindingRecord` v2 under the steward profile (the
24//! checks above, plus the grant key fingerprint, `mesh_id` and `seq`), and
25//! [`SeedBinder::unbind_v2`] withdraws it. Both live in `seed_bind/v2.rs`.
26
27use std::collections::{BTreeMap, HashSet};
28use std::sync::{Arc, Mutex};
29
30use clawft_types::placement::{Capability, NodeFacts, ProbeNote, Provenance};
31use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34use sha2::{Digest, Sha256};
35
36use super::seed::{API_TIMEOUT, SeedApiRuntime};
37use super::seed_http::Method;
38use super::types::{RuntimeError, WorkloadRuntime};
39use crate::chain::{ChainManager, EVENT_KIND_WORKLOAD_NODE_BIND, EVENT_KIND_WORKLOAD_REFUSE};
40use crate::node_facts_advert::{NodeFactsAdvertError, SignedNodeFacts, sign_node_facts};
41use crate::node_registry::node_id_from_pubkey;
42use crate::workload_pkg::codec::hex_encode;
43use crate::workload_pkg::manifest::valid_token;
44
45/// Chain source of bind events.
46pub const BIND_CHAIN_SOURCE: &str = "workload.bind";
47/// Domain tag signed before a bind record.
48pub const BIND_DOMAIN: &[u8] = b"weftos.workload.node.bind.v1\0";
49/// Default age after which a bind record is no longer accepted.
50pub const DEFAULT_MAX_BIND_AGE_SECS: u64 = 600;
51/// File name of the persisted bind state, beside `workload-placements.json`.
52/// The daemon's `workload.node.bind` uses `dir.join(BIND_STATE_FILE)` (the
53/// runtime dir).
54pub const BIND_STATE_FILE: &str = "workload-seed-binds.json";
55const MAX_STATE_BYTES: u64 = 256 * 1024;
56const MAX_BOUND_DEVICES: usize = 1000;
57/// Tolerated clock skew for `bound_at`.
58pub const BIND_CLOCK_SKEW_SECS: u64 = 60;
59/// Facts lifetime for an adapter-attested Seed.
60pub const SEED_FACTS_TTL_SECS: u64 = 300;
61const MAX_RECORD_BYTES: usize = 4096;
62
63/// What the operator signs.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct BindRecord {
67    /// The Seed's device id, as `GET /api/v1/identity` reports it.
68    pub device_id: String,
69    /// The Seed's device public key, exactly as `GET /api/v1/identity`
70    /// reports it.
71    pub device_pubkey: String,
72    /// The WeftOS node id the Seed is bound to (derived from the adapter
73    /// key, [`seed_node_id`]).
74    pub node_id: String,
75    /// Unix seconds the operator made the binding.
76    pub bound_at: u64,
77}
78
79/// A bind record with the operator's signature over the exact bytes.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct SignedBind {
82    /// Exact JSON bytes of the [`BindRecord`] that were signed.
83    pub record: String,
84    /// Operator Ed25519 public key (32 bytes).
85    pub operator_key: Vec<u8>,
86    /// Ed25519 signature (64 bytes) over [`BIND_DOMAIN`] || record.
87    pub signature: Vec<u8>,
88}
89
90/// Node id of a Seed whose adapter key is `adapter`.
91pub fn seed_node_id(adapter: &SigningKey) -> String {
92    node_id_from_pubkey(&adapter.verifying_key().to_bytes())
93}
94
95fn signed_bytes(record: &str) -> Vec<u8> {
96    let mut v = BIND_DOMAIN.to_vec();
97    v.extend_from_slice(record.as_bytes());
98    v
99}
100
101/// Sign `record` as the operator.
102pub fn sign_bind(record: &BindRecord, operator: &SigningKey) -> SignedBind {
103    let record = serde_json::to_string(record).unwrap_or_default();
104    let signature = operator.sign(&signed_bytes(&record)).to_bytes().to_vec();
105    SignedBind {
106        record,
107        operator_key: operator.verifying_key().to_bytes().to_vec(),
108        signature,
109    }
110}
111
112/// Why a bind was refused. [`Self::code`] is the stable chain code.
113#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
115pub enum BindError {
116    /// Not a well-formed record or envelope.
117    #[error("malformed bind record: {0}")]
118    Malformed(String),
119    /// Signed by a key that is not a pinned operator key.
120    #[error("bind record is not signed by a pinned operator key")]
121    UntrustedOperator,
122    /// The signature does not verify: the record was altered.
123    #[error("bind record signature does not verify")]
124    BadSignature,
125    /// The record names another node than this adapter's.
126    #[error("bind record is for node {record}, this adapter is node {adapter}")]
127    WrongNode {
128        /// Node id in the record.
129        record: String,
130        /// This adapter's node id.
131        adapter: String,
132    },
133    /// The Seed's own identity differs from the record.
134    #[error("the Seed's identity does not match the bind record ({0})")]
135    IdentityMismatch(&'static str),
136    /// `bound_at` is in the future or older than the accepted age.
137    #[error("bind record is outside its validity window")]
138    Expired,
139    /// This exact record was already accepted, or is older than the
140    /// binding held for the device.
141    #[error("bind record replayed or older than the current binding")]
142    Replayed,
143    /// The node is already bound to a different device.
144    #[error("node {0} is already bound to another device")]
145    NodeTaken(String),
146    /// The link to the Seed is not pinned (and no lab opt-in is set).
147    #[error("{0}")]
148    UnpinnedTransport(String),
149    /// A licence-record check failed (v2 binding: signature, mesh id,
150    /// steward profile, admission posture). Only with the licence module.
151    #[cfg(all(feature = "mesh", feature = "ecc", feature = "exochain", feature = "native"))]
152    #[error("{0}")]
153    Licence(crate::licence::LicenceError),
154    /// The device is already bound to another mesh here; unbind first.
155    #[error("device {device_id} is already bound to another mesh (seed_bound_elsewhere); unbind it first")]
156    SeedBoundElsewhere {
157        /// The Seed's device id.
158        device_id: String,
159    },
160    /// The bind state could not be saved, so nothing was bound.
161    #[error("bind state not saved: {0}")]
162    State(String),
163    /// The Seed could not be asked.
164    #[error("could not read the Seed identity: {0}")]
165    Seed(String),
166}
167
168impl BindError {
169    /// Stable code recorded in the chained refusal.
170    pub fn code(&self) -> &'static str {
171        match self {
172            Self::Malformed(_) => "malformed",
173            Self::UntrustedOperator => "untrusted_operator",
174            Self::BadSignature => "bad_signature",
175            Self::WrongNode { .. } => "wrong_node",
176            Self::IdentityMismatch(_) => "identity_mismatch",
177            Self::Expired => "expired",
178            Self::Replayed => "replayed",
179            Self::NodeTaken(_) => "node_taken",
180            Self::UnpinnedTransport(_) => "unpinned_transport",
181            #[cfg(all(feature = "mesh", feature = "ecc", feature = "exochain", feature = "native"))]
182            Self::Licence(e) => v2::licence_code(e),
183            Self::SeedBoundElsewhere { .. } => "seed_bound_elsewhere",
184            Self::State(_) => "state_unwritable",
185            Self::Seed(_) => "seed_unreachable",
186        }
187    }
188}
189
190/// A verified binding. Only [`SeedBinder::bind`] makes one.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Binding {
193    record: BindRecord,
194}
195
196impl Binding {
197    /// The bound record.
198    pub fn record(&self) -> &BindRecord {
199        &self.record
200    }
201}
202
203/// Verifies and remembers bindings.
204pub struct SeedBinder {
205    operators: Vec<[u8; 32]>,
206    chain: Arc<ChainManager>,
207    max_age_secs: u64,
208    seen: Mutex<HashSet<[u8; 32]>>,
209    /// Device id to (bound_at, node id).
210    bound: Mutex<BTreeMap<String, (u64, String)>>,
211    state_file: Option<std::path::PathBuf>,
212}
213
214#[derive(Debug, Default, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216struct BindState {
217    version: u32,
218    /// Device id to its latest accepted `bound_at` and node id.
219    bound: BTreeMap<String, BoundEntry>,
220}
221
222#[derive(Debug, Serialize, Deserialize)]
223#[serde(deny_unknown_fields)]
224struct BoundEntry {
225    bound_at: u64,
226    node_id: String,
227}
228
229fn write_state(
230    path: &std::path::Path,
231    bound: &BTreeMap<String, (u64, String)>,
232) -> Result<(), String> {
233    use std::io::Write;
234    use std::os::unix::fs::OpenOptionsExt;
235    let st = BindState {
236        version: 1,
237        bound: bound
238            .iter()
239            .map(|(d, (at, n))| {
240                (
241                    d.clone(),
242                    BoundEntry {
243                        bound_at: *at,
244                        node_id: n.clone(),
245                    },
246                )
247            })
248            .collect(),
249    };
250    let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
251    let tmp = path.with_extension("json.tmp");
252    let mut f = std::fs::OpenOptions::new()
253        .create(true)
254        .write(true)
255        .truncate(true)
256        .mode(0o600)
257        .open(&tmp)
258        .map_err(|e| e.to_string())?;
259    f.write_all(&bytes).map_err(|e| e.to_string())?;
260    f.sync_all().map_err(|e| e.to_string())?;
261    std::fs::rename(&tmp, path).map_err(|e| e.to_string())
262}
263
264impl SeedBinder {
265    /// A binder trusting `operators` (pinned operator public keys).
266    pub fn new(operators: Vec<[u8; 32]>, chain: Arc<ChainManager>) -> Self {
267        Self {
268            operators,
269            chain,
270            max_age_secs: DEFAULT_MAX_BIND_AGE_SECS,
271            seen: Mutex::new(HashSet::new()),
272            bound: Mutex::new(BTreeMap::new()),
273            state_file: None,
274        }
275    }
276
277    /// Persist each device's latest `bound_at` to `path` (mode 0600,
278    /// atomic) and restore it now, so a replayed or older record is still
279    /// refused after a restart. A missing file starts empty; a malformed,
280    /// oversized or non-regular one is refused (nothing is overwritten).
281    pub fn with_state_file(
282        mut self,
283        path: impl Into<std::path::PathBuf>,
284    ) -> Result<Self, BindError> {
285        let path = path.into();
286        let bad = |m: String| BindError::State(format!("{}: {m}", path.display()));
287        if let Ok(meta) = std::fs::symlink_metadata(&path) {
288            if !meta.file_type().is_file() || meta.len() > MAX_STATE_BYTES {
289                return Err(bad("not a regular file of at most 256 KiB".into()));
290            }
291            let text = std::fs::read_to_string(&path).map_err(|e| bad(e.to_string()))?;
292            let st: BindState = serde_json::from_str(&text).map_err(|e| bad(e.to_string()))?;
293            if st.version != 1 || st.bound.len() > MAX_BOUND_DEVICES {
294                return Err(bad("unsupported version or too many devices".into()));
295            }
296            if let Ok(mut b) = self.bound.lock() {
297                for (d, e) in st.bound {
298                    b.insert(d, (e.bound_at, e.node_id));
299                }
300            }
301        }
302        self.state_file = Some(path);
303        Ok(self)
304    }
305
306    /// Accept records for at most `secs` after `bound_at`.
307    pub fn with_max_age_secs(mut self, secs: u64) -> Self {
308        self.max_age_secs = secs;
309        self
310    }
311
312    /// The device id bound to `node_id`, if any.
313    pub fn device_of(&self, node_id: &str) -> Option<String> {
314        self.bound
315            .lock()
316            .ok()?
317            .iter()
318            .find(|(_, (_, n))| n == node_id)
319            .map(|(d, _)| d.clone())
320    }
321
322    fn refuse(&self, signed: &SignedBind, e: &BindError) {
323        self.refuse_record(&signed.record, e);
324    }
325
326    /// Chain a refusal for the record text `record` (v1 or v2 payload).
327    fn refuse_record(&self, record: &str, e: &BindError) {
328        let record_hash = hex_encode(&Sha256::digest(record.as_bytes()));
329        // Only identifiers, clipped: never the record or any token.
330        let claimed = serde_json::from_str::<Value>(record).unwrap_or(Value::Null);
331        let clip = |k: &str| {
332            claimed
333                .get(k)
334                .and_then(Value::as_str)
335                .map(|s| s.chars().take(64).collect::<String>())
336        };
337        self.chain.append(
338            BIND_CHAIN_SOURCE,
339            EVENT_KIND_WORKLOAD_REFUSE,
340            Some(json!({
341                "phase": "node.bind", "code": e.code(), "reason": e.to_string(),
342                "record_hash": record_hash,
343                "claimed_node_id": clip("node_id"), "claimed_device_id": clip("device_id"),
344            })),
345        );
346    }
347
348    /// Verify `signed` for `rt` at `now` (unix seconds) and chain the
349    /// outcome (`workload.node.bind`, or `workload.refuse` on any failure).
350    pub async fn bind(
351        &self,
352        signed: &SignedBind,
353        rt: &SeedApiRuntime,
354        now: u64,
355    ) -> Result<Binding, BindError> {
356        match self.verify(signed, rt, now).await {
357            Ok(b) => Ok(b),
358            Err(e) => {
359                self.refuse(signed, &e);
360                Err(e)
361            }
362        }
363    }
364
365    async fn verify(
366        &self,
367        signed: &SignedBind,
368        rt: &SeedApiRuntime,
369        now: u64,
370    ) -> Result<Binding, BindError> {
371        let bad = |m: &str| BindError::Malformed(m.into());
372        // Before anything is read from the Seed: an identity read over an
373        // unauthenticated link proves nothing.
374        rt.link_security()
375            .require_pinned(rt.node_id())
376            .map_err(BindError::UnpinnedTransport)?;
377        if signed.record.len() > MAX_RECORD_BYTES {
378            return Err(bad("record too large"));
379        }
380        let key: [u8; 32] = signed
381            .operator_key
382            .as_slice()
383            .try_into()
384            .map_err(|_| bad("operator key is not 32 bytes"))?;
385        let sig: [u8; 64] = signed
386            .signature
387            .as_slice()
388            .try_into()
389            .map_err(|_| bad("signature is not 64 bytes"))?;
390        if !self.operators.contains(&key) {
391            return Err(BindError::UntrustedOperator);
392        }
393        let vk = VerifyingKey::from_bytes(&key).map_err(|_| bad("bad operator key"))?;
394        vk.verify(&signed_bytes(&signed.record), &Signature::from_bytes(&sig))
395            .map_err(|_| BindError::BadSignature)?;
396        let record: BindRecord = serde_json::from_str(&signed.record)
397            .map_err(|e| BindError::Malformed(e.to_string()))?;
398        if !valid_token(&record.device_id, 128)
399            || !valid_token(&record.node_id, 64)
400            || record.device_pubkey.is_empty()
401            || record.device_pubkey.len() > 256
402        {
403            return Err(bad("device id, device key or node id is not well formed"));
404        }
405        let adapter = rt.node_id().to_string();
406        if record.node_id != adapter {
407            return Err(BindError::WrongNode {
408                record: record.node_id,
409                adapter,
410            });
411        }
412        if record.bound_at > now.saturating_add(BIND_CLOCK_SKEW_SECS)
413            || now.saturating_sub(record.bound_at) > self.max_age_secs
414        {
415            return Err(BindError::Expired);
416        }
417        // The Seed is asked last, so an untrusted record costs it nothing.
418        let (device_id, device_key) = rt
419            .identity()
420            .await
421            .map_err(|e| BindError::Seed(e.to_string()))?;
422        if device_id != record.device_id {
423            return Err(BindError::IdentityMismatch("device id"));
424        }
425        if device_key != record.device_pubkey {
426            return Err(BindError::IdentityMismatch("device key"));
427        }
428        let digest: [u8; 32] =
429            Sha256::digest([signed.record.as_bytes(), signed.signature.as_slice()].concat()).into();
430        let mut seen = self.seen.lock().map_err(|_| bad("binder poisoned"))?;
431        let mut bound = self.bound.lock().map_err(|_| bad("binder poisoned"))?;
432        if seen.contains(&digest) {
433            return Err(BindError::Replayed);
434        }
435        if bound
436            .get(&record.device_id)
437            .is_some_and(|(at, _)| record.bound_at <= *at)
438        {
439            return Err(BindError::Replayed);
440        }
441        if bound
442            .iter()
443            .any(|(d, (_, n))| *n == record.node_id && *d != record.device_id)
444        {
445            return Err(BindError::NodeTaken(record.node_id));
446        }
447        if !bound.contains_key(&record.device_id) && bound.len() >= MAX_BOUND_DEVICES {
448            return Err(BindError::State("too many bound devices".into()));
449        }
450        // Saved before it is accepted: if the replay memory cannot be
451        // persisted, nothing is bound.
452        let mut next = bound.clone();
453        next.insert(
454            record.device_id.clone(),
455            (record.bound_at, record.node_id.clone()),
456        );
457        if let Some(path) = &self.state_file {
458            write_state(path, &next).map_err(BindError::State)?;
459        }
460        *bound = next;
461        seen.insert(digest);
462        self.chain.append(
463            BIND_CHAIN_SOURCE,
464            EVENT_KIND_WORKLOAD_NODE_BIND,
465            Some(json!({
466                "device_id": record.device_id, "device_pubkey": record.device_pubkey,
467                "node_id": record.node_id, "bound_at": record.bound_at,
468                "operator_key": hex_encode(&key),
469                "record_hash": hex_encode(&Sha256::digest(signed.record.as_bytes())),
470            })),
471        );
472        Ok(Binding { record })
473    }
474}
475
476// The v2 (licence) form needs the licence module, which is gated like the
477// mesh swarm code; without it only the v1 bind exists.
478#[cfg(all(feature = "mesh", feature = "ecc", feature = "exochain", feature = "native"))]
479mod v2;
480#[cfg(all(feature = "mesh", feature = "ecc", feature = "exochain", feature = "native"))]
481pub use v2::{StewardBind, UnbindOutcome, grant_fingerprint};
482
483impl SeedApiRuntime {
484    /// This adapter's operator-assigned node id.
485    pub fn node_id(&self) -> &str {
486        &self.cfg.node_id
487    }
488
489    /// The Seed's own `(device_id, public_key)` from `GET /api/v1/identity`.
490    pub async fn identity(&self) -> Result<(String, String), RuntimeError> {
491        let v = self
492            .api(Method::Get, "/api/v1/identity", None, API_TIMEOUT)
493            .await?;
494        let field = |k: &str| {
495            v.get(k)
496                .and_then(Value::as_str)
497                .filter(|s| !s.is_empty() && s.len() <= 256)
498                .map(str::to_string)
499                .ok_or_else(|| RuntimeError::Backend(format!("seed /api/v1/identity: no {k}")))
500        };
501        Ok((field("device_id")?, field("public_key")?))
502    }
503}
504
505/// Facts for a bound Seed, signed by the adapter key. Every capability is
506/// forced to provenance `claimed`: the Seed's own report, relayed by the
507/// adapter, is never `probed` or `measured` (a `measured` value only comes
508/// from WeftOS's own conformance runs).
509pub fn attest_seed_facts(
510    binding: &Binding,
511    rt: &SeedApiRuntime,
512    adapter: &SigningKey,
513    now: u64,
514    seq: u64,
515) -> Result<SignedNodeFacts, NodeFactsAdvertError> {
516    let node_id = seed_node_id(adapter);
517    if node_id != binding.record.node_id {
518        return Err(NodeFactsAdvertError::NodeMismatch {
519            claimed: binding.record.node_id.clone(),
520            derived: node_id,
521        });
522    }
523    let mut facts = NodeFacts::new(node_id, now, SEED_FACTS_TTL_SECS, seq);
524    facts.capabilities = rt
525        .provides()
526        .into_iter()
527        .map(|c: Capability| Capability {
528            provenance: Provenance::Claimed,
529            ..c
530        })
531        .collect();
532    facts.notes.push(ProbeNote::new(
533        "remote.api",
534        format!(
535            "claimed by the adapter for Seed device {} (bound by an operator record), not probed",
536            binding.record.device_id
537        ),
538    ));
539    sign_node_facts(&facts, adapter)
540}