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> {
|
|
|
|
|
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
|
|
|
|
|
}
|
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())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SshRevocationExportFormat {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Jsonl => "jsonl",
|
|
|
|
|
Self::OpenSshKrlSpec => "openssh-krl-spec",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
_ => 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
#[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-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),
|
|
|
|
|
};
|
|
|
|
|
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"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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-16 00:17:08 +02:00
|
|
|
}
|