1use 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
35pub const ENROLL_TTL_SECS: i64 = 900;
37pub const MAX_CLOCK_SKEW_SECS: i64 = 60;
41pub const MAX_TARGETS: usize = 256;
44
45pub const LOCKED: &str = "hub-locked";
49
50pub 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
72pub 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
82pub 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
91fn 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
98fn 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
112pub 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
128pub 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
148pub 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
170pub 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
228pub struct ListenInfo {
229 pub endpoint: String,
230 pub cert_sha256: String,
231}
232
233impl ListenInfo {
234 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 pub fn base(&self) -> &str {
259 self.endpoint.strip_suffix('/').unwrap_or(&self.endpoint)
260 }
261}
262
263#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
267pub struct TargetReport {
268 pub id: String,
269 pub project: String,
270 pub exporter: String,
271 pub mode: String,
273 pub apply: bool,
276 pub sha256: Option<String>,
278 pub state: String,
280 pub error: Option<String>,
281}
282
283#[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#[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#[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 #[serde(default)]
326 pub wait_secs: u32,
327}
328
329#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
331#[serde(tag = "kind", rename_all = "snake_case")]
332pub enum Action {
333 Push {
336 target: String,
337 sha256: String,
338 content_b64: String,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
342 approval: Option<SignedApproval>,
343 },
344 Upload { target: String },
346}
347
348#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
349pub struct BeatReply {
350 pub interval_secs: u32,
351 pub hub_locked: bool,
354 #[serde(default)]
359 pub results_ack: bool,
360 pub actions: Vec<Action>,
361 #[serde(default)]
364 pub pending: Vec<PendingNote>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub hub_pubkey: Option<String>,
369}
370
371#[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 #[serde(default)]
386 pub apply: bool,
387 pub validate: Option<String>,
388 pub reload: Option<String>,
389 #[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 pub interval_secs: Option<u32>,
405 #[serde(default, rename = "target")]
406 pub targets: Vec<Target>,
407 #[serde(default)]
412 pub approver: Option<String>,
413}
414
415pub 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 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 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#[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 pub desired_sha: Option<String>,
531 pub accepted_sha: Option<String>,
533 pub state: String,
534 pub error: Option<String>,
535 pub status: String,
538 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 pub projects: Vec<String>,
551 pub enrolled_at: String,
552 pub last_seen: Option<String>,
553 pub last_ts_ms: i64,
556 pub revoked_at: Option<String>,
557 pub host: Option<HostInfo>,
558 pub targets: Vec<TargetStatus>,
559 #[serde(default)]
562 pub approval: String,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub listen: Option<ListenInfo>,
567 #[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 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 #[serde(default)]
594 hub_node_seed: String,
595 #[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
629fn 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 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 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 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 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 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 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 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 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 let _ = self.save();
806 Ok(out)
807 }
808
809 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 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 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 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 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 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 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 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
998pub 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
1038pub const APPROVAL_TTL_SECS: i64 = 3600;
1042pub 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#[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 pub sha256: String,
1063 pub from_sha: Option<String>,
1065 pub from_seq: Option<i64>,
1067 pub to_seq: Option<i64>,
1068 pub requested_at: String,
1069 pub requested_secs: i64,
1070 pub status: String,
1072 pub decided_at: Option<String>,
1073 pub decided_secs: Option<i64>,
1074 pub decided_by: Option<String>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub signed: Option<SignedApproval>,
1079}
1080
1081#[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#[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#[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 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
1125pub 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
1136pub 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
1143pub 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 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
1187pub 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 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 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
1223pub fn default_approver_dir() -> Option<PathBuf> {
1225 dirs::data_dir().map(|d| d.join("io.unenverse"))
1226}
1227
1228pub 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
1256pub 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
1298fn 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 s.record_beat(&n.id, &beat, &|_| Ok("new".into()), "t")
2126 .unwrap();
2127 assert_eq!(status(&s), "pending");
2128 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 s.record_beat(&n.id, &beat, &|_| Ok("newer".into()), "t")
2146 .unwrap();
2147 assert_eq!(status(&s), "awaiting_approval");
2148 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 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 assert!(!verify_hub_request(&node_pub, "POST", "/p", 5, body, &n));
2220 assert!(!verify_request(&hub_pub, "POST", "/p", 5, body, &h));
2221 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 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 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 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 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 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 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 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 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}