geth/crates/geth-ssh-identity/src/lib.rs

606 lines
18 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use std::path::Path;
use std::process::Command;
2026-05-17 18:29:47 +02:00
use std::str::FromStr;
2026-05-15 15:08:20 +02:00
use geth_types::{NodeId, SshCertId, SshCertRequestId, SshRevocationId, UnixMillis};
use serde::{Deserialize, Serialize};
2026-05-15 15:08:20 +02:00
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";
2026-05-15 15:08:20 +02:00
pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> {
2026-05-18 11:51:12 +02:00
let output = Command::new("ssh-keygen").arg("-?").output();
match output {
2026-05-15 15:08:20 +02:00
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<Self, Self::Err> {
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<Self, Self::Err> {
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<String>,
pub requested_validity: Option<String>,
pub renewal_of: Option<SshCertId>,
pub reason: Option<String>,
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<u64>,
pub output_path: Option<String>,
pub signing_command: Vec<String>,
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<Self, Self::Err> {
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<String>,
pub created_at: UnixMillis,
pub published: bool,
}
2026-05-17 18:29:47 +02:00
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshRevocationExportFormat {
Jsonl,
OpenSshKrlSpec,
2026-05-18 11:51:12 +02:00
OpenSshKrl,
2026-05-17 18:29:47 +02:00
}
impl SshRevocationExportFormat {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Jsonl => "jsonl",
Self::OpenSshKrlSpec => "openssh-krl-spec",
2026-05-18 11:51:12 +02:00
Self::OpenSshKrl => "openssh-krl",
2026-05-17 18:29:47 +02:00
}
}
}
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<Self, Self::Err> {
match value {
"jsonl" => Ok(Self::Jsonl),
"openssh-krl-spec" | "krl-spec" => Ok(Self::OpenSshKrlSpec),
2026-05-18 11:51:12 +02:00
"openssh-krl" | "krl" => Ok(Self::OpenSshKrl),
2026-05-17 18:29:47 +02:00
_ => 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<u64>,
) -> Result<Vec<String>, 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)
}
2026-05-17 18:29:47 +02:00
pub fn openssh_krl_spec(entries: &[SshRevocationEntry]) -> Result<String, SshIdentityError> {
let mut spec = String::new();
for entry in entries {
spec.push_str(&openssh_krl_spec_line(entry)?);
spec.push('\n');
}
Ok(spec)
}
2026-05-18 11:56:42 +02:00
pub fn parse_openssh_krl_spec(
spec: &str,
) -> Result<Vec<(SshRevocationKind, String)>, SshIdentityError> {
let mut entries = Vec::new();
for line in spec.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((directive, target)) = line.split_once(':') else {
return Err(SshIdentityError::InvalidKrlSpecLine(line.to_owned()));
};
let target = target.trim();
if target.is_empty() || target.bytes().any(|byte| byte == b'\n' || byte == b'\r') {
return Err(SshIdentityError::InvalidRevocationTarget(target.to_owned()));
}
let kind = match directive.trim() {
"key" => SshRevocationKind::PublicKey,
"serial" => SshRevocationKind::Serial,
"id" => SshRevocationKind::KeyId,
other => return Err(SshIdentityError::InvalidKrlSpecLine(other.to_owned())),
};
entries.push((kind, target.to_owned()));
}
Ok(entries)
}
2026-05-18 11:51:12 +02:00
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(())
}
2026-05-17 18:29:47 +02:00
pub fn openssh_krl_spec_line(entry: &SshRevocationEntry) -> Result<String, SshIdentityError> {
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,
2026-05-18 11:51:12 +02:00
#[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),
2026-05-17 18:29:47 +02:00
#[error("invalid ssh revocation export format: {0}")]
InvalidRevocationExportFormat(String),
#[error("invalid ssh revocation target: {0}")]
InvalidRevocationTarget(String),
2026-05-18 11:56:42 +02:00
#[error("invalid OpenSSH KRL specification line: {0}")]
InvalidKrlSpecLine(String),
#[error("binary OpenSSH KRL files cannot be enumerated; import JSONL or the KRL spec source")]
BinaryKrlImportUnsupported,
#[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()));
}
2026-05-17 18:29:47 +02:00
#[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"));
}
2026-05-18 11:56:42 +02:00
#[test]
fn openssh_krl_spec_import_parses_supported_directives() {
let parsed = parse_openssh_krl_spec(
r#"
# comment
serial: 42
id: node:laptop
key: ssh-ed25519 AAAA test
"#,
)
.expect("parse spec");
assert_eq!(
parsed,
vec![
(SshRevocationKind::Serial, "42".to_owned()),
(SshRevocationKind::KeyId, "node:laptop".to_owned()),
(
SshRevocationKind::PublicKey,
"ssh-ed25519 AAAA test".to_owned()
),
]
);
}
2026-05-17 18:29:47 +02:00
#[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());
}
2026-05-18 11:51:12 +02:00
#[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")
);
}
}