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}