Enforce SSH workflow capabilities locally

This commit is contained in:
Eric Wendland 2026-05-19 15:44:13 +02:00
commit f7e85960f7
8 changed files with 328 additions and 62 deletions

View file

@ -173,11 +173,15 @@ Roadmap items should be actionable and checkable:
specification imports are supported; binary KRL import is unsupported because specification imports are supported; binary KRL import is unsupported because
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
public-key and certificate binary KRL revocations when `ssh-keygen` is public-key and certificate binary KRL revocations when `ssh-keygen` is
available. Authorized peers can pull SSH certificate-flow metadata with available. Local SSH certificate and revocation commands accept optional
`ssh_cert.sync` on `resource:ssh:certs` and revocation metadata with `--subject` principals and enforce `ssh_cert.*` capabilities on
`ssh_revocation.sync` on `resource:ssh:revocations`. The daemon live-syncs `resource:ssh:certs` plus `ssh_revocation.*` capabilities on
known peers every 30 seconds using per-peer cursors; this is pull-only `resource:ssh:revocations` for non-owner subjects. Authorized peers can pull
metadata sync, not yet a CRDT/resource-log replication model. 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, - cr-sqlite apply, iroh-docs, iroh-blobs provider/fetch, Automerge sync,
broader auth enforcement, and Keyhive/BeeKEM-style authorization are future broader auth enforcement, and Keyhive/BeeKEM-style authorization are future
roadmap items unless implemented later. roadmap items unless implemented later.

View file

@ -112,16 +112,16 @@ The bootstrap implementation provides:
pub <topic> <message> --node <node-id>` publishes to an authorized peer; pub <topic> <message> --node <node-id>` publishes to an authorized peer;
`geth pubsub sub <topic> --node <node-id>` reads an authorized peer snapshot `geth pubsub sub <topic> --node <node-id>` reads an authorized peer snapshot
- SSH certificate flow metadata: - SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name>` - `geth ssh cert request --public-key <path> --principal <name> [--subject <principal>]`
- `geth ssh cert requests` - `geth ssh cert requests [--subject <principal>]`
- `geth ssh cert approve <request-id> --ca-key <path>` - `geth ssh cert approve <request-id> --ca-key <path> [--subject <principal>]`
- `geth ssh cert import <request-id> --cert <path>` - `geth ssh cert import <request-id> --cert <path> [--subject <principal>]`
- `geth ssh cert list` - `geth ssh cert list [--subject <principal>]`
- `geth ssh cert sync <node-id>` - `geth ssh cert sync <node-id>`
- `geth ssh revocation add <kind> <target>` - `geth ssh revocation add <kind> <target> [--subject <principal>]`
- `geth ssh revocation list` - `geth ssh revocation list [--subject <principal>]`
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]` - `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl] [--subject <principal>]`
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec]` - `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec] [--subject <principal>]`
- `geth ssh revocation sync <node-id>` - `geth ssh revocation sync <node-id>`
- pipe registry/connect commands: `geth pipe listen <name>` and - pipe registry/connect commands: `geth pipe listen <name>` and
`geth pipe connect <name> [--node <node-id>]` `geth pipe connect <name> [--node <node-id>]`
@ -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/` 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 for `kv.write_key:apps/foo/config` explain checks. `geth kv set --subject
<principal>` enforces those local grants for test callers; the local node/agent <principal>` 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 <principal>` 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 ## Local State

View file

@ -403,8 +403,13 @@ pub enum SshCertCommand {
renewal_of: Option<String>, renewal_of: Option<String>,
#[arg(long)] #[arg(long)]
reason: Option<String>, reason: Option<String>,
#[arg(long)]
subject: Option<String>,
},
Requests {
#[arg(long)]
subject: Option<String>,
}, },
Requests,
Approve { Approve {
request_id: String, request_id: String,
#[arg(long)] #[arg(long)]
@ -415,13 +420,20 @@ pub enum SshCertCommand {
serial: Option<u64>, serial: Option<u64>,
#[arg(long)] #[arg(long)]
out: Option<PathBuf>, out: Option<PathBuf>,
#[arg(long)]
subject: Option<String>,
}, },
Import { Import {
request_id: String, request_id: String,
#[arg(long)] #[arg(long)]
cert: PathBuf, cert: PathBuf,
#[arg(long)]
subject: Option<String>,
},
List {
#[arg(long)]
subject: Option<String>,
}, },
List,
Sync { Sync {
node: String, node: String,
}, },
@ -434,8 +446,13 @@ pub enum SshRevocationCommand {
target: String, target: String,
#[arg(long)] #[arg(long)]
reason: Option<String>, reason: Option<String>,
#[arg(long)]
subject: Option<String>,
},
List {
#[arg(long)]
subject: Option<String>,
}, },
List,
Export { Export {
#[arg(long)] #[arg(long)]
out: PathBuf, out: PathBuf,
@ -443,11 +460,15 @@ pub enum SshRevocationCommand {
format: String, format: String,
#[arg(long)] #[arg(long)]
ca_public: Option<PathBuf>, ca_public: Option<PathBuf>,
#[arg(long)]
subject: Option<String>,
}, },
Import { Import {
path: PathBuf, path: PathBuf,
#[arg(long, default_value = "jsonl")] #[arg(long, default_value = "jsonl")]
format: String, format: String,
#[arg(long)]
subject: Option<String>,
}, },
Sync { Sync {
node: String, node: String,
@ -701,6 +722,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
valid_for, valid_for,
renewal_of, renewal_of,
reason, reason,
subject,
} => ControlRequest::SshCertRequest { } => ControlRequest::SshCertRequest {
public_key_path: public_key, public_key_path: public_key,
cert_kind: kind, cert_kind: kind,
@ -708,26 +730,34 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
requested_validity: valid_for, requested_validity: valid_for,
renewal_of, renewal_of,
reason, reason,
subject,
}, },
SshCertCommand::Requests => ControlRequest::SshCertRequests, SshCertCommand::Requests { subject } => ControlRequest::SshCertRequests { subject },
SshCertCommand::Approve { SshCertCommand::Approve {
request_id, request_id,
ca_key, ca_key,
valid_for, valid_for,
serial, serial,
out, out,
subject,
} => ControlRequest::SshCertApprove { } => ControlRequest::SshCertApprove {
request_id, request_id,
ca_key_path: ca_key, ca_key_path: ca_key,
valid_for, valid_for,
serial, serial,
out, out,
subject,
}, },
SshCertCommand::Import { request_id, cert } => ControlRequest::SshCertImport { SshCertCommand::Import {
request_id,
cert,
subject,
} => ControlRequest::SshCertImport {
request_id, request_id,
cert_path: cert, cert_path: cert,
subject,
}, },
SshCertCommand::List => ControlRequest::SshCertList, SshCertCommand::List { subject } => ControlRequest::SshCertList { subject },
SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node }, SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node },
}, },
SshCommand::Revocation { command } => match command { SshCommand::Revocation { command } => match command {
@ -735,24 +765,36 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
kind, kind,
target, target,
reason, reason,
subject,
} => ControlRequest::SshRevocationAdd { } => ControlRequest::SshRevocationAdd {
kind, kind,
target, target,
reason, reason,
subject,
}, },
SshRevocationCommand::List => ControlRequest::SshRevocationList, SshRevocationCommand::List { subject } => {
ControlRequest::SshRevocationList { subject }
}
SshRevocationCommand::Export { SshRevocationCommand::Export {
out, out,
format, format,
ca_public, ca_public,
subject,
} => ControlRequest::SshRevocationExport { } => ControlRequest::SshRevocationExport {
out, out,
format, format,
ca_public, 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 }, SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node },
}, },
}, },

View file

@ -139,20 +139,27 @@ pub enum ControlRequest {
requested_validity: Option<String>, requested_validity: Option<String>,
renewal_of: Option<String>, renewal_of: Option<String>,
reason: Option<String>, reason: Option<String>,
subject: Option<String>,
},
SshCertRequests {
subject: Option<String>,
}, },
SshCertRequests,
SshCertApprove { SshCertApprove {
request_id: String, request_id: String,
ca_key_path: PathBuf, ca_key_path: PathBuf,
valid_for: Option<String>, valid_for: Option<String>,
serial: Option<u64>, serial: Option<u64>,
out: Option<PathBuf>, out: Option<PathBuf>,
subject: Option<String>,
}, },
SshCertImport { SshCertImport {
request_id: String, request_id: String,
cert_path: PathBuf, cert_path: PathBuf,
subject: Option<String>,
},
SshCertList {
subject: Option<String>,
}, },
SshCertList,
SshCertSync { SshCertSync {
node: String, node: String,
}, },
@ -160,16 +167,21 @@ pub enum ControlRequest {
kind: String, kind: String,
target: String, target: String,
reason: Option<String>, reason: Option<String>,
subject: Option<String>,
},
SshRevocationList {
subject: Option<String>,
}, },
SshRevocationList,
SshRevocationExport { SshRevocationExport {
out: PathBuf, out: PathBuf,
format: String, format: String,
ca_public: Option<PathBuf>, ca_public: Option<PathBuf>,
subject: Option<String>,
}, },
SshRevocationImport { SshRevocationImport {
path: PathBuf, path: PathBuf,
format: String, format: String,
subject: Option<String>,
}, },
SshRevocationSync { SshRevocationSync {
node: String, node: String,
@ -920,6 +932,7 @@ mod tests {
out: PathBuf::from("revocations.krl-spec"), out: PathBuf::from("revocations.krl-spec"),
format: "openssh-krl-spec".to_owned(), format: "openssh-krl-spec".to_owned(),
ca_public: None, ca_public: None,
subject: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -929,6 +942,7 @@ mod tests {
let request = ControlRequest::SshRevocationImport { let request = ControlRequest::SshRevocationImport {
path: PathBuf::from("revocations.jsonl"), path: PathBuf::from("revocations.jsonl"),
format: "jsonl".to_owned(), format: "jsonl".to_owned(),
subject: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),

View file

@ -2965,7 +2965,15 @@ pub fn handle_request(
requested_validity, requested_validity,
renewal_of, renewal_of,
reason, reason,
subject,
} => { } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.request",
)?;
if principals.is_empty() { if principals.is_empty() {
return Err(NodeError::MissingSshCertPrincipal); return Err(NodeError::MissingSshCertPrincipal);
} }
@ -2995,20 +3003,37 @@ pub fn handle_request(
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?; store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
Ok(ControlResponse::SshCertRequested { request }) Ok(ControlResponse::SshCertRequested { request })
} }
ControlRequest::SshCertRequests => Ok(ControlResponse::SshCertRequests { ControlRequest::SshCertRequests { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.read",
)?;
Ok(ControlResponse::SshCertRequests {
requests: store requests: store
.list_ssh_cert_requests()? .list_ssh_cert_requests()?
.into_iter() .into_iter()
.map(ssh_cert_request_from_stored) .map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
}), })
}
ControlRequest::SshCertApprove { ControlRequest::SshCertApprove {
request_id, request_id,
ca_key_path, ca_key_path,
valid_for, valid_for,
serial, serial,
out, out,
subject,
} => { } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.approve",
)?;
let stored = store let stored = store
.get_ssh_cert_request(&request_id)? .get_ssh_cert_request(&request_id)?
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?; .ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
@ -3051,7 +3076,15 @@ pub fn handle_request(
ControlRequest::SshCertImport { ControlRequest::SshCertImport {
request_id, request_id,
cert_path, 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 certificate = std::fs::read_to_string(&cert_path)?;
let record = SshCertificateRecord { let record = SshCertificateRecord {
id: certificate_id(&certificate), id: certificate_id(&certificate),
@ -3069,7 +3102,15 @@ pub fn handle_request(
certificate: record, certificate: record,
}) })
} }
ControlRequest::SshCertList => Ok(ControlResponse::SshCertList { ControlRequest::SshCertList { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.read",
)?;
Ok(ControlResponse::SshCertList {
requests: store requests: store
.list_ssh_cert_requests()? .list_ssh_cert_requests()?
.into_iter() .into_iter()
@ -3080,12 +3121,21 @@ pub fn handle_request(
.into_iter() .into_iter()
.map(ssh_certificate_from_stored) .map(ssh_certificate_from_stored)
.collect(), .collect(),
}), })
}
ControlRequest::SshRevocationAdd { ControlRequest::SshRevocationAdd {
kind, kind,
target, target,
reason, reason,
subject,
} => { } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.publish",
)?;
let kind = kind let kind = kind
.parse::<SshRevocationKind>() .parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?; .map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
@ -3101,18 +3151,35 @@ pub fn handle_request(
store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?; store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?;
Ok(ControlResponse::SshRevocationAdded { revocation }) Ok(ControlResponse::SshRevocationAdded { revocation })
} }
ControlRequest::SshRevocationList => Ok(ControlResponse::SshRevocationList { ControlRequest::SshRevocationList { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.read",
)?;
Ok(ControlResponse::SshRevocationList {
revocations: store revocations: store
.list_ssh_revocations()? .list_ssh_revocations()?
.into_iter() .into_iter()
.map(ssh_revocation_from_stored) .map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
}), })
}
ControlRequest::SshRevocationExport { ControlRequest::SshRevocationExport {
out, out,
format, format,
ca_public, ca_public,
subject,
} => { } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.read",
)?;
let revocations = store let revocations = store
.list_ssh_revocations()? .list_ssh_revocations()?
.into_iter() .into_iter()
@ -3158,7 +3225,18 @@ pub fn handle_request(
note, 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 body = std::fs::read_to_string(&path)?;
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let revocations = match format.as_str() { let revocations = match format.as_str() {
@ -3672,6 +3750,20 @@ fn ensure_kv_write_authorized(
subject: Option<String>, subject: Option<String>,
resource_id: &str, resource_id: &str,
key: &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> { ) -> Result<(), NodeError> {
let Some(subject) = subject else { let Some(subject) = subject else {
return Ok(()); return Ok(());
@ -3680,13 +3772,12 @@ fn ensure_kv_write_authorized(
return Ok(()); return Ok(());
} }
let capability = format!("kv.write_key:{key}");
let ops = load_auth_ops_for_resource(store, resource_id)?; let ops = load_auth_ops_for_resource(store, resource_id)?;
let explanation = geth_auth::explain_auth_ops( let explanation = geth_auth::explain_auth_ops(
&ops, &ops,
PrincipalId::new(subject.clone()), PrincipalId::new(subject.to_owned()),
ResourceId::new(resource_id.to_owned()), ResourceId::new(resource_id.to_owned()),
Capability::new(capability.clone()), Capability::new(capability.to_owned()),
); );
if explanation.allowed { if explanation.allowed {
Ok(()) Ok(())
@ -4241,6 +4332,7 @@ mod tests {
requested_validity: Some("+52w".to_owned()), requested_validity: Some("+52w".to_owned()),
renewal_of: None, renewal_of: None,
reason: Some("test sync".to_owned()), reason: Some("test sync".to_owned()),
subject: None,
}, },
) )
.expect("right ssh cert request"); .expect("right ssh cert request");
@ -4254,6 +4346,7 @@ mod tests {
kind: "key-id".to_owned(), kind: "key-id".to_owned(),
target: "old-node-key".to_owned(), target: "old-node-key".to_owned(),
reason: Some("test sync".to_owned()), reason: Some("test sync".to_owned()),
subject: None,
}, },
) )
.expect("right ssh revocation"); .expect("right ssh revocation");
@ -5004,6 +5097,7 @@ mod tests {
requested_validity: Some("+4w".to_owned()), requested_validity: Some("+4w".to_owned()),
renewal_of: None, renewal_of: None,
reason: Some("background sync test".to_owned()), reason: Some("background sync test".to_owned()),
subject: None,
}, },
) )
.expect("right second ssh cert request"); .expect("right second ssh cert request");
@ -5017,6 +5111,7 @@ mod tests {
kind: "key-id".to_owned(), kind: "key-id".to_owned(),
target: "newly-revoked-key".to_owned(), target: "newly-revoked-key".to_owned(),
reason: Some("background sync test".to_owned()), reason: Some("background sync test".to_owned()),
subject: None,
}, },
) )
.expect("right second ssh revocation"); .expect("right second ssh revocation");

View file

@ -1375,6 +1375,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
requested_validity: Some("+52w".to_owned()), requested_validity: Some("+52w".to_owned()),
renewal_of: None, renewal_of: None,
reason: Some("renewal".to_owned()), reason: Some("renewal".to_owned()),
subject: None,
}, },
) )
.expect("request cert"); .expect("request cert");
@ -1391,6 +1392,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
valid_for: Some("+4w".to_owned()), valid_for: Some("+4w".to_owned()),
serial: Some(42), serial: Some(42),
out: None, out: None,
subject: None,
}, },
) )
.expect("approve cert"); .expect("approve cert");
@ -1410,6 +1412,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
kind: "public-key".to_owned(), kind: "public-key".to_owned(),
target: "ssh:blake3:test".to_owned(), target: "ssh:blake3:test".to_owned(),
reason: Some("lost key".to_owned()), reason: Some("lost key".to_owned()),
subject: None,
}, },
) )
.expect("add revocation"); .expect("add revocation");
@ -1419,6 +1422,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
out: export_path.clone(), out: export_path.clone(),
format: "jsonl".to_owned(), format: "jsonl".to_owned(),
ca_public: None, ca_public: None,
subject: None,
}, },
) )
.expect("export revocations"); .expect("export revocations");
@ -1435,6 +1439,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
out: krl_spec_path.clone(), out: krl_spec_path.clone(),
format: "openssh-krl-spec".to_owned(), format: "openssh-krl-spec".to_owned(),
ca_public: None, ca_public: None,
subject: None,
}, },
) )
.expect("export revocation krl spec"); .expect("export revocation krl spec");
@ -1465,6 +1470,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationImport { geth_control::ControlRequest::SshRevocationImport {
path: krl_spec_path, path: krl_spec_path,
format: "openssh-krl-spec".to_owned(), format: "openssh-krl-spec".to_owned(),
subject: None,
}, },
) )
.expect("import krl spec"); .expect("import krl spec");
@ -1490,6 +1496,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationImport { geth_control::ControlRequest::SshRevocationImport {
path: home.path().join("revocations.jsonl"), path: home.path().join("revocations.jsonl"),
format: "jsonl".to_owned(), format: "jsonl".to_owned(),
subject: None,
}, },
) )
.expect("import jsonl revocations"); .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] #[test]
fn ssh_revocation_export_can_write_binary_openssh_krl() { fn ssh_revocation_export_can_write_binary_openssh_krl() {
if Command::new("ssh-keygen").arg("-?").output().is_err() { 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(), kind: "public-key".to_owned(),
target: public_key, target: public_key,
reason: Some("test binary krl".to_owned()), reason: Some("test binary krl".to_owned()),
subject: None,
}, },
) )
.expect("add revocation"); .expect("add revocation");
@ -1541,6 +1632,7 @@ fn ssh_revocation_export_can_write_binary_openssh_krl() {
out: krl_path.clone(), out: krl_path.clone(),
format: "openssh-krl".to_owned(), format: "openssh-krl".to_owned(),
ca_public: None, ca_public: None,
subject: None,
}, },
) )
.expect("export binary krl"); .expect("export binary krl");

View file

@ -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 full CRDT-replicated resources, but the daemon can already pull cert-flow and
revocation metadata from authorized peers over the protected Iroh control ALPN. revocation metadata from authorized peers over the protected Iroh control ALPN.
Manual sync commands and the background live-sync loop share the same capability 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 checks and cursor state. Local SSH certificate and revocation metadata commands
watermarks and skips module pulls whose remote high-water value has not advanced. 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 ## Keychain, Auth, And Secrets

View file

@ -230,8 +230,16 @@ resource-scoped capability decisions.
manual command. manual command.
- `[x]` SSH metadata live-sync stores per-peer high-water cursors in - `[x]` SSH metadata live-sync stores per-peer high-water cursors in
`module_state` and requests only records at or beyond the cursor. `module_state` and requests only records at or beyond the cursor.
- `[ ]` Future completion requires auth checks for local request, approve, - `[x]` Local SSH cert request/read/approve/import commands can enforce
import, publish, and read capabilities. `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 ## Phase 3: CAS, KV, And Pubsub