Skip to main content

koi_certmesh/
protocol.rs

1//! Wire types for certmesh HTTP endpoints.
2//!
3//! These types define the JSON shapes for join requests/responses
4//! and status queries. They are the public API contract.
5
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8
9use crate::roster::{CertPolicy, EnrollmentState};
10
11/// Client request to join the mesh.
12///
13/// The joining machine must identify itself by hostname so the CA
14/// issues a certificate with the correct subject (not the CA’s own
15/// hostname).
16#[derive(Debug, Serialize, Deserialize, ToSchema)]
17pub struct JoinRequest {
18    /// Hostname of the machine requesting to join.
19    pub hostname: String,
20    /// Auth response (TOTP code). Absent when enrolling with an invite token.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub auth: Option<koi_crypto::auth::AuthResponse>,
23    /// Single-use, hostname-bound enrollment invite token (ADR-015 F2).
24    /// Mutually exclusive with `auth`; when present it is the join credential.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub invite_token: Option<String>,
27    /// PKCS#10 CSR (PEM) generated by the joining member (ADR-015 F1).
28    ///
29    /// The member generates its own keypair and sends only this CSR — the CA
30    /// signs it and **never sees the private key**. Required for remote
31    /// enrollment; the CA refuses to generate member keys server-side.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub csr: Option<String>,
34    /// Optional extra SANs the joiner wants (IP addresses, aliases).
35    /// The server always includes `[hostname, hostname.local]`.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub sans: Vec<String>,
38}
39
40/// Request to mint an enrollment invite token (operator-only, DAT-gated).
41#[derive(Debug, Serialize, Deserialize, ToSchema)]
42pub struct InviteRequest {
43    /// Hostname the invite authorizes (bound at mint time).
44    pub hostname: String,
45    /// Time-to-live in minutes. Non-positive falls back to the default (60).
46    #[serde(default)]
47    pub ttl_mins: i64,
48}
49
50/// Response carrying a freshly minted invite token (returned exactly once).
51#[derive(Debug, Serialize, Deserialize, ToSchema)]
52pub struct InviteResponse {
53    /// The one-time invite **code** — deliver to the joining host out of band.
54    ///
55    /// The code is `<secret>.<ca_fingerprint>` (ADR-017 F3): the joiner pins the
56    /// embedded fingerprint and preflights the CA before sending its CSR, and the
57    /// CA consumes only the secret half. The plaintext secret exists only here.
58    pub token: String,
59    /// Hostname this invite is bound to.
60    pub hostname: String,
61    /// RFC 3339 absolute expiry.
62    pub expires_at: String,
63    /// The CA fingerprint embedded in the invite code (also carried separately for
64    /// JSON consumers). The joiner pins this and aborts the join if the CA it
65    /// reaches advertises a different fingerprint (ADR-017 F3).
66    pub ca_fingerprint: String,
67}
68
69// ── Member-side key custody (ADR-015 F1, local daemon endpoints) ─────
70
71/// Ask the local daemon to generate this member's keypair and a CSR.
72///
73/// The daemon generates the keypair, persists the private key locally (0600),
74/// and returns only the CSR — the key never leaves the daemon, and the CLI
75/// never sees it.
76#[derive(Debug, Serialize, Deserialize, ToSchema)]
77pub struct MemberCsrRequest {
78    /// Hostname (subject CN) for the member certificate.
79    pub hostname: String,
80    /// Extra SANs the member wants in its cert (IPs/aliases).
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub sans: Vec<String>,
83}
84
85/// Response carrying the member's CSR (PEM). The private key stays on the daemon.
86#[derive(Debug, Serialize, Deserialize, ToSchema)]
87pub struct MemberCsrResponse {
88    pub csr: String,
89}
90
91/// Ask the local daemon to install a CA-signed cert next to the member key.
92///
93/// When `ca_endpoint` + `ca_fingerprint` are supplied (the normal join flow), the
94/// daemon also persists the **member renewal state** (`certmesh/member.json`) so
95/// the background loop can later pull a rotate-key renewal from the CA over mTLS
96/// (ADR-017 F6). They are optional so a bare cert install (e.g. re-install) still
97/// works without re-arming renewal.
98#[derive(Debug, Serialize, Deserialize, ToSchema)]
99pub struct InstallCertRequest {
100    /// Hostname whose key was prepared via [`MemberCsrRequest`].
101    pub hostname: String,
102    /// The CA-signed leaf certificate (PEM).
103    pub cert_pem: String,
104    /// The CA root certificate (PEM) to install + trust.
105    pub ca_pem: String,
106    /// The CA endpoint the joiner reached (e.g. `http://ca-host:5641`). The host
107    /// component is the mTLS renewal target. Absent → renewal state not armed.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub ca_endpoint: Option<String>,
110    /// The pinned CA fingerprint (from the join response). Absent → not armed.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub ca_fingerprint: Option<String>,
113    /// SANs the member requested (persisted so renewal CSRs carry the same set).
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub sans: Vec<String>,
116    /// CA-held lifecycle policy from the join response (drives the renew schedule).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub policy: Option<CertPolicy>,
119}
120
121/// Response after installing the member cert locally.
122#[derive(Debug, Serialize, Deserialize, ToSchema)]
123pub struct InstallCertResponse {
124    pub installed: bool,
125    pub cert_path: String,
126}
127
128/// Server response after successful enrollment.
129#[derive(Debug, Serialize, Deserialize, ToSchema)]
130pub struct JoinResponse {
131    pub hostname: String,
132    pub ca_cert: String,
133    pub service_cert: String,
134    /// The member private key. **Empty** for the CSR-based flow (ADR-015 F1) —
135    /// the member generated and kept its own key, so the CA has nothing to
136    /// return here. Only ever populated by the legacy CA-generates path.
137    #[serde(default, skip_serializing_if = "String::is_empty")]
138    pub service_key: String,
139    pub ca_fingerprint: String,
140    /// Path where the CA wrote cert files, if any. Empty for CSR-based joins —
141    /// the member persists its own cert locally.
142    #[serde(default, skip_serializing_if = "String::is_empty")]
143    pub cert_path: String,
144    /// CA-held lifecycle policy (ADR-017). The member persists this so its
145    /// background loop renews on the CA's schedule (`renew_threshold_days`) and
146    /// knows its grace window (`grace_days`). Phase 2's signed bundle refreshes it.
147    #[serde(default)]
148    pub policy: CertPolicy,
149}
150
151/// Certmesh status overview (returned by GET /status).
152///
153/// The security posture is reported as the two real booleans
154/// (`enrollment_open`, `requires_approval`); `enrollment_state` is the
155/// open/closed wire enum derived from `enrollment_open`.
156#[derive(Debug, Serialize, Deserialize, ToSchema)]
157pub struct CertmeshStatus {
158    pub ca_initialized: bool,
159    pub ca_locked: bool,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub ca_fingerprint: Option<String>,
162    /// Active authentication method ("totp", or absent if uninitialized).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub auth_method: Option<String>,
165    /// Whether the mesh is currently accepting new members.
166    pub enrollment_open: bool,
167    /// Whether joins require operator approval at the CA.
168    pub requires_approval: bool,
169    pub enrollment_state: EnrollmentState,
170    pub member_count: usize,
171    /// Monotonic roster sequence (ADR-017 F8) — the trust bundle's `seq`.
172    #[serde(default)]
173    pub seq: u64,
174    /// CA-held certificate lifecycle policy (ADR-017).
175    #[serde(default)]
176    pub policy: CertPolicy,
177    pub members: Vec<MemberSummary>,
178}
179
180/// Compact member summary for status display.
181#[derive(Debug, Serialize, Deserialize, ToSchema)]
182pub struct MemberSummary {
183    pub hostname: String,
184    pub role: String,
185    pub status: String,
186    pub cert_fingerprint: String,
187    pub cert_expires: String,
188}
189
190/// Request to set a post-renewal reload hook for this host.
191#[derive(Debug, Serialize, Deserialize, ToSchema)]
192pub struct SetHookRequest {
193    /// Hostname of the member setting the hook.
194    pub hostname: String,
195    /// Shell command to run after certificate renewal.
196    pub reload: String,
197}
198
199/// Response after setting a reload hook.
200#[derive(Debug, Serialize, ToSchema)]
201pub struct SetHookResponse {
202    pub hostname: String,
203    pub reload: String,
204}
205
206// ── Service Delegation - CA management via HTTP ─────────────────────
207
208/// POST /create request - initialize a new CA via the running service.
209///
210/// The security posture is carried as the two real booleans the roster
211/// stores (`enrollment_open`, `requires_approval`) plus `auto_unlock`, the
212/// create-time decision of whether to save the passphrase to the vault so
213/// the daemon boots unlocked. The named presets are resolved to these
214/// booleans by the ceremony/CLI before this request is built.
215#[derive(Debug, Serialize, Deserialize, ToSchema)]
216pub struct CreateCaRequest {
217    /// Passphrase for encrypting the CA key.
218    pub passphrase: String,
219    /// Hex-encoded 32-byte entropy seed (collected locally by CLI).
220    pub entropy_hex: String,
221    /// Optional operator name (recorded in the audit log).
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub operator: Option<String>,
224    /// Whether the mesh starts accepting new members.
225    #[serde(default)]
226    pub enrollment_open: bool,
227    /// Whether joins require operator approval at the CA.
228    #[serde(default)]
229    pub requires_approval: bool,
230    /// Whether to save the passphrase to the vault for automatic unlock on boot.
231    #[serde(default)]
232    pub auto_unlock: bool,
233    /// Optional hex-encoded TOTP secret.
234    ///
235    /// When provided by a ceremony-driven client, the server uses this
236    /// secret instead of generating one. The client has already shown
237    /// the QR code and verified the user's authenticator app.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub totp_secret_hex: Option<String>,
240}
241
242/// POST /create response.
243#[derive(Debug, Serialize, Deserialize, ToSchema)]
244pub struct CreateCaResponse {
245    /// Auth setup info (TOTP URI).
246    pub auth_setup: koi_crypto::auth::AuthSetup,
247    /// SHA-256 fingerprint of the CA certificate.
248    pub ca_fingerprint: String,
249}
250
251/// POST /unlock request - decrypt the CA key.
252#[derive(Debug, Serialize, Deserialize, ToSchema)]
253pub struct UnlockRequest {
254    pub passphrase: String,
255}
256
257/// POST /unlock response.
258#[derive(Debug, Serialize, Deserialize, ToSchema)]
259pub struct UnlockResponse {
260    pub success: bool,
261}
262
263/// POST /auth/rotate request - rotate the enrollment auth credential.
264#[derive(Debug, Serialize, Deserialize, ToSchema)]
265pub struct RotateAuthRequest {
266    pub passphrase: String,
267    /// Auth method to rotate to. If None, keeps the current method.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub method: Option<String>,
270}
271
272/// POST /auth/rotate response.
273#[derive(Debug, Serialize, Deserialize, ToSchema)]
274pub struct RotateAuthResponse {
275    /// Setup info for the new auth credential.
276    pub auth_setup: koi_crypto::auth::AuthSetup,
277}
278
279/// GET /log response - audit log entries.
280#[derive(Debug, Serialize, Deserialize, ToSchema)]
281pub struct AuditLogResponse {
282    pub entries: String,
283}
284
285/// POST /destroy response - CA and all certmesh state removed.
286#[derive(Debug, Serialize, Deserialize, ToSchema)]
287pub struct DestroyResponse {
288    pub destroyed: bool,
289}
290
291// ── Phase 5 - Backup/Restore/Revocation ───────────────────────────
292
293/// POST /backup request - create an encrypted backup bundle.
294#[derive(Debug, Serialize, Deserialize, ToSchema)]
295pub struct BackupRequest {
296    pub ca_passphrase: String,
297    pub backup_passphrase: String,
298}
299
300/// POST /backup response - backup encoded as hex.
301#[derive(Debug, Serialize, Deserialize, ToSchema)]
302pub struct BackupResponse {
303    pub backup_hex: String,
304    pub format: String,
305    pub version: u16,
306}
307
308/// POST /restore request - restore from an encrypted backup bundle.
309#[derive(Debug, Serialize, Deserialize, ToSchema)]
310pub struct RestoreRequest {
311    pub backup_hex: String,
312    pub backup_passphrase: String,
313    pub new_passphrase: String,
314}
315
316/// POST /restore response.
317#[derive(Debug, Serialize, Deserialize, ToSchema)]
318pub struct RestoreResponse {
319    pub restored: bool,
320}
321
322/// POST /revoke request - revoke a member.
323#[derive(Debug, Serialize, Deserialize, ToSchema)]
324pub struct RevokeRequest {
325    pub hostname: String,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub reason: Option<String>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub operator: Option<String>,
330}
331
332/// POST /revoke response.
333#[derive(Debug, Serialize, Deserialize, ToSchema)]
334pub struct RevokeResponse {
335    pub revoked: bool,
336}
337
338/// Enrollment toggle summary (open/close-enrollment responses).
339#[derive(Debug, Serialize, Deserialize, ToSchema)]
340pub struct EnrollmentSummary {
341    pub enrollment_state: EnrollmentState,
342}
343
344// ── Phase 3 - Failover + Lifecycle ──────────────────────────────────
345
346/// POST /promote request - auth-verified CA key transfer.
347#[derive(Debug, Serialize, Deserialize, ToSchema)]
348pub struct PromoteRequest {
349    pub auth: koi_crypto::auth::AuthResponse,
350    /// Client's ephemeral X25519 public key for DH key agreement.
351    /// When present, the server encrypts the CA key with the DH-derived
352    /// shared key instead of a passphrase, so the passphrase never
353    /// traverses the wire.
354    #[serde(
355        default,
356        skip_serializing_if = "Option::is_none",
357        with = "optional_byte_array"
358    )]
359    pub ephemeral_public: Option<[u8; 32]>,
360}
361
362/// POST /promote response - encrypted CA key, auth credential, and roster.
363///
364/// When DH key agreement is used (`ephemeral_public` is present), the
365/// CA key material is encrypted with the DH-derived shared key. The
366/// standby combines its own ephemeral secret with `ephemeral_public`
367/// to derive the same key and decrypt. The passphrase is never sent
368/// over the wire.
369#[derive(Debug, Serialize, Deserialize, ToSchema)]
370pub struct PromoteResponse {
371    pub encrypted_ca_key: koi_crypto::keys::EncryptedKey,
372    /// Serialized auth credential (StoredAuth JSON).
373    pub auth_data: serde_json::Value,
374    pub roster_json: String,
375    pub ca_cert_pem: String,
376    /// Server's ephemeral X25519 public key for DH key agreement.
377    /// Present only when the client sent an `ephemeral_public` in the request.
378    #[serde(
379        default,
380        skip_serializing_if = "Option::is_none",
381        with = "optional_byte_array"
382    )]
383    pub ephemeral_public: Option<[u8; 32]>,
384}
385
386/// POST /renew request — **member-initiated, CSR-only** rotate-key renewal
387/// (ADR-017 F6). The member generates a fresh keypair locally and sends only the
388/// CSR over mTLS; the CA signs it. The CA **never** generates or receives a
389/// member private key, on enroll *or* renew. Authorized by the mTLS client cert
390/// (the caller's CN must equal `hostname`).
391#[derive(Debug, Serialize, Deserialize, ToSchema)]
392pub struct RenewRequest {
393    pub hostname: String,
394    /// PKCS#10 CSR (PEM) for the member's freshly rotated keypair.
395    pub csr: String,
396}
397
398/// POST /renew response — the CA-signed leaf (no private key).
399///
400/// The member installs the leaf next to its locally held new key and runs its own
401/// reload hook; the CA performs no hook execution on the member's behalf.
402#[derive(Debug, Serialize, Deserialize, ToSchema)]
403pub struct RenewResponse {
404    pub hostname: String,
405    /// The renewed CA-signed leaf certificate (PEM).
406    pub service_cert: String,
407    /// The CA root certificate (PEM).
408    pub ca_cert: String,
409    /// The CA fingerprint, for the member to cross-check against its pin.
410    pub ca_fingerprint: String,
411    /// RFC 3339 absolute expiry of the renewed leaf.
412    pub expires: String,
413}
414
415/// Result of executing a reload hook after cert renewal.
416#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
417pub struct HookResult {
418    pub success: bool,
419    pub command: String,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub output: Option<String>,
422}
423
424/// POST /health request - member heartbeat.
425#[derive(Debug, Serialize, Deserialize, ToSchema)]
426pub struct HealthRequest {
427    pub hostname: String,
428    pub pinned_ca_fingerprint: String,
429}
430
431/// POST /health response.
432#[derive(Debug, Serialize, Deserialize, ToSchema)]
433pub struct HealthResponse {
434    pub valid: bool,
435    pub ca_fingerprint: String,
436}
437
438/// Serde helper for `Option<[u8; 32]>` — serializes as a hex string.
439mod optional_byte_array {
440    use serde::{self, Deserialize, Deserializer, Serializer};
441
442    pub fn serialize<S>(value: &Option<[u8; 32]>, serializer: S) -> Result<S::Ok, S::Error>
443    where
444        S: Serializer,
445    {
446        match value {
447            Some(bytes) => {
448                let hex = koi_common::encoding::hex_encode(bytes);
449                serializer.serialize_str(&hex)
450            }
451            None => serializer.serialize_none(),
452        }
453    }
454
455    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<[u8; 32]>, D::Error>
456    where
457        D: Deserializer<'de>,
458    {
459        let opt: Option<String> = Option::deserialize(deserializer)?;
460        match opt {
461            Some(hex) => {
462                let bytes =
463                    koi_common::encoding::hex_decode(&hex).map_err(serde::de::Error::custom)?;
464                if bytes.len() != 32 {
465                    return Err(serde::de::Error::custom(format!(
466                        "expected 32 bytes, got {}",
467                        bytes.len()
468                    )));
469                }
470                let mut arr = [0u8; 32];
471                arr.copy_from_slice(&bytes);
472                Ok(Some(arr))
473            }
474            None => Ok(None),
475        }
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn join_request_serde_round_trip() {
485        let req = JoinRequest {
486            hostname: "node-05".to_string(),
487            auth: Some(koi_crypto::auth::AuthResponse::Totp {
488                code: "123456".to_string(),
489            }),
490            invite_token: None,
491            csr: None,
492            sans: vec!["10.0.0.5".to_string()],
493        };
494        let json = serde_json::to_string(&req).unwrap();
495        let parsed: JoinRequest = serde_json::from_str(&json).unwrap();
496        assert_eq!(parsed.hostname, "node-05");
497        assert!(
498            matches!(parsed.auth, Some(koi_crypto::auth::AuthResponse::Totp { ref code }) if code == "123456")
499        );
500        assert!(parsed.invite_token.is_none());
501        assert_eq!(parsed.sans, vec!["10.0.0.5"]);
502    }
503
504    #[test]
505    fn join_request_with_invite_token_round_trip() {
506        let json = r#"{"hostname":"node-06","invite_token":"deadbeef"}"#;
507        let parsed: JoinRequest = serde_json::from_str(json).unwrap();
508        assert_eq!(parsed.hostname, "node-06");
509        assert!(parsed.auth.is_none());
510        assert_eq!(parsed.invite_token.as_deref(), Some("deadbeef"));
511        assert!(parsed.sans.is_empty());
512        // Re-serialization omits the absent auth field (happy path = no nulls).
513        let reser = serde_json::to_string(&parsed).unwrap();
514        assert!(!reser.contains("\"auth\""));
515    }
516
517    #[test]
518    fn join_request_with_csr_round_trip() {
519        let json = r#"{"hostname":"web-01","invite_token":"deadbeef","csr":"-----BEGIN CERTIFICATE REQUEST-----\nx\n-----END CERTIFICATE REQUEST-----\n"}"#;
520        let parsed: JoinRequest = serde_json::from_str(json).unwrap();
521        assert!(parsed
522            .csr
523            .as_deref()
524            .unwrap()
525            .contains("CERTIFICATE REQUEST"));
526        assert!(parsed.auth.is_none());
527    }
528
529    #[test]
530    fn member_csr_request_round_trip() {
531        let parsed: MemberCsrRequest =
532            serde_json::from_str(r#"{"hostname":"web-01","sans":["10.0.0.9"]}"#).unwrap();
533        assert_eq!(parsed.hostname, "web-01");
534        assert_eq!(parsed.sans, vec!["10.0.0.9"]);
535        // sans defaults to empty.
536        let bare: MemberCsrRequest = serde_json::from_str(r#"{"hostname":"web-01"}"#).unwrap();
537        assert!(bare.sans.is_empty());
538    }
539
540    #[test]
541    fn install_cert_request_round_trip() {
542        let req = InstallCertRequest {
543            hostname: "web-01".to_string(),
544            cert_pem: "CERT".to_string(),
545            ca_pem: "CA".to_string(),
546            ca_endpoint: Some("http://ca-host:5641".to_string()),
547            ca_fingerprint: Some("deadbeef".to_string()),
548            sans: vec!["web-01".to_string()],
549            policy: Some(CertPolicy::default()),
550        };
551        let json = serde_json::to_string(&req).unwrap();
552        let parsed: InstallCertRequest = serde_json::from_str(&json).unwrap();
553        assert_eq!(parsed.hostname, "web-01");
554        assert_eq!(parsed.cert_pem, "CERT");
555        assert_eq!(parsed.ca_endpoint.as_deref(), Some("http://ca-host:5641"));
556        assert_eq!(parsed.ca_fingerprint.as_deref(), Some("deadbeef"));
557
558        // Bare install (no renewal coords) still round-trips.
559        let bare: InstallCertRequest =
560            serde_json::from_str(r#"{"hostname":"web-01","cert_pem":"C","ca_pem":"CA"}"#).unwrap();
561        assert!(bare.ca_endpoint.is_none());
562        assert!(bare.policy.is_none());
563    }
564
565    #[test]
566    fn invite_request_defaults_ttl() {
567        let parsed: InviteRequest = serde_json::from_str(r#"{"hostname":"web-01"}"#).unwrap();
568        assert_eq!(parsed.hostname, "web-01");
569        assert_eq!(parsed.ttl_mins, 0);
570    }
571
572    #[test]
573    fn invite_response_round_trip() {
574        let resp = InviteResponse {
575            token: "abc123.deadbeef".to_string(),
576            hostname: "web-01".to_string(),
577            expires_at: "2026-06-18T12:00:00Z".to_string(),
578            ca_fingerprint: "deadbeef".to_string(),
579        };
580        let json = serde_json::to_string(&resp).unwrap();
581        let parsed: InviteResponse = serde_json::from_str(&json).unwrap();
582        assert_eq!(parsed.token, "abc123.deadbeef");
583        assert_eq!(parsed.hostname, "web-01");
584        assert_eq!(parsed.ca_fingerprint, "deadbeef");
585    }
586
587    #[test]
588    fn join_request_without_sans_deserializes() {
589        // auth field is a tagged enum; sans defaults to empty
590        let json = r#"{"hostname":"node-05","auth":{"method":"totp","code":"123456"}}"#;
591        let parsed: JoinRequest = serde_json::from_str(json).unwrap();
592        assert_eq!(parsed.hostname, "node-05");
593        assert!(parsed.sans.is_empty());
594    }
595
596    #[test]
597    fn join_response_serializes() {
598        let resp = JoinResponse {
599            hostname: "node-05".to_string(),
600            ca_cert: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
601            service_cert: "-----BEGIN CERTIFICATE-----\nsvc\n-----END CERTIFICATE-----\n"
602                .to_string(),
603            service_key: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----\n"
604                .to_string(),
605            ca_fingerprint: "abc123".to_string(),
606            cert_path: "/home/koi/.koi/certs/node-05".to_string(),
607            policy: CertPolicy::default(),
608        };
609        let json = serde_json::to_string(&resp).unwrap();
610        assert!(json.contains("node-05"));
611        assert!(json.contains("ca_fingerprint"));
612    }
613
614    #[test]
615    fn set_hook_request_serde_round_trip() {
616        let req = SetHookRequest {
617            hostname: "node-01".to_string(),
618            reload: "systemctl restart nginx".to_string(),
619        };
620        let json = serde_json::to_string(&req).unwrap();
621        let parsed: SetHookRequest = serde_json::from_str(&json).unwrap();
622        assert_eq!(parsed.hostname, "node-01");
623        assert_eq!(parsed.reload, "systemctl restart nginx");
624    }
625
626    #[test]
627    fn set_hook_response_serializes() {
628        let resp = SetHookResponse {
629            hostname: "node-01".to_string(),
630            reload: "systemctl restart nginx".to_string(),
631        };
632        let json = serde_json::to_string(&resp).unwrap();
633        assert!(json.contains("node-01"));
634        assert!(json.contains("systemctl restart nginx"));
635    }
636
637    // ── Phase 3 serde tests ──────────────────────────────────────────
638
639    #[test]
640    fn promote_request_serde_round_trip() {
641        let req = PromoteRequest {
642            auth: koi_crypto::auth::AuthResponse::Totp {
643                code: "654321".to_string(),
644            },
645            ephemeral_public: None,
646        };
647        let json = serde_json::to_string(&req).unwrap();
648        let parsed: PromoteRequest = serde_json::from_str(&json).unwrap();
649        assert!(
650            matches!(parsed.auth, koi_crypto::auth::AuthResponse::Totp { ref code } if code == "654321")
651        );
652        assert!(parsed.ephemeral_public.is_none());
653    }
654
655    #[test]
656    fn promote_request_with_ephemeral_public_round_trip() {
657        let pub_key = [42u8; 32];
658        let req = PromoteRequest {
659            auth: koi_crypto::auth::AuthResponse::Totp {
660                code: "654321".to_string(),
661            },
662            ephemeral_public: Some(pub_key),
663        };
664        let json = serde_json::to_string(&req).unwrap();
665        assert!(json.contains("ephemeral_public"));
666        let parsed: PromoteRequest = serde_json::from_str(&json).unwrap();
667        assert_eq!(parsed.ephemeral_public, Some(pub_key));
668    }
669
670    #[test]
671    fn promote_response_serde_round_trip() {
672        let resp = PromoteResponse {
673            encrypted_ca_key: koi_crypto::keys::EncryptedKey {
674                ciphertext: vec![1, 2, 3],
675                salt: vec![4, 5, 6],
676                nonce: vec![7, 8, 9],
677                kdf_params: Default::default(),
678            },
679            auth_data: serde_json::json!({"method": "totp", "encrypted_secret": {"ciphertext": [10], "salt": [11], "nonce": [12]}}),
680            roster_json: r#"{"metadata":{}}"#.to_string(),
681            ca_cert_pem: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
682            ephemeral_public: None,
683        };
684        let json = serde_json::to_string(&resp).unwrap();
685        let parsed: PromoteResponse = serde_json::from_str(&json).unwrap();
686        assert_eq!(parsed.encrypted_ca_key.ciphertext, vec![1, 2, 3]);
687        assert_eq!(parsed.ca_cert_pem.len(), resp.ca_cert_pem.len());
688        assert!(parsed.ephemeral_public.is_none());
689    }
690
691    #[test]
692    fn promote_response_with_ephemeral_public_round_trip() {
693        let server_pub = [99u8; 32];
694        let resp = PromoteResponse {
695            encrypted_ca_key: koi_crypto::keys::EncryptedKey {
696                ciphertext: vec![1, 2, 3],
697                salt: vec![4, 5, 6],
698                nonce: vec![7, 8, 9],
699                kdf_params: Default::default(),
700            },
701            auth_data: serde_json::json!({"method": "totp"}),
702            roster_json: "{}".to_string(),
703            ca_cert_pem: "cert".to_string(),
704            ephemeral_public: Some(server_pub),
705        };
706        let json = serde_json::to_string(&resp).unwrap();
707        let parsed: PromoteResponse = serde_json::from_str(&json).unwrap();
708        assert_eq!(parsed.ephemeral_public, Some(server_pub));
709    }
710
711    #[test]
712    fn renew_request_is_csr_only_no_key() {
713        // ADR-017 F6: the renewal request carries ONLY a CSR — never a private
714        // key. The struct has no `key_pem` field; assert the wire shape too.
715        let req = RenewRequest {
716            hostname: "node-05".to_string(),
717            csr: "-----BEGIN CERTIFICATE REQUEST-----\nx\n-----END CERTIFICATE REQUEST-----\n"
718                .to_string(),
719        };
720        let json = serde_json::to_string(&req).unwrap();
721        assert!(
722            !json.contains("key"),
723            "renew request must never carry a key"
724        );
725        let parsed: RenewRequest = serde_json::from_str(&json).unwrap();
726        assert_eq!(parsed.hostname, "node-05");
727        assert!(parsed.csr.contains("CERTIFICATE REQUEST"));
728    }
729
730    #[test]
731    fn renew_response_carries_cert_not_key() {
732        let resp = RenewResponse {
733            hostname: "node-05".to_string(),
734            service_cert: "-----BEGIN CERTIFICATE-----\nsvc\n-----END CERTIFICATE-----\n"
735                .to_string(),
736            ca_cert: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n".to_string(),
737            ca_fingerprint: "abc123".to_string(),
738            expires: "2026-09-15T00:00:00Z".to_string(),
739        };
740        let json = serde_json::to_string(&resp).unwrap();
741        assert!(
742            !json.contains("PRIVATE KEY"),
743            "renew response must never carry a private key"
744        );
745        let parsed: RenewResponse = serde_json::from_str(&json).unwrap();
746        assert_eq!(parsed.hostname, "node-05");
747        assert!(parsed.service_cert.contains("BEGIN CERTIFICATE"));
748        assert_eq!(parsed.ca_fingerprint, "abc123");
749    }
750
751    #[test]
752    fn hook_result_omits_none_output() {
753        let hr = HookResult {
754            success: false,
755            command: "bad-cmd".to_string(),
756            output: None,
757        };
758        let json = serde_json::to_string(&hr).unwrap();
759        assert!(!json.contains("output"));
760    }
761
762    #[test]
763    fn health_request_serde_round_trip() {
764        let req = HealthRequest {
765            hostname: "node-05".to_string(),
766            pinned_ca_fingerprint: "abcdef".to_string(),
767        };
768        let json = serde_json::to_string(&req).unwrap();
769        let parsed: HealthRequest = serde_json::from_str(&json).unwrap();
770        assert_eq!(parsed.hostname, "node-05");
771        assert_eq!(parsed.pinned_ca_fingerprint, "abcdef");
772    }
773
774    #[test]
775    fn health_response_serde_round_trip() {
776        let resp = HealthResponse {
777            valid: true,
778            ca_fingerprint: "cafp123".to_string(),
779        };
780        let json = serde_json::to_string(&resp).unwrap();
781        let parsed: HealthResponse = serde_json::from_str(&json).unwrap();
782        assert!(parsed.valid);
783        assert_eq!(parsed.ca_fingerprint, "cafp123");
784    }
785
786    // ── Phase 2 tests ──────────────────────────────────────────────────
787
788    #[test]
789    fn certmesh_status_serializes() {
790        let status = CertmeshStatus {
791            ca_initialized: true,
792            ca_locked: false,
793            ca_fingerprint: Some("abc123".to_string()),
794            auth_method: None,
795            enrollment_open: true,
796            requires_approval: false,
797            enrollment_state: EnrollmentState::Open,
798            member_count: 1,
799            seq: 0,
800            policy: CertPolicy::default(),
801            members: vec![MemberSummary {
802                hostname: "node-01".to_string(),
803                role: "primary".to_string(),
804                status: "active".to_string(),
805                cert_fingerprint: "abc".to_string(),
806                cert_expires: "2026-03-13T00:00:00Z".to_string(),
807            }],
808        };
809        let json = serde_json::to_string(&status).unwrap();
810        assert!(json.contains("\"ca_initialized\":true"));
811        assert!(json.contains("\"member_count\":1"));
812        assert!(json.contains("\"enrollment_open\":true"));
813        assert!(json.contains("\"requires_approval\":false"));
814    }
815
816    #[test]
817    fn certmesh_status_reports_posture_booleans() {
818        let status = CertmeshStatus {
819            ca_initialized: true,
820            ca_locked: false,
821            ca_fingerprint: Some("fp-org".to_string()),
822            auth_method: None,
823            enrollment_open: false,
824            requires_approval: true,
825            enrollment_state: EnrollmentState::Closed,
826            member_count: 0,
827            seq: 0,
828            policy: CertPolicy::default(),
829            members: vec![],
830        };
831        let json = serde_json::to_string(&status).unwrap();
832        assert!(json.contains("\"enrollment_open\":false"));
833        assert!(json.contains("\"requires_approval\":true"));
834        assert!(json.contains("\"enrollment_state\":\"closed\""));
835    }
836
837    // ── Service delegation serde tests ──────────────────────────────
838
839    #[test]
840    fn create_ca_request_serde_round_trip() {
841        let req = CreateCaRequest {
842            passphrase: "hunter2".to_string(),
843            entropy_hex: "0a1b2c3d".to_string(),
844            operator: None,
845            enrollment_open: true,
846            requires_approval: false,
847            auto_unlock: true,
848            totp_secret_hex: None,
849        };
850        let json = serde_json::to_string(&req).unwrap();
851        let parsed: CreateCaRequest = serde_json::from_str(&json).unwrap();
852        assert_eq!(parsed.passphrase, "hunter2");
853        assert_eq!(parsed.entropy_hex, "0a1b2c3d");
854        assert!(parsed.operator.is_none());
855        assert!(parsed.enrollment_open);
856        assert!(!parsed.requires_approval);
857        assert!(parsed.auto_unlock);
858    }
859
860    #[test]
861    fn create_ca_request_with_operator() {
862        let req = CreateCaRequest {
863            passphrase: "pass".to_string(),
864            entropy_hex: "ff".to_string(),
865            operator: Some("ops@acme.com".to_string()),
866            enrollment_open: false,
867            requires_approval: true,
868            auto_unlock: false,
869            totp_secret_hex: None,
870        };
871        let json = serde_json::to_string(&req).unwrap();
872        let parsed: CreateCaRequest = serde_json::from_str(&json).unwrap();
873        assert_eq!(parsed.operator.as_deref(), Some("ops@acme.com"));
874        assert!(!parsed.enrollment_open);
875        assert!(parsed.requires_approval);
876        assert!(!parsed.auto_unlock);
877    }
878
879    #[test]
880    fn create_ca_request_omits_none_operator() {
881        let req = CreateCaRequest {
882            passphrase: "p".to_string(),
883            entropy_hex: "aa".to_string(),
884            operator: None,
885            enrollment_open: false,
886            requires_approval: false,
887            auto_unlock: false,
888            totp_secret_hex: None,
889        };
890        let json = serde_json::to_string(&req).unwrap();
891        assert!(!json.contains("operator"));
892    }
893
894    #[test]
895    fn create_ca_response_serde_round_trip() {
896        let resp = CreateCaResponse {
897            auth_setup: koi_crypto::auth::AuthSetup::Totp {
898                totp_uri: "otpauth://totp/Koi:admin?secret=ABC123".to_string(),
899            },
900            ca_fingerprint: "sha256:abcdef".to_string(),
901        };
902        let json = serde_json::to_string(&resp).unwrap();
903        let parsed: CreateCaResponse = serde_json::from_str(&json).unwrap();
904        assert!(json.contains("ABC123"));
905        assert_eq!(parsed.ca_fingerprint, "sha256:abcdef");
906    }
907
908    #[test]
909    fn unlock_request_serde_round_trip() {
910        let req = UnlockRequest {
911            passphrase: "my-secret".to_string(),
912        };
913        let json = serde_json::to_string(&req).unwrap();
914        let parsed: UnlockRequest = serde_json::from_str(&json).unwrap();
915        assert_eq!(parsed.passphrase, "my-secret");
916    }
917
918    #[test]
919    fn unlock_response_serde_round_trip() {
920        let resp = UnlockResponse { success: true };
921        let json = serde_json::to_string(&resp).unwrap();
922        let parsed: UnlockResponse = serde_json::from_str(&json).unwrap();
923        assert!(parsed.success);
924    }
925
926    #[test]
927    fn rotate_auth_request_serde_round_trip() {
928        let req = RotateAuthRequest {
929            passphrase: "rotate-pass".to_string(),
930            method: None,
931        };
932        let json = serde_json::to_string(&req).unwrap();
933        let parsed: RotateAuthRequest = serde_json::from_str(&json).unwrap();
934        assert_eq!(parsed.passphrase, "rotate-pass");
935    }
936
937    #[test]
938    fn rotate_auth_response_serde_round_trip() {
939        let resp = RotateAuthResponse {
940            auth_setup: koi_crypto::auth::AuthSetup::Totp {
941                totp_uri: "otpauth://totp/Koi:admin?secret=NEWBASE32".to_string(),
942            },
943        };
944        let json = serde_json::to_string(&resp).unwrap();
945        let _: RotateAuthResponse = serde_json::from_str(&json).unwrap();
946        assert!(json.contains("NEWBASE32"));
947    }
948
949    #[test]
950    fn audit_log_response_serde_round_trip() {
951        let resp = AuditLogResponse {
952            entries: "2026-02-11T00:00:00Z ca_initialized\n".to_string(),
953        };
954        let json = serde_json::to_string(&resp).unwrap();
955        let parsed: AuditLogResponse = serde_json::from_str(&json).unwrap();
956        assert!(parsed.entries.contains("ca_initialized"));
957    }
958
959    #[test]
960    fn destroy_response_serde_round_trip() {
961        let resp = DestroyResponse { destroyed: true };
962        let json = serde_json::to_string(&resp).unwrap();
963        let parsed: DestroyResponse = serde_json::from_str(&json).unwrap();
964        assert!(parsed.destroyed);
965    }
966
967    #[test]
968    fn certmesh_status_serde_round_trip() {
969        let status = CertmeshStatus {
970            ca_initialized: true,
971            ca_locked: false,
972            ca_fingerprint: Some("fp-round-trip".to_string()),
973            auth_method: None,
974            enrollment_open: true,
975            requires_approval: true,
976            enrollment_state: EnrollmentState::Open,
977            member_count: 2,
978            seq: 5,
979            policy: CertPolicy::default(),
980            members: vec![
981                MemberSummary {
982                    hostname: "node-01".to_string(),
983                    role: "primary".to_string(),
984                    status: "active".to_string(),
985                    cert_fingerprint: "fp1".to_string(),
986                    cert_expires: "2026-06-01".to_string(),
987                },
988                MemberSummary {
989                    hostname: "node-02".to_string(),
990                    role: "member".to_string(),
991                    status: "active".to_string(),
992                    cert_fingerprint: "fp2".to_string(),
993                    cert_expires: "2026-06-01".to_string(),
994                },
995            ],
996        };
997        let json = serde_json::to_string(&status).unwrap();
998        let parsed: CertmeshStatus = serde_json::from_str(&json).unwrap();
999        assert!(parsed.ca_initialized);
1000        assert!(!parsed.ca_locked);
1001        assert!(parsed.enrollment_open);
1002        assert!(parsed.requires_approval);
1003        assert_eq!(parsed.member_count, 2);
1004        assert_eq!(parsed.members.len(), 2);
1005        assert_eq!(parsed.members[0].hostname, "node-01");
1006        assert_eq!(parsed.members[1].hostname, "node-02");
1007    }
1008
1009    #[test]
1010    fn certmesh_status_uninitialized_round_trip() {
1011        let status = CertmeshStatus {
1012            ca_initialized: false,
1013            ca_locked: false,
1014            ca_fingerprint: None,
1015            auth_method: None,
1016            enrollment_open: false,
1017            requires_approval: false,
1018            enrollment_state: EnrollmentState::Closed,
1019            member_count: 0,
1020            seq: 0,
1021            policy: CertPolicy::default(),
1022            members: vec![],
1023        };
1024        let json = serde_json::to_string(&status).unwrap();
1025        let parsed: CertmeshStatus = serde_json::from_str(&json).unwrap();
1026        assert!(!parsed.ca_initialized);
1027        assert_eq!(parsed.member_count, 0);
1028        assert!(parsed.members.is_empty());
1029    }
1030
1031    #[test]
1032    fn enrollment_summary_serializes() {
1033        let summary = EnrollmentSummary {
1034            enrollment_state: EnrollmentState::Open,
1035        };
1036        let json = serde_json::to_string(&summary).unwrap();
1037        assert!(json.contains("\"enrollment_state\":\"open\""));
1038    }
1039}