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

351 lines
10 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use std::path::Path;
use std::process::Command;
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> {
let status = Command::new("ssh-keygen").arg("-?").status();
match status {
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,
}
#[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)
}
#[derive(Debug, thiserror::Error)]
pub enum SshIdentityError {
#[error("ssh-keygen failed or is unavailable")]
SshKeygenUnavailable,
#[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("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()));
}
}