Add SSH cert flows and user service installer
This commit is contained in:
parent
26f81ff1ef
commit
f302342b1c
21 changed files with 2158 additions and 14 deletions
|
|
@ -1,19 +1,17 @@
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
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";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SshIdentityError {
|
||||
#[error("ssh-keygen failed or is unavailable")]
|
||||
SshKeygenUnavailable,
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
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 status = Command::new("ssh-keygen").arg("-?").status();
|
||||
|
|
@ -38,3 +36,316 @@ pub fn sign_command(key_path: &Path, namespace: &str, input_path: &Path) -> Comm
|
|||
.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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue