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}