Skip to main content

koi_certmesh/
core_identity.rs

1//! Identity, status, and the ADR-020 trust primitives (sign/verify/seal/open/diagnose).
2//!
3//! Part of the inherent impl CertmeshCore, split from lib.rs (certmesh M2).
4//! As a child module of the crate root, 'use super::*' inherits lib.rs's
5//! imports, sibling modules, and crate-private state/helpers as in the original.
6use super::*;
7
8impl CertmeshCore {
9    /// The CA certificate fingerprint, or `None` when no CA is initialized.
10    ///
11    /// Reads the in-memory CA when unlocked, else derives it from the on-disk CA
12    /// cert (the fingerprint is public). Used by the daemon to advertise the CA's
13    /// fingerprint in the `_certmesh._tcp` mDNS TXT (ADR-017 F12) and as a cheap
14    /// preflight datum.
15    pub async fn ca_fingerprint(&self) -> Option<String> {
16        // In-memory path: compute under the lock, but drop the guard before any I/O
17        // (never hold the CA mutex across disk reads).
18        let in_memory = {
19            let ca_guard = self.state.ca.lock().await;
20            ca_guard.as_ref().map(ca::ca_fingerprint)
21        };
22        if in_memory.is_some() {
23            return in_memory;
24        }
25        // Locked CA: derive from the on-disk cert off the async executor.
26        let paths = self.state.paths.clone();
27        tokio::task::spawn_blocking(move || ca::ca_fingerprint_from_disk(&paths).ok())
28            .await
29            .ok()
30            .flatten()
31    }
32
33    /// Get the current certmesh status.
34    pub async fn certmesh_status(&self) -> protocol::CertmeshStatus {
35        let ca_guard = self.state.ca.lock().await;
36        let roster = self.state.roster.lock().await;
37        let auth_guard = self.state.auth.lock().await;
38        let auth_method = auth_guard.as_ref().map(|a| a.method_name());
39        build_status(self.paths(), &ca_guard, &roster, auth_method)
40    }
41
42    /// This node's current trust posture — the mode oracle every
43    /// mode-transparent primitive consults (ADR-020 §0).
44    ///
45    /// `signed` is true when this node holds a usable cryptographic identity: its
46    /// CA-signed leaf (`cert.pem`/`key.pem`) is on disk *and* the node is anchored
47    /// to a mesh (the CA is initialized here, or a `member.json` records the mesh
48    /// it joined — so an orphaned leaf left after `destroy` does not read as
49    /// secure). A cheap filesystem check, safe to call from any primitive.
50    /// `encrypted` (the Confidential rung) stays false until the `seal`/`open`
51    /// encryption rung lands (ADR-020 §4).
52    ///
53    /// Posture answers "do I have an identity", not "is it fresh" — identity
54    /// *health* (expiry, renewal status) is reported separately by
55    /// `ensure_identity` / `diagnose` (later ADR-020 phases).
56    pub fn posture(&self) -> Posture {
57        Posture {
58            signed: self.has_local_identity(),
59            encrypted: false,
60        }
61    }
62
63    /// Whether this node holds a usable local identity (a CA-signed leaf on disk,
64    /// anchored to a mesh). Backs [`posture`](Self::posture).
65    fn has_local_identity(&self) -> bool {
66        node_has_identity(self.paths())
67    }
68
69    /// Load this node's live identity from disk, or `None` if it has none.
70    ///
71    /// Read-only: loads the on-disk leaf (cert/key) for the local hostname plus
72    /// the CA anchor it chains to, derives the pinned CA fingerprint, and computes
73    /// the leaf's renewal/expiry health from the CA-held policy. Returns `None`
74    /// when the node is Open — consistent with [`posture`](Self::posture)`.signed`.
75    /// Does not renew or enroll (that is `ensure_identity`'s job).
76    pub async fn local_identity(&self) -> Option<Identity> {
77        if !self.has_local_identity() {
78            return None;
79        }
80        let hostname = Self::local_hostname()?;
81        let leaf = self.paths().certs_dir().join(&hostname);
82        let cert_pem = std::fs::read_to_string(leaf.join("cert.pem")).ok()?;
83        let key_pem = std::fs::read_to_string(leaf.join("key.pem")).ok()?;
84        // CA anchor: the leaf-local ca.pem, falling back to the CA dir (CA node).
85        let ca_cert_pem = std::fs::read_to_string(leaf.join("ca.pem"))
86            .ok()
87            .or_else(|| std::fs::read_to_string(self.paths().ca_cert_path()).ok())?;
88        let ca_fingerprint =
89            koi_crypto::pinning::fingerprint_sha256(pem::parse(&ca_cert_pem).ok()?.contents());
90        let policy = self.local_policy().await;
91        let renewal = RenewalHealth::from_leaf(&cert_pem, &policy)?;
92        Some(Identity {
93            hostname,
94            cert_pem,
95            key_pem,
96            ca_cert_pem,
97            ca_fingerprint,
98            renewal,
99        })
100    }
101
102    /// The CA-held cert lifecycle policy this node follows: from `member.json`
103    /// if it joined a mesh, else the local roster's (CA node), else the default.
104    async fn local_policy(&self) -> roster::CertPolicy {
105        if let Some(ms) = member::load(&self.paths().member_state_path()) {
106            return ms.policy;
107        }
108        self.state.roster.lock().await.metadata.policy.clone()
109    }
110
111    /// Ensure this node holds a current identity, then return it (`None` if it
112    /// cannot — the node is Open with no way to enroll). ADR-020 §7.
113    ///
114    /// Mode-transparent + idempotent — the consumer calls this without branching:
115    /// - **Open** (no CA, not a member): returns `None`.
116    /// - **CA node** (CA unlocked): self-enrolls if needed and re-issues a self
117    ///   leaf that is within the renewal threshold (local, no network).
118    /// - **Joined member**: pull-renews from the CA when the leaf is due
119    ///   (`renew_self_if_due`); best-effort — on a network/CA failure it logs and
120    ///   returns the current (un-renewed) identity rather than erroring.
121    ///
122    /// First-join identity acquisition that needs out-of-band authorization (an
123    /// invite/TOTP) is *not* performed here — that is the explicit `join` flow.
124    pub async fn ensure_identity(&self) -> Option<Identity> {
125        if self.paths().is_ca_initialized() {
126            // CA node: self-enroll is idempotent (reuses a fresh leaf, re-issues
127            // one within the renewal threshold). Requires the CA unlocked.
128            let unlocked = self.state.ca.lock().await.is_some();
129            if unlocked {
130                if let Err(e) = self.self_enroll().await {
131                    tracing::warn!(error = %e, "ensure_identity: self-enroll failed");
132                }
133            }
134        } else if member::load(&self.paths().member_state_path()).is_some() {
135            // Joined member: renew if due (network pull to the CA). Best-effort.
136            if let Err(e) = self.renew_self_if_due().await {
137                tracing::warn!(error = %e, "ensure_identity: renewal check failed");
138            }
139        }
140        self.local_identity().await
141    }
142
143    /// Sign `bytes` into an [`Envelope`](koi_common::envelope::Envelope) (ADR-020 §3).
144    ///
145    /// Mode-transparent: Open posture → a freshness-stamped passthrough (no
146    /// signature); Authenticated → ES256-signed, carrying this node's leaf cert so
147    /// any holder of the CA can verify it. The consumer calls this identically in
148    /// both postures.
149    pub async fn sign(&self, bytes: &[u8]) -> koi_common::envelope::Envelope {
150        use rand::RngCore;
151        let mut nonce = [0u8; 16];
152        rand::rng().fill_bytes(&mut nonce);
153        let ts = chrono::Utc::now().timestamp();
154        let identity = self.local_identity().await;
155        let signer = identity
156            .as_ref()
157            .map(|id| (id.key_pem.as_str(), id.cert_pem.as_str()));
158        envelope::build_envelope(signer, bytes, &nonce, ts)
159    }
160
161    /// Verify an [`Envelope`](koi_common::envelope::Envelope) → an
162    /// [`Assurance`](koi_common::envelope::Assurance) (ADR-020 §3).
163    ///
164    /// Self-contained (carry-cert): validates the carried leaf against this node's
165    /// pinned CA + checks freshness + best-effort revocation. Read a trusted
166    /// identity only via `Assurance::identity()`. On an Open node (no anchor) any
167    /// envelope verifies as `Anonymous`.
168    pub async fn verify(
169        &self,
170        env: &koi_common::envelope::Envelope,
171    ) -> koi_common::envelope::Assurance {
172        let ca_cert_pem = self.local_ca_cert_pem().await;
173        let revoked = self.revoked_fingerprints().await;
174        let now = chrono::Utc::now().timestamp();
175        envelope::verify_envelope(env, ca_cert_pem.as_deref(), &revoked, now)
176    }
177
178    /// Seal `bytes` into a [`Sealed`](koi_common::sealed::Sealed) (ADR-020 §4).
179    ///
180    /// The confidentiality rung, shipped today as **passthrough**: the bytes are
181    /// signed (integrity + freshness) but **not encrypted**. Reuses [`sign`](Self::sign)'s
182    /// machinery — a `Sealed` is a signed [`Envelope`](koi_common::envelope::Envelope)
183    /// plus a confidentiality version tag. The consumer codes against the final API
184    /// now; the group-key rung lands later with no consumer change. A one-time
185    /// `warn!` makes the passthrough (un-encrypted) state loud, not silent.
186    pub async fn seal(&self, bytes: &[u8]) -> koi_common::sealed::Sealed {
187        static PASSTHROUGH_WARNED: std::sync::Once = std::sync::Once::new();
188        PASSTHROUGH_WARNED.call_once(|| {
189            tracing::warn!(
190                "seal(): running in passthrough mode — messages are signed but NOT \
191                 encrypted (group-key confidentiality is not yet available)"
192            );
193        });
194        use rand::RngCore;
195        let mut nonce = [0u8; 16];
196        rand::rng().fill_bytes(&mut nonce);
197        let ts = chrono::Utc::now().timestamp();
198        let identity = self.local_identity().await;
199        let signer = identity
200            .as_ref()
201            .map(|id| (id.key_pem.as_str(), id.cert_pem.as_str()));
202        sealed::seal_passthrough(signer, bytes, &nonce, ts)
203    }
204
205    /// Open a [`Sealed`](koi_common::sealed::Sealed) → [`Opened`](koi_common::sealed::Opened)
206    /// (ADR-020 §4): the recovered bytes plus the trust state they arrived with.
207    ///
208    /// Self-contained (carry-cert), reusing [`verify`](Self::verify)'s machinery. A
209    /// tampered / unknown-signer / expired / revoked message yields an `Err`, never
210    /// bytes — read a trusted identity via `opened.assurance.identity()`.
211    pub async fn open(
212        &self,
213        sealed: &koi_common::sealed::Sealed,
214    ) -> Result<koi_common::sealed::Opened, CertmeshError> {
215        let ca_cert_pem = self.local_ca_cert_pem().await;
216        let revoked = self.revoked_fingerprints().await;
217        let now = chrono::Utc::now().timestamp();
218        sealed::open_sealed(sealed, ca_cert_pem.as_deref(), &revoked, now)
219    }
220
221    /// Run the trust-doctor (ADR-020 §13) → a structured [`TrustDiagnosis`].
222    ///
223    /// Aggregates this node's real trust state — posture, identity + renewal health
224    /// (reusing [`local_identity`](Self::local_identity)), on-disk-leaf integrity
225    /// (chains to its CA), self-revocation, and the CA trust-install limitation —
226    /// into distinct, named checks each carrying an exact remedy. The rollup exits
227    /// non-zero only when something is RED (`TrustDiagnosis::exit_code`).
228    pub async fn diagnose(&self) -> koi_common::diagnosis::TrustDiagnosis {
229        let posture = self.posture();
230        let identity = self.local_identity().await;
231        let now = chrono::Utc::now();
232        let (integrity_ok, self_revoked) = match &identity {
233            Some(id) => {
234                let integrity = diagnosis::leaf_chains_to_ca(&id.cert_pem, &id.ca_cert_pem);
235                // Is this node's own leaf in the (best-effort) revoked set?
236                let self_fp = pem::parse(&id.cert_pem)
237                    .ok()
238                    .map(|p| koi_crypto::pinning::fingerprint_sha256(p.contents()));
239                let revoked = self.revoked_fingerprints().await;
240                let self_revoked = self_fp
241                    .as_ref()
242                    .map(|fp| {
243                        revoked
244                            .iter()
245                            .any(|r| koi_crypto::pinning::fingerprints_match(r, fp))
246                    })
247                    .unwrap_or(false);
248                (Some(integrity), self_revoked)
249            }
250            None => (None, false),
251        };
252        diagnosis::build_diagnosis(posture, identity.as_ref(), integrity_ok, self_revoked, now)
253    }
254
255    /// The CA certificate this node trusts as its verification anchor: the leaf's
256    /// `ca.pem` (member or CA node), falling back to the CA cert on disk. `None`
257    /// on an Open node with no anchor.
258    async fn local_ca_cert_pem(&self) -> Option<String> {
259        if let Some(hostname) = Self::local_hostname() {
260            let leaf_ca = self.paths().certs_dir().join(&hostname).join("ca.pem");
261            if let Ok(pem) = std::fs::read_to_string(&leaf_ca) {
262                return Some(pem);
263            }
264        }
265        std::fs::read_to_string(self.paths().ca_cert_path()).ok()
266    }
267
268    /// Best-effort revoked-leaf fingerprints from the local roster. A CA node holds
269    /// the full roster; a pure member's roster is empty, so revocation there is
270    /// eventual-consistent — the CA chain remains the hard gate (ADR-020 §3).
271    async fn revoked_fingerprints(&self) -> Vec<String> {
272        let roster = self.state.roster.lock().await;
273        roster
274            .members
275            .iter()
276            .filter(|m| m.status == roster::MemberStatus::Revoked)
277            .map(|m| m.cert_fingerprint.clone())
278            .collect()
279    }
280
281    /// Gate `router`'s routes by authentication (ADR-020 §6 `require_auth`).
282    ///
283    /// Mode-transparent: a **no-op in Open posture** (homelab-open); in secure
284    /// posture every request must carry an authenticated client CN (the mTLS
285    /// `ClientCn` the listener / same-port dial injects) or it is rejected with
286    /// 401. Apply once to your *write* routes — no per-handler boilerplate, and the
287    /// same consumer code runs green in both postures.
288    ///
289    /// (P2 gates on the mTLS client identity; a signed-envelope-header path is a
290    /// planned refinement. For per-CN/role authorization, see
291    /// [`require_auth_with`](Self::require_auth_with).)
292    pub fn require_auth(&self, router: Router) -> Router {
293        router.layer(axum::middleware::from_fn_with_state(
294            Arc::clone(&self.state),
295            http::require_auth_mw,
296        ))
297    }
298
299    /// Gate `router`'s routes by authentication **and** a caller-supplied CN/role
300    /// policy (ADR-020 §6, wishlist 4.1).
301    ///
302    /// Like [`require_auth`](Self::require_auth) — a **no-op in Open posture** — but
303    /// in secure posture, after confirming an authenticated client CN, it calls
304    /// `policy(cn, &request)`: `true` allows the request, `false` rejects it with
305    /// 403. This lets a consumer express "only these CNs/roles may write" (an
306    /// allowlist, a roster-role check, a path-scoped rule) without re-implementing
307    /// the middleware or re-deriving the mTLS identity. Keep [`require_auth`](Self::require_auth)
308    /// for the zero-config "any mesh member" default.
309    ///
310    /// The policy receives the **authoritative** mTLS CN (derived from the client
311    /// certificate, never a claimed field) and the full `axum` request, so it can
312    /// branch on method/path as well as identity.
313    ///
314    /// ```ignore
315    /// // Only `web-01` and `web-02` may reach the write routes.
316    /// let allow = ["web-01", "web-02"];
317    /// let router = core.require_auth_with(router, move |cn, _req| allow.contains(&cn));
318    /// ```
319    pub fn require_auth_with<F>(&self, router: Router, policy: F) -> Router
320    where
321        F: Fn(&str, &axum::extract::Request) -> bool + Send + Sync + 'static,
322    {
323        let state = Arc::clone(&self.state);
324        let policy: http::AuthPolicy = Arc::new(policy);
325        router.layer(axum::middleware::from_fn(move |req, next| {
326            let state = Arc::clone(&state);
327            let policy = Arc::clone(&policy);
328            async move { http::require_auth_with_mw(state, policy, req, next).await }
329        }))
330    }
331}