use std::path::Path; use std::process::Command; use std::str::FromStr; use geth_types::{NodeId, SshCertId, SshCertRequestId, SshRevocationId, UnixMillis}; use serde::{Deserialize, Serialize}; pub const KEYCHAIN_NAMESPACE: &str = "geth.keychain.v1@geth.local"; pub const AUTH_OP_NAMESPACE: &str = "geth.auth-op.v1@geth.local"; pub const RESOURCE_GRANT_NAMESPACE: &str = "geth.resource-grant.v1@geth.local"; pub const RESOURCE_SECRET_NAMESPACE: &str = "geth.resource-secret.v1@geth.local"; pub const REVOCATION_NAMESPACE: &str = "geth.revocation.v1@geth.local"; pub const SSH_CERT_REQUEST_NAMESPACE: &str = "geth.ssh-cert-request.v1@geth.local"; pub const SSH_CERT_ISSUANCE_NAMESPACE: &str = "geth.ssh-cert-issuance.v1@geth.local"; pub const SSH_REVOCATION_LIST_NAMESPACE: &str = "geth.ssh-revocation-list.v1@geth.local"; pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> { let output = Command::new("ssh-keygen").arg("-?").output(); match output { Ok(_) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Err(SshIdentityError::SshKeygenUnavailable) } Err(error) => Err(SshIdentityError::Io(error)), } } pub fn sign_command(key_path: &Path, namespace: &str, input_path: &Path) -> Command { let mut command = Command::new("ssh-keygen"); command .arg("-Y") .arg("sign") .arg("-f") .arg(key_path) .arg("-n") .arg(namespace) .arg(input_path); command } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SshCertKind { User, Host, } impl SshCertKind { #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::User => "user", Self::Host => "host", } } } impl std::fmt::Display for SshCertKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl std::str::FromStr for SshCertKind { type Err = SshIdentityError; fn from_str(value: &str) -> Result { match value { "user" => Ok(Self::User), "host" => Ok(Self::Host), _ => Err(SshIdentityError::InvalidCertKind(value.to_owned())), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SshCertRequestStatus { Pending, Approved, Signed, Rejected, Revoked, } impl SshCertRequestStatus { #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::Pending => "pending", Self::Approved => "approved", Self::Signed => "signed", Self::Rejected => "rejected", Self::Revoked => "revoked", } } } impl std::fmt::Display for SshCertRequestStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl std::str::FromStr for SshCertRequestStatus { type Err = SshIdentityError; fn from_str(value: &str) -> Result { match value { "pending" => Ok(Self::Pending), "approved" => Ok(Self::Approved), "signed" => Ok(Self::Signed), "rejected" => Ok(Self::Rejected), "revoked" => Ok(Self::Revoked), _ => Err(SshIdentityError::InvalidRequestStatus(value.to_owned())), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SshCertRequest { pub id: SshCertRequestId, pub requester_node: NodeId, pub public_key: String, pub public_key_fingerprint: String, pub cert_kind: SshCertKind, pub principals: Vec, pub requested_validity: Option, pub renewal_of: Option, pub reason: Option, pub status: SshCertRequestStatus, pub created_at: UnixMillis, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SshCertApproval { pub request_id: SshCertRequestId, pub approved_by_node: NodeId, pub ca_key_path: String, pub key_id: String, pub valid_for: String, pub serial: Option, pub output_path: Option, pub signing_command: Vec, pub note: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SshCertificateRecord { pub id: SshCertId, pub request_id: SshCertRequestId, pub certificate: String, pub certificate_fingerprint: String, pub imported_at: UnixMillis, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SshRevocationKind { PublicKey, Certificate, Serial, KeyId, } impl SshRevocationKind { #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::PublicKey => "public-key", Self::Certificate => "certificate", Self::Serial => "serial", Self::KeyId => "key-id", } } } impl std::fmt::Display for SshRevocationKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl std::str::FromStr for SshRevocationKind { type Err = SshIdentityError; fn from_str(value: &str) -> Result { match value { "public-key" | "key" => Ok(Self::PublicKey), "certificate" | "cert" => Ok(Self::Certificate), "serial" => Ok(Self::Serial), "key-id" | "keyid" => Ok(Self::KeyId), _ => Err(SshIdentityError::InvalidRevocationKind(value.to_owned())), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SshRevocationEntry { pub id: SshRevocationId, pub kind: SshRevocationKind, pub target: String, pub reason: Option, pub created_at: UnixMillis, pub published: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SshRevocationExportFormat { Jsonl, OpenSshKrlSpec, OpenSshKrl, } impl SshRevocationExportFormat { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Jsonl => "jsonl", Self::OpenSshKrlSpec => "openssh-krl-spec", Self::OpenSshKrl => "openssh-krl", } } } impl std::fmt::Display for SshRevocationExportFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl FromStr for SshRevocationExportFormat { type Err = SshIdentityError; fn from_str(value: &str) -> Result { match value { "jsonl" => Ok(Self::Jsonl), "openssh-krl-spec" | "krl-spec" => Ok(Self::OpenSshKrlSpec), "openssh-krl" | "krl" => Ok(Self::OpenSshKrl), _ => Err(SshIdentityError::InvalidRevocationExportFormat( value.to_owned(), )), } } } #[derive(Debug, thiserror::Error)] pub enum SshCertFlowError { #[error("certificate request has no principals")] MissingPrincipals, } #[must_use] pub fn ssh_public_key_fingerprint(public_key: &str) -> String { format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes())) } pub fn cert_request_id( requester_node: &NodeId, public_key: &str, principals: &[String], created_at: UnixMillis, ) -> SshCertRequestId { let mut input = String::new(); input.push_str(requester_node.as_str()); input.push('\n'); input.push_str(public_key.trim()); input.push('\n'); input.push_str(&principals.join(",")); input.push('\n'); input.push_str(&created_at.0.to_string()); SshCertRequestId::new(format!( "ssh-cert-request:{}", blake3::hash(input.as_bytes()) )) } pub fn certificate_id(certificate: &str) -> SshCertId { SshCertId::new(format!( "ssh-cert:{}", blake3::hash(certificate.trim().as_bytes()) )) } pub fn revocation_id( kind: &SshRevocationKind, target: &str, created_at: UnixMillis, ) -> SshRevocationId { let input = format!("{}\n{}\n{}", kind, target.trim(), created_at.0); SshRevocationId::new(format!("ssh-revocation:{}", blake3::hash(input.as_bytes()))) } pub fn build_ssh_cert_sign_command( request: &SshCertRequest, ca_key_path: &Path, public_key_path: &Path, valid_for: &str, serial: Option, ) -> Result, SshCertFlowError> { if request.principals.is_empty() { return Err(SshCertFlowError::MissingPrincipals); } let mut command = vec![ "ssh-keygen".to_owned(), "-s".to_owned(), ca_key_path.display().to_string(), "-I".to_owned(), request.id.to_string(), "-n".to_owned(), request.principals.join(","), "-V".to_owned(), valid_for.to_owned(), ]; if let Some(serial) = serial { command.push("-z".to_owned()); command.push(serial.to_string()); } if request.cert_kind == SshCertKind::Host { command.push("-h".to_owned()); } command.push(public_key_path.display().to_string()); Ok(command) } pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result { let mut spec = String::new(); for entry in entries { spec.push_str(&openssh_krl_spec_line(entry)?); spec.push('\n'); } Ok(spec) } pub fn write_openssh_krl( entries: &[SshRevocationEntry], out: &Path, ca_public: Option<&Path>, ) -> Result<(), SshIdentityError> { ensure_ssh_keygen_available()?; if let Some(parent) = out.parent() { std::fs::create_dir_all(parent)?; } let spec = openssh_krl_spec(entries)?; let spec_path = out.with_extension("geth-krl-spec.tmp"); std::fs::write(&spec_path, spec)?; let mut command = Command::new("ssh-keygen"); command.arg("-k").arg("-f").arg(out); if let Some(ca_public) = ca_public { command.arg("-s").arg(ca_public); } command.arg(&spec_path); let output = command.output()?; let _ = std::fs::remove_file(&spec_path); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); return Err(SshIdentityError::SshKeygenFailed(stderr)); } Ok(()) } pub fn openssh_krl_spec_line(entry: &SshRevocationEntry) -> Result { let target = entry.target.trim(); if target.is_empty() || target.bytes().any(|byte| byte == b'\n' || byte == b'\r') { return Err(SshIdentityError::InvalidRevocationTarget( entry.target.clone(), )); } let directive = match entry.kind { SshRevocationKind::PublicKey | SshRevocationKind::Certificate => "key", SshRevocationKind::Serial => "serial", SshRevocationKind::KeyId => "id", }; Ok(format!("{directive}: {target}")) } #[derive(Debug, thiserror::Error)] pub enum SshIdentityError { #[error("ssh-keygen failed or is unavailable")] SshKeygenUnavailable, #[error("ssh-keygen failed: {0}")] SshKeygenFailed(String), #[error("invalid ssh certificate kind: {0}")] InvalidCertKind(String), #[error("invalid ssh certificate request status: {0}")] InvalidRequestStatus(String), #[error("invalid ssh revocation kind: {0}")] InvalidRevocationKind(String), #[error("invalid ssh revocation export format: {0}")] InvalidRevocationExportFormat(String), #[error("invalid ssh revocation target: {0}")] InvalidRevocationTarget(String), #[error("io error: {0}")] Io(#[from] std::io::Error), } #[cfg(test)] mod tests { use super::*; #[test] fn ssh_cert_request_roundtrips() { let request = SshCertRequest { id: "ssh-cert-request:1".into(), requester_node: "node:laptop".into(), public_key: "ssh-ed25519 AAAA test".to_owned(), public_key_fingerprint: ssh_public_key_fingerprint("ssh-ed25519 AAAA test"), cert_kind: SshCertKind::User, principals: vec!["eric".to_owned()], requested_validity: Some("+52w".to_owned()), renewal_of: None, reason: Some("bootstrap".to_owned()), status: SshCertRequestStatus::Pending, created_at: UnixMillis(1), }; let json = serde_json::to_string(&request).expect("json"); let decoded: SshCertRequest = serde_json::from_str(&json).expect("decode"); assert_eq!(decoded, request); } #[test] fn sign_command_includes_host_flag_for_host_certs() { let request = SshCertRequest { id: "ssh-cert-request:1".into(), requester_node: "node:server".into(), public_key: "ssh-ed25519 AAAA host".to_owned(), public_key_fingerprint: ssh_public_key_fingerprint("ssh-ed25519 AAAA host"), cert_kind: SshCertKind::Host, principals: vec!["server.local".to_owned()], requested_validity: None, renewal_of: None, reason: None, status: SshCertRequestStatus::Pending, created_at: UnixMillis(1), }; let command = build_ssh_cert_sign_command( &request, Path::new("/keys/ca_sk"), Path::new("/tmp/host.pub"), "+4w", Some(7), ) .expect("command"); assert!(command.contains(&"-h".to_owned())); assert!(command.contains(&"server.local".to_owned())); } #[test] fn openssh_krl_spec_maps_revocation_kinds() { let entries = vec![ SshRevocationEntry { id: "ssh-revocation:1".into(), kind: SshRevocationKind::Serial, target: "42".to_owned(), reason: None, created_at: UnixMillis(1), published: false, }, SshRevocationEntry { id: "ssh-revocation:2".into(), kind: SshRevocationKind::KeyId, target: "node:laptop".to_owned(), reason: None, created_at: UnixMillis(2), published: false, }, SshRevocationEntry { id: "ssh-revocation:3".into(), kind: SshRevocationKind::PublicKey, target: "ssh-ed25519 AAAA test".to_owned(), reason: None, created_at: UnixMillis(3), published: false, }, ]; let spec = openssh_krl_spec(&entries).expect("krl spec"); assert!(spec.contains("serial: 42\n")); assert!(spec.contains("id: node:laptop\n")); assert!(spec.contains("key: ssh-ed25519 AAAA test\n")); } #[test] fn openssh_krl_spec_rejects_multiline_targets() { let entry = SshRevocationEntry { id: "ssh-revocation:1".into(), kind: SshRevocationKind::PublicKey, target: "ssh-ed25519 AAAA\nextra".to_owned(), reason: None, created_at: UnixMillis(1), published: false, }; assert!(openssh_krl_spec_line(&entry).is_err()); } #[test] fn openssh_krl_binary_export_revokes_public_key_when_ssh_keygen_available() { if ensure_ssh_keygen_available().is_err() { return; } let dir = tempfile::tempdir().expect("tempdir"); let key_path = dir.path().join("revoked"); let status = Command::new("ssh-keygen") .arg("-q") .arg("-t") .arg("ed25519") .arg("-N") .arg("") .arg("-f") .arg(&key_path) .status() .expect("run ssh-keygen"); assert!(status.success()); let public_key_path = key_path.with_extension("pub"); let public_key = std::fs::read_to_string(&public_key_path).expect("read public key"); let entry = SshRevocationEntry { id: "ssh-revocation:key".into(), kind: SshRevocationKind::PublicKey, target: public_key, reason: Some("test".to_owned()), created_at: UnixMillis(1), published: true, }; let krl_path = dir.path().join("revoked.krl"); write_openssh_krl(&[entry], &krl_path, None).expect("write krl"); let output = Command::new("ssh-keygen") .arg("-Q") .arg("-f") .arg(&krl_path) .arg(&public_key_path) .output() .expect("query krl"); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stdout) .to_ascii_lowercase() .contains("revoked") ); } }