Skip to main content

clawft_kernel/workload_runtime/
seed_ops.rs

1//! Seed operations outside the cog lifecycle: pairing and firmware upgrade.
2//!
3//! Firmware upgrade is a governed, backed-up operation (ADR-100 section 5):
4//! a 0.10.x upgrade hit the witness-chain `writes_gated` state, recovered
5//! with `/api/v1/store/truncate-confirm` after a backup. So:
6//! 1. [`SeedApiRuntime::backup`] snapshots what the API exposes;
7//! 2. [`SeedApiRuntime::upgrade_firmware`] refuses without a verified backup
8//!    or while writes are gated, then applies;
9//! 3. [`SeedApiRuntime::recover_writes_gated`] runs the truncate-confirm
10//!    recovery only when writes are gated and the backup still verifies.
11//!
12//! Callers go through [`super::WorkloadHost`], which gates each step as
13//! `workload.install` with kind `seed-firmware` and chains the result.
14
15use std::path::{Path, PathBuf};
16
17use clawft_types::secret::SecretString;
18use serde_json::{Value, json};
19
20use super::seed::{API_TIMEOUT, SeedApiRuntime};
21use super::seed_http::Method;
22use super::types::RuntimeError;
23
24/// Workload kind used to govern firmware operations.
25pub const KIND_SEED_FIRMWARE: &str = "seed-firmware";
26
27/// A local backup of one Seed's API-visible state, bound to that Seed.
28///
29/// Only [`SeedApiRuntime::backup`] builds one. [`SeedBackup::verify`]
30/// re-reads `manifest.json` and every file, and the upgrade and recovery
31/// paths also check the backup belongs to the Seed being changed (same
32/// operator node id, device id and device public key).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SeedBackup {
35    dir: PathBuf,
36    node_id: String,
37    device_id: String,
38    public_key: String,
39    firmware: String,
40    files: Vec<(String, String)>,
41    manifest_blake3: String,
42}
43
44/// Files every backup must hold.
45pub const REQUIRED_BACKUP_FILES: [&str; 4] = [
46    "status.json",
47    "identity.json",
48    "apps.json",
49    "witness-chain.json",
50];
51
52/// Result of an upgrade request.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum UpgradeOutcome {
55    /// Nothing pending.
56    UpToDate {
57        /// Current firmware.
58        version: String,
59    },
60    /// Upgrade applied; check [`SeedApiRuntime::writes_gated`] afterwards.
61    Applied {
62        /// Firmware before the upgrade.
63        from: String,
64    },
65}
66
67fn gated(status: &Value) -> bool {
68    status
69        .pointer("/integrity/writes_gated")
70        .and_then(Value::as_bool)
71        .unwrap_or(false)
72}
73
74fn write_private(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> {
75    use std::io::Write;
76    use std::os::unix::fs::OpenOptionsExt;
77    let mut f = std::fs::OpenOptions::new()
78        .create_new(true)
79        .write(true)
80        .mode(0o600)
81        .open(path)
82        .map_err(|e| RuntimeError::Backend(format!("backup write {}: {e}", path.display())))?;
83    f.write_all(bytes)
84        .map_err(|e| RuntimeError::Backend(format!("backup write: {e}")))
85}
86
87impl SeedBackup {
88    /// Backup directory.
89    pub fn dir(&self) -> &Path {
90        &self.dir
91    }
92    /// Operator node id of the Seed it was taken from.
93    pub fn node_id(&self) -> &str {
94        &self.node_id
95    }
96    /// Seed device id at backup time.
97    pub fn device_id(&self) -> &str {
98        &self.device_id
99    }
100    /// Firmware version at backup time.
101    pub fn firmware(&self) -> &str {
102        &self.firmware
103    }
104    /// `(file name, blake3)` for each captured file.
105    pub fn files(&self) -> &[(String, String)] {
106        &self.files
107    }
108
109    /// Re-hash the manifest and every file, and check the manifest still
110    /// names this Seed and exactly these files (the required set included).
111    pub fn verify(&self) -> Result<(), RuntimeError> {
112        let refuse = |m: String| RuntimeError::AdmissionRefused(m);
113        let m = std::fs::read(self.dir.join("manifest.json"))
114            .map_err(|e| refuse(format!("backup manifest: {e}")))?;
115        if blake3::hash(&m).to_hex().as_str() != self.manifest_blake3 {
116            return Err(refuse("backup manifest changed".into()));
117        }
118        let doc: Value =
119            serde_json::from_slice(&m).map_err(|e| refuse(format!("backup manifest: {e}")))?;
120        if doc != self.manifest_doc() {
121            return Err(refuse("backup manifest does not match this backup".into()));
122        }
123        for req in REQUIRED_BACKUP_FILES {
124            if !self.files.iter().any(|(n, _)| n == req) {
125                return Err(refuse(format!("backup lacks {req}")));
126            }
127        }
128        for (name, hash) in &self.files {
129            let b = std::fs::read(self.dir.join(name))
130                .map_err(|e| refuse(format!("backup file {name}: {e}")))?;
131            if blake3::hash(&b).to_hex().as_str() != hash {
132                return Err(refuse(format!("backup file {name} changed")));
133            }
134        }
135        Ok(())
136    }
137
138    fn manifest_doc(&self) -> Value {
139        json!({
140            "node_id": self.node_id,
141            "device_id": self.device_id,
142            "public_key": self.public_key,
143            "firmware": self.firmware,
144            "files": self.files,
145        })
146    }
147
148    /// Chain-safe summary.
149    pub fn audit(&self) -> Value {
150        json!({
151            "node_id": self.node_id,
152            "device_id": self.device_id,
153            "firmware": self.firmware,
154            "files": self.files.len(),
155            "manifest_blake3": self.manifest_blake3,
156        })
157    }
158}
159
160fn identity_field(v: &Value, key: &str) -> Result<String, RuntimeError> {
161    v.get(key)
162        .and_then(Value::as_str)
163        .filter(|s| !s.is_empty() && s.len() <= 256)
164        .map(str::to_string)
165        .ok_or_else(|| RuntimeError::Backend(format!("seed /api/v1/identity: no {key}")))
166}
167
168impl SeedApiRuntime {
169    /// Pair with the Seed: open the pairing window (authorized by
170    /// `bootstrap`, which may be empty over the trusted USB link), request a
171    /// client token, and store it in the operator secret store.
172    pub async fn pair(
173        &self,
174        client_name: &str,
175        bootstrap: &SecretString,
176    ) -> Result<(), RuntimeError> {
177        if !crate::workload_pkg::manifest::valid_token(client_name, 64) {
178            return Err(RuntimeError::InvalidConfig(
179                "client name must be a plain token".into(),
180            ));
181        }
182        let t = &self.transport;
183        let (s, _) = t
184            .request(
185                Method::Post,
186                "/api/v1/pair/window",
187                None,
188                bootstrap,
189                API_TIMEOUT,
190            )
191            .await?;
192        if !(200..300).contains(&s) {
193            return Err(RuntimeError::Backend(format!("seed pair window: HTTP {s}")));
194        }
195        let body = json!({ "client_name": client_name });
196        let (s, v) = t
197            .request(
198                Method::Post,
199                "/api/v1/pair",
200                Some(&body),
201                bootstrap,
202                API_TIMEOUT,
203            )
204            .await?;
205        if !(200..300).contains(&s) {
206            return Err(RuntimeError::Backend(format!("seed pair: HTTP {s}")));
207        }
208        let token = v
209            .get("token")
210            .and_then(Value::as_str)
211            .filter(|t| t.len() >= 16 && t.len() <= 512 && t.bytes().all(|b| b.is_ascii_graphic()))
212            .ok_or_else(|| {
213                RuntimeError::Backend("seed pair: no usable token in response".into())
214            })?;
215        self.creds.put(&self.cfg.node_id, SecretString::new(token))
216    }
217
218    /// Current `writes_gated` flag from `/api/v1/status`.
219    pub async fn writes_gated(&self) -> Result<bool, RuntimeError> {
220        let s = self
221            .api(Method::Get, "/api/v1/status", None, API_TIMEOUT)
222            .await?;
223        Ok(gated(&s))
224    }
225
226    /// Snapshot status, identity, installed apps, each app's config and the
227    /// witness chain into a new private directory `dir`.
228    pub async fn backup(&self, dir: &Path) -> Result<SeedBackup, RuntimeError> {
229        let mut docs: Vec<(String, Value)> = Vec::new();
230        for (name, path) in [
231            ("status.json", "/api/v1/status"),
232            ("identity.json", "/api/v1/identity"),
233            ("apps.json", "/api/v1/apps"),
234            ("witness-chain.json", "/api/v1/witness/chain"),
235        ] {
236            docs.push((
237                name.into(),
238                self.api(Method::Get, path, None, API_TIMEOUT).await?,
239            ));
240        }
241        for c in self.installed().await? {
242            let v = self
243                .api(
244                    Method::Get,
245                    &format!("/api/v1/apps/{}/config", c.id),
246                    None,
247                    API_TIMEOUT,
248                )
249                .await?;
250            docs.push((format!("config-{}.json", c.id), v));
251        }
252        let identity = docs
253            .iter()
254            .find(|(n, _)| n == "identity.json")
255            .map(|(_, v)| v.clone())
256            .unwrap_or(Value::Null);
257        let device_id = identity_field(&identity, "device_id")?;
258        let public_key = identity_field(&identity, "public_key")?;
259        let firmware = identity_field(&identity, "firmware_version")?;
260        {
261            use std::os::unix::fs::DirBuilderExt;
262            std::fs::DirBuilder::new()
263                .mode(0o700)
264                .create(dir)
265                .map_err(|e| RuntimeError::Backend(format!("backup dir {}: {e}", dir.display())))?;
266        }
267        let mut files = Vec::new();
268        for (name, v) in &docs {
269            let bytes = serde_json::to_vec_pretty(v).unwrap_or_default();
270            write_private(&dir.join(name), &bytes)?;
271            files.push((name.clone(), blake3::hash(&bytes).to_hex().to_string()));
272        }
273        let mut backup = SeedBackup {
274            dir: dir.to_path_buf(),
275            node_id: self.cfg.node_id.clone(),
276            device_id,
277            public_key,
278            firmware,
279            files,
280            manifest_blake3: String::new(),
281        };
282        let manifest = serde_json::to_vec_pretty(&backup.manifest_doc()).unwrap_or_default();
283        write_private(&dir.join("manifest.json"), &manifest)?;
284        backup.manifest_blake3 = blake3::hash(&manifest).to_hex().to_string();
285        Ok(backup)
286    }
287
288    /// Verify `backup` and check it was taken from this Seed: same operator
289    /// node id, and the Seed's current identity has the same device id and
290    /// public key. With `same_firmware`, the firmware must also be unchanged
291    /// (the backup reflects the state about to be upgraded). Returns the
292    /// current firmware version.
293    pub async fn check_backup(
294        &self,
295        backup: &SeedBackup,
296        same_firmware: bool,
297    ) -> Result<String, RuntimeError> {
298        backup.verify()?;
299        let refuse = |m: &str| Err(RuntimeError::AdmissionRefused(m.into()));
300        if backup.node_id != self.cfg.node_id {
301            return refuse("backup was taken from a different Seed node");
302        }
303        let id = self
304            .api(Method::Get, "/api/v1/identity", None, API_TIMEOUT)
305            .await?;
306        if identity_field(&id, "device_id")? != backup.device_id
307            || identity_field(&id, "public_key")? != backup.public_key
308        {
309            return refuse("backup device identity does not match this Seed");
310        }
311        let firmware = identity_field(&id, "firmware_version")?;
312        if same_firmware && firmware != backup.firmware {
313            return refuse("backup predates the current firmware; take a new backup");
314        }
315        Ok(firmware)
316    }
317
318    /// Apply the pending firmware upgrade, but only if it is the version
319    /// the operator pinned (`target_version`). Refuses without a verified
320    /// backup of this Seed at its current firmware, while writes are gated,
321    /// or when the Seed does not report which version is pending.
322    pub async fn upgrade_firmware(
323        &self,
324        backup: &SeedBackup,
325        target_version: &str,
326    ) -> Result<UpgradeOutcome, RuntimeError> {
327        if !super::cog_spec::valid_value(target_version) {
328            return Err(RuntimeError::InvalidConfig(
329                "firmware target version must be a plain version string".into(),
330            ));
331        }
332        self.check_backup(backup, true).await?;
333        if self.writes_gated().await? {
334            return Err(RuntimeError::InvalidState(
335                "writes are gated; recover (truncate-confirm after backup) before upgrading".into(),
336            ));
337        }
338        let check = self
339            .api(Method::Get, "/api/v1/upgrade/check", None, API_TIMEOUT)
340            .await?;
341        let current = check
342            .get("current_version")
343            .and_then(Value::as_str)
344            .unwrap_or_default()
345            .to_string();
346        if !check
347            .get("pending_update")
348            .and_then(Value::as_bool)
349            .unwrap_or(false)
350        {
351            return Ok(UpgradeOutcome::UpToDate { version: current });
352        }
353        match check.get("target_version").and_then(Value::as_str) {
354            Some(t) if t == target_version => {}
355            Some(t) => {
356                return Err(RuntimeError::AdmissionRefused(format!(
357                    "pending firmware is {t}, the operator pinned {target_version}"
358                )));
359            }
360            None => {
361                return Err(RuntimeError::AdmissionRefused(
362                    "the Seed does not say which firmware is pending; refusing to apply it".into(),
363                ));
364            }
365        }
366        self.api(Method::Post, "/api/v1/upgrade/apply", None, API_TIMEOUT * 6)
367            .await?;
368        Ok(UpgradeOutcome::Applied { from: current })
369    }
370
371    /// Recover the witness-chain `writes_gated` state. Only runs when the
372    /// Seed reports writes gated and `backup` still verifies.
373    pub async fn recover_writes_gated(&self, backup: &SeedBackup) -> Result<(), RuntimeError> {
374        // The backup may predate the upgrade that gated writes.
375        self.check_backup(backup, false).await?;
376        if !self.writes_gated().await? {
377            return Err(RuntimeError::InvalidState(
378                "writes are not gated; nothing to recover".into(),
379            ));
380        }
381        self.api(
382            Method::Post,
383            "/api/v1/store/truncate-confirm",
384            Some(&json!({ "confirm": true })),
385            API_TIMEOUT,
386        )
387        .await
388        .map(|_| ())
389    }
390}