diff --git a/.gitignore b/.gitignore index b83d222..4fcb722 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target/ +*.swp diff --git a/Cargo.lock b/Cargo.lock index bbf8fc2..4e33810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,7 @@ dependencies = [ "geth-cas", "geth-cli", "geth-config", + "geth-control", "geth-node", "tempfile", "tokio", @@ -516,6 +517,7 @@ version = "0.1.0" dependencies = [ "geth-auth", "geth-resource", + "geth-ssh-identity", "geth-types", "serde", "serde_json", @@ -595,6 +597,7 @@ dependencies = [ "geth-control", "geth-crypto", "geth-resource", + "geth-ssh-identity", "geth-store", "geth-types", "serde_json", @@ -642,6 +645,10 @@ dependencies = [ name = "geth-ssh-identity" version = "0.1.0" dependencies = [ + "blake3", + "geth-types", + "serde", + "serde_json", "thiserror 2.0.18", ] diff --git a/README.md b/README.md index cdc111e..8b5ff94 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ It has daemon mode and control mode: ```sh geth init geth daemon run +geth daemon service install geth status geth node id geth resource list @@ -26,6 +27,18 @@ registry, module router, local metadata store, and synchronized data structures. Most non-daemon commands talk to the daemon through a local Unix socket at `$GETH_HOME/run/geth.sock`. +The daemon can also install itself as a user service: + +```sh +geth daemon service install +geth daemon service status +geth daemon service uninstall +``` + +The bootstrap service managers are systemd user units on Linux, launchd user +agents on macOS, and per-user scheduled tasks on Windows. These are user-level +services, not system services. + ## Transport And SSH All remote node-to-node geth communication is designed to happen over Iroh only. @@ -37,12 +50,21 @@ explicit namespaces such as `geth.keychain.v1@geth.local`. Future SSH proxying may carry SSH protocol bytes over authorized Iroh streams, but the geth transport remains Iroh. +SSH certificate request and renewal flows are managed as geth metadata. A node +can create a certificate request, another machine can approve it and receive an +explicit `ssh-keygen -s ...` command suitable for a CA key or YubiKey-backed CA, +and the resulting `-cert.pub` can be imported for distribution. Certificate and +key revocation entries are tracked locally and can be exported as JSONL for +distribution. Future Iroh replication will distribute these records between +authorized nodes. + ## MVP Features The bootstrap implementation provides: - `geth init` - `geth daemon run` +- `geth daemon service install|uninstall|start|stop|status|print` - `geth status` - `geth node id` - `geth resource list` @@ -50,6 +72,15 @@ The bootstrap implementation provides: - `geth keychain status` - `geth auth explain ` - local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list` +- SSH certificate flow metadata: + - `geth ssh cert request --public-key --principal ` + - `geth ssh cert requests` + - `geth ssh cert approve --ca-key ` + - `geth ssh cert import --cert ` + - `geth ssh cert list` + - `geth ssh revocation add ` + - `geth ssh revocation list` + - `geth ssh revocation export --out ` Other command groups exist as explicit stubs: `db`, `kv`, `pipe`, `document`, `pubsub`, `secret`, and `ssh`. diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 9658703..c3f7b49 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand}; use geth_config::GethPaths; use geth_control::{ControlRequest, ControlResponse}; +use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport}; use std::path::PathBuf; #[derive(Debug, Parser)] @@ -76,6 +77,44 @@ pub enum Command { #[derive(Debug, Subcommand)] pub enum DaemonCommand { Run, + Service { + #[command(subcommand)] + command: ServiceCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum ServiceCommand { + Install { + #[arg(long, default_value = "auto")] + manager: String, + #[arg(long)] + bin: Option, + #[arg(long)] + start: bool, + }, + Uninstall { + #[arg(long, default_value = "auto")] + manager: String, + }, + Start { + #[arg(long, default_value = "auto")] + manager: String, + }, + Stop { + #[arg(long, default_value = "auto")] + manager: String, + }, + Status { + #[arg(long, default_value = "auto")] + manager: String, + }, + Print { + #[arg(long, default_value = "auto")] + manager: String, + #[arg(long)] + bin: Option, + }, } #[derive(Debug, Subcommand)] @@ -171,7 +210,68 @@ pub enum DocumentCommand { #[derive(Debug, Subcommand)] pub enum SshCommand { - Proxy { node: String }, + Proxy { + node: String, + }, + Cert { + #[command(subcommand)] + command: SshCertCommand, + }, + Revocation { + #[command(subcommand)] + command: SshRevocationCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum SshCertCommand { + Request { + #[arg(long)] + public_key: PathBuf, + #[arg(long, default_value = "user")] + kind: String, + #[arg(long = "principal", required = true)] + principals: Vec, + #[arg(long)] + valid_for: Option, + #[arg(long)] + renewal_of: Option, + #[arg(long)] + reason: Option, + }, + Requests, + Approve { + request_id: String, + #[arg(long)] + ca_key: PathBuf, + #[arg(long)] + valid_for: Option, + #[arg(long)] + serial: Option, + #[arg(long)] + out: Option, + }, + Import { + request_id: String, + #[arg(long)] + cert: PathBuf, + }, + List, +} + +#[derive(Debug, Subcommand)] +pub enum SshRevocationCommand { + Add { + kind: String, + target: String, + #[arg(long)] + reason: Option, + }, + List, + Export { + #[arg(long)] + out: PathBuf, + }, } #[derive(Debug, Args)] @@ -194,6 +294,13 @@ pub async fn run() -> Result<()> { .await .context("run geth daemon")?; } + Command::Daemon { + command: DaemonCommand::Service { command }, + } => { + let report = + run_service_command(&paths, command).context("manage geth user service")?; + print_service_report(report, cli.json || cli.jsonl)?; + } command => { let request = request_for_command(command)?; let response = geth_node::send_control(&paths, request) @@ -277,14 +384,113 @@ fn request_for_command(command: Command) -> Result { module: "document".to_owned(), command: format!("{command:?}"), }, - Command::Ssh { command } => ControlRequest::ModuleStub { - module: "ssh-proxy".to_owned(), - command: format!("{command:?}"), + Command::Ssh { command } => match command { + SshCommand::Proxy { node } => ControlRequest::ModuleStub { + module: "ssh-proxy".to_owned(), + command: format!("proxy {node}"), + }, + SshCommand::Cert { command } => match command { + SshCertCommand::Request { + public_key, + kind, + principals, + valid_for, + renewal_of, + reason, + } => ControlRequest::SshCertRequest { + public_key_path: public_key, + cert_kind: kind, + principals, + requested_validity: valid_for, + renewal_of, + reason, + }, + SshCertCommand::Requests => ControlRequest::SshCertRequests, + SshCertCommand::Approve { + request_id, + ca_key, + valid_for, + serial, + out, + } => ControlRequest::SshCertApprove { + request_id, + ca_key_path: ca_key, + valid_for, + serial, + out, + }, + SshCertCommand::Import { request_id, cert } => ControlRequest::SshCertImport { + request_id, + cert_path: cert, + }, + SshCertCommand::List => ControlRequest::SshCertList, + }, + SshCommand::Revocation { command } => match command { + SshRevocationCommand::Add { + kind, + target, + reason, + } => ControlRequest::SshRevocationAdd { + kind, + target, + reason, + }, + SshRevocationCommand::List => ControlRequest::SshRevocationList, + SshRevocationCommand::Export { out } => ControlRequest::SshRevocationExport { out }, + }, }, Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), }) } +fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result { + Ok(match command { + ServiceCommand::Install { + manager, + bin, + start, + } => { + geth_node::init_node(paths).context("initialize geth home before service install")?; + let manager = manager.parse::()?; + let executable = service_executable(bin)?; + geth_node::service::install_user_service( + paths, + ServiceInstallOptions { + manager, + executable, + start, + }, + )? + } + ServiceCommand::Uninstall { manager } => { + geth_node::service::uninstall_user_service(manager.parse::()?)? + } + ServiceCommand::Start { manager } => { + geth_node::service::start_user_service(manager.parse::()?)? + } + ServiceCommand::Stop { manager } => { + geth_node::service::stop_user_service(manager.parse::()?)? + } + ServiceCommand::Status { manager } => { + geth_node::service::status_user_service(manager.parse::()?)? + } + ServiceCommand::Print { manager, bin } => { + let executable = service_executable(bin)?; + geth_node::service::print_user_service( + paths, + manager.parse::()?, + &executable, + )? + } + }) +} + +fn service_executable(bin: Option) -> Result { + bin.map(Ok) + .unwrap_or_else(std::env::current_exe) + .context("resolve current geth executable") +} + fn print_response(response: ControlResponse, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(&response)?); @@ -356,6 +562,103 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("reason: {}", explain.reason); println!("evaluated_ops: {}", explain.evaluated_ops); } + ControlResponse::SshCertRequested { request } => { + println!("ssh cert request: {}", request.id); + println!("status: {}", request.status); + println!("kind: {}", request.cert_kind); + println!("principals: {}", request.principals.join(",")); + println!("public_key_fingerprint: {}", request.public_key_fingerprint); + } + ControlResponse::SshCertRequests { requests } => { + if requests.is_empty() { + println!("no ssh certificate requests"); + } else { + for request in requests { + println!( + "{}\t{}\t{}\t{}\t{}", + request.id, + request.status, + request.cert_kind, + request.principals.join(","), + request.public_key_fingerprint + ); + } + } + } + ControlResponse::SshCertApproved { approval } => { + println!("approved ssh cert request: {}", approval.request_id); + println!("valid_for: {}", approval.valid_for); + if let Some(serial) = approval.serial { + println!("serial: {serial}"); + } + if let Some(output_path) = approval.output_path { + println!("expected_certificate: {output_path}"); + } + println!("signing_command:"); + println!("{}", shell_quote_command(&approval.signing_command)); + println!("note: {}", approval.note); + } + ControlResponse::SshCertImported { certificate } => { + println!("imported ssh certificate: {}", certificate.id); + println!("request: {}", certificate.request_id); + println!("fingerprint: {}", certificate.certificate_fingerprint); + } + ControlResponse::SshCertList { + requests, + certificates, + } => { + println!("requests:"); + if requests.is_empty() { + println!(" none"); + } else { + for request in requests { + println!( + " {}\t{}\t{}\t{}", + request.id, + request.status, + request.cert_kind, + request.principals.join(",") + ); + } + } + println!("certificates:"); + if certificates.is_empty() { + println!(" none"); + } else { + for certificate in certificates { + println!( + " {}\t{}\t{}", + certificate.id, certificate.request_id, certificate.certificate_fingerprint + ); + } + } + } + ControlResponse::SshRevocationAdded { revocation } => { + println!("added ssh revocation: {}", revocation.id); + println!("kind: {}", revocation.kind); + println!("target: {}", revocation.target); + if let Some(reason) = revocation.reason { + println!("reason: {reason}"); + } + } + ControlResponse::SshRevocationList { revocations } => { + if revocations.is_empty() { + println!("no ssh revocations"); + } else { + for revocation in revocations { + println!( + "{}\t{}\t{}\t{}", + revocation.id, + revocation.kind, + revocation.target, + revocation.reason.unwrap_or_default() + ); + } + } + } + ControlResponse::SshRevocationExported { out, count } => { + println!("exported {count} ssh revocations to {}", out.display()); + } ControlResponse::NotImplemented { module, command } => { println!("{module} {command}: not implemented yet"); } @@ -363,3 +666,57 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } Ok(()) } + +fn print_service_report(report: ServiceReport, json: bool) -> Result<()> { + if json { + println!( + "{}", + serde_json::json!({ + "manager": report.manager.to_string(), + "action": format!("{:?}", report.action), + "service_name": report.service_name, + "definition_path": report.definition_path, + "definition": report.definition, + "commands": report.commands, + "note": report.note, + }) + ); + return Ok(()); + } + + println!("service: {}", report.service_name); + println!("manager: {}", report.manager); + println!("action: {:?}", report.action); + if let Some(path) = report.definition_path { + println!("definition: {}", path.display()); + } + if !report.commands.is_empty() { + println!("commands:"); + for command in report.commands { + println!(" {}", shell_quote_command(&command)); + } + } + if let Some(definition) = report.definition { + println!("definition_body:"); + print!("{definition}"); + } + println!("note: {}", report.note); + Ok(()) +} + +fn shell_quote_command(command: &[String]) -> String { + command + .iter() + .map(|arg| { + if arg + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_./:=+@,".contains(&byte)) + { + arg.clone() + } else { + format!("'{}'", arg.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index abb89f7..e134aec 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -11,4 +11,5 @@ serde_json.workspace = true thiserror.workspace = true geth-auth = { path = "../geth-auth" } geth-resource = { path = "../geth-resource" } +geth-ssh-identity = { path = "../geth-ssh-identity" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index a7a09ae..78c1159 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,5 +1,8 @@ use geth_auth::AuthExplanation; use geth_resource::ResourceDescriptor; +use geth_ssh_identity::{ + SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, +}; use geth_types::BlobHash; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -34,6 +37,36 @@ pub enum ControlRequest { resource: String, capability: String, }, + SshCertRequest { + public_key_path: PathBuf, + cert_kind: String, + principals: Vec, + requested_validity: Option, + renewal_of: Option, + reason: Option, + }, + SshCertRequests, + SshCertApprove { + request_id: String, + ca_key_path: PathBuf, + valid_for: Option, + serial: Option, + out: Option, + }, + SshCertImport { + request_id: String, + cert_path: PathBuf, + }, + SshCertList, + SshRevocationAdd { + kind: String, + target: String, + reason: Option, + }, + SshRevocationList, + SshRevocationExport { + out: PathBuf, + }, ModuleStub { module: String, command: String, @@ -72,6 +105,32 @@ pub enum ControlResponse { }, KeychainStatus(KeychainStatusResponse), AuthExplain(AuthExplanation), + SshCertRequested { + request: SshCertRequest, + }, + SshCertRequests { + requests: Vec, + }, + SshCertApproved { + approval: SshCertApproval, + }, + SshCertImported { + certificate: SshCertificateRecord, + }, + SshCertList { + requests: Vec, + certificates: Vec, + }, + SshRevocationAdded { + revocation: SshRevocationEntry, + }, + SshRevocationList { + revocations: Vec, + }, + SshRevocationExported { + out: PathBuf, + count: usize, + }, NotImplemented { module: String, command: String, diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 69c5d45..ce5ca59 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -16,5 +16,6 @@ geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-crypto = { path = "../geth-crypto" } geth-resource = { path = "../geth-resource" } +geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 79b07fa..2956e59 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1,3 +1,5 @@ +pub mod service; + use geth_auth::AuthExplanation; use geth_cas::{LocalCas, hash_path}; use geth_config::GethPaths; @@ -7,8 +9,17 @@ use geth_control::{ }; use geth_crypto::AgentKey; use geth_resource::ResourceDescriptor; -use geth_store::{Store, StoredResource}; -use geth_types::{ResourceId, ResourceKind, ResourceName}; +use geth_ssh_identity::{ + SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, + SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, + certificate_id, revocation_id, ssh_public_key_fingerprint, +}; +use geth_store::{ + Store, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, +}; +use geth_types::{ + NodeId, ResourceId, ResourceKind, ResourceName, SshCertId, SshCertRequestId, UnixMillis, +}; use std::path::Path; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -25,10 +36,24 @@ pub enum NodeError { Cas(#[from] geth_cas::CasError), #[error("control error: {0}")] Control(#[from] geth_control::ControlError), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("invalid resource kind: {0}")] InvalidResourceKind(String), + #[error("invalid ssh certificate kind: {0}")] + InvalidSshCertKind(String), + #[error("invalid ssh certificate request status: {0}")] + InvalidSshCertStatus(String), + #[error("invalid ssh revocation kind: {0}")] + InvalidSshRevocationKind(String), + #[error("ssh certificate request not found: {0}")] + SshCertRequestNotFound(String), + #[error("ssh certificate request must include at least one principal")] + MissingSshCertPrincipal, + #[error("ssh certificate flow error: {0}")] + SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError), } #[derive(Clone, Debug)] @@ -221,6 +246,176 @@ pub fn handle_request( } => Ok(ControlResponse::AuthExplain(AuthExplanation::stub( subject, resource, capability, ))), + ControlRequest::SshCertRequest { + public_key_path, + cert_kind, + principals, + requested_validity, + renewal_of, + reason, + } => { + if principals.is_empty() { + return Err(NodeError::MissingSshCertPrincipal); + } + let cert_kind = cert_kind + .parse::() + .map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?; + let public_key = std::fs::read_to_string(&public_key_path)?; + let created_at = UnixMillis(geth_store::now_ms()); + let request = SshCertRequest { + id: cert_request_id( + &NodeId::new(node.node_id.clone()), + &public_key, + &principals, + created_at, + ), + requester_node: NodeId::new(node.node_id.clone()), + public_key_fingerprint: ssh_public_key_fingerprint(&public_key), + public_key, + cert_kind, + principals, + requested_validity, + renewal_of: renewal_of.map(SshCertId::new), + reason, + status: SshCertRequestStatus::Pending, + created_at, + }; + store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?; + Ok(ControlResponse::SshCertRequested { request }) + } + ControlRequest::SshCertRequests => Ok(ControlResponse::SshCertRequests { + requests: store + .list_ssh_cert_requests()? + .into_iter() + .map(ssh_cert_request_from_stored) + .collect::, _>>()?, + }), + ControlRequest::SshCertApprove { + request_id, + ca_key_path, + valid_for, + serial, + out, + } => { + let stored = store + .get_ssh_cert_request(&request_id)? + .ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?; + let mut request = ssh_cert_request_from_stored(stored)?; + request.status = SshCertRequestStatus::Approved; + store.update_ssh_cert_request_status(request.id.as_str(), request.status.as_str())?; + let public_key_path = out.clone().unwrap_or_else(|| { + node.paths + .home() + .join("ssh-cert-requests") + .join(format!("{}.pub", request.id)) + }); + if let Some(parent) = public_key_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&public_key_path, &request.public_key)?; + let valid_for = valid_for + .or_else(|| request.requested_validity.clone()) + .unwrap_or_else(|| "+52w".to_owned()); + let signing_command = build_ssh_cert_sign_command( + &request, + &ca_key_path, + &public_key_path, + &valid_for, + serial, + )?; + let approval = SshCertApproval { + request_id: request.id, + approved_by_node: NodeId::new(node.node_id.clone()), + ca_key_path: ca_key_path.display().to_string(), + key_id: request_id, + valid_for, + serial, + output_path: Some(expected_openssh_cert_path(&public_key_path)), + signing_command, + note: "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned(), + }; + Ok(ControlResponse::SshCertApproved { approval }) + } + ControlRequest::SshCertImport { + request_id, + cert_path, + } => { + let certificate = std::fs::read_to_string(&cert_path)?; + let record = SshCertificateRecord { + id: certificate_id(&certificate), + request_id: SshCertRequestId::new(request_id.clone()), + certificate_fingerprint: ssh_public_key_fingerprint(&certificate), + certificate, + imported_at: UnixMillis(geth_store::now_ms()), + }; + store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?; + store.update_ssh_cert_request_status( + &request_id, + SshCertRequestStatus::Signed.as_str(), + )?; + Ok(ControlResponse::SshCertImported { + certificate: record, + }) + } + ControlRequest::SshCertList => Ok(ControlResponse::SshCertList { + requests: store + .list_ssh_cert_requests()? + .into_iter() + .map(ssh_cert_request_from_stored) + .collect::, _>>()?, + certificates: store + .list_ssh_certificates()? + .into_iter() + .map(ssh_certificate_from_stored) + .collect(), + }), + ControlRequest::SshRevocationAdd { + kind, + target, + reason, + } => { + let kind = kind + .parse::() + .map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?; + let created_at = UnixMillis(geth_store::now_ms()); + let revocation = SshRevocationEntry { + id: revocation_id(&kind, &target, created_at), + kind, + target, + reason, + created_at, + published: true, + }; + store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?; + Ok(ControlResponse::SshRevocationAdded { revocation }) + } + ControlRequest::SshRevocationList => Ok(ControlResponse::SshRevocationList { + revocations: store + .list_ssh_revocations()? + .into_iter() + .map(ssh_revocation_from_stored) + .collect::, _>>()?, + }), + ControlRequest::SshRevocationExport { out } => { + let revocations = store + .list_ssh_revocations()? + .into_iter() + .map(ssh_revocation_from_stored) + .collect::, _>>()?; + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + let mut body = String::new(); + for revocation in &revocations { + body.push_str(&serde_json::to_string(revocation)?); + body.push('\n'); + } + std::fs::write(&out, body)?; + Ok(ControlResponse::SshRevocationExported { + out, + count: revocations.len(), + }) + } ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -242,3 +437,100 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result String { format!("node:{agent_id}") } + +fn expected_openssh_cert_path(public_key_path: &Path) -> String { + let text = public_key_path.display().to_string(); + if let Some(prefix) = text.strip_suffix(".pub") { + format!("{prefix}-cert.pub") + } else { + format!("{text}-cert.pub") + } +} + +fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertRequest { + StoredSshCertRequest { + request_id: request.id.to_string(), + requester_node: request.requester_node.to_string(), + public_key: request.public_key.clone(), + public_key_fingerprint: request.public_key_fingerprint.clone(), + cert_kind: request.cert_kind.to_string(), + principals: request.principals.clone(), + requested_validity: request.requested_validity.clone(), + renewal_of: request.renewal_of.as_ref().map(ToString::to_string), + reason: request.reason.clone(), + status: request.status.to_string(), + created_at_ms: request.created_at.0, + } +} + +fn ssh_cert_request_from_stored(stored: StoredSshCertRequest) -> Result { + let cert_kind = stored + .cert_kind + .parse::() + .map_err(|_| NodeError::InvalidSshCertKind(stored.cert_kind.clone()))?; + let status = stored + .status + .parse::() + .map_err(|_| NodeError::InvalidSshCertStatus(stored.status.clone()))?; + Ok(SshCertRequest { + id: SshCertRequestId::new(stored.request_id), + requester_node: NodeId::new(stored.requester_node), + public_key: stored.public_key, + public_key_fingerprint: stored.public_key_fingerprint, + cert_kind, + principals: stored.principals, + requested_validity: stored.requested_validity, + renewal_of: stored.renewal_of.map(SshCertId::new), + reason: stored.reason, + status, + created_at: UnixMillis(stored.created_at_ms), + }) +} + +fn stored_from_ssh_certificate(certificate: &SshCertificateRecord) -> StoredSshCertificate { + StoredSshCertificate { + cert_id: certificate.id.to_string(), + request_id: certificate.request_id.to_string(), + certificate: certificate.certificate.clone(), + certificate_fingerprint: certificate.certificate_fingerprint.clone(), + imported_at_ms: certificate.imported_at.0, + } +} + +fn ssh_certificate_from_stored(stored: StoredSshCertificate) -> SshCertificateRecord { + SshCertificateRecord { + id: SshCertId::new(stored.cert_id), + request_id: SshCertRequestId::new(stored.request_id), + certificate: stored.certificate, + certificate_fingerprint: stored.certificate_fingerprint, + imported_at: UnixMillis(stored.imported_at_ms), + } +} + +fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevocation { + StoredSshRevocation { + revocation_id: revocation.id.to_string(), + kind: revocation.kind.to_string(), + target: revocation.target.clone(), + reason: revocation.reason.clone(), + created_at_ms: revocation.created_at.0, + published: revocation.published, + } +} + +fn ssh_revocation_from_stored( + stored: StoredSshRevocation, +) -> Result { + let kind = stored + .kind + .parse::() + .map_err(|_| NodeError::InvalidSshRevocationKind(stored.kind.clone()))?; + Ok(SshRevocationEntry { + id: geth_types::SshRevocationId::new(stored.revocation_id), + kind, + target: stored.target, + reason: stored.reason, + created_at: UnixMillis(stored.created_at_ms), + published: stored.published, + }) +} diff --git a/crates/geth-node/src/service.rs b/crates/geth-node/src/service.rs new file mode 100644 index 0000000..6343c37 --- /dev/null +++ b/crates/geth-node/src/service.rs @@ -0,0 +1,622 @@ +use geth_config::GethPaths; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServiceManager { + Auto, + SystemdUser, + LaunchdUser, + WindowsTask, +} + +impl ServiceManager { + pub fn detect() -> Result { + match std::env::consts::OS { + "linux" => Ok(Self::SystemdUser), + "macos" => Ok(Self::LaunchdUser), + "windows" => Ok(Self::WindowsTask), + other => Err(ServiceError::UnsupportedPlatform(other.to_owned())), + } + } + + pub fn resolve(self) -> Result { + match self { + Self::Auto => Self::detect(), + manager => Ok(manager), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Auto => "auto", + Self::SystemdUser => "systemd-user", + Self::LaunchdUser => "launchd-user", + Self::WindowsTask => "windows-task", + } + } +} + +impl std::fmt::Display for ServiceManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for ServiceManager { + type Err = ServiceError; + + fn from_str(value: &str) -> Result { + match value { + "auto" => Ok(Self::Auto), + "systemd" | "systemd-user" => Ok(Self::SystemdUser), + "launchd" | "launchd-user" => Ok(Self::LaunchdUser), + "windows" | "windows-task" | "scheduled-task" => Ok(Self::WindowsTask), + _ => Err(ServiceError::InvalidManager(value.to_owned())), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServiceAction { + Installed, + Uninstalled, + Started, + Stopped, + Status, + Printed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceReport { + pub manager: ServiceManager, + pub action: ServiceAction, + pub service_name: String, + pub definition_path: Option, + pub definition: Option, + pub commands: Vec>, + pub note: String, +} + +#[derive(Clone, Debug)] +pub struct ServiceInstallOptions { + pub manager: ServiceManager, + pub executable: PathBuf, + pub start: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("unsupported service platform: {0}")] + UnsupportedPlatform(String), + #[error("invalid service manager: {0}")] + InvalidManager(String), + #[error("could not determine home directory")] + MissingHome, + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("service command failed: {program} {args:?}: {stderr}")] + CommandFailed { + program: String, + args: Vec, + stderr: String, + }, +} + +pub fn install_user_service( + paths: &GethPaths, + options: ServiceInstallOptions, +) -> Result { + let manager = options.manager.resolve()?; + match manager { + ServiceManager::SystemdUser => { + install_systemd_user(paths, &options.executable, options.start) + } + ServiceManager::LaunchdUser => { + install_launchd_user(paths, &options.executable, options.start) + } + ServiceManager::WindowsTask => { + install_windows_task(paths, &options.executable, options.start) + } + ServiceManager::Auto => unreachable!("auto is resolved above"), + } +} + +pub fn uninstall_user_service(manager: ServiceManager) -> Result { + let manager = manager.resolve()?; + match manager { + ServiceManager::SystemdUser => uninstall_systemd_user(), + ServiceManager::LaunchdUser => uninstall_launchd_user(), + ServiceManager::WindowsTask => uninstall_windows_task(), + ServiceManager::Auto => unreachable!("auto is resolved above"), + } +} + +pub fn start_user_service(manager: ServiceManager) -> Result { + let manager = manager.resolve()?; + match manager { + ServiceManager::SystemdUser => { + let command = run_command("systemctl", &["--user", "start", SYSTEMD_UNIT])?; + Ok(report( + manager, + ServiceAction::Started, + Some(systemd_unit_path()?), + None, + vec![command], + "started systemd user service", + )) + } + ServiceManager::LaunchdUser => { + let command = run_command( + "launchctl", + &["load", "-w", &launchd_plist_path()?.display().to_string()], + )?; + Ok(report( + manager, + ServiceAction::Started, + Some(launchd_plist_path()?), + None, + vec![command], + "loaded launchd user agent", + )) + } + ServiceManager::WindowsTask => { + let command = run_command("schtasks", &["/Run", "/TN", WINDOWS_TASK_NAME])?; + Ok(report( + manager, + ServiceAction::Started, + None, + None, + vec![command], + "started Windows per-user scheduled task", + )) + } + ServiceManager::Auto => unreachable!("auto is resolved above"), + } +} + +pub fn stop_user_service(manager: ServiceManager) -> Result { + let manager = manager.resolve()?; + match manager { + ServiceManager::SystemdUser => { + let command = run_command("systemctl", &["--user", "stop", SYSTEMD_UNIT])?; + Ok(report( + manager, + ServiceAction::Stopped, + Some(systemd_unit_path()?), + None, + vec![command], + "stopped systemd user service", + )) + } + ServiceManager::LaunchdUser => { + let command = run_command( + "launchctl", + &["unload", &launchd_plist_path()?.display().to_string()], + )?; + Ok(report( + manager, + ServiceAction::Stopped, + Some(launchd_plist_path()?), + None, + vec![command], + "unloaded launchd user agent", + )) + } + ServiceManager::WindowsTask => { + let command = run_command("schtasks", &["/End", "/TN", WINDOWS_TASK_NAME])?; + Ok(report( + manager, + ServiceAction::Stopped, + None, + None, + vec![command], + "stopped Windows per-user scheduled task", + )) + } + ServiceManager::Auto => unreachable!("auto is resolved above"), + } +} + +pub fn status_user_service(manager: ServiceManager) -> Result { + let manager = manager.resolve()?; + match manager { + ServiceManager::SystemdUser => { + let command = run_command( + "systemctl", + &["--user", "status", SYSTEMD_UNIT, "--no-pager"], + )?; + Ok(report( + manager, + ServiceAction::Status, + Some(systemd_unit_path()?), + None, + vec![command], + "queried systemd user service", + )) + } + ServiceManager::LaunchdUser => { + let command = run_command("launchctl", &["list", LAUNCHD_LABEL])?; + Ok(report( + manager, + ServiceAction::Status, + Some(launchd_plist_path()?), + None, + vec![command], + "queried launchd user agent", + )) + } + ServiceManager::WindowsTask => { + let command = run_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?; + Ok(report( + manager, + ServiceAction::Status, + None, + None, + vec![command], + "queried Windows per-user scheduled task", + )) + } + ServiceManager::Auto => unreachable!("auto is resolved above"), + } +} + +pub fn print_user_service( + paths: &GethPaths, + manager: ServiceManager, + executable: &Path, +) -> Result { + let manager = manager.resolve()?; + let (path, definition) = match manager { + ServiceManager::SystemdUser => { + (Some(systemd_unit_path()?), systemd_unit(paths, executable)) + } + ServiceManager::LaunchdUser => ( + Some(launchd_plist_path()?), + launchd_plist(paths, executable), + ), + ServiceManager::WindowsTask => (None, windows_task_command(paths, executable)), + ServiceManager::Auto => unreachable!("auto is resolved above"), + }; + Ok(report( + manager, + ServiceAction::Printed, + path, + Some(definition), + Vec::new(), + "printed user service definition", + )) +} + +const SYSTEMD_UNIT: &str = "geth.service"; +const LAUNCHD_LABEL: &str = "local.geth.daemon"; +const WINDOWS_TASK_NAME: &str = "geth-daemon"; + +fn install_systemd_user( + paths: &GethPaths, + executable: &Path, + start: bool, +) -> Result { + let unit_path = systemd_unit_path()?; + if let Some(parent) = unit_path.parent() { + std::fs::create_dir_all(parent)?; + } + let definition = systemd_unit(paths, executable); + std::fs::write(&unit_path, &definition)?; + + let mut commands = vec![ + run_command("systemctl", &["--user", "daemon-reload"])?, + run_command("systemctl", &["--user", "enable", SYSTEMD_UNIT])?, + ]; + if start { + commands.push(run_command( + "systemctl", + &["--user", "start", SYSTEMD_UNIT], + )?); + } + + Ok(report( + ServiceManager::SystemdUser, + ServiceAction::Installed, + Some(unit_path), + Some(definition), + commands, + "installed systemd user service; lingering may be needed for startup before login", + )) +} + +fn uninstall_systemd_user() -> Result { + let unit_path = systemd_unit_path()?; + let mut commands = Vec::new(); + commands.push(run_command( + "systemctl", + &["--user", "disable", "--now", SYSTEMD_UNIT], + )?); + if unit_path.exists() { + std::fs::remove_file(&unit_path)?; + } + commands.push(run_command("systemctl", &["--user", "daemon-reload"])?); + Ok(report( + ServiceManager::SystemdUser, + ServiceAction::Uninstalled, + Some(unit_path), + None, + commands, + "uninstalled systemd user service", + )) +} + +fn install_launchd_user( + paths: &GethPaths, + executable: &Path, + start: bool, +) -> Result { + let plist_path = launchd_plist_path()?; + if let Some(parent) = plist_path.parent() { + std::fs::create_dir_all(parent)?; + } + let definition = launchd_plist(paths, executable); + std::fs::write(&plist_path, &definition)?; + let mut commands = vec![run_command( + "launchctl", + &["load", "-w", &plist_path.display().to_string()], + )?]; + if start { + commands.push(run_command("launchctl", &["start", LAUNCHD_LABEL])?); + } + Ok(report( + ServiceManager::LaunchdUser, + ServiceAction::Installed, + Some(plist_path), + Some(definition), + commands, + "installed launchd user agent", + )) +} + +fn uninstall_launchd_user() -> Result { + let plist_path = launchd_plist_path()?; + let mut commands = Vec::new(); + if plist_path.exists() { + commands.push(run_command( + "launchctl", + &["unload", &plist_path.display().to_string()], + )?); + std::fs::remove_file(&plist_path)?; + } + Ok(report( + ServiceManager::LaunchdUser, + ServiceAction::Uninstalled, + Some(plist_path), + None, + commands, + "uninstalled launchd user agent", + )) +} + +fn install_windows_task( + paths: &GethPaths, + executable: &Path, + start: bool, +) -> Result { + let task_command = windows_task_run_command(paths, executable); + let mut commands = vec![run_command( + "schtasks", + &[ + "/Create", + "/TN", + WINDOWS_TASK_NAME, + "/SC", + "ONLOGON", + "/TR", + &task_command, + "/F", + ], + )?]; + if start { + commands.push(run_command( + "schtasks", + &["/Run", "/TN", WINDOWS_TASK_NAME], + )?); + } + Ok(report( + ServiceManager::WindowsTask, + ServiceAction::Installed, + None, + Some(task_command), + commands, + "installed Windows per-user scheduled task", + )) +} + +fn uninstall_windows_task() -> Result { + let command = run_command("schtasks", &["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"])?; + Ok(report( + ServiceManager::WindowsTask, + ServiceAction::Uninstalled, + None, + None, + vec![command], + "uninstalled Windows per-user scheduled task", + )) +} + +fn systemd_unit(paths: &GethPaths, executable: &Path) -> String { + format!( + r#"[Unit] +Description=geth personal mesh daemon +Documentation=https://example.invalid/local/geth + +[Service] +Type=simple +Environment=GETH_HOME={} +ExecStart={} daemon run +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=default.target +"#, + systemd_escape(paths.home()), + systemd_escape(executable) + ) +} + +fn launchd_plist(paths: &GethPaths, executable: &Path) -> String { + format!( + r#" + + + + Label + {} + ProgramArguments + + {} + daemon + run + + EnvironmentVariables + + GETH_HOME + {} + + RunAtLoad + + KeepAlive + + StandardOutPath + {}/daemon.out.log + StandardErrorPath + {}/daemon.err.log + + +"#, + LAUNCHD_LABEL, + xml_escape(&executable.display().to_string()), + xml_escape(&paths.home().display().to_string()), + xml_escape(&paths.run_dir().display().to_string()), + xml_escape(&paths.run_dir().display().to_string()) + ) +} + +fn windows_task_command(paths: &GethPaths, executable: &Path) -> String { + windows_task_run_command(paths, executable) +} + +fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String { + format!( + r#"cmd.exe /C "set GETH_HOME={}&& "{}" daemon run""#, + paths.home().display(), + executable.display() + ) +} + +fn systemd_unit_path() -> Result { + Ok(home_dir()?.join(".config/systemd/user").join(SYSTEMD_UNIT)) +} + +fn launchd_plist_path() -> Result { + Ok(home_dir()? + .join("Library/LaunchAgents") + .join(format!("{LAUNCHD_LABEL}.plist"))) +} + +fn home_dir() -> Result { + std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) + .ok_or(ServiceError::MissingHome) +} + +fn run_command(program: &str, args: &[&str]) -> Result, ServiceError> { + let output = Command::new(program).args(args).output()?; + let command = std::iter::once(program.to_owned()) + .chain(args.iter().map(|arg| (*arg).to_owned())) + .collect::>(); + if output.status.success() { + Ok(command) + } else { + Err(ServiceError::CommandFailed { + program: program.to_owned(), + args: args.iter().map(|arg| (*arg).to_owned()).collect(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(), + }) + } +} + +fn report( + manager: ServiceManager, + action: ServiceAction, + definition_path: Option, + definition: Option, + commands: Vec>, + note: &str, +) -> ServiceReport { + ServiceReport { + service_name: match manager { + ServiceManager::SystemdUser => SYSTEMD_UNIT.to_owned(), + ServiceManager::LaunchdUser => LAUNCHD_LABEL.to_owned(), + ServiceManager::WindowsTask => WINDOWS_TASK_NAME.to_owned(), + ServiceManager::Auto => "geth".to_owned(), + }, + manager, + action, + definition_path, + definition, + commands, + note: note.to_owned(), + } +} + +fn systemd_escape(path: &Path) -> String { + let value = path.display().to_string(); + if value.bytes().any(|byte| byte.is_ascii_whitespace()) { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn systemd_unit_is_user_service() { + let paths = GethPaths::from_home("/tmp/geth home"); + let unit = systemd_unit(&paths, Path::new("/usr/local/bin/geth")); + assert!(unit.contains("WantedBy=default.target")); + assert!(unit.contains("GETH_HOME=")); + assert!(unit.contains("ExecStart=/usr/local/bin/geth daemon run")); + assert!(!unit.contains("WantedBy=multi-user.target")); + } + + #[test] + fn launchd_plist_is_user_agent() { + let paths = GethPaths::from_home("/Users/eric/Library/Application Support/geth"); + let plist = launchd_plist(&paths, Path::new("/usr/local/bin/geth")); + assert!(plist.contains("local.geth.daemon")); + assert!(plist.contains("daemon")); + assert!(plist.contains("RunAtLoad")); + } + + #[test] + fn windows_task_uses_current_user_logon_trigger() { + let paths = GethPaths::from_home(r"C:\Users\Eric\AppData\Local\geth"); + let command = windows_task_run_command(&paths, Path::new(r"C:\bin\geth.exe")); + assert!(command.contains("cmd.exe /C")); + assert!(command.contains("GETH_HOME=")); + assert!(command.contains("daemon run")); + } +} diff --git a/crates/geth-ssh-identity/Cargo.toml b/crates/geth-ssh-identity/Cargo.toml index b9b8bb9..87ce566 100644 --- a/crates/geth-ssh-identity/Cargo.toml +++ b/crates/geth-ssh-identity/Cargo.toml @@ -6,4 +6,10 @@ rust-version.workspace = true license.workspace = true [dependencies] +blake3.workspace = true +serde.workspace = true thiserror.workspace = true +geth-types = { path = "../geth-types" } + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index 3914a18..8d0ac36 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -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 { + 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 { + 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, + pub requested_validity: Option, + pub renewal_of: Option, + pub reason: Option, + 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, + pub output_path: Option, + pub signing_command: Vec, + 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 { + 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, + 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, +) -> Result, 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())); + } +} diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 4fb8613..b598764 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -118,6 +118,34 @@ impl Store { state_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS ssh_cert_requests ( + request_id TEXT PRIMARY KEY, + requester_node TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + cert_kind TEXT NOT NULL, + principals_json TEXT NOT NULL, + requested_validity TEXT, + renewal_of TEXT, + reason TEXT, + status TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS ssh_certificates ( + cert_id TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + certificate TEXT NOT NULL, + certificate_fingerprint TEXT NOT NULL, + imported_at_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS ssh_revocations ( + revocation_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + target TEXT NOT NULL, + reason TEXT, + created_at_ms INTEGER NOT NULL, + published INTEGER NOT NULL DEFAULT 0 + ); INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1'); "#, )?; @@ -200,6 +228,170 @@ impl Store { rows.collect::, _>>() .map_err(StoreError::from) } + + pub fn insert_ssh_cert_request( + &self, + request: &StoredSshCertRequest, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO ssh_cert_requests( + request_id, requester_node, public_key, public_key_fingerprint, cert_kind, + principals_json, requested_validity, renewal_of, reason, status, created_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, + params![ + request.request_id, + request.requester_node, + request.public_key, + request.public_key_fingerprint, + request.cert_kind, + serde_json::to_string(&request.principals)?, + request.requested_validity, + request.renewal_of, + request.reason, + request.status, + request.created_at_ms + ], + )?; + Ok(()) + } + + pub fn get_ssh_cert_request( + &self, + request_id: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, + principals_json, requested_validity, renewal_of, reason, status, created_at_ms + FROM ssh_cert_requests WHERE request_id = ?1"#, + )?; + let mut rows = stmt.query(params![request_id])?; + if let Some(row) = rows.next()? { + Ok(Some(stored_ssh_cert_request_from_row(row)?)) + } else { + Ok(None) + } + } + + pub fn update_ssh_cert_request_status( + &self, + request_id: &str, + status: &str, + ) -> Result<(), StoreError> { + self.conn.execute( + "UPDATE ssh_cert_requests SET status = ?2 WHERE request_id = ?1", + params![request_id, status], + )?; + Ok(()) + } + + pub fn list_ssh_cert_requests(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, + principals_json, requested_validity, renewal_of, reason, status, created_at_ms + FROM ssh_cert_requests ORDER BY created_at_ms, request_id"#, + )?; + let rows = stmt.query_map([], stored_ssh_cert_request_from_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + + pub fn insert_ssh_certificate( + &self, + certificate: &StoredSshCertificate, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO ssh_certificates( + cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![ + certificate.cert_id, + certificate.request_id, + certificate.certificate, + certificate.certificate_fingerprint, + certificate.imported_at_ms + ], + )?; + Ok(()) + } + + pub fn list_ssh_certificates(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms + FROM ssh_certificates ORDER BY imported_at_ms, cert_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredSshCertificate { + cert_id: row.get(0)?, + request_id: row.get(1)?, + certificate: row.get(2)?, + certificate_fingerprint: row.get(3)?, + imported_at_ms: row.get(4)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + + pub fn insert_ssh_revocation( + &self, + revocation: &StoredSshRevocation, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO ssh_revocations( + revocation_id, kind, target, reason, created_at_ms, published + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![ + revocation.revocation_id, + revocation.kind, + revocation.target, + revocation.reason, + revocation.created_at_ms, + if revocation.published { 1_i64 } else { 0_i64 } + ], + )?; + Ok(()) + } + + pub fn list_ssh_revocations(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT revocation_id, kind, target, reason, created_at_ms, published + FROM ssh_revocations ORDER BY created_at_ms, revocation_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredSshRevocation { + revocation_id: row.get(0)?, + kind: row.get(1)?, + target: row.get(2)?, + reason: row.get(3)?, + created_at_ms: row.get(4)?, + published: row.get::<_, i64>(5)? != 0, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn stored_ssh_cert_request_from_row( + row: &rusqlite::Row<'_>, +) -> Result { + let principals_json: String = row.get(5)?; + let principals = serde_json::from_str(&principals_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok(StoredSshCertRequest { + request_id: row.get(0)?, + requester_node: row.get(1)?, + public_key: row.get(2)?, + public_key_fingerprint: row.get(3)?, + cert_kind: row.get(4)?, + principals, + requested_validity: row.get(6)?, + renewal_of: row.get(7)?, + reason: row.get(8)?, + status: row.get(9)?, + created_at_ms: row.get(10)?, + }) } #[derive(Clone, Debug, PartialEq, Eq)] @@ -217,6 +409,40 @@ pub struct CasObject { pub path: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredSshCertRequest { + pub request_id: String, + pub requester_node: String, + pub public_key: String, + pub public_key_fingerprint: String, + pub cert_kind: String, + pub principals: Vec, + pub requested_validity: Option, + pub renewal_of: Option, + pub reason: Option, + pub status: String, + pub created_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredSshCertificate { + pub cert_id: String, + pub request_id: String, + pub certificate: String, + pub certificate_fingerprint: String, + pub imported_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredSshRevocation { + pub revocation_id: String, + pub kind: String, + pub target: String, + pub reason: Option, + pub created_at_ms: i64, + pub published: bool, +} + #[must_use] pub fn now_ms() -> i64 { let now = std::time::SystemTime::now() @@ -237,4 +463,47 @@ mod tests { let resources = store.list_resources().expect("resources"); assert!(resources.is_empty()); } + + #[test] + fn ssh_cert_request_and_revocation_roundtrip() { + let store = Store::open_memory().expect("open"); + let request = StoredSshCertRequest { + request_id: "ssh-cert-request:1".to_owned(), + requester_node: "node:laptop".to_owned(), + public_key: "ssh-ed25519 AAAA test".to_owned(), + public_key_fingerprint: "ssh:blake3:test".to_owned(), + cert_kind: "user".to_owned(), + principals: vec!["eric".to_owned()], + requested_validity: Some("+52w".to_owned()), + renewal_of: None, + reason: Some("renewal".to_owned()), + status: "pending".to_owned(), + created_at_ms: 1, + }; + store + .insert_ssh_cert_request(&request) + .expect("insert request"); + assert_eq!( + store + .get_ssh_cert_request("ssh-cert-request:1") + .expect("get request"), + Some(request.clone()) + ); + + let revocation = StoredSshRevocation { + revocation_id: "ssh-revocation:1".to_owned(), + kind: "public-key".to_owned(), + target: "ssh:blake3:test".to_owned(), + reason: Some("lost key".to_owned()), + created_at_ms: 2, + published: true, + }; + store + .insert_ssh_revocation(&revocation) + .expect("insert revocation"); + assert_eq!( + store.list_ssh_revocations().expect("list revocations"), + vec![revocation] + ); + } } diff --git a/crates/geth-types/src/lib.rs b/crates/geth-types/src/lib.rs index ffd26c1..9e7be9a 100644 --- a/crates/geth-types/src/lib.rs +++ b/crates/geth-types/src/lib.rs @@ -57,6 +57,9 @@ string_id!(TopicId); string_id!(AuthOpId); string_id!(KeyId); string_id!(SecretId); +string_id!(SshCertRequestId); +string_id!(SshCertId); +string_id!(SshRevocationId); #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct UnixMillis(pub i64); diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index ff55137..53a58ba 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -18,5 +18,6 @@ geth-cli = { path = "../geth-cli" } [dev-dependencies] geth-cas = { path = "../geth-cas" } geth-config = { path = "../geth-config" } +geth-control = { path = "../geth-control" } geth-node = { path = "../geth-node" } tempfile.workspace = true diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 69f9847..aff51fa 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -103,3 +103,72 @@ fn initialized_node_can_roundtrip_cas_blob() { b"hello geth integration" ); } + +#[test] +fn ssh_cert_request_approval_and_revocation_export_use_local_state() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let public_key_path = home.path().join("id_ed25519.pub"); + std::fs::write(&public_key_path, "ssh-ed25519 AAAATEST eric@geth\n").expect("write pubkey"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SshCertRequest { + public_key_path: public_key_path.clone(), + cert_kind: "user".to_owned(), + principals: vec!["eric".to_owned()], + requested_validity: Some("+52w".to_owned()), + renewal_of: None, + reason: Some("renewal".to_owned()), + }, + ) + .expect("request cert"); + let request_id = match response { + geth_control::ControlResponse::SshCertRequested { request } => request.id.to_string(), + other => panic!("unexpected response: {other:?}"), + }; + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SshCertApprove { + request_id: request_id.clone(), + ca_key_path: home.path().join("ca_sk"), + valid_for: Some("+4w".to_owned()), + serial: Some(42), + out: None, + }, + ) + .expect("approve cert"); + match response { + geth_control::ControlResponse::SshCertApproved { approval } => { + assert_eq!(approval.request_id.to_string(), request_id); + assert!(approval.signing_command.contains(&"ssh-keygen".to_owned())); + assert!(approval.signing_command.contains(&"42".to_owned())); + } + other => panic!("unexpected response: {other:?}"), + } + + let export_path = home.path().join("revocations.jsonl"); + geth_node::handle_request( + &node, + geth_control::ControlRequest::SshRevocationAdd { + kind: "public-key".to_owned(), + target: "ssh:blake3:test".to_owned(), + reason: Some("lost key".to_owned()), + }, + ) + .expect("add revocation"); + geth_node::handle_request( + &node, + geth_control::ControlRequest::SshRevocationExport { + out: export_path.clone(), + }, + ) + .expect("export revocations"); + assert!( + std::fs::read_to_string(export_path) + .expect("read revocations") + .contains("lost key") + ); +} diff --git a/docs/adr/0001-single-binary-cli-and-daemon.md b/docs/adr/0001-single-binary-cli-and-daemon.md index e74a21a..51d41ef 100644 --- a/docs/adr/0001-single-binary-cli-and-daemon.md +++ b/docs/adr/0001-single-binary-cli-and-daemon.md @@ -12,7 +12,13 @@ The project ships one executable: `geth`. It has daemon mode with Control commands use a local Unix socket. There are no separate `gethd` or `gethctl` binaries. +The same binary also owns daemon service installation through `geth daemon +service ...`. Service installation targets user service managers: systemd user +units on Linux, launchd user agents on macOS, and per-user scheduled tasks on +Windows. + ## Consequences Packaging and user mental model stay simple. The daemon remains the owner of identity, Iroh endpoint state, resources, and synchronized data structures. +The bootstrap avoids privileged system service installation. diff --git a/docs/adr/0009-ssh-trust-and-ssh-proxy.md b/docs/adr/0009-ssh-trust-and-ssh-proxy.md index ad842d6..9947c01 100644 --- a/docs/adr/0009-ssh-trust-and-ssh-proxy.md +++ b/docs/adr/0009-ssh-trust-and-ssh-proxy.md @@ -10,7 +10,16 @@ SSH keys are admin signing identities and ecosystem integration points. SSH is not a geth transport. Future SSH proxy support will carry SSH protocol bytes over authorized Iroh streams, and OpenSSH will still perform normal login auth. +Geth also manages OpenSSH certificate request, renewal, approval, import, and +revocation-list metadata. Signing is explicit: an approved request yields a +concrete `ssh-keygen -s ...` command that can be run on the machine holding the +CA key or attached YubiKey. + ## Consequences Knowing an Iroh EndpointID is insufficient to reach sshd. Geth authorization must allow `ssh_proxy.connect` before any SSH/admin endpoint is opened. + +Certificate and key revocations are durable geth metadata and should be +distributed over Iroh between authorized nodes. Discovery does not grant trust in +certificate requests, issued certificates, or revocation lists. diff --git a/docs/adr/0014-ssh-certificate-flows.md b/docs/adr/0014-ssh-certificate-flows.md new file mode 100644 index 0000000..a36220f --- /dev/null +++ b/docs/adr/0014-ssh-certificate-flows.md @@ -0,0 +1,39 @@ +# ADR 0014: SSH Certificate Flows And Revocation Lists + +## Status + +Accepted. + +## Decision + +Geth manages OpenSSH certificate request, renewal, approval, import, and +revocation-list metadata. + +A node may request a user or host certificate by submitting its public key, +principals, requested validity, and optional renewal target. An authorized +machine can approve that request. Approval does not silently sign inside the +daemon; it returns an explicit `ssh-keygen -s ...` command that can be run on the +machine with the CA private key or YubiKey-backed CA key. The resulting +`-cert.pub` file is imported back into geth for distribution. + +Certificate and key revocations are durable records. The bootstrap stores and +exports them as JSONL. Future work may derive OpenSSH KRL files and replicate +signed revocation lists over Iroh. + +## Consequences + +SSH certificate lifecycle management becomes part of the SSH trust integration +layer without making SSH a geth transport. The daemon can coordinate requests and +distribution while the CA/YubiKey machine remains the explicit signing point. + +Future authorization should protect capabilities such as: + +- `ssh_cert.request` +- `ssh_cert.approve` +- `ssh_cert.import` +- `ssh_revocation.publish` +- `ssh_revocation.read` + +The current bootstrap does not claim that revocation reaches offline nodes +immediately. Revocation distribution is eventual until stronger synchronization +and policy enforcement are implemented. diff --git a/docs/adr/0015-user-service-installation.md b/docs/adr/0015-user-service-installation.md new file mode 100644 index 0000000..67e8c81 --- /dev/null +++ b/docs/adr/0015-user-service-installation.md @@ -0,0 +1,29 @@ +# ADR 0015: User Service Installation + +## Status + +Accepted. + +## Decision + +Geth provides `geth daemon service ...` commands to install, uninstall, start, +stop, inspect, and print daemon service definitions. + +The service is always installed as a user service: + +- Linux: systemd user unit, `~/.config/systemd/user/geth.service` +- macOS: launchd user agent, `~/Library/LaunchAgents/local.geth.daemon.plist` +- Windows: per-user scheduled task named `geth-daemon` + +The service runs the same single binary as `geth daemon run` and preserves +`GETH_HOME` in the service environment. + +## Consequences + +There are still no separate `gethd` or `gethctl` binaries. Installation does not +require privileged system service access. Linux users who need the daemon before +interactive login may still need platform-specific user-service setup such as +systemd lingering. + +Future service managers can be added behind the same service-manager enum and +CLI shape. diff --git a/docs/architecture.md b/docs/architecture.md index 1cd32c2..641dcc6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,12 @@ the future shared Iroh endpoint, resource registry, module routing, and local control socket. Control commands connect to the Unix socket and send typed JSONL requests. +Service management is also exposed through the single binary. `geth daemon +service ...` installs and controls a user-level service definition for the local +daemon. The initial backends are systemd user units on Linux, launchd user agents +on macOS, and per-user scheduled tasks on Windows. Geth does not install itself +as a privileged system service. + ## Iroh-Only Remote Communication Remote geth node-to-node communication is Iroh-only. The daemon will own one @@ -17,6 +23,13 @@ identities for keychain and authorization operations. SSH proxying, when added, will carry SSH bytes over an authorized Iroh stream and will not make SSH a geth transport backend. +SSH certificate flows use the same split. Nodes can request new OpenSSH +certificates or renewals through geth metadata. A machine with the CA key or +YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command, +then import the resulting certificate for distribution. Certificate and key +revocations are stored as signed-list-ready records and will be replicated over +Iroh in later phases. + ## Resource Model Everything meaningful is modeled as a resource. Resources have a kind, name, @@ -42,6 +55,11 @@ are future work. `geth-kv`, `geth-db`, `geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy` currently define types, command shape, and roadmap stubs. +`geth-ssh-identity` defines SSH trust namespaces plus certificate request, +approval, certificate import, and revocation-list data models. The bootstrap +persists these flows locally and exports revocations as JSONL. It does not yet +generate OpenSSH KRL binaries or replicate the lists over Iroh. + ## Keychain, Auth, And Secrets The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents, @@ -85,4 +103,10 @@ strong forward secrecy or post-compromise security. - Bearer secrets are resource-scoped capabilities. - Bearer access does not imply trust graph mutation rights. - Authorization is capability-based and resource-scoped. +- SSH certificate issuance must be explicitly approved by an authorized + principal before signing. +- SSH certificate and key revocations are durable metadata that should be + distributed over Iroh, not fetched through unauthenticated discovery. - Network and control decoders treat input as untrusted. +- Service installation targets user service managers, not system service + managers. diff --git a/docs/roadmap.md b/docs/roadmap.md index 934aaee..0b1ac60 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,7 @@ - local store - local identity - local CAS +- user service installer for systemd, launchd, and Windows scheduled tasks - docs and ADRs ## Phase 1: Iroh Foundation @@ -16,10 +17,14 @@ - peer discovery - peer exchange - basic authenticated peer connection +- additional user service manager backends where useful ## Phase 2: Trust And Authorization - SSH-admin-rooted keychain +- SSH certificate request and renewal queues +- CA/YubiKey approval workflow +- certificate and key revocation-list records - signed keychain ops - resource auth ops - grants and revocations @@ -39,6 +44,8 @@ - dumbpipe-style streams - TCP and Unix socket forwarding - SSH proxy over Iroh +- SSH certificate and revocation distribution over Iroh +- OpenSSH KRL export/import support - restricted geth admin shell ## Phase 5: DB And Documents