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
|
|
|
|
2026-05-16 00:17:08 +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";
|
2026-05-16 00:17:08 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-16 00:17:08 +02:00
|
|
|
|
|
|
|
|
#[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())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 01:29:55 +02:00
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct SshRecordProvenance {
|
|
|
|
|
pub namespace: String,
|
|
|
|
|
pub signer_node: NodeId,
|
|
|
|
|
pub signer_agent: String,
|
|
|
|
|
pub signer_public_key: String,
|
|
|
|
|
pub signature_hex: String,
|
|
|
|
|
pub signed_at: UnixMillis,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:17:08 +02:00
|
|
|
#[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,
|
2026-05-21 01:29:55 +02:00
|
|
|
pub provenance: Option<SshRecordProvenance>,
|
2026-05-16 00:17:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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>,
|
2026-05-19 15:56:47 +02:00
|
|
|
pub signed: bool,
|
|
|
|
|
pub certificate_id: Option<SshCertId>,
|
2026-05-16 00:17:08 +02:00
|
|
|
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,
|
2026-05-21 01:29:55 +02:00
|
|
|
pub provenance: Option<SshRecordProvenance>,
|
2026-05-16 00:17:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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-21 01:29:55 +02:00
|
|
|
pub provenance: Option<SshRecordProvenance>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct SshCertRequestSigningPayload {
|
|
|
|
|
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 SshCertificateSigningPayload {
|
|
|
|
|
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)]
|
|
|
|
|
pub struct SshRevocationSigningPayload {
|
|
|
|
|
pub id: SshRevocationId,
|
|
|
|
|
pub kind: SshRevocationKind,
|
|
|
|
|
pub target: String,
|
|
|
|
|
pub reason: Option<String>,
|
|
|
|
|
pub created_at: UnixMillis,
|
|
|
|
|
pub published: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn ssh_cert_request_signing_payload(request: &SshCertRequest) -> SshCertRequestSigningPayload {
|
|
|
|
|
SshCertRequestSigningPayload {
|
|
|
|
|
id: request.id.clone(),
|
|
|
|
|
requester_node: request.requester_node.clone(),
|
|
|
|
|
public_key: request.public_key.clone(),
|
|
|
|
|
public_key_fingerprint: request.public_key_fingerprint.clone(),
|
|
|
|
|
cert_kind: request.cert_kind.clone(),
|
|
|
|
|
principals: request.principals.clone(),
|
|
|
|
|
requested_validity: request.requested_validity.clone(),
|
|
|
|
|
renewal_of: request.renewal_of.clone(),
|
|
|
|
|
reason: request.reason.clone(),
|
|
|
|
|
status: request.status.clone(),
|
|
|
|
|
created_at: request.created_at,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn ssh_certificate_signing_payload(
|
|
|
|
|
certificate: &SshCertificateRecord,
|
|
|
|
|
) -> SshCertificateSigningPayload {
|
|
|
|
|
SshCertificateSigningPayload {
|
|
|
|
|
id: certificate.id.clone(),
|
|
|
|
|
request_id: certificate.request_id.clone(),
|
|
|
|
|
certificate: certificate.certificate.clone(),
|
|
|
|
|
certificate_fingerprint: certificate.certificate_fingerprint.clone(),
|
|
|
|
|
imported_at: certificate.imported_at,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn ssh_revocation_signing_payload(
|
|
|
|
|
revocation: &SshRevocationEntry,
|
|
|
|
|
) -> SshRevocationSigningPayload {
|
|
|
|
|
SshRevocationSigningPayload {
|
|
|
|
|
id: revocation.id.clone(),
|
|
|
|
|
kind: revocation.kind.clone(),
|
|
|
|
|
target: revocation.target.clone(),
|
|
|
|
|
reason: revocation.reason.clone(),
|
|
|
|
|
created_at: revocation.created_at,
|
|
|
|
|
published: revocation.published,
|
|
|
|
|
}
|
2026-05-16 00:17:08 +02:00
|
|
|
}
|
|
|
|
|
|
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(),
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:17:08 +02:00
|
|
|
#[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}"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:17:08 +02:00
|
|
|
#[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),
|
2026-05-16 00:17:08 +02:00
|
|
|
#[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,
|
2026-05-16 00:17:08 +02:00
|
|
|
#[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),
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-16 00:17:08 +02:00
|
|
|
};
|
|
|
|
|
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),
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-16 00:17:08 +02:00
|
|
|
};
|
|
|
|
|
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,
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-17 18:29:47 +02:00
|
|
|
},
|
|
|
|
|
SshRevocationEntry {
|
|
|
|
|
id: "ssh-revocation:2".into(),
|
|
|
|
|
kind: SshRevocationKind::KeyId,
|
|
|
|
|
target: "node:laptop".to_owned(),
|
|
|
|
|
reason: None,
|
|
|
|
|
created_at: UnixMillis(2),
|
|
|
|
|
published: false,
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-17 18:29:47 +02:00
|
|
|
},
|
|
|
|
|
SshRevocationEntry {
|
|
|
|
|
id: "ssh-revocation:3".into(),
|
|
|
|
|
kind: SshRevocationKind::PublicKey,
|
|
|
|
|
target: "ssh-ed25519 AAAA test".to_owned(),
|
|
|
|
|
reason: None,
|
|
|
|
|
created_at: UnixMillis(3),
|
|
|
|
|
published: false,
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-17 18:29:47 +02:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-17 18:29:47 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-18 11:51:12 +02:00
|
|
|
};
|
|
|
|
|
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")
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-18 11:58:39 +02:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn openssh_krl_binary_export_revokes_certificate_when_ssh_keygen_available() {
|
|
|
|
|
if ensure_ssh_keygen_available().is_err() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let ca_key_path = dir.path().join("ca");
|
|
|
|
|
let user_key_path = dir.path().join("user");
|
|
|
|
|
let ca_status = Command::new("ssh-keygen")
|
|
|
|
|
.arg("-q")
|
|
|
|
|
.arg("-t")
|
|
|
|
|
.arg("ed25519")
|
|
|
|
|
.arg("-N")
|
|
|
|
|
.arg("")
|
|
|
|
|
.arg("-f")
|
|
|
|
|
.arg(&ca_key_path)
|
|
|
|
|
.status()
|
|
|
|
|
.expect("generate ca key");
|
|
|
|
|
assert!(ca_status.success());
|
|
|
|
|
|
|
|
|
|
let user_status = Command::new("ssh-keygen")
|
|
|
|
|
.arg("-q")
|
|
|
|
|
.arg("-t")
|
|
|
|
|
.arg("ed25519")
|
|
|
|
|
.arg("-N")
|
|
|
|
|
.arg("")
|
|
|
|
|
.arg("-f")
|
|
|
|
|
.arg(&user_key_path)
|
|
|
|
|
.status()
|
|
|
|
|
.expect("generate user key");
|
|
|
|
|
assert!(user_status.success());
|
|
|
|
|
|
|
|
|
|
let user_public_key_path = user_key_path.with_extension("pub");
|
|
|
|
|
let sign_status = Command::new("ssh-keygen")
|
|
|
|
|
.arg("-q")
|
|
|
|
|
.arg("-s")
|
|
|
|
|
.arg(&ca_key_path)
|
|
|
|
|
.arg("-I")
|
|
|
|
|
.arg("geth-test-cert")
|
|
|
|
|
.arg("-n")
|
|
|
|
|
.arg("eric")
|
|
|
|
|
.arg("-V")
|
|
|
|
|
.arg("+1d")
|
|
|
|
|
.arg("-z")
|
|
|
|
|
.arg("100")
|
|
|
|
|
.arg(&user_public_key_path)
|
|
|
|
|
.status()
|
|
|
|
|
.expect("sign user certificate");
|
|
|
|
|
assert!(sign_status.success());
|
|
|
|
|
|
|
|
|
|
let cert_path = dir.path().join("user-cert.pub");
|
|
|
|
|
let certificate = std::fs::read_to_string(&cert_path).expect("read certificate");
|
|
|
|
|
let entry = SshRevocationEntry {
|
|
|
|
|
id: "ssh-revocation:cert".into(),
|
|
|
|
|
kind: SshRevocationKind::Certificate,
|
|
|
|
|
target: certificate,
|
|
|
|
|
reason: Some("test certificate revocation".to_owned()),
|
|
|
|
|
created_at: UnixMillis(1),
|
|
|
|
|
published: true,
|
2026-05-21 01:29:55 +02:00
|
|
|
provenance: None,
|
2026-05-18 11:58:39 +02:00
|
|
|
};
|
|
|
|
|
let krl_path = dir.path().join("revoked-certs.krl");
|
|
|
|
|
|
|
|
|
|
write_openssh_krl(&[entry], &krl_path, None).expect("write certificate krl");
|
|
|
|
|
|
|
|
|
|
let output = Command::new("ssh-keygen")
|
|
|
|
|
.arg("-Q")
|
|
|
|
|
.arg("-f")
|
|
|
|
|
.arg(&krl_path)
|
|
|
|
|
.arg(&cert_path)
|
|
|
|
|
.output()
|
|
|
|
|
.expect("query certificate krl");
|
|
|
|
|
assert!(!output.status.success());
|
|
|
|
|
assert!(
|
|
|
|
|
String::from_utf8_lossy(&output.stdout)
|
|
|
|
|
.to_ascii_lowercase()
|
|
|
|
|
.contains("revoked")
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-16 00:17:08 +02:00
|
|
|
}
|