1use 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
45pub const BIND_CHAIN_SOURCE: &str = "workload.bind";
47pub const BIND_DOMAIN: &[u8] = b"weftos.workload.node.bind.v1\0";
49pub const DEFAULT_MAX_BIND_AGE_SECS: u64 = 600;
51pub const BIND_STATE_FILE: &str = "workload-seed-binds.json";
55const MAX_STATE_BYTES: u64 = 256 * 1024;
56const MAX_BOUND_DEVICES: usize = 1000;
57pub const BIND_CLOCK_SKEW_SECS: u64 = 60;
59pub const SEED_FACTS_TTL_SECS: u64 = 300;
61const MAX_RECORD_BYTES: usize = 4096;
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct BindRecord {
67 pub device_id: String,
69 pub device_pubkey: String,
72 pub node_id: String,
75 pub bound_at: u64,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct SignedBind {
82 pub record: String,
84 pub operator_key: Vec<u8>,
86 pub signature: Vec<u8>,
88}
89
90pub 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
101pub 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#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
115pub enum BindError {
116 #[error("malformed bind record: {0}")]
118 Malformed(String),
119 #[error("bind record is not signed by a pinned operator key")]
121 UntrustedOperator,
122 #[error("bind record signature does not verify")]
124 BadSignature,
125 #[error("bind record is for node {record}, this adapter is node {adapter}")]
127 WrongNode {
128 record: String,
130 adapter: String,
132 },
133 #[error("the Seed's identity does not match the bind record ({0})")]
135 IdentityMismatch(&'static str),
136 #[error("bind record is outside its validity window")]
138 Expired,
139 #[error("bind record replayed or older than the current binding")]
142 Replayed,
143 #[error("node {0} is already bound to another device")]
145 NodeTaken(String),
146 #[error("{0}")]
148 UnpinnedTransport(String),
149 #[cfg(all(feature = "mesh", feature = "ecc", feature = "exochain", feature = "native"))]
152 #[error("{0}")]
153 Licence(crate::licence::LicenceError),
154 #[error("device {device_id} is already bound to another mesh (seed_bound_elsewhere); unbind it first")]
156 SeedBoundElsewhere {
157 device_id: String,
159 },
160 #[error("bind state not saved: {0}")]
162 State(String),
163 #[error("could not read the Seed identity: {0}")]
165 Seed(String),
166}
167
168impl BindError {
169 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#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Binding {
193 record: BindRecord,
194}
195
196impl Binding {
197 pub fn record(&self) -> &BindRecord {
199 &self.record
200 }
201}
202
203pub struct SeedBinder {
205 operators: Vec<[u8; 32]>,
206 chain: Arc<ChainManager>,
207 max_age_secs: u64,
208 seen: Mutex<HashSet<[u8; 32]>>,
209 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 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 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 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 pub fn with_max_age_secs(mut self, secs: u64) -> Self {
308 self.max_age_secs = secs;
309 self
310 }
311
312 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 fn refuse_record(&self, record: &str, e: &BindError) {
328 let record_hash = hex_encode(&Sha256::digest(record.as_bytes()));
329 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 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 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 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 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#[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 pub fn node_id(&self) -> &str {
486 &self.cfg.node_id
487 }
488
489 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
505pub 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}