Skip to main content

vault_core/
nodes.rs

1//! Phase 34 — Nodes: the protocol, the hub's registry and the node's config.
2//!
3//! A **hub** is an `unv-server` holding the vault. A **node** is an agent on
4//! another host that observes, and when told to, applies config files derived
5//! from that vault. This module is everything both ends must agree on; the
6//! filesystem side of an apply is in [`crate::nodes_apply`].
7//!
8//! # What a node is, and is not
9//!
10//! A node never holds a vault key and never holds a scoped read token. Its
11//! identity is an Ed25519 key it generated itself. The hub stores the public
12//! half at enrollment and checks a signature on every request, so the hub
13//! pins the node's key the way the node pins the hub's certificate (ADR-0140
14//! explains why this is request signing and not client certificates).
15//!
16//! # State is not in the vault
17//!
18//! The registry is `nodes.json` beside `pools.json`. Heartbeats arrive every
19//! minute; putting them through `save_vault` would grow the audit chain without
20//! bound (the read-event and pool-cursor mistake, a third time) and turn every
21//! beat into a compare-and-swap that can conflict with a human's edit.
22//!
23//! # Rendered content never touches disk on the hub
24//!
25//! The registry holds hashes, never file content: a rendered `wg0.conf`
26//! contains a private key and `nodes.json` is not encrypted.
27
28use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
29use rand::RngCore;
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32use std::collections::HashSet;
33use std::path::{Path, PathBuf};
34
35/// How long an enrollment token lives unless the operator asks otherwise.
36pub const ENROLL_TTL_SECS: i64 = 900;
37/// A signed request whose timestamp is further than this from the hub's clock
38/// is refused. Replays inside the window are stopped by the strictly increasing
39/// timestamp, not by the window.
40pub const MAX_CLOCK_SKEW_SECS: i64 = 60;
41/// Targets one node may declare. A bound, so a hostile beat cannot grow the
42/// registry without limit.
43pub const MAX_TARGETS: usize = 256;
44
45/// The error `record_beat`'s `desired` callback returns when the hub cannot
46/// render at all because the vault is locked. It is not a refusal: the target
47/// is simply `unknown` until the hub is unlocked again.
48pub const LOCKED: &str = "hub-locked";
49
50/// Exporter names a target may use. `compose-env` is the `.env` Compose
51/// substitutes from, which is a separate file and therefore a separate target.
52/// True for a built-in exporter name or a stack adapter's (Phase 38).
53pub fn valid_exporter(name: &str) -> bool {
54    EXPORTERS.contains(&name) || crate::stack::adapter(name).is_some()
55}
56
57pub const EXPORTERS: &[&str] = &[
58    "wireguard",
59    "nginx",
60    "apache",
61    "haproxy",
62    "ansible",
63    "postgres",
64    "k8s",
65    "ssh",
66    "traefik",
67    "compose",
68    "compose-env",
69    "env",
70];
71
72// ── Identity and request signing ──────────────────────────────────────────────
73
74/// A fresh node identity as `(seed_hex, public_key_hex)`.
75pub fn generate_identity() -> (String, String) {
76    let mut seed = [0u8; 32];
77    rand::thread_rng().fill_bytes(&mut seed);
78    let public = SigningKey::from_bytes(&seed).verifying_key().to_bytes();
79    (hex::encode(seed), hex::encode(public))
80}
81
82/// SHA-256 of the raw public key, as hex. What the operator compares.
83pub fn key_fingerprint(public_hex: &str) -> Result<String, String> {
84    let bytes = hex::decode(public_hex).map_err(|_| "public key is not hex".to_string())?;
85    if bytes.len() != 32 {
86        return Err("public key must be 32 bytes".into());
87    }
88    Ok(hex::encode(Sha256::digest(&bytes)))
89}
90
91/// The exact bytes a signature covers. Method and path are inside it so a
92/// signature for one route cannot be replayed against another; the body is
93/// inside it by hash so the hub never has to re-serialise anything.
94fn signing_input(method: &str, path: &str, ts_ms: i64, body: &[u8]) -> Vec<u8> {
95    signing_input_for("envv-node-v1", method, path, ts_ms, body)
96}
97
98/// The same input under another domain. A hub's request to a node and a node's
99/// request to a hub must never be interchangeable, so each direction has its own
100/// first line.
101fn signing_input_for(domain: &str, method: &str, path: &str, ts_ms: i64, body: &[u8]) -> Vec<u8> {
102    format!(
103        "{domain}\n{}\n{}\n{}\n{}",
104        method.to_ascii_uppercase(),
105        path,
106        ts_ms,
107        hex::encode(Sha256::digest(body))
108    )
109    .into_bytes()
110}
111
112/// Signs a request. Returns the signature as hex.
113pub fn sign_request(
114    seed_hex: &str,
115    method: &str,
116    path: &str,
117    ts_ms: i64,
118    body: &[u8],
119) -> Result<String, String> {
120    let seed: [u8; 32] = hex::decode(seed_hex)
121        .map_err(|_| "node key is not hex".to_string())?
122        .try_into()
123        .map_err(|_| "node key must be 32 bytes".to_string())?;
124    let sig = SigningKey::from_bytes(&seed).sign(&signing_input(method, path, ts_ms, body));
125    Ok(hex::encode(sig.to_bytes()))
126}
127
128/// True only for a valid signature by `public_hex` over exactly this request.
129pub fn verify_request(
130    public_hex: &str,
131    method: &str,
132    path: &str,
133    ts_ms: i64,
134    body: &[u8],
135    sig_hex: &str,
136) -> bool {
137    verify_in(
138        "envv-node-v1",
139        public_hex,
140        method,
141        path,
142        ts_ms,
143        body,
144        sig_hex,
145    )
146}
147
148/// As [`sign_request`], for a hub talking to a node that listens (Phase 34.1).
149pub fn sign_hub_request(
150    seed_hex: &str,
151    method: &str,
152    path: &str,
153    ts_ms: i64,
154    body: &[u8],
155) -> Result<String, String> {
156    let seed: [u8; 32] = hex::decode(seed_hex)
157        .map_err(|_| "hub key is not hex".to_string())?
158        .try_into()
159        .map_err(|_| "hub key must be 32 bytes".to_string())?;
160    let sig = SigningKey::from_bytes(&seed).sign(&signing_input_for(
161        "envv-hub-v1",
162        method,
163        path,
164        ts_ms,
165        body,
166    ));
167    Ok(hex::encode(sig.to_bytes()))
168}
169
170/// As [`verify_request`], for the hub's requests.
171pub fn verify_hub_request(
172    public_hex: &str,
173    method: &str,
174    path: &str,
175    ts_ms: i64,
176    body: &[u8],
177    sig_hex: &str,
178) -> bool {
179    verify_in(
180        "envv-hub-v1",
181        public_hex,
182        method,
183        path,
184        ts_ms,
185        body,
186        sig_hex,
187    )
188}
189
190fn verify_in(
191    domain: &str,
192    public_hex: &str,
193    method: &str,
194    path: &str,
195    ts_ms: i64,
196    body: &[u8],
197    sig_hex: &str,
198) -> bool {
199    let Ok(pk) = hex::decode(public_hex) else {
200        return false;
201    };
202    let Ok(pk): Result<[u8; 32], _> = pk.try_into() else {
203        return false;
204    };
205    let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
206        return false;
207    };
208    let Ok(sig) = hex::decode(sig_hex) else {
209        return false;
210    };
211    let Ok(sig): Result<[u8; 64], _> = sig.try_into() else {
212        return false;
213    };
214    vk.verify(
215        &signing_input_for(domain, method, path, ts_ms, body),
216        &Signature::from_bytes(&sig),
217    )
218    .is_ok()
219}
220
221// ── Listening nodes (Phase 34.1) ──────────────────────────────────────────────
222
223/// How a hub reaches a node that listens instead of dialling: its address and
224/// the SHA-256 of the TLS certificate it generated for that. Recorded at
225/// enrollment, over the channel the one-time token already authenticates, and
226/// pinned from then on.
227#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
228pub struct ListenInfo {
229    pub endpoint: String,
230    pub cert_sha256: String,
231}
232
233impl ListenInfo {
234    /// `https://host[:port]` and nothing else: a path, query, userinfo or
235    /// fragment would let an address smuggle something to the hub's client.
236    pub fn validate(&self) -> Result<(), String> {
237        let rest = self
238            .endpoint
239            .strip_prefix("https://")
240            .ok_or("A listening node must be reachable over https")?;
241        let rest = rest.strip_suffix('/').unwrap_or(rest);
242        let ok = !rest.is_empty()
243            && rest.len() <= 255
244            && rest.bytes().all(|b| {
245                b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')
246            });
247        if !ok {
248            return Err("endpoint must be https://host or https://host:port".into());
249        }
250        if self.cert_sha256.len() != 64 || !self.cert_sha256.bytes().all(|b| b.is_ascii_hexdigit())
251        {
252            return Err("cert_sha256 must be 64 hex characters".into());
253        }
254        Ok(())
255    }
256
257    /// The endpoint without a trailing slash.
258    pub fn base(&self) -> &str {
259        self.endpoint.strip_suffix('/').unwrap_or(&self.endpoint)
260    }
261}
262
263// ── Wire types ────────────────────────────────────────────────────────────────
264
265/// One target as the node reports it. Hash and state only; never content.
266#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
267pub struct TargetReport {
268    pub id: String,
269    pub project: String,
270    pub exporter: String,
271    /// `push` (vault to file) or `pull` (file to vault).
272    pub mode: String,
273    /// Whether this node will write the file. Declared by the operator in the
274    /// node's own config; the hub cannot change it.
275    pub apply: bool,
276    /// SHA-256 of the file on disk, or `None` when it does not exist.
277    pub sha256: Option<String>,
278    /// `ok`, `missing`, `unreadable`, `applied`, `failed`.
279    pub state: String,
280    pub error: Option<String>,
281}
282
283/// A change the node saw between two beats (hub down, or locked).
284#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
285pub struct DriftEvent {
286    pub target: String,
287    pub at: String,
288    pub from: Option<String>,
289    pub to: Option<String>,
290}
291
292/// The outcome of one apply, reported on the next beat. The hub turns each into
293/// an audit row; heartbeats themselves write nothing.
294#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
295pub struct ApplyResult {
296    pub target: String,
297    pub at: String,
298    pub sha256: String,
299    pub ok: bool,
300    pub error: Option<String>,
301}
302
303#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
304pub struct HostInfo {
305    pub hostname: String,
306    pub os: String,
307    pub kernel: String,
308    pub arch: String,
309    pub version: String,
310    pub uptime_secs: u64,
311}
312
313/// The heartbeat. No IP address: the hub has the socket where it needs one.
314#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
315pub struct Beat {
316    pub host: HostInfo,
317    pub targets: Vec<TargetReport>,
318    #[serde(default)]
319    pub events: Vec<DriftEvent>,
320    #[serde(default)]
321    pub results: Vec<ApplyResult>,
322    /// Seconds the node is willing to wait for the hub to answer. The hub
323    /// holds the request open this long when it has nothing to say, so a vault
324    /// save reaches the node in about a second instead of one interval.
325    #[serde(default)]
326    pub wait_secs: u32,
327}
328
329/// Something the hub asks a node to do.
330#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
331#[serde(tag = "kind", rename_all = "snake_case")]
332pub enum Action {
333    /// Write these bytes to a push target whose `apply` is true. `sha256` is
334    /// what the node must recompute and compare before it writes anything.
335    Push {
336        target: String,
337        sha256: String,
338        content_b64: String,
339        /// The hub's signed statement that a human approved exactly these bytes
340        /// for this node and target (Phase 37). Absent when the node needs none.
341        #[serde(default, skip_serializing_if = "Option::is_none")]
342        approval: Option<SignedApproval>,
343    },
344    /// Send the content of a pull target once. Requested by a human.
345    Upload { target: String },
346}
347
348#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
349pub struct BeatReply {
350    pub interval_secs: u32,
351    /// True when the hub could not render (vault locked). Observe goes on; push
352    /// idles.
353    pub hub_locked: bool,
354    /// True when the hub recorded the `results` of this beat. A hub that cannot
355    /// (locked, so the audit chain is closed) says false and the node sends
356    /// them again; clearing them on a reply that did not keep them would lose
357    /// the only record that an apply happened.
358    #[serde(default)]
359    pub results_ack: bool,
360    pub actions: Vec<Action>,
361    /// Pushes the hub is holding for a human (Phase 37). Informational: the
362    /// node shows them, and nothing about them is a command.
363    #[serde(default)]
364    pub pending: Vec<PendingNote>,
365    /// The hub's approval-signing public key (Phase 37). A node pins the first
366    /// one it sees over its pinned TLS channel and refuses a different one.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub hub_pubkey: Option<String>,
369}
370
371// ── The node's own config ─────────────────────────────────────────────────────
372
373/// One `[[target]]` in the node's config. Everything executable is here, on
374/// this host, written by the operator. Nothing the hub sends can add to it.
375#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
376#[serde(deny_unknown_fields)]
377pub struct Target {
378    pub id: String,
379    pub path: PathBuf,
380    pub project: String,
381    pub exporter: String,
382    #[serde(default = "default_mode")]
383    pub mode: String,
384    /// Observe until deliberately flipped.
385    #[serde(default)]
386    pub apply: bool,
387    pub validate: Option<String>,
388    pub reload: Option<String>,
389    /// Refuse any push that does not carry the hub's signed approval of exactly
390    /// these bytes (Phase 37). Set here, on the host, so a hub bug or a hub
391    /// policy change cannot turn it off.
392    #[serde(default)]
393    pub require_approval: bool,
394}
395
396fn default_mode() -> String {
397    "push".into()
398}
399
400#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
401#[serde(deny_unknown_fields)]
402pub struct NodeConfig {
403    /// Seconds between beats when the hub has nothing to say. 10 to 3600.
404    pub interval_secs: Option<u32>,
405    #[serde(default, rename = "target")]
406    pub targets: Vec<Target>,
407    /// The public key (hex) of the device whose signature this node accepts as a
408    /// human's approval (Phase 37.1). When set, a push that needs approval must
409    /// carry a token signed by this key; the hub's own key no longer counts, so a
410    /// hub that has been taken over cannot approve its own pushes.
411    #[serde(default)]
412    pub approver: Option<String>,
413}
414
415/// `^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$`
416pub fn valid_id(s: &str) -> bool {
417    let b = s.as_bytes();
418    !b.is_empty()
419        && b.len() <= 63
420        && b[0].is_ascii_alphanumeric()
421        && b.iter()
422            .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'-'))
423}
424
425fn valid_command(s: &str) -> bool {
426    !s.trim().is_empty() && s.len() <= 512 && !s.contains('\0')
427}
428
429impl NodeConfig {
430    pub fn parse(text: &str) -> Result<Self, String> {
431        let cfg: NodeConfig = toml::from_str(text).map_err(|e| format!("node config: {e}"))?;
432        cfg.validate()?;
433        Ok(cfg)
434    }
435
436    pub fn validate(&self) -> Result<(), String> {
437        if let Some(i) = self.interval_secs {
438            if !(10..=3600).contains(&i) {
439                return Err("interval_secs must be between 10 and 3600".into());
440            }
441        }
442        if let Some(a) = &self.approver {
443            let ok = a.len() == 64
444                && hex::decode(a)
445                    .ok()
446                    .and_then(|b| <[u8; 32]>::try_from(b).ok())
447                    .is_some_and(|b| VerifyingKey::from_bytes(&b).is_ok());
448            if !ok {
449                return Err("approver must be a 64-character hex Ed25519 public key".into());
450            }
451        }
452        if self.targets.len() > MAX_TARGETS {
453            return Err(format!("at most {MAX_TARGETS} targets"));
454        }
455        let mut seen = HashSet::new();
456        for t in &self.targets {
457            if !valid_id(&t.id) {
458                return Err(format!(
459                    "target id '{}' must be letters, digits, '_', '.' or '-' (max 63)",
460                    t.id
461                ));
462            }
463            if !seen.insert(t.id.clone()) {
464                return Err(format!("target id '{}' is used twice", t.id));
465            }
466            // has_root: "/etc/x" is rooted but not absolute on Windows (no drive), and is still unambiguous on the current drive.
467            if !(t.path.is_absolute() || t.path.has_root()) {
468                return Err(format!("target '{}': path must be absolute", t.id));
469            }
470            if t.path.to_string_lossy().contains('\0') {
471                return Err(format!("target '{}': path contains NUL", t.id));
472            }
473            if t.project.trim().is_empty() {
474                return Err(format!("target '{}': project is empty", t.id));
475            }
476            if !valid_exporter(&t.exporter) {
477                return Err(format!(
478                    "target '{}': unknown exporter '{}'. Supported: {}",
479                    t.id,
480                    t.exporter,
481                    EXPORTERS
482                        .iter()
483                        .copied()
484                        .chain(crate::stack::adapters().iter().map(|a| a.id.as_str()))
485                        .collect::<Vec<_>>()
486                        .join(", ")
487                ));
488            }
489            if t.mode != "push" && t.mode != "pull" {
490                return Err(format!(
491                    "target '{}': mode must be \"push\" or \"pull\"",
492                    t.id
493                ));
494            }
495            // A pull target's file is the source of truth. Letting the hub
496            // write it would make "pull" mean its opposite.
497            if t.mode == "pull"
498                && (t.apply || t.validate.is_some() || t.reload.is_some() || t.require_approval)
499            {
500                return Err(format!(
501                    "target '{}': a pull target is only read, so apply, validate, reload and require_approval do not belong on it",
502                    t.id
503                ));
504            }
505            for (name, v) in [("validate", &t.validate), ("reload", &t.reload)] {
506                if let Some(c) = v {
507                    if !valid_command(c) {
508                        return Err(format!("target '{}': {name} is empty or too long", t.id));
509                    }
510                }
511            }
512        }
513        Ok(())
514    }
515}
516
517// ── The hub's registry ────────────────────────────────────────────────────────
518
519/// What the hub knows about one target on one node.
520#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
521pub struct TargetStatus {
522    pub id: String,
523    pub project: String,
524    pub exporter: String,
525    pub mode: String,
526    pub apply: bool,
527    pub reported_sha: Option<String>,
528    /// What the hub would write now. `None` when it could not render (locked,
529    /// unknown project, or the config check failed).
530    pub desired_sha: Option<String>,
531    /// For a pull target: the hash a human last accepted into the vault.
532    pub accepted_sha: Option<String>,
533    pub state: String,
534    pub error: Option<String>,
535    /// `in_sync`, `drift`, `pending`, `missing`, `changed`, `unreviewed`,
536    /// `unknown`, `refused`.
537    pub status: String,
538    /// Why the hub withheld a push, when `status` is `refused`.
539    pub refusal: Option<String>,
540}
541
542#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
543pub struct NodeRecord {
544    pub id: String,
545    pub name: String,
546    pub pubkey: String,
547    pub fingerprint: String,
548    /// Projects this node may be sent. Set by the operator when minting the
549    /// token; a node cannot widen it by declaring a target.
550    pub projects: Vec<String>,
551    pub enrolled_at: String,
552    pub last_seen: Option<String>,
553    /// Highest request timestamp accepted. Persisted so a restart does not
554    /// reopen the replay window.
555    pub last_ts_ms: i64,
556    pub revoked_at: Option<String>,
557    pub host: Option<HostInfo>,
558    pub targets: Vec<TargetStatus>,
559    /// `required` holds every push to this node for a human (Phase 37); empty or
560    /// `none` pushes as soon as a node's own config allows it.
561    #[serde(default)]
562    pub approval: String,
563    /// Set when the node listens and the hub dials it (Phase 34.1). Absent for a
564    /// node that dials the hub.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub listen: Option<ListenInfo>,
567    /// When the hub last polled this node, for a listening node.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub last_polled: Option<String>,
570}
571
572#[derive(Clone, Debug, Serialize, Deserialize)]
573struct TokenRecord {
574    /// SHA-256 of the token. The plaintext is shown once and never stored.
575    hash: String,
576    name: String,
577    projects: Vec<String>,
578    expires_at_secs: i64,
579    used: bool,
580}
581
582#[derive(Default, Serialize, Deserialize)]
583struct StoreFile {
584    #[serde(default)]
585    tokens: Vec<TokenRecord>,
586    #[serde(default)]
587    nodes: Vec<NodeRecord>,
588    #[serde(default)]
589    approvals: Vec<ApprovalRecord>,
590    /// The hub's transport signing seed for listening nodes (Phase 34.1), created
591    /// on first use. It lives here, not in the vault, so a locked hub can still
592    /// poll (observation continues); pushes still need the unlocked vault.
593    #[serde(default)]
594    hub_node_seed: String,
595    /// Devices whose signature counts as the owner's approval (Phase 37.1).
596    #[serde(default)]
597    approvers: Vec<Approver>,
598}
599
600#[derive(Debug, PartialEq)]
601pub enum AuthError {
602    UnknownNode,
603    Revoked,
604    BadSignature,
605    Skew,
606    Replay,
607}
608
609impl AuthError {
610    pub fn message(&self) -> &'static str {
611        match self {
612            AuthError::UnknownNode | AuthError::BadSignature => "Node authentication failed",
613            AuthError::Revoked => "This node has been revoked",
614            AuthError::Skew => "Request timestamp is outside the allowed clock skew",
615            AuthError::Replay => "Request timestamp is not newer than the last one accepted",
616        }
617    }
618}
619
620pub struct NodeStore {
621    path: PathBuf,
622    data: StoreFile,
623}
624
625fn token_hash(token: &str) -> String {
626    hex::encode(Sha256::digest(token.as_bytes()))
627}
628
629/// Writes `bytes` to `path` through a temp file, 0600 on Unix.
630fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
631    use std::io::Write;
632    if let Some(dir) = path.parent() {
633        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
634    }
635    let tmp = path.with_extension("json.tmp");
636    let mut opts = std::fs::OpenOptions::new();
637    opts.write(true).create(true).truncate(true);
638    #[cfg(unix)]
639    {
640        use std::os::unix::fs::OpenOptionsExt;
641        opts.mode(0o600);
642    }
643    let mut f = opts.open(&tmp).map_err(|e| e.to_string())?;
644    f.write_all(bytes).map_err(|e| e.to_string())?;
645    f.sync_all().map_err(|e| e.to_string())?;
646    drop(f);
647    std::fs::rename(&tmp, path).map_err(|e| e.to_string())
648}
649
650impl NodeStore {
651    /// Opens the registry, empty when the file does not exist. A file that
652    /// exists and does not parse is an error, not an empty registry: silently
653    /// forgetting every enrolled node would re-open every enrollment token.
654    pub fn open(path: &Path) -> Result<Self, String> {
655        let data = match std::fs::read_to_string(path) {
656            Ok(t) => serde_json::from_str(&t)
657                .map_err(|e| format!("{} is not a valid node registry: {e}", path.display()))?,
658            Err(e) if e.kind() == std::io::ErrorKind::NotFound => StoreFile::default(),
659            Err(e) => return Err(format!("{}: {e}", path.display())),
660        };
661        Ok(Self {
662            path: path.to_path_buf(),
663            data,
664        })
665    }
666
667    fn save(&self) -> Result<(), String> {
668        let text = serde_json::to_vec_pretty(&self.data).map_err(|e| e.to_string())?;
669        write_private(&self.path, &text)
670    }
671
672    /// Mints a single-use enrollment token. Returns the plaintext once.
673    pub fn mint_token(
674        &mut self,
675        name: &str,
676        projects: Vec<String>,
677        ttl_secs: i64,
678        now_secs: i64,
679    ) -> Result<(String, i64), String> {
680        if !valid_id(name) {
681            return Err("node name must be letters, digits, '_', '.' or '-' (max 63)".into());
682        }
683        if projects.is_empty() {
684            return Err("name at least one project this node may receive".into());
685        }
686        if self
687            .data
688            .nodes
689            .iter()
690            .any(|n| n.name == name && n.revoked_at.is_none())
691        {
692            return Err(format!("a node named '{name}' is already enrolled"));
693        }
694        let ttl = ttl_secs.clamp(30, 7 * 86_400);
695        let mut raw = [0u8; 32];
696        rand::thread_rng().fill_bytes(&mut raw);
697        let token = format!("envn_{}", hex::encode(raw));
698        let expires = now_secs + ttl;
699        // Expired and spent tokens are dead weight; dropping them here keeps
700        // the file from growing with every enrollment ever attempted.
701        self.data
702            .tokens
703            .retain(|t| !t.used && t.expires_at_secs > now_secs);
704        self.data.tokens.push(TokenRecord {
705            hash: token_hash(&token),
706            name: name.to_string(),
707            projects,
708            expires_at_secs: expires,
709            used: false,
710        });
711        self.save()?;
712        Ok((token, expires))
713    }
714
715    /// Spends a token and registers the node's public key.
716    pub fn enroll(
717        &mut self,
718        token: &str,
719        pubkey_hex: &str,
720        listen: Option<ListenInfo>,
721        now_secs: i64,
722        now_iso: &str,
723    ) -> Result<NodeRecord, String> {
724        if let Some(l) = &listen {
725            l.validate()?;
726        }
727        let fingerprint = key_fingerprint(pubkey_hex)?;
728        // A point that is not on the curve can never verify, and storing it
729        // would make the node permanently unable to authenticate.
730        let raw: [u8; 32] = hex::decode(pubkey_hex)
731            .map_err(|_| "public key is not hex".to_string())?
732            .try_into()
733            .map_err(|_| "public key must be 32 bytes".to_string())?;
734        VerifyingKey::from_bytes(&raw).map_err(|_| "public key is not a valid Ed25519 key")?;
735
736        let h = token_hash(token);
737        let rec = self
738            .data
739            .tokens
740            .iter_mut()
741            .find(|t| t.hash == h)
742            .ok_or("Invalid or expired enrollment token")?;
743        // One message for every way a token can be bad: which one it was is
744        // information for whoever is guessing.
745        if rec.used || rec.expires_at_secs <= now_secs {
746            return Err("Invalid or expired enrollment token".into());
747        }
748        rec.used = true;
749        let node = NodeRecord {
750            id: crate::new_uuid(),
751            name: rec.name.clone(),
752            pubkey: pubkey_hex.to_ascii_lowercase(),
753            fingerprint,
754            projects: rec.projects.clone(),
755            enrolled_at: now_iso.to_string(),
756            last_seen: None,
757            last_ts_ms: 0,
758            revoked_at: None,
759            host: None,
760            targets: Vec::new(),
761            approval: String::new(),
762            listen,
763            last_polled: None,
764        };
765        self.data.nodes.push(node.clone());
766        self.save()?;
767        Ok(node)
768    }
769
770    /// Checks a signed request and, on success, advances the node's replay mark.
771    pub fn authenticate(
772        &mut self,
773        node_id: &str,
774        method: &str,
775        path: &str,
776        ts_ms: i64,
777        body: &[u8],
778        sig_hex: &str,
779        now_ms: i64,
780    ) -> Result<NodeRecord, AuthError> {
781        let node = self
782            .data
783            .nodes
784            .iter_mut()
785            .find(|n| n.id == node_id)
786            .ok_or(AuthError::UnknownNode)?;
787        // Signature first: skew, replay and revocation are only worth telling
788        // to somebody who holds the key.
789        if !verify_request(&node.pubkey, method, path, ts_ms, body, sig_hex) {
790            return Err(AuthError::BadSignature);
791        }
792        if node.revoked_at.is_some() {
793            return Err(AuthError::Revoked);
794        }
795        if (now_ms - ts_ms).abs() > MAX_CLOCK_SKEW_SECS * 1000 {
796            return Err(AuthError::Skew);
797        }
798        if ts_ms <= node.last_ts_ms {
799            return Err(AuthError::Replay);
800        }
801        node.last_ts_ms = ts_ms;
802        let out = node.clone();
803        // Best effort: failing to persist the mark must not drop a beat, but
804        // the in-memory mark still stops replays until a restart.
805        let _ = self.save();
806        Ok(out)
807    }
808
809    /// The hub's transport key for listening nodes as `(seed, public)`, made on
810    /// first use.
811    pub fn hub_node_key(&mut self) -> Result<(String, String), String> {
812        if self.data.hub_node_seed.is_empty() {
813            self.data.hub_node_seed = generate_hub_seed();
814            self.save()?;
815        }
816        let public = hub_public(&self.data.hub_node_seed)?;
817        Ok((self.data.hub_node_seed.clone(), public))
818    }
819
820    /// Active nodes the hub has to dial.
821    pub fn listening(&self) -> Vec<NodeRecord> {
822        self.data
823            .nodes
824            .iter()
825            .filter(|n| n.listen.is_some() && n.revoked_at.is_none())
826            .cloned()
827            .collect()
828    }
829
830    /// Notes that the hub polled `id` just now.
831    pub fn mark_polled(&mut self, id: &str, now_iso: &str) {
832        if let Some(n) = self.data.nodes.iter_mut().find(|n| n.id == id) {
833            n.last_polled = Some(now_iso.to_string());
834            let _ = self.save();
835        }
836    }
837
838    pub fn list(&self) -> Vec<NodeRecord> {
839        self.data.nodes.clone()
840    }
841
842    pub fn get(&self, id: &str) -> Option<NodeRecord> {
843        self.data.nodes.iter().find(|n| n.id == id).cloned()
844    }
845
846    /// Finds an active node by name or id.
847    pub fn find(&self, name_or_id: &str) -> Option<NodeRecord> {
848        self.data
849            .nodes
850            .iter()
851            .find(|n| n.revoked_at.is_none() && (n.id == name_or_id || n.name == name_or_id))
852            .cloned()
853    }
854
855    pub fn revoke(&mut self, id: &str, now_iso: &str) -> Result<NodeRecord, String> {
856        let n = self
857            .data
858            .nodes
859            .iter_mut()
860            .find(|n| n.id == id)
861            .ok_or("No such node")?;
862        if n.revoked_at.is_none() {
863            n.revoked_at = Some(now_iso.to_string());
864        }
865        let out = n.clone();
866        self.save()?;
867        Ok(out)
868    }
869
870    /// Replaces a node's observed state from a beat. `desired` is what the hub
871    /// would write for each target id (`Ok(sha)`), or why it will not (`Err`).
872    pub fn record_beat(
873        &mut self,
874        id: &str,
875        beat: &Beat,
876        desired: &dyn Fn(&TargetReport) -> Result<String, String>,
877        now_iso: &str,
878    ) -> Result<(), String> {
879        let now_secs = unix_now();
880        let approvals: Vec<ApprovalRecord> = self.data.approvals.clone();
881        let node = self
882            .data
883            .nodes
884            .iter_mut()
885            .find(|n| n.id == id)
886            .ok_or("No such node")?;
887        if beat.targets.len() > MAX_TARGETS {
888            return Err(format!("at most {MAX_TARGETS} targets"));
889        }
890        let accepted: Vec<(String, String)> = node
891            .targets
892            .iter()
893            .filter_map(|t| t.accepted_sha.clone().map(|a| (t.id.clone(), a)))
894            .collect();
895        let requires_approval = matches!(node.approval.as_str(), "required" | "device");
896        let mut out = Vec::new();
897        for r in &beat.targets {
898            if !valid_id(&r.id) || !valid_exporter(&r.exporter) {
899                continue;
900            }
901            let accepted_sha = accepted
902                .iter()
903                .find(|(i, _)| i == &r.id)
904                .map(|(_, a)| a.clone());
905            let (desired_sha, refusal) = if r.mode == "push" {
906                if !node.projects.iter().any(|p| p == &r.project) {
907                    (
908                        None,
909                        Some(format!(
910                            "node '{}' was not enrolled for project '{}'",
911                            node.name, r.project
912                        )),
913                    )
914                } else {
915                    match desired(r) {
916                        Ok(s) => (Some(s), None),
917                        Err(e) if e == LOCKED => (None, None),
918                        Err(e) => (None, Some(e)),
919                    }
920                }
921            } else {
922                (None, None)
923            };
924            let mut status =
925                derive_status(r, desired_sha.as_deref(), accepted_sha.as_deref(), &refusal);
926            // A push that is ready to go is held for a human when the node needs one.
927            if status == "pending" && requires_approval {
928                if let Some(d) = desired_sha.as_deref() {
929                    status = approval_status(&approvals, id, &r.id, d, now_secs).into();
930                }
931            }
932            out.push(TargetStatus {
933                id: r.id.clone(),
934                project: r.project.clone(),
935                exporter: r.exporter.clone(),
936                mode: r.mode.clone(),
937                apply: r.apply,
938                reported_sha: r.sha256.clone(),
939                desired_sha,
940                accepted_sha,
941                state: r.state.clone(),
942                error: r.error.clone(),
943                status,
944                refusal,
945            });
946        }
947        node.targets = out;
948        node.host = Some(beat.host.clone());
949        node.last_seen = Some(now_iso.to_string());
950        // An approved push whose bytes the node now reports has done its job; one
951        // nobody acted on in time, or a request nobody answered, lapses.
952        for a in self.data.approvals.iter_mut().filter(|a| a.node_id == id) {
953            let applied = beat
954                .targets
955                .iter()
956                .any(|r| r.id == a.target && r.sha256.as_deref() == Some(a.sha256.as_str()));
957            match a.status.as_str() {
958                "approved" if applied => a.status = "consumed".into(),
959                "approved" if now_secs >= a.decided_secs.unwrap_or(0) + APPROVAL_TTL_SECS => {
960                    a.status = "expired".into()
961                }
962                "pending" if now_secs >= a.requested_secs + PENDING_TTL_SECS => {
963                    a.status = "expired".into()
964                }
965                _ => {}
966            }
967        }
968        self.save()
969    }
970
971    /// Records the hash a human accepted into the vault for a pull target.
972    pub fn accept(&mut self, id: &str, target: &str) -> Result<String, String> {
973        let node = self
974            .data
975            .nodes
976            .iter_mut()
977            .find(|n| n.id == id)
978            .ok_or("No such node")?;
979        let t = node
980            .targets
981            .iter_mut()
982            .find(|t| t.id == target)
983            .ok_or("No such target on this node")?;
984        if t.mode != "pull" {
985            return Err("Only a pull target has something to accept".into());
986        }
987        let sha = t
988            .reported_sha
989            .clone()
990            .ok_or("The file does not exist on the node, so there is nothing to accept")?;
991        t.accepted_sha = Some(sha.clone());
992        t.status = "in_sync".into();
993        self.save()?;
994        Ok(sha)
995    }
996}
997
998/// The one place a target's status word is decided.
999pub fn derive_status(
1000    r: &TargetReport,
1001    desired: Option<&str>,
1002    accepted: Option<&str>,
1003    refusal: &Option<String>,
1004) -> String {
1005    if r.mode == "pull" {
1006        return match (r.sha256.as_deref(), accepted) {
1007            (None, _) => "missing",
1008            (Some(_), None) => "unreviewed",
1009            (Some(a), Some(b)) if a == b => "in_sync",
1010            _ => "changed",
1011        }
1012        .into();
1013    }
1014    if refusal.is_some() {
1015        return "refused".into();
1016    }
1017    match (r.sha256.as_deref(), desired) {
1018        (_, None) => "unknown",
1019        (None, Some(_)) => {
1020            if r.apply {
1021                "pending"
1022            } else {
1023                "missing"
1024            }
1025        }
1026        (Some(a), Some(d)) if a == d => "in_sync",
1027        (Some(_), Some(_)) => {
1028            if r.apply {
1029                "pending"
1030            } else {
1031                "drift"
1032            }
1033        }
1034    }
1035    .into()
1036}
1037
1038// ── Approval (Phase 37, ADR-0143) ─────────────────────────────────────────────
1039
1040/// How long an approved push stays valid, and so how long its signed token does.
1041pub const APPROVAL_TTL_SECS: i64 = 3600;
1042/// How long a request waits for a human before it lapses.
1043pub const PENDING_TTL_SECS: i64 = 7 * 86_400;
1044
1045fn unix_now() -> i64 {
1046    std::time::SystemTime::now()
1047        .duration_since(std::time::UNIX_EPOCH)
1048        .map_or(0, |d| d.as_secs() as i64)
1049}
1050
1051/// A request for a human to say yes to these exact bytes for this node target.
1052/// Holds hashes and snapshot numbers, never content: the content is in the
1053/// config history, where the human reads it as a diff.
1054#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1055pub struct ApprovalRecord {
1056    pub id: String,
1057    pub node_id: String,
1058    pub target: String,
1059    pub project: String,
1060    pub exporter: String,
1061    /// SHA-256 of the proposed file. The approval is for this and nothing else.
1062    pub sha256: String,
1063    /// What the node reported having when the request was made.
1064    pub from_sha: Option<String>,
1065    /// History snapshot of the file the node has (when known) and of the proposal.
1066    pub from_seq: Option<i64>,
1067    pub to_seq: Option<i64>,
1068    pub requested_at: String,
1069    pub requested_secs: i64,
1070    /// `pending`, `approved`, `rejected`, `consumed`, `expired`.
1071    pub status: String,
1072    pub decided_at: Option<String>,
1073    pub decided_secs: Option<i64>,
1074    pub decided_by: Option<String>,
1075    /// Set when the yes was signed on the owner's own device (Phase 37.1); the
1076    /// hub then sends exactly this and never signs an approval itself.
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub signed: Option<SignedApproval>,
1079}
1080
1081/// A device whose signature counts as the owner's approval (Phase 37.1, ADR-0147).
1082#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1083pub struct Approver {
1084    pub pubkey: String,
1085    pub fingerprint: String,
1086    pub label: String,
1087    pub added: String,
1088}
1089
1090/// A held push, as told to the node.
1091#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1092pub struct PendingNote {
1093    pub target: String,
1094    pub sha256: String,
1095    pub status: String,
1096    pub approval_id: String,
1097}
1098
1099/// What the hub signs when a human approves. Canonical JSON is signed as text,
1100/// so there is nothing to re-serialise on the verifying side.
1101#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1102pub struct ApprovalToken {
1103    pub v: u8,
1104    pub node_id: String,
1105    pub target: String,
1106    pub sha256: String,
1107    pub approval_id: String,
1108    pub approved_by: String,
1109    pub approved_at: String,
1110    pub expires_secs: i64,
1111    pub nonce: String,
1112}
1113
1114#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
1115pub struct SignedApproval {
1116    /// The token as JSON text; the signature covers exactly these bytes.
1117    pub token: String,
1118    pub sig: String,
1119}
1120
1121fn approval_input(token_json: &str) -> Vec<u8> {
1122    format!("envv-approval-v1\n{token_json}").into_bytes()
1123}
1124
1125/// The public key for a hub's approval-signing seed.
1126pub fn hub_public(seed_hex: &str) -> Result<String, String> {
1127    let seed: [u8; 32] = hex::decode(seed_hex)
1128        .map_err(|_| "hub key is not hex".to_string())?
1129        .try_into()
1130        .map_err(|_| "hub key must be 32 bytes".to_string())?;
1131    Ok(hex::encode(
1132        SigningKey::from_bytes(&seed).verifying_key().to_bytes(),
1133    ))
1134}
1135
1136/// A fresh hub approval seed, hex.
1137pub fn generate_hub_seed() -> String {
1138    let mut seed = [0u8; 32];
1139    rand::thread_rng().fill_bytes(&mut seed);
1140    hex::encode(seed)
1141}
1142
1143/// The hub's approval-signing seed, created on first use. It lives in the vault's
1144/// own encrypted `vault_meta` (as the unique-ID registry's secrets do), so a
1145/// copy of `nodes.json` is not enough to forge an approval.
1146pub fn hub_seed(conn: &rusqlite::Connection) -> Result<String, String> {
1147    use rusqlite::OptionalExtension;
1148    let have: Option<String> = conn
1149        .query_row(
1150            "SELECT value FROM vault_meta WHERE key = 'node_hub_seed'",
1151            [],
1152            |r| r.get(0),
1153        )
1154        .optional()
1155        .map_err(|e| e.to_string())?;
1156    if let Some(s) = have {
1157        return Ok(s);
1158    }
1159    let seed = generate_hub_seed();
1160    conn.execute(
1161        "INSERT OR IGNORE INTO vault_meta (key, value) VALUES ('node_hub_seed', ?1)",
1162        [&seed],
1163    )
1164    .map_err(|e| e.to_string())?;
1165    // Two first uses can race; whichever insert won is the key.
1166    conn.query_row(
1167        "SELECT value FROM vault_meta WHERE key = 'node_hub_seed'",
1168        [],
1169        |r| r.get(0),
1170    )
1171    .map_err(|e| e.to_string())
1172}
1173
1174pub fn sign_approval(seed_hex: &str, token: &ApprovalToken) -> Result<SignedApproval, String> {
1175    let seed: [u8; 32] = hex::decode(seed_hex)
1176        .map_err(|_| "hub key is not hex".to_string())?
1177        .try_into()
1178        .map_err(|_| "hub key must be 32 bytes".to_string())?;
1179    let json = serde_json::to_string(token).map_err(|e| e.to_string())?;
1180    let sig = SigningKey::from_bytes(&seed).sign(&approval_input(&json));
1181    Ok(SignedApproval {
1182        token: json,
1183        sig: hex::encode(sig.to_bytes()),
1184    })
1185}
1186
1187/// The owner device's approval seed in `dir/approver.key`, made on first use
1188/// (0600). One file per machine, shared by the CLI and the desktop app.
1189pub fn approver_seed(dir: &Path) -> Result<String, String> {
1190    let path = dir.join("approver.key");
1191    if let Ok(t) = std::fs::read_to_string(&path) {
1192        let t = t.trim().to_string();
1193        hub_public(&t).map_err(|e| format!("{}: {e}", path.display()))?;
1194        return Ok(t);
1195    }
1196    let seed = generate_hub_seed();
1197    std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
1198    // Written through the same private-file path as the registry.
1199    let tmp = path.with_extension("key.tmp");
1200    {
1201        use std::io::Write;
1202        let mut o = std::fs::OpenOptions::new();
1203        o.write(true).create_new(true);
1204        #[cfg(unix)]
1205        {
1206            use std::os::unix::fs::OpenOptionsExt;
1207            o.mode(0o600);
1208        }
1209        let _ = std::fs::remove_file(&tmp);
1210        let mut f = o.open(&tmp).map_err(|e| e.to_string())?;
1211        f.write_all(seed.as_bytes()).map_err(|e| e.to_string())?;
1212        f.sync_all().map_err(|e| e.to_string())?;
1213    }
1214    // A second process may have won the race; keep whichever landed first.
1215    if path.exists() {
1216        let _ = std::fs::remove_file(&tmp);
1217        return approver_seed(dir);
1218    }
1219    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
1220    Ok(seed)
1221}
1222
1223/// Where the CLI keeps the device key: the directory the desktop app uses too.
1224pub fn default_approver_dir() -> Option<PathBuf> {
1225    dirs::data_dir().map(|d| d.join("io.unenverse"))
1226}
1227
1228/// Signs an approval on the owner's device for exactly one request. The lifetime
1229/// is the hub's own ([`APPROVAL_TTL_SECS`]); the nonce is fresh.
1230pub fn sign_device_approval(
1231    seed_hex: &str,
1232    node_id: &str,
1233    target: &str,
1234    sha256: &str,
1235    approval_id: &str,
1236    now_secs: i64,
1237    now_iso: &str,
1238) -> Result<SignedApproval, String> {
1239    let fp = key_fingerprint(&hub_public(seed_hex)?)?;
1240    sign_approval(
1241        seed_hex,
1242        &ApprovalToken {
1243            v: 1,
1244            node_id: node_id.into(),
1245            target: target.into(),
1246            sha256: sha256.into(),
1247            approval_id: approval_id.into(),
1248            approved_by: format!("device:{}", &fp[..12]),
1249            approved_at: now_iso.into(),
1250            expires_secs: now_secs + APPROVAL_TTL_SECS,
1251            nonce: crate::new_uuid(),
1252        },
1253    )
1254}
1255
1256/// Checks a token the way a node does before it writes: the signature is the
1257/// hub's, it names this node, this target and exactly this hash, and it has not
1258/// expired. Returns the token so the caller can remember its nonce.
1259pub fn verify_approval(
1260    hub_public_hex: &str,
1261    signed: &SignedApproval,
1262    node_id: &str,
1263    target: &str,
1264    sha256: &str,
1265    now_secs: i64,
1266) -> Result<ApprovalToken, String> {
1267    let pk: [u8; 32] = hex::decode(hub_public_hex)
1268        .map_err(|_| "hub key is not hex".to_string())?
1269        .try_into()
1270        .map_err(|_| "hub key must be 32 bytes".to_string())?;
1271    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| "hub key is not a valid Ed25519 key")?;
1272    let sig: [u8; 64] = hex::decode(&signed.sig)
1273        .map_err(|_| "approval signature is not hex".to_string())?
1274        .try_into()
1275        .map_err(|_| "approval signature has the wrong length".to_string())?;
1276    vk.verify(&approval_input(&signed.token), &Signature::from_bytes(&sig))
1277        .map_err(|_| "approval signature does not verify")?;
1278    let t: ApprovalToken =
1279        serde_json::from_str(&signed.token).map_err(|e| format!("approval is unreadable: {e}"))?;
1280    if t.v != 1 {
1281        return Err("unknown approval version".into());
1282    }
1283    if t.node_id != node_id {
1284        return Err("approval is for a different node".into());
1285    }
1286    if t.target != target {
1287        return Err("approval is for a different target".into());
1288    }
1289    if t.sha256 != sha256 {
1290        return Err("approval is for different bytes than the ones sent".into());
1291    }
1292    if now_secs >= t.expires_secs {
1293        return Err("approval has expired".into());
1294    }
1295    Ok(t)
1296}
1297
1298/// The status word for a push that is ready but may be held.
1299fn approval_status(
1300    approvals: &[ApprovalRecord],
1301    node_id: &str,
1302    target: &str,
1303    sha: &str,
1304    now_secs: i64,
1305) -> &'static str {
1306    match latest_approval(approvals, node_id, target, sha) {
1307        Some(a)
1308            if a.status == "approved"
1309                && now_secs < a.decided_secs.unwrap_or(0) + APPROVAL_TTL_SECS =>
1310        {
1311            "pending"
1312        }
1313        Some(a) if a.status == "rejected" => "rejected",
1314        _ => "awaiting_approval",
1315    }
1316}
1317
1318fn latest_approval<'a>(
1319    approvals: &'a [ApprovalRecord],
1320    node_id: &str,
1321    target: &str,
1322    sha: &str,
1323) -> Option<&'a ApprovalRecord> {
1324    approvals
1325        .iter()
1326        .rev()
1327        .find(|a| a.node_id == node_id && a.target == target && a.sha256 == sha)
1328}
1329
1330impl NodeStore {
1331    /// Sets whether every push to a node is held for a human.
1332    pub fn set_approval_policy(&mut self, id: &str, policy: &str) -> Result<NodeRecord, String> {
1333        if !matches!(policy, "required" | "none" | "device") {
1334            return Err("approval must be \"required\", \"device\" or \"none\"".into());
1335        }
1336        if policy == "device" && self.data.approvers.is_empty() {
1337            return Err(
1338                "Register an approver device first (`unv node approver register`); \
1339                 otherwise nothing could ever approve this node's pushes."
1340                    .into(),
1341            );
1342        }
1343        let n = self
1344            .data
1345            .nodes
1346            .iter_mut()
1347            .find(|n| n.id == id && n.revoked_at.is_none())
1348            .ok_or("No such node")?;
1349        n.approval = if policy == "none" {
1350            String::new()
1351        } else {
1352            policy.to_string()
1353        };
1354        let out = n.clone();
1355        self.save()?;
1356        Ok(out)
1357    }
1358
1359    /// The approval for exactly these bytes, if there is one and it can still be
1360    /// used: approved, within its lifetime, for an active node.
1361    pub fn usable_approval(
1362        &self,
1363        node_id: &str,
1364        target: &str,
1365        sha: &str,
1366        now_secs: i64,
1367    ) -> Option<ApprovalRecord> {
1368        let node_ok = self
1369            .data
1370            .nodes
1371            .iter()
1372            .any(|n| n.id == node_id && n.revoked_at.is_none());
1373        latest_approval(&self.data.approvals, node_id, target, sha)
1374            .filter(|a| {
1375                node_ok
1376                    && a.status == "approved"
1377                    && now_secs < a.decided_secs.unwrap_or(0) + APPROVAL_TTL_SECS
1378            })
1379            .cloned()
1380    }
1381
1382    /// The newest request for these bytes in any state.
1383    pub fn approval_for(&self, node_id: &str, target: &str, sha: &str) -> Option<ApprovalRecord> {
1384        latest_approval(&self.data.approvals, node_id, target, sha).cloned()
1385    }
1386
1387    /// Opens a request for a human, unless one for the same bytes is already
1388    /// open, decided, or spent. A consumed or expired request for the same bytes
1389    /// (a revert) opens a fresh one: an old yes does not cover a later push.
1390    #[allow(clippy::too_many_arguments)]
1391    pub fn request_approval(
1392        &mut self,
1393        node_id: &str,
1394        target: &TargetReport,
1395        sha: &str,
1396        from_seq: Option<i64>,
1397        to_seq: Option<i64>,
1398        now_secs: i64,
1399        now_iso: &str,
1400    ) -> Result<ApprovalRecord, String> {
1401        if let Some(a) = latest_approval(&self.data.approvals, node_id, &target.id, sha) {
1402            if matches!(a.status.as_str(), "pending" | "approved" | "rejected") {
1403                return Ok(a.clone());
1404            }
1405        }
1406        // Bound the table: spent requests are history the audit chain keeps.
1407        self.data.approvals.retain(|a| {
1408            matches!(a.status.as_str(), "pending" | "approved")
1409                || now_secs - a.requested_secs < 7 * 86_400
1410        });
1411        let rec = ApprovalRecord {
1412            id: crate::new_uuid(),
1413            node_id: node_id.to_string(),
1414            target: target.id.clone(),
1415            project: target.project.clone(),
1416            exporter: target.exporter.clone(),
1417            sha256: sha.to_string(),
1418            from_sha: target.sha256.clone(),
1419            from_seq,
1420            to_seq,
1421            requested_at: now_iso.to_string(),
1422            requested_secs: now_secs,
1423            status: "pending".into(),
1424            decided_at: None,
1425            decided_secs: None,
1426            decided_by: None,
1427            signed: None,
1428        };
1429        self.data.approvals.push(rec.clone());
1430        self.save()?;
1431        Ok(rec)
1432    }
1433
1434    /// A human's answer. Only a pending request can be decided: an approval is
1435    /// never revived, and a decision on stale bytes is a decision on a request
1436    /// the hub no longer asks for.
1437    pub fn decide(
1438        &mut self,
1439        approval_id: &str,
1440        approve: bool,
1441        by: &str,
1442        now_secs: i64,
1443        now_iso: &str,
1444    ) -> Result<ApprovalRecord, String> {
1445        let a = self
1446            .data
1447            .approvals
1448            .iter_mut()
1449            .find(|a| a.id == approval_id)
1450            .ok_or("No such approval request")?;
1451        if a.status != "pending" {
1452            return Err(format!("That request is already {}", a.status));
1453        }
1454        if now_secs >= a.requested_secs + PENDING_TTL_SECS {
1455            a.status = "expired".into();
1456            let _ = self.save();
1457            return Err("That request has expired; the next beat opens a new one".into());
1458        }
1459        a.status = if approve { "approved" } else { "rejected" }.into();
1460        a.decided_at = Some(now_iso.to_string());
1461        a.decided_secs = Some(now_secs);
1462        a.decided_by = Some(by.to_string());
1463        let out = a.clone();
1464        self.save()?;
1465        Ok(out)
1466    }
1467
1468    /// Registers a device whose signature counts as the owner's approval.
1469    pub fn add_approver(
1470        &mut self,
1471        pubkey_hex: &str,
1472        label: &str,
1473        now_iso: &str,
1474    ) -> Result<Approver, String> {
1475        let raw: [u8; 32] = hex::decode(pubkey_hex)
1476            .map_err(|_| "public key is not hex".to_string())?
1477            .try_into()
1478            .map_err(|_| "public key must be 32 bytes".to_string())?;
1479        VerifyingKey::from_bytes(&raw).map_err(|_| "public key is not a valid Ed25519 key")?;
1480        let label = label.trim();
1481        if label.is_empty() || label.len() > 64 || label.chars().any(char::is_control) {
1482            return Err("give the device a short label (up to 64 characters)".into());
1483        }
1484        let pubkey = pubkey_hex.to_ascii_lowercase();
1485        if let Some(a) = self.data.approvers.iter().find(|a| a.pubkey == pubkey) {
1486            return Ok(a.clone());
1487        }
1488        if self.data.approvers.len() >= 16 {
1489            return Err("at most 16 approver devices".into());
1490        }
1491        let a = Approver {
1492            fingerprint: key_fingerprint(&pubkey)?,
1493            pubkey,
1494            label: label.to_string(),
1495            added: now_iso.to_string(),
1496        };
1497        self.data.approvers.push(a.clone());
1498        self.save()?;
1499        Ok(a)
1500    }
1501
1502    /// Removes a device by (a prefix of at least 8 characters of) its fingerprint.
1503    /// Nodes set to `device` approval lose their only approver if this was the
1504    /// last one: they fall back to `required`, so nothing is left unapprovable and
1505    /// nothing silently becomes approvable by the hub alone.
1506    pub fn remove_approver(&mut self, fp_prefix: &str) -> Result<Approver, String> {
1507        if fp_prefix.len() < 8 {
1508            return Err("name at least 8 characters of the fingerprint".into());
1509        }
1510        let hits: Vec<usize> = self
1511            .data
1512            .approvers
1513            .iter()
1514            .enumerate()
1515            .filter(|(_, a)| a.fingerprint.starts_with(fp_prefix))
1516            .map(|(i, _)| i)
1517            .collect();
1518        let [i] = hits[..] else {
1519            return Err(if hits.is_empty() {
1520                "No such approver".into()
1521            } else {
1522                "That prefix fits more than one approver".into()
1523            });
1524        };
1525        let gone = self.data.approvers.remove(i);
1526        if self.data.approvers.is_empty() {
1527            for n in self
1528                .data
1529                .nodes
1530                .iter_mut()
1531                .filter(|n| n.approval == "device")
1532            {
1533                n.approval = "required".into();
1534            }
1535        }
1536        self.save()?;
1537        Ok(gone)
1538    }
1539
1540    pub fn approvers(&self) -> Vec<Approver> {
1541        self.data.approvers.clone()
1542    }
1543
1544    /// A human's yes, signed on their own device. The hub holds no key that could
1545    /// have made it: it checks the signature against the registered devices, that
1546    /// the token names exactly this request (node, target, bytes, request id), and
1547    /// that its lifetime is no longer than the hub's own.
1548    pub fn decide_signed(
1549        &mut self,
1550        approval_id: &str,
1551        signed: SignedApproval,
1552        now_secs: i64,
1553        now_iso: &str,
1554    ) -> Result<ApprovalRecord, String> {
1555        let keys: Vec<(String, String)> = self
1556            .data
1557            .approvers
1558            .iter()
1559            .map(|a| (a.pubkey.clone(), a.fingerprint.clone()))
1560            .collect();
1561        let rec = self
1562            .data
1563            .approvals
1564            .iter_mut()
1565            .find(|a| a.id == approval_id)
1566            .ok_or("No such approval request")?;
1567        if rec.status != "pending" {
1568            return Err(format!("That request is already {}", rec.status));
1569        }
1570        if now_secs >= rec.requested_secs + PENDING_TTL_SECS {
1571            rec.status = "expired".into();
1572            let _ = self.save();
1573            return Err("That request has expired; the next beat opens a new one".into());
1574        }
1575        let mut by = None;
1576        let mut last_err = "no approver device is registered".to_string();
1577        for (pk, fp) in &keys {
1578            match verify_approval(
1579                pk,
1580                &signed,
1581                &rec.node_id,
1582                &rec.target,
1583                &rec.sha256,
1584                now_secs,
1585            ) {
1586                Ok(t) => {
1587                    if t.approval_id != rec.id {
1588                        last_err = "approval is for a different request".into();
1589                        continue;
1590                    }
1591                    if t.expires_secs > now_secs + APPROVAL_TTL_SECS + 60 {
1592                        last_err = "approval lasts longer than the hub allows".into();
1593                        continue;
1594                    }
1595                    if t.nonce.is_empty() {
1596                        last_err = "approval has no nonce".into();
1597                        continue;
1598                    }
1599                    by = Some(format!("device:{}", &fp[..12]));
1600                    break;
1601                }
1602                Err(e) => last_err = e,
1603            }
1604        }
1605        let Some(by) = by else {
1606            return Err(format!("Not accepted: {last_err}"));
1607        };
1608        rec.status = "approved".into();
1609        rec.decided_at = Some(now_iso.to_string());
1610        rec.decided_secs = Some(now_secs);
1611        rec.decided_by = Some(by);
1612        rec.signed = Some(signed);
1613        let out = rec.clone();
1614        self.save()?;
1615        Ok(out)
1616    }
1617
1618    /// Requests, newest first; for one node or all.
1619    pub fn approvals(&self, node_id: Option<&str>) -> Vec<ApprovalRecord> {
1620        let mut v: Vec<ApprovalRecord> = self
1621            .data
1622            .approvals
1623            .iter()
1624            .filter(|a| node_id.is_none_or(|n| a.node_id == n))
1625            .cloned()
1626            .collect();
1627        v.reverse();
1628        v
1629    }
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634    use super::*;
1635
1636    fn scratch(tag: &str) -> PathBuf {
1637        let n = std::time::SystemTime::now()
1638            .duration_since(std::time::UNIX_EPOCH)
1639            .unwrap()
1640            .as_nanos();
1641        let d = std::env::temp_dir().join(format!("unv-nodes-{tag}-{n}"));
1642        std::fs::create_dir_all(&d).unwrap();
1643        d
1644    }
1645
1646    fn enrolled() -> (NodeStore, NodeRecord, String, PathBuf) {
1647        let dir = scratch("store");
1648        let mut s = NodeStore::open(&dir.join("nodes.json")).unwrap();
1649        let (seed, public) = generate_identity();
1650        let (tok, _) = s
1651            .mint_token("vps-01", vec!["edge".into()], 900, 1_000)
1652            .unwrap();
1653        let n = s
1654            .enroll(&tok, &public, None, 1_001, "2026-10-08T00:00:00Z")
1655            .unwrap();
1656        (s, n, seed, dir)
1657    }
1658
1659    fn report(mode: &str, apply: bool, sha: Option<&str>) -> TargetReport {
1660        TargetReport {
1661            id: "nginx-main".into(),
1662            project: "edge".into(),
1663            exporter: "nginx".into(),
1664            mode: mode.into(),
1665            apply,
1666            sha256: sha.map(String::from),
1667            state: "ok".into(),
1668            error: None,
1669        }
1670    }
1671
1672    #[test]
1673    fn a_signature_covers_method_path_time_and_body() {
1674        let (seed, public) = generate_identity();
1675        let sig = sign_request(&seed, "POST", "/api/nodes/beat", 5, b"{}").unwrap();
1676        assert!(verify_request(
1677            &public,
1678            "post",
1679            "/api/nodes/beat",
1680            5,
1681            b"{}",
1682            &sig
1683        ));
1684        assert!(!verify_request(
1685            &public,
1686            "POST",
1687            "/api/nodes/upload",
1688            5,
1689            b"{}",
1690            &sig
1691        ));
1692        assert!(!verify_request(
1693            &public,
1694            "POST",
1695            "/api/nodes/beat",
1696            6,
1697            b"{}",
1698            &sig
1699        ));
1700        assert!(!verify_request(
1701            &public,
1702            "POST",
1703            "/api/nodes/beat",
1704            5,
1705            b"{ }",
1706            &sig
1707        ));
1708        let (_, other) = generate_identity();
1709        assert!(!verify_request(
1710            &other,
1711            "POST",
1712            "/api/nodes/beat",
1713            5,
1714            b"{}",
1715            &sig
1716        ));
1717        assert!(!verify_request("zz", "POST", "/", 5, b"", "00"));
1718    }
1719
1720    #[test]
1721    fn a_token_enrolls_exactly_one_node_and_only_once() {
1722        let dir = scratch("once");
1723        let mut s = NodeStore::open(&dir.join("nodes.json")).unwrap();
1724        let (tok, _) = s.mint_token("a", vec!["p".into()], 900, 100).unwrap();
1725        let (_, pk) = generate_identity();
1726        s.enroll(&tok, &pk, None, 101, "t").unwrap();
1727        let (_, pk2) = generate_identity();
1728        let again = s.enroll(&tok, &pk2, None, 102, "t").unwrap_err();
1729        assert_eq!(again, "Invalid or expired enrollment token");
1730        assert_eq!(s.list().len(), 1);
1731    }
1732
1733    #[test]
1734    fn an_expired_or_unknown_token_gets_the_same_answer() {
1735        let dir = scratch("exp");
1736        let mut s = NodeStore::open(&dir.join("nodes.json")).unwrap();
1737        let (tok, exp) = s.mint_token("a", vec!["p".into()], 60, 100).unwrap();
1738        let (_, pk) = generate_identity();
1739        let late = s.enroll(&tok, &pk, None, exp, "t").unwrap_err();
1740        let unknown = s.enroll("envn_nope", &pk, None, 101, "t").unwrap_err();
1741        assert_eq!(late, unknown);
1742    }
1743
1744    #[test]
1745    fn the_token_is_stored_as_a_hash() {
1746        let dir = scratch("hash");
1747        let mut s = NodeStore::open(&dir.join("nodes.json")).unwrap();
1748        let (tok, _) = s.mint_token("a", vec!["p".into()], 900, 100).unwrap();
1749        let on_disk = std::fs::read_to_string(dir.join("nodes.json")).unwrap();
1750        assert!(
1751            !on_disk.contains(&tok),
1752            "plaintext token reached nodes.json"
1753        );
1754        assert!(on_disk.contains(&token_hash(&tok)));
1755    }
1756
1757    #[test]
1758    fn a_key_that_is_not_a_curve_point_is_refused_and_does_not_spend_the_token() {
1759        let dir = scratch("badkey");
1760        let mut s = NodeStore::open(&dir.join("nodes.json")).unwrap();
1761        let (tok, _) = s.mint_token("a", vec!["p".into()], 900, 100).unwrap();
1762        // About half of all 32-byte strings do not decompress to a point.
1763        let bad = (0u8..=255)
1764            .map(|b| hex::encode([b; 32]))
1765            .find(|k| {
1766                VerifyingKey::from_bytes(&hex::decode(k).unwrap().try_into().unwrap()).is_err()
1767            })
1768            .expect("some repeated byte is not a point");
1769        assert!(s
1770            .enroll(&tok, &bad, None, 101, "t")
1771            .unwrap_err()
1772            .contains("valid Ed25519"));
1773        let (_, ok) = generate_identity();
1774        assert!(
1775            s.enroll(&tok, &ok, None, 102, "t").is_ok(),
1776            "a refused key spent the token"
1777        );
1778    }
1779
1780    #[test]
1781    fn authentication_refuses_replay_skew_revocation_and_forgery() {
1782        let (mut s, n, seed, _d) = enrolled();
1783        let now = 1_700_000_000_000i64;
1784        let sig = |ts: i64| sign_request(&seed, "POST", "/api/nodes/beat", ts, b"x").unwrap();
1785        let auth = |s: &mut NodeStore, ts: i64, sg: &str| {
1786            s.authenticate(&n.id, "POST", "/api/nodes/beat", ts, b"x", sg, now)
1787        };
1788        assert!(auth(&mut s, now, &sig(now)).is_ok());
1789        // The same request again is a replay, not a second beat.
1790        assert_eq!(auth(&mut s, now, &sig(now)).unwrap_err(), AuthError::Replay);
1791        assert_eq!(
1792            auth(&mut s, now - 1, &sig(now - 1)).unwrap_err(),
1793            AuthError::Replay
1794        );
1795        assert!(auth(&mut s, now + 1, &sig(now + 1)).is_ok());
1796        let far = now + 61_000;
1797        assert_eq!(auth(&mut s, far, &sig(far)).unwrap_err(), AuthError::Skew);
1798        assert_eq!(
1799            auth(&mut s, now + 2, "00").unwrap_err(),
1800            AuthError::BadSignature
1801        );
1802        s.revoke(&n.id, "t").unwrap();
1803        assert_eq!(
1804            auth(&mut s, now + 3, &sig(now + 3)).unwrap_err(),
1805            AuthError::Revoked
1806        );
1807    }
1808
1809    #[test]
1810    fn the_replay_mark_survives_a_restart() {
1811        let (mut s, n, seed, dir) = enrolled();
1812        let now = 1_700_000_000_000i64;
1813        let sig = sign_request(&seed, "POST", "/p", now, b"").unwrap();
1814        s.authenticate(&n.id, "POST", "/p", now, b"", &sig, now)
1815            .unwrap();
1816        let mut reopened = NodeStore::open(&dir.join("nodes.json")).unwrap();
1817        assert_eq!(
1818            reopened
1819                .authenticate(&n.id, "POST", "/p", now, b"", &sig, now)
1820                .unwrap_err(),
1821            AuthError::Replay
1822        );
1823    }
1824
1825    #[test]
1826    fn a_corrupt_registry_is_an_error_not_an_empty_one() {
1827        let dir = scratch("corrupt");
1828        std::fs::write(dir.join("nodes.json"), "{ not json").unwrap();
1829        assert!(NodeStore::open(&dir.join("nodes.json")).is_err());
1830    }
1831
1832    #[cfg(unix)]
1833    #[test]
1834    fn the_registry_is_private() {
1835        use std::os::unix::fs::PermissionsExt;
1836        let (_s, _n, _seed, dir) = enrolled();
1837        let mode = std::fs::metadata(dir.join("nodes.json"))
1838            .unwrap()
1839            .permissions()
1840            .mode();
1841        assert_eq!(mode & 0o777, 0o600);
1842    }
1843
1844    #[test]
1845    fn status_words() {
1846        let none = None;
1847        let d = Some("aa");
1848        assert_eq!(
1849            derive_status(&report("push", false, Some("aa")), d, None, &none),
1850            "in_sync"
1851        );
1852        assert_eq!(
1853            derive_status(&report("push", false, Some("bb")), d, None, &none),
1854            "drift"
1855        );
1856        assert_eq!(
1857            derive_status(&report("push", true, Some("bb")), d, None, &none),
1858            "pending"
1859        );
1860        assert_eq!(
1861            derive_status(&report("push", false, None), d, None, &none),
1862            "missing"
1863        );
1864        assert_eq!(
1865            derive_status(&report("push", false, Some("aa")), None, None, &none),
1866            "unknown"
1867        );
1868        assert_eq!(
1869            derive_status(
1870                &report("push", true, Some("aa")),
1871                d,
1872                None,
1873                &Some("no".into())
1874            ),
1875            "refused"
1876        );
1877        assert_eq!(
1878            derive_status(&report("pull", false, Some("aa")), None, None, &none),
1879            "unreviewed"
1880        );
1881        assert_eq!(
1882            derive_status(&report("pull", false, Some("aa")), None, Some("aa"), &none),
1883            "in_sync"
1884        );
1885        assert_eq!(
1886            derive_status(&report("pull", false, Some("bb")), None, Some("aa"), &none),
1887            "changed"
1888        );
1889        assert_eq!(
1890            derive_status(&report("pull", false, None), None, Some("aa"), &none),
1891            "missing"
1892        );
1893    }
1894
1895    #[test]
1896    fn a_node_cannot_be_sent_a_project_it_was_not_enrolled_for() {
1897        let (mut s, n, _seed, _d) = enrolled();
1898        let mut r = report("push", true, Some("bb"));
1899        r.project = "payroll".into();
1900        let beat = Beat {
1901            targets: vec![r],
1902            ..Default::default()
1903        };
1904        s.record_beat(&n.id, &beat, &|_| Ok("aa".into()), "t")
1905            .unwrap();
1906        let t = &s.get(&n.id).unwrap().targets[0];
1907        assert_eq!(t.status, "refused");
1908        assert!(t.desired_sha.is_none());
1909    }
1910
1911    #[test]
1912    fn accepting_a_pull_target_records_its_current_hash() {
1913        let (mut s, n, _seed, _d) = enrolled();
1914        let beat = Beat {
1915            targets: vec![report("pull", false, Some("cc"))],
1916            ..Default::default()
1917        };
1918        s.record_beat(&n.id, &beat, &|_| Err("x".into()), "t")
1919            .unwrap();
1920        assert_eq!(s.get(&n.id).unwrap().targets[0].status, "unreviewed");
1921        s.accept(&n.id, "nginx-main").unwrap();
1922        // The next beat still sees the accepted hash.
1923        s.record_beat(&n.id, &beat, &|_| Err("x".into()), "t")
1924            .unwrap();
1925        assert_eq!(s.get(&n.id).unwrap().targets[0].status, "in_sync");
1926    }
1927
1928    #[test]
1929    fn config_parses_and_every_mistake_is_named() {
1930        let ok = r#"
1931interval_secs = 30
1932[[target]]
1933id = "nginx-main"
1934path = "/etc/nginx/sites-enabled/x.conf"
1935project = "edge"
1936exporter = "nginx"
1937apply = true
1938validate = "nginx -t"
1939reload = "systemctl reload nginx"
1940"#;
1941        let c = NodeConfig::parse(ok).unwrap();
1942        assert_eq!(c.targets[0].mode, "push");
1943        let bad = |t: &str| NodeConfig::parse(t).unwrap_err();
1944        let base = |extra: &str| {
1945            format!(
1946                "[[target]]\nid=\"a\"\npath=\"/etc/a\"\nproject=\"p\"\nexporter=\"nginx\"\n{extra}"
1947            )
1948        };
1949        assert!(bad(&base("aply = true")).contains("unknown field"));
1950        assert!(bad(&base("mode = \"sideways\"")).contains("mode must be"));
1951        assert!(bad(&base("mode = \"pull\"\napply = true")).contains("only read"));
1952        assert!(bad(&base("mode = \"pull\"\nreload = \"x\"")).contains("only read"));
1953        assert!(bad(&base("reload = \"\"")).contains("reload"));
1954        assert!(bad(&base("").replace("/etc/a", "etc/a")).contains("absolute"));
1955        assert!(bad(&base("").replace("nginx", "emacs")).contains("unknown exporter"));
1956        assert!(bad(&base("").replace("id=\"a\"", "id=\"../a\"")).contains("id"));
1957        assert!(bad(&format!("{}\n{}", base(""), base(""))).contains("twice"));
1958        assert!(bad("interval_secs = 1").contains("interval_secs"));
1959    }
1960    // ── Approval (Phase 37) ───────────────────────────────────────────────────
1961
1962    fn token(node: &str, target: &str, sha: &str, exp: i64) -> ApprovalToken {
1963        ApprovalToken {
1964            v: 1,
1965            node_id: node.into(),
1966            target: target.into(),
1967            sha256: sha.into(),
1968            approval_id: "a1".into(),
1969            approved_by: "owner".into(),
1970            approved_at: "t".into(),
1971            expires_secs: exp,
1972            nonce: "n1".into(),
1973        }
1974    }
1975
1976    #[test]
1977    fn an_approval_verifies_only_for_the_node_target_and_bytes_it_names() {
1978        let seed = generate_hub_seed();
1979        let public = hub_public(&seed).unwrap();
1980        let signed = sign_approval(&seed, &token("n1", "nginx", "abc", 2_000)).unwrap();
1981        assert!(verify_approval(&public, &signed, "n1", "nginx", "abc", 1_000).is_ok());
1982        let err = |node, target, sha, now| {
1983            verify_approval(&public, &signed, node, target, sha, now).unwrap_err()
1984        };
1985        assert!(err("other", "nginx", "abc", 1_000).contains("different node"));
1986        assert!(err("n1", "wg", "abc", 1_000).contains("different target"));
1987        assert!(err("n1", "nginx", "abd", 1_000).contains("different bytes"));
1988        assert!(err("n1", "nginx", "abc", 2_000).contains("expired"));
1989        // Edited token text, another hub's key, a mangled signature.
1990        let mut forged = signed.clone();
1991        forged.token = forged.token.replace("\"abc\"", "\"abd\"");
1992        assert!(
1993            verify_approval(&public, &forged, "n1", "nginx", "abd", 1_000)
1994                .unwrap_err()
1995                .contains("does not verify")
1996        );
1997        let other = hub_public(&generate_hub_seed()).unwrap();
1998        assert!(verify_approval(&other, &signed, "n1", "nginx", "abc", 1_000).is_err());
1999        let mut bad = signed.clone();
2000        bad.sig = "00".into();
2001        assert!(verify_approval(&public, &bad, "n1", "nginx", "abc", 1_000).is_err());
2002    }
2003
2004    fn request(s: &mut NodeStore, node: &str, sha: &str, now: i64) -> ApprovalRecord {
2005        s.request_approval(
2006            node,
2007            &report("push", true, Some("old")),
2008            sha,
2009            Some(1),
2010            Some(2),
2011            now,
2012            "t",
2013        )
2014        .unwrap()
2015    }
2016
2017    #[test]
2018    fn a_request_is_opened_once_per_set_of_bytes_and_decided_once() {
2019        let (mut s, n, _seed, _d) = enrolled();
2020        let a = request(&mut s, &n.id, "new", 1_000);
2021        assert_eq!(a.status, "pending");
2022        assert_eq!(
2023            request(&mut s, &n.id, "new", 1_001).id,
2024            a.id,
2025            "asking again must not open a second request"
2026        );
2027        assert_ne!(request(&mut s, &n.id, "other", 1_002).id, a.id);
2028        assert!(
2029            s.usable_approval(&n.id, "nginx-main", "new", 1_003)
2030                .is_none(),
2031            "pending is not approved"
2032        );
2033        let done = s.decide(&a.id, true, "owner", 1_010, "t").unwrap();
2034        assert_eq!(
2035            (done.status.as_str(), done.decided_by.as_deref()),
2036            ("approved", Some("owner"))
2037        );
2038        assert!(s
2039            .decide(&a.id, false, "owner", 1_011, "t")
2040            .unwrap_err()
2041            .contains("already approved"));
2042        assert!(s
2043            .usable_approval(&n.id, "nginx-main", "new", 1_020)
2044            .is_some());
2045        // An approval is for those bytes only.
2046        assert!(s
2047            .usable_approval(&n.id, "nginx-main", "other", 1_020)
2048            .is_none());
2049        assert!(s.usable_approval(&n.id, "wg", "new", 1_020).is_none());
2050    }
2051
2052    #[test]
2053    fn an_approval_lapses_and_a_revoked_node_cannot_use_one() {
2054        let (mut s, n, _seed, _d) = enrolled();
2055        let a = request(&mut s, &n.id, "new", 1_000);
2056        s.decide(&a.id, true, "owner", 1_000, "t").unwrap();
2057        assert!(s
2058            .usable_approval(&n.id, "nginx-main", "new", 1_000 + APPROVAL_TTL_SECS - 1)
2059            .is_some());
2060        assert!(s
2061            .usable_approval(&n.id, "nginx-main", "new", 1_000 + APPROVAL_TTL_SECS)
2062            .is_none());
2063        s.revoke(&n.id, "t").unwrap();
2064        assert!(s
2065            .usable_approval(&n.id, "nginx-main", "new", 1_001)
2066            .is_none());
2067    }
2068
2069    #[test]
2070    fn a_rejected_request_stays_rejected_for_those_bytes_and_an_old_request_cannot_be_decided() {
2071        let (mut s, n, _seed, _d) = enrolled();
2072        let a = request(&mut s, &n.id, "new", 1_000);
2073        s.decide(&a.id, false, "owner", 1_001, "t").unwrap();
2074        assert_eq!(
2075            request(&mut s, &n.id, "new", 1_002).status,
2076            "rejected",
2077            "asking again must not reopen it"
2078        );
2079        let b = request(&mut s, &n.id, "newer", 2_000);
2080        let late = 2_000 + PENDING_TTL_SECS;
2081        assert!(s
2082            .decide(&b.id, true, "owner", late, "t")
2083            .unwrap_err()
2084            .contains("expired"));
2085        assert!(s
2086            .decide("nope", true, "owner", 1, "t")
2087            .unwrap_err()
2088            .contains("No such"));
2089    }
2090
2091    #[test]
2092    fn a_spent_request_for_the_same_bytes_does_not_cover_a_later_push() {
2093        let (mut s, n, _seed, _d) = enrolled();
2094        let a = request(&mut s, &n.id, "v1", 1_000);
2095        s.decide(&a.id, true, "owner", 1_001, "t").unwrap();
2096        // The node reports having the approved bytes: the approval is consumed.
2097        let beat = Beat {
2098            targets: vec![report("push", true, Some("v1"))],
2099            ..Default::default()
2100        };
2101        s.record_beat(&n.id, &beat, &|_| Ok("v1".into()), "t")
2102            .unwrap();
2103        assert_eq!(
2104            s.approval_for(&n.id, "nginx-main", "v1").unwrap().status,
2105            "consumed"
2106        );
2107        assert!(s
2108            .usable_approval(&n.id, "nginx-main", "v1", 1_002)
2109            .is_none());
2110        // The same bytes wanted again later (a revert) need a new yes.
2111        let again = request(&mut s, &n.id, "v1", 5_000);
2112        assert_ne!(again.id, a.id);
2113        assert_eq!(again.status, "pending");
2114    }
2115
2116    #[test]
2117    fn status_follows_the_policy_and_the_decision() {
2118        let (mut s, n, _seed, _d) = enrolled();
2119        let beat = Beat {
2120            targets: vec![report("push", true, Some("old"))],
2121            ..Default::default()
2122        };
2123        let status = |s: &NodeStore| s.get(&n.id).unwrap().targets[0].status.clone();
2124        // No policy: ready to go.
2125        s.record_beat(&n.id, &beat, &|_| Ok("new".into()), "t")
2126            .unwrap();
2127        assert_eq!(status(&s), "pending");
2128        // Policy required, nothing asked yet: held.
2129        s.set_approval_policy(&n.id, "required").unwrap();
2130        s.record_beat(&n.id, &beat, &|_| Ok("new".into()), "t")
2131            .unwrap();
2132        assert_eq!(status(&s), "awaiting_approval");
2133        let now = unix_now();
2134        let a = s
2135            .request_approval(&n.id, &beat.targets[0], "new", None, None, now, "t")
2136            .unwrap();
2137        s.record_beat(&n.id, &beat, &|_| Ok("new".into()), "t")
2138            .unwrap();
2139        assert_eq!(status(&s), "awaiting_approval");
2140        s.decide(&a.id, true, "owner", now, "t").unwrap();
2141        s.record_beat(&n.id, &beat, &|_| Ok("new".into()), "t")
2142            .unwrap();
2143        assert_eq!(status(&s), "pending", "approved: the hub will push it");
2144        // The vault moved on: the old yes does not cover the new bytes.
2145        s.record_beat(&n.id, &beat, &|_| Ok("newer".into()), "t")
2146            .unwrap();
2147        assert_eq!(status(&s), "awaiting_approval");
2148        // A rejection is shown as one.
2149        let b = s
2150            .request_approval(&n.id, &beat.targets[0], "newer", None, None, now, "t")
2151            .unwrap();
2152        s.decide(&b.id, false, "owner", now, "t").unwrap();
2153        s.record_beat(&n.id, &beat, &|_| Ok("newer".into()), "t")
2154            .unwrap();
2155        assert_eq!(status(&s), "rejected");
2156        // Policy off again.
2157        s.set_approval_policy(&n.id, "none").unwrap();
2158        s.record_beat(&n.id, &beat, &|_| Ok("newer".into()), "t")
2159            .unwrap();
2160        assert_eq!(status(&s), "pending");
2161        assert!(s.set_approval_policy(&n.id, "sometimes").is_err());
2162    }
2163
2164    #[test]
2165    fn a_pull_target_cannot_require_approval() {
2166        let t = "[[target]]\nid=\"a\"\npath=\"/etc/a\"\nproject=\"p\"\nexporter=\"nginx\"\nmode=\"pull\"\nrequire_approval=true\n";
2167        assert!(NodeConfig::parse(t).unwrap_err().contains("only read"));
2168        let ok = "[[target]]\nid=\"a\"\npath=\"/etc/a\"\nproject=\"p\"\nexporter=\"nginx\"\napply=true\nrequire_approval=true\n";
2169        assert!(NodeConfig::parse(ok).unwrap().targets[0].require_approval);
2170    }
2171
2172    fn listen(endpoint: &str) -> ListenInfo {
2173        ListenInfo {
2174            endpoint: endpoint.into(),
2175            cert_sha256: "ab".repeat(32),
2176        }
2177    }
2178
2179    #[test]
2180    fn a_listening_address_is_a_bare_https_origin() {
2181        for ok in [
2182            "https://node.example",
2183            "https://10.0.0.5:9443",
2184            "https://[::1]:9443/",
2185            "https://n-1.lan",
2186        ] {
2187            assert!(listen(ok).validate().is_ok(), "{ok}");
2188        }
2189        for bad in [
2190            "http://node.example",
2191            "https://user@node.example",
2192            "https://node.example/path",
2193            "https://node.example?x=1",
2194            "https://node.example#f",
2195            "https://",
2196            "ftp://node.example",
2197            "",
2198        ] {
2199            assert!(listen(bad).validate().is_err(), "{bad}");
2200        }
2201        let mut l = listen("https://n.example");
2202        l.cert_sha256 = "xyz".into();
2203        assert!(l.validate().is_err());
2204        assert_eq!(listen("https://n.example/").base(), "https://n.example");
2205    }
2206
2207    #[test]
2208    fn a_node_signature_is_not_a_hub_signature_and_the_reverse() {
2209        let (node_seed, node_pub) = generate_identity();
2210        let hub_seed = generate_hub_seed();
2211        let hub_pub = hub_public(&hub_seed).unwrap();
2212        let body = b"{}";
2213        let n = sign_request(&node_seed, "POST", "/p", 5, body).unwrap();
2214        let h = sign_hub_request(&hub_seed, "POST", "/p", 5, body).unwrap();
2215        assert!(verify_request(&node_pub, "POST", "/p", 5, body, &n));
2216        assert!(verify_hub_request(&hub_pub, "POST", "/p", 5, body, &h));
2217        // Same input, other direction: refused. Without the domain line a node
2218        // could replay what the hub told it, or the reverse.
2219        assert!(!verify_hub_request(&node_pub, "POST", "/p", 5, body, &n));
2220        assert!(!verify_request(&hub_pub, "POST", "/p", 5, body, &h));
2221        // And a hub signature is bound to its path, time and body.
2222        assert!(!verify_hub_request(&hub_pub, "POST", "/other", 5, body, &h));
2223        assert!(!verify_hub_request(&hub_pub, "POST", "/p", 6, body, &h));
2224        assert!(!verify_hub_request(&hub_pub, "POST", "/p", 5, b"{ }", &h));
2225    }
2226
2227    #[test]
2228    fn enrollment_records_where_a_listening_node_is_and_refuses_a_bad_address() {
2229        let dir = scratch("listen");
2230        let path = dir.join("nodes.json");
2231        let mut s = NodeStore::open(&path).unwrap();
2232        let (_, pk) = generate_identity();
2233        let (tok, _) = s.mint_token("edge", vec!["web".into()], 900, 100).unwrap();
2234        let bad = s
2235            .enroll(&tok, &pk, Some(listen("http://plain.example")), 101, "t")
2236            .unwrap_err();
2237        assert!(bad.contains("https"), "{bad}");
2238        // A refused address does not spend the token.
2239        let n = s
2240            .enroll(
2241                &tok,
2242                &pk,
2243                Some(listen("https://edge.example:9443")),
2244                102,
2245                "t",
2246            )
2247            .unwrap();
2248        assert_eq!(
2249            n.listen.as_ref().unwrap().base(),
2250            "https://edge.example:9443"
2251        );
2252        assert_eq!(s.listening().len(), 1);
2253        // A dialling node is not in the list the hub dials.
2254        let (_, pk2) = generate_identity();
2255        let (tok2, _) = s
2256            .mint_token("dialer", vec!["web".into()], 900, 100)
2257            .unwrap();
2258        s.enroll(&tok2, &pk2, None, 103, "t").unwrap();
2259        assert_eq!(s.listening().len(), 1);
2260        s.revoke(&n.id, "t").unwrap();
2261        assert!(s.listening().is_empty(), "a revoked node is not dialled");
2262        // Survives a reopen.
2263        let again = NodeStore::open(&path).unwrap();
2264        assert!(again.get(&n.id).unwrap().listen.is_some());
2265    }
2266
2267    #[test]
2268    fn the_hub_transport_key_is_made_once_and_kept() {
2269        let dir = scratch("hubkey");
2270        let path = dir.join("nodes.json");
2271        let mut s = NodeStore::open(&path).unwrap();
2272        let (seed, public) = s.hub_node_key().unwrap();
2273        assert_eq!(hub_public(&seed).unwrap(), public);
2274        assert_eq!(s.hub_node_key().unwrap().0, seed);
2275        let mut reopened = NodeStore::open(&path).unwrap();
2276        assert_eq!(reopened.hub_node_key().unwrap(), (seed, public));
2277    }
2278
2279    fn device() -> (String, String) {
2280        let seed = generate_hub_seed();
2281        let public = hub_public(&seed).unwrap();
2282        (seed, public)
2283    }
2284
2285    #[test]
2286    fn device_approval_is_accepted_only_for_the_request_it_names_and_only_from_a_registered_device()
2287    {
2288        let (mut s, n, _seed, _dir) = enrolled();
2289        let (dev_seed, dev_pub) = device();
2290        // `device` is not a policy until a device exists.
2291        assert!(s.set_approval_policy(&n.id, "device").is_err());
2292        s.add_approver(&dev_pub, "laptop", "t").unwrap();
2293        s.set_approval_policy(&n.id, "device").unwrap();
2294
2295        let rec = s
2296            .request_approval(
2297                &n.id,
2298                &report("push", true, Some("old")),
2299                "newsha",
2300                None,
2301                None,
2302                2_000,
2303                "t",
2304            )
2305            .unwrap();
2306        let sign = |seed: &str, node: &str, target: &str, sha: &str, id: &str, now: i64| {
2307            sign_device_approval(seed, node, target, sha, id, now, "t").unwrap()
2308        };
2309        // Not registered: refused.
2310        let (other_seed, _) = device();
2311        let bad = sign(&other_seed, &n.id, "nginx-main", "newsha", &rec.id, 2_010);
2312        assert!(s
2313            .decide_signed(&rec.id, bad, 2_010, "t")
2314            .unwrap_err()
2315            .contains("Not accepted"));
2316        // Registered, but for other bytes / target / node / request: refused.
2317        for (node, target, sha, id) in [
2318            (n.id.as_str(), "nginx-main", "othersha", rec.id.as_str()),
2319            (n.id.as_str(), "other", "newsha", rec.id.as_str()),
2320            ("someone", "nginx-main", "newsha", rec.id.as_str()),
2321            (n.id.as_str(), "nginx-main", "newsha", "another-request"),
2322        ] {
2323            let t = sign(&dev_seed, node, target, sha, id, 2_010);
2324            assert!(
2325                s.decide_signed(&rec.id, t, 2_010, "t").is_err(),
2326                "{node} {target} {sha} {id}"
2327            );
2328        }
2329        // A lifetime longer than the hub's own is refused.
2330        let long = sign_approval(
2331            &dev_seed,
2332            &ApprovalToken {
2333                v: 1,
2334                node_id: n.id.clone(),
2335                target: "nginx-main".into(),
2336                sha256: "newsha".into(),
2337                approval_id: rec.id.clone(),
2338                approved_by: "x".into(),
2339                approved_at: "t".into(),
2340                expires_secs: 2_010 + APPROVAL_TTL_SECS * 10,
2341                nonce: "n".into(),
2342            },
2343        )
2344        .unwrap();
2345        assert!(s
2346            .decide_signed(&rec.id, long, 2_010, "t")
2347            .unwrap_err()
2348            .contains("longer"));
2349        // The right one is accepted, recorded, and cannot be decided twice.
2350        let good = sign(&dev_seed, &n.id, "nginx-main", "newsha", &rec.id, 2_010);
2351        let a = s.decide_signed(&rec.id, good.clone(), 2_010, "t").unwrap();
2352        assert_eq!(a.status, "approved");
2353        assert!(a.decided_by.as_deref().unwrap().starts_with("device:"));
2354        assert_eq!(a.signed, Some(good.clone()));
2355        assert!(s
2356            .decide_signed(&rec.id, good, 2_011, "t")
2357            .unwrap_err()
2358            .contains("already"));
2359        assert!(s
2360            .usable_approval(&n.id, "nginx-main", "newsha", 2_020)
2361            .is_some());
2362    }
2363
2364    #[test]
2365    fn removing_the_last_approver_returns_device_nodes_to_hub_approval() {
2366        let (mut s, n, _seed, _dir) = enrolled();
2367        let (_, p1) = device();
2368        let a = s.add_approver(&p1, "laptop", "t").unwrap();
2369        assert_eq!(
2370            s.add_approver(&p1, "again", "t").unwrap(),
2371            a,
2372            "adding twice is one device"
2373        );
2374        s.set_approval_policy(&n.id, "device").unwrap();
2375        assert!(
2376            s.remove_approver("abc").is_err(),
2377            "a short prefix is a guess"
2378        );
2379        s.remove_approver(&a.fingerprint[..10]).unwrap();
2380        assert_eq!(s.get(&n.id).unwrap().approval, "required");
2381        assert!(s.approvers().is_empty());
2382        assert!(s.add_approver("zz", "x", "t").is_err());
2383        assert!(s.add_approver(&p1, "", "t").is_err());
2384    }
2385
2386    #[test]
2387    fn the_device_seed_is_made_once_private_and_stable() {
2388        let dir = scratch("approver");
2389        let a = approver_seed(&dir).unwrap();
2390        assert_eq!(approver_seed(&dir).unwrap(), a);
2391        assert_eq!(hub_public(&a).unwrap().len(), 64);
2392        #[cfg(unix)]
2393        {
2394            use std::os::unix::fs::PermissionsExt;
2395            let m = std::fs::metadata(dir.join("approver.key"))
2396                .unwrap()
2397                .permissions()
2398                .mode();
2399            assert_eq!(m & 0o077, 0, "the key is readable by others: {m:o}");
2400        }
2401        std::fs::write(dir.join("approver.key"), "not a key").unwrap();
2402        assert!(
2403            approver_seed(&dir).is_err(),
2404            "a damaged key is an error, not a new identity"
2405        );
2406    }
2407
2408    #[test]
2409    fn a_node_config_names_its_approver_as_a_real_public_key_or_not_at_all() {
2410        let (_, p) = device();
2411        assert!(NodeConfig::parse(&format!("approver = \"{p}\"")).is_ok());
2412        assert!(NodeConfig::parse("approver = \"zz\"").is_err());
2413        assert!(NodeConfig::parse("").unwrap().approver.is_none());
2414    }
2415}