nixfleet_proto/trust.rs
1//! Trust root declarations. LOADBEARING: algorithm is a property of the key,
2//! not the artifact - artifacts MUST NOT carry their own algorithm claim, or
3//! an attacker could downgrade by lying about which algo signed the bytes.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// `algorithm` is `String` (not enum) for forward-compat. Unknown values
9/// surface as `UnsupportedAlgorithm` at verify time. Today: ed25519, with
10/// `public` as 32-byte base64 (padded).
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct TrustedPubkey {
14 pub algorithm: String,
15 pub public: String,
16}
17
18/// Loaded from `/etc/nixfleet/{cp,agent}/trust.json`. Restart-only reload.
19#[derive(Debug, Clone, Deserialize, Serialize)]
20#[serde(rename_all = "camelCase")]
21pub struct TrustConfig {
22 pub schema_version: u32,
23
24 pub ci_release_key: KeySlot,
25
26 /// Forwarded opaquely to `nix.settings.trusted-public-keys`.
27 #[serde(default)]
28 pub cache_keys: Vec<String>,
29
30 #[serde(default)]
31 pub org_root_key: Option<KeySlot>,
32
33 /// PEM-encoded fleet root CA cert. Offline-signed (operator workstation,
34 /// file or Yubikey) and embedded in trust.json so verifiers anchor cert
35 /// chains at a key the CP never holds at rest. `None` until the operator
36 /// has run `nixfleet-trust-bootstrap`.
37 #[serde(default)]
38 pub root_ca_pem: Option<String>,
39
40 /// PEM-encoded issuance CAs the fleet trusts to mint agent certs, each
41 /// signed by `root_ca_pem`. Multiple entries support rotation overlap -
42 /// agents accept any cert chain anchored at one of these intermediates.
43 #[serde(default)]
44 pub issuance_ca_pems: Vec<String>,
45}
46
47impl TrustConfig {
48 pub const CURRENT_SCHEMA_VERSION: u32 = 1;
49}
50
51/// LOADBEARING: `reject_before` is the compromise kill-switch - artifacts
52/// signed before this timestamp are refused regardless of which key signed.
53/// `successor` + `retire_at` declare a planned rotation: while
54/// `now < retire_at` the successor's signature is accepted (overlap window);
55/// past `retire_at` the reconciler emits `Action::RotateTrustRoot` so the
56/// operator's tooling can promote `current -> previous`, `successor -> current`.
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct KeySlot {
60 #[serde(default)]
61 pub current: Option<TrustedPubkey>,
62
63 #[serde(default)]
64 pub previous: Option<TrustedPubkey>,
65
66 #[serde(default)]
67 pub reject_before: Option<DateTime<Utc>>,
68
69 /// Pre-announced next key. Accepted during the overlap window
70 /// (`now < retire_at`). Must be set together with `retire_at` (Nix-side
71 /// assertion in contracts/trust.nix). Promotion to `current` is operator-
72 /// driven, never automated by CP.
73 #[serde(default)]
74 pub successor: Option<TrustedPubkey>,
75
76 /// RFC 3339 deadline for rotation. Drives both the verifier's overlap-
77 /// window check and the reconciler's `RotateTrustRoot` signal.
78 #[serde(default)]
79 pub retire_at: Option<DateTime<Utc>>,
80}
81
82impl KeySlot {
83 /// Time-less view: `[current, previous]` (newer first). For schema-only
84 /// inspection / fixtures. Verifiers should call [`Self::active_keys_at`] so
85 /// successor is honored during the overlap window.
86 pub fn active_keys(&self) -> Vec<TrustedPubkey> {
87 let mut keys = Vec::with_capacity(2);
88 if let Some(k) = &self.current {
89 keys.push(k.clone());
90 }
91 if let Some(k) = &self.previous {
92 keys.push(k.clone());
93 }
94 keys
95 }
96
97 /// LOADBEARING: `[current, previous, successor (if now < retire_at)]`.
98 /// Verifiers iterate first-match-wins; this ordering lets the successor
99 /// signature verify during the overlap window without forcing the
100 /// operator to rotate `current` before the deadline. Outside the overlap
101 /// it's identical to [`Self::active_keys`].
102 pub fn active_keys_at(&self, now: DateTime<Utc>) -> Vec<TrustedPubkey> {
103 let mut keys = self.active_keys();
104 if let (Some(k), Some(retire_at)) = (&self.successor, self.retire_at)
105 && now < retire_at
106 {
107 keys.push(k.clone());
108 }
109 keys
110 }
111}