From f7e85960f778d38580e4fc09fa22a6b40190d79f Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Tue, 19 May 2026 15:44:13 +0200 Subject: [PATCH] Enforce SSH workflow capabilities locally --- AGENTS.md | 14 +-- README.md | 26 +++--- crates/geth-cli/src/lib.rs | 62 ++++++++++--- crates/geth-control/src/lib.rs | 20 ++++- crates/geth-node/src/lib.rs | 155 ++++++++++++++++++++++++++------- crates/geth/tests/bootstrap.rs | 92 +++++++++++++++++++ docs/architecture.md | 9 +- docs/roadmap.md | 12 ++- 8 files changed, 328 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3869a0a..3726484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,11 +173,15 @@ Roadmap items should be actionable and checkable: specification imports are supported; binary KRL import is unsupported because OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover public-key and certificate binary KRL revocations when `ssh-keygen` is - available. Authorized peers can pull SSH certificate-flow metadata with - `ssh_cert.sync` on `resource:ssh:certs` and revocation metadata with - `ssh_revocation.sync` on `resource:ssh:revocations`. The daemon live-syncs - known peers every 30 seconds using per-peer cursors; this is pull-only - metadata sync, not yet a CRDT/resource-log replication model. + available. Local SSH certificate and revocation commands accept optional + `--subject` principals and enforce `ssh_cert.*` capabilities on + `resource:ssh:certs` plus `ssh_revocation.*` capabilities on + `resource:ssh:revocations` for non-owner subjects. Authorized peers can pull + SSH certificate-flow metadata with `ssh_cert.sync` on `resource:ssh:certs` and + revocation metadata with `ssh_revocation.sync` on + `resource:ssh:revocations`. The daemon live-syncs known peers every 30 + seconds using per-peer cursors; this is pull-only metadata sync, not yet a + CRDT/resource-log replication model. - cr-sqlite apply, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items unless implemented later. diff --git a/README.md b/README.md index 75e8b24..219a2f4 100644 --- a/README.md +++ b/README.md @@ -112,16 +112,16 @@ The bootstrap implementation provides: pub --node ` publishes to an authorized peer; `geth pubsub sub --node ` reads an authorized peer snapshot - 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 cert request --public-key --principal [--subject ]` + - `geth ssh cert requests [--subject ]` + - `geth ssh cert approve --ca-key [--subject ]` + - `geth ssh cert import --cert [--subject ]` + - `geth ssh cert list [--subject ]` - `geth ssh cert sync ` - - `geth ssh revocation add ` - - `geth ssh revocation list` - - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec|openssh-krl]` - - `geth ssh revocation import [--format jsonl|openssh-krl-spec]` + - `geth ssh revocation add [--subject ]` + - `geth ssh revocation list [--subject ]` + - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec|openssh-krl] [--subject ]` + - `geth ssh revocation import [--format jsonl|openssh-krl-spec] [--subject ]` - `geth ssh revocation sync ` - pipe registry/connect commands: `geth pipe listen ` and `geth pipe connect [--node ]` @@ -203,7 +203,13 @@ specific resource capabilities but do not create trusted node identity. The auth evaluator supports scoped KV write grants such as `kv.write_prefix:apps/foo/` for `kv.write_key:apps/foo/config` explain checks. `geth kv set --subject ` enforces those local grants for test callers; the local node/agent -still has owner access for local administration. +still has owner access for local administration. SSH certificate and revocation +commands also accept `--subject ` on local metadata operations to +exercise the same capability checks: certificate requests/read/approval/import +use `ssh_cert.request`, `ssh_cert.read`, `ssh_cert.approve`, and +`ssh_cert.import` on `resource:ssh:certs`, while revocation publish/read/import +use `ssh_revocation.publish`, `ssh_revocation.read`, and +`ssh_revocation.import` on `resource:ssh:revocations`. ## Local State diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 282123d..769ba6e 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -403,8 +403,13 @@ pub enum SshCertCommand { renewal_of: Option, #[arg(long)] reason: Option, + #[arg(long)] + subject: Option, + }, + Requests { + #[arg(long)] + subject: Option, }, - Requests, Approve { request_id: String, #[arg(long)] @@ -415,13 +420,20 @@ pub enum SshCertCommand { serial: Option, #[arg(long)] out: Option, + #[arg(long)] + subject: Option, }, Import { request_id: String, #[arg(long)] cert: PathBuf, + #[arg(long)] + subject: Option, + }, + List { + #[arg(long)] + subject: Option, }, - List, Sync { node: String, }, @@ -434,8 +446,13 @@ pub enum SshRevocationCommand { target: String, #[arg(long)] reason: Option, + #[arg(long)] + subject: Option, + }, + List { + #[arg(long)] + subject: Option, }, - List, Export { #[arg(long)] out: PathBuf, @@ -443,11 +460,15 @@ pub enum SshRevocationCommand { format: String, #[arg(long)] ca_public: Option, + #[arg(long)] + subject: Option, }, Import { path: PathBuf, #[arg(long, default_value = "jsonl")] format: String, + #[arg(long)] + subject: Option, }, Sync { node: String, @@ -701,6 +722,7 @@ fn request_for_command(command: Command) -> Result { valid_for, renewal_of, reason, + subject, } => ControlRequest::SshCertRequest { public_key_path: public_key, cert_kind: kind, @@ -708,26 +730,34 @@ fn request_for_command(command: Command) -> Result { requested_validity: valid_for, renewal_of, reason, + subject, }, - SshCertCommand::Requests => ControlRequest::SshCertRequests, + SshCertCommand::Requests { subject } => ControlRequest::SshCertRequests { subject }, SshCertCommand::Approve { request_id, ca_key, valid_for, serial, out, + subject, } => ControlRequest::SshCertApprove { request_id, ca_key_path: ca_key, valid_for, serial, out, + subject, }, - SshCertCommand::Import { request_id, cert } => ControlRequest::SshCertImport { + SshCertCommand::Import { + request_id, + cert, + subject, + } => ControlRequest::SshCertImport { request_id, cert_path: cert, + subject, }, - SshCertCommand::List => ControlRequest::SshCertList, + SshCertCommand::List { subject } => ControlRequest::SshCertList { subject }, SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node }, }, SshCommand::Revocation { command } => match command { @@ -735,24 +765,36 @@ fn request_for_command(command: Command) -> Result { kind, target, reason, + subject, } => ControlRequest::SshRevocationAdd { kind, target, reason, + subject, }, - SshRevocationCommand::List => ControlRequest::SshRevocationList, + SshRevocationCommand::List { subject } => { + ControlRequest::SshRevocationList { subject } + } SshRevocationCommand::Export { out, format, ca_public, + subject, } => ControlRequest::SshRevocationExport { out, format, ca_public, + subject, + }, + SshRevocationCommand::Import { + path, + format, + subject, + } => ControlRequest::SshRevocationImport { + path, + format, + subject, }, - SshRevocationCommand::Import { path, format } => { - ControlRequest::SshRevocationImport { path, format } - } SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node }, }, }, diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 8f57d80..a332e84 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -139,20 +139,27 @@ pub enum ControlRequest { requested_validity: Option, renewal_of: Option, reason: Option, + subject: Option, + }, + SshCertRequests { + subject: Option, }, - SshCertRequests, SshCertApprove { request_id: String, ca_key_path: PathBuf, valid_for: Option, serial: Option, out: Option, + subject: Option, }, SshCertImport { request_id: String, cert_path: PathBuf, + subject: Option, + }, + SshCertList { + subject: Option, }, - SshCertList, SshCertSync { node: String, }, @@ -160,16 +167,21 @@ pub enum ControlRequest { kind: String, target: String, reason: Option, + subject: Option, + }, + SshRevocationList { + subject: Option, }, - SshRevocationList, SshRevocationExport { out: PathBuf, format: String, ca_public: Option, + subject: Option, }, SshRevocationImport { path: PathBuf, format: String, + subject: Option, }, SshRevocationSync { node: String, @@ -920,6 +932,7 @@ mod tests { out: PathBuf::from("revocations.krl-spec"), format: "openssh-krl-spec".to_owned(), ca_public: None, + subject: None, }; assert_eq!( decode_request(&encode_request(&request).expect("encode")).expect("decode"), @@ -929,6 +942,7 @@ mod tests { let request = ControlRequest::SshRevocationImport { path: PathBuf::from("revocations.jsonl"), format: "jsonl".to_owned(), + subject: None, }; assert_eq!( decode_request(&encode_request(&request).expect("encode")).expect("decode"), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 5080926..aeed305 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -2965,7 +2965,15 @@ pub fn handle_request( requested_validity, renewal_of, reason, + subject, } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:certs", + "ssh_cert.request", + )?; if principals.is_empty() { return Err(NodeError::MissingSshCertPrincipal); } @@ -2995,20 +3003,37 @@ pub fn handle_request( 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::SshCertRequests { subject } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:certs", + "ssh_cert.read", + )?; + 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, + subject, } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:certs", + "ssh_cert.approve", + )?; let stored = store .get_ssh_cert_request(&request_id)? .ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?; @@ -3051,7 +3076,15 @@ pub fn handle_request( ControlRequest::SshCertImport { request_id, cert_path, + subject, } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:certs", + "ssh_cert.import", + )?; let certificate = std::fs::read_to_string(&cert_path)?; let record = SshCertificateRecord { id: certificate_id(&certificate), @@ -3069,23 +3102,40 @@ pub fn handle_request( 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::SshCertList { subject } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:certs", + "ssh_cert.read", + )?; + 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, + subject, } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:revocations", + "ssh_revocation.publish", + )?; let kind = kind .parse::() .map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?; @@ -3101,18 +3151,35 @@ pub fn handle_request( 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::SshRevocationList { subject } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:revocations", + "ssh_revocation.read", + )?; + Ok(ControlResponse::SshRevocationList { + revocations: store + .list_ssh_revocations()? + .into_iter() + .map(ssh_revocation_from_stored) + .collect::, _>>()?, + }) + } ControlRequest::SshRevocationExport { out, format, ca_public, + subject, } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:revocations", + "ssh_revocation.read", + )?; let revocations = store .list_ssh_revocations()? .into_iter() @@ -3158,7 +3225,18 @@ pub fn handle_request( note, }) } - ControlRequest::SshRevocationImport { path, format } => { + ControlRequest::SshRevocationImport { + path, + format, + subject, + } => { + ensure_subject_authorized( + &store, + node, + subject.as_deref(), + "resource:ssh:revocations", + "ssh_revocation.import", + )?; let body = std::fs::read_to_string(&path)?; let created_at = UnixMillis(geth_store::now_ms()); let revocations = match format.as_str() { @@ -3672,6 +3750,20 @@ fn ensure_kv_write_authorized( subject: Option, resource_id: &str, key: &str, +) -> Result<(), NodeError> { + let Some(subject) = subject else { + return Ok(()); + }; + let capability = format!("kv.write_key:{key}"); + ensure_subject_authorized(store, node, Some(&subject), resource_id, &capability) +} + +fn ensure_subject_authorized( + store: &Store, + node: &LocalNode, + subject: Option<&str>, + resource_id: &str, + capability: &str, ) -> Result<(), NodeError> { let Some(subject) = subject else { return Ok(()); @@ -3680,13 +3772,12 @@ fn ensure_kv_write_authorized( return Ok(()); } - let capability = format!("kv.write_key:{key}"); let ops = load_auth_ops_for_resource(store, resource_id)?; let explanation = geth_auth::explain_auth_ops( &ops, - PrincipalId::new(subject.clone()), + PrincipalId::new(subject.to_owned()), ResourceId::new(resource_id.to_owned()), - Capability::new(capability.clone()), + Capability::new(capability.to_owned()), ); if explanation.allowed { Ok(()) @@ -4241,6 +4332,7 @@ mod tests { requested_validity: Some("+52w".to_owned()), renewal_of: None, reason: Some("test sync".to_owned()), + subject: None, }, ) .expect("right ssh cert request"); @@ -4254,6 +4346,7 @@ mod tests { kind: "key-id".to_owned(), target: "old-node-key".to_owned(), reason: Some("test sync".to_owned()), + subject: None, }, ) .expect("right ssh revocation"); @@ -5004,6 +5097,7 @@ mod tests { requested_validity: Some("+4w".to_owned()), renewal_of: None, reason: Some("background sync test".to_owned()), + subject: None, }, ) .expect("right second ssh cert request"); @@ -5017,6 +5111,7 @@ mod tests { kind: "key-id".to_owned(), target: "newly-revoked-key".to_owned(), reason: Some("background sync test".to_owned()), + subject: None, }, ) .expect("right second ssh revocation"); diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 30c5758..bd12394 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1375,6 +1375,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { requested_validity: Some("+52w".to_owned()), renewal_of: None, reason: Some("renewal".to_owned()), + subject: None, }, ) .expect("request cert"); @@ -1391,6 +1392,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { valid_for: Some("+4w".to_owned()), serial: Some(42), out: None, + subject: None, }, ) .expect("approve cert"); @@ -1410,6 +1412,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { kind: "public-key".to_owned(), target: "ssh:blake3:test".to_owned(), reason: Some("lost key".to_owned()), + subject: None, }, ) .expect("add revocation"); @@ -1419,6 +1422,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { out: export_path.clone(), format: "jsonl".to_owned(), ca_public: None, + subject: None, }, ) .expect("export revocations"); @@ -1435,6 +1439,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { out: krl_spec_path.clone(), format: "openssh-krl-spec".to_owned(), ca_public: None, + subject: None, }, ) .expect("export revocation krl spec"); @@ -1465,6 +1470,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { geth_control::ControlRequest::SshRevocationImport { path: krl_spec_path, format: "openssh-krl-spec".to_owned(), + subject: None, }, ) .expect("import krl spec"); @@ -1490,6 +1496,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { geth_control::ControlRequest::SshRevocationImport { path: home.path().join("revocations.jsonl"), format: "jsonl".to_owned(), + subject: None, }, ) .expect("import jsonl revocations"); @@ -1501,6 +1508,89 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() { } } +#[test] +fn ssh_cert_and_revocation_commands_check_subject_capabilities() { + 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"); + + assert!( + 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("unauthorized".to_owned()), + subject: Some("node:ssh-operator".to_owned()), + }, + ) + .is_err() + ); + + geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthGrant { + subject: "node:ssh-operator".to_owned(), + resource: "resource:ssh:certs".to_owned(), + capability: "ssh_cert.request".to_owned(), + grant_id: Some("grant:ssh-cert-request".to_owned()), + }, + ) + .expect("grant cert request"); + geth_node::handle_request( + &node, + geth_control::ControlRequest::SshCertRequest { + public_key_path, + cert_kind: "user".to_owned(), + principals: vec!["eric".to_owned()], + requested_validity: Some("+52w".to_owned()), + renewal_of: None, + reason: Some("authorized".to_owned()), + subject: Some("node:ssh-operator".to_owned()), + }, + ) + .expect("authorized cert request"); + + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::SshRevocationAdd { + kind: "key-id".to_owned(), + target: "old-key".to_owned(), + reason: Some("unauthorized".to_owned()), + subject: Some("node:ssh-operator".to_owned()), + }, + ) + .is_err() + ); + + geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthGrant { + subject: "node:ssh-operator".to_owned(), + resource: "resource:ssh:revocations".to_owned(), + capability: "ssh_revocation.publish".to_owned(), + grant_id: Some("grant:ssh-revocation-publish".to_owned()), + }, + ) + .expect("grant revocation publish"); + geth_node::handle_request( + &node, + geth_control::ControlRequest::SshRevocationAdd { + kind: "key-id".to_owned(), + target: "old-key".to_owned(), + reason: Some("authorized".to_owned()), + subject: Some("node:ssh-operator".to_owned()), + }, + ) + .expect("authorized revocation add"); +} + #[test] fn ssh_revocation_export_can_write_binary_openssh_krl() { if Command::new("ssh-keygen").arg("-?").output().is_err() { @@ -1531,6 +1621,7 @@ fn ssh_revocation_export_can_write_binary_openssh_krl() { kind: "public-key".to_owned(), target: public_key, reason: Some("test binary krl".to_owned()), + subject: None, }, ) .expect("add revocation"); @@ -1541,6 +1632,7 @@ fn ssh_revocation_export_can_write_binary_openssh_krl() { out: krl_path.clone(), format: "openssh-krl".to_owned(), ca_public: None, + subject: None, }, ) .expect("export binary krl"); diff --git a/docs/architecture.md b/docs/architecture.md index e429f13..fe7e3da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -206,8 +206,13 @@ unsupported and asks for JSONL or the spec source. Revocation lists are not yet full CRDT-replicated resources, but the daemon can already pull cert-flow and revocation metadata from authorized peers over the protected Iroh control ALPN. Manual sync commands and the background live-sync loop share the same capability -checks and cursor state. The live-sync loop first asks for authorized stream -watermarks and skips module pulls whose remote high-water value has not advanced. +checks and cursor state. Local SSH certificate and revocation metadata commands +also accept an optional subject principal for authorization testing: non-owner +subjects must hold `ssh_cert.*` capabilities on `resource:ssh:certs` or +`ssh_revocation.*` capabilities on `resource:ssh:revocations` before requests, +approval/import/read operations, or revocation publish/read/import operations +are accepted. The live-sync loop first asks for authorized stream watermarks and +skips module pulls whose remote high-water value has not advanced. ## Keychain, Auth, And Secrets diff --git a/docs/roadmap.md b/docs/roadmap.md index a8191c0..f1c3cc8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -230,8 +230,16 @@ resource-scoped capability decisions. manual command. - `[x]` SSH metadata live-sync stores per-peer high-water cursors in `module_state` and requests only records at or beyond the cursor. - - `[ ]` Future completion requires auth checks for local request, approve, - import, publish, and read capabilities. + - `[x]` Local SSH cert request/read/approve/import commands can enforce + `ssh_cert.request`, `ssh_cert.read`, `ssh_cert.approve`, and + `ssh_cert.import` for explicit non-owner `--subject` principals. + - `[x]` Local SSH revocation publish/read/import commands can enforce + `ssh_revocation.publish`, `ssh_revocation.read`, and + `ssh_revocation.import` for explicit non-owner `--subject` principals. + - `[x]` Tests cover denied and granted non-owner local SSH cert request and + revocation publish flows. + - `[ ]` Future completion requires all accepted SSH cert/revocation records + to be signed and reducible before replication. ## Phase 3: CAS, KV, And Pubsub