Enforce SSH workflow capabilities locally
This commit is contained in:
parent
c9803ea7f2
commit
f7e85960f7
8 changed files with 328 additions and 62 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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 } => {
|
||||||
requests: store
|
ensure_subject_authorized(
|
||||||
.list_ssh_cert_requests()?
|
&store,
|
||||||
.into_iter()
|
node,
|
||||||
.map(ssh_cert_request_from_stored)
|
subject.as_deref(),
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
"resource:ssh:certs",
|
||||||
}),
|
"ssh_cert.read",
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::SshCertRequests {
|
||||||
|
requests: store
|
||||||
|
.list_ssh_cert_requests()?
|
||||||
|
.into_iter()
|
||||||
|
.map(ssh_cert_request_from_stored)
|
||||||
|
.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,23 +3102,40 @@ pub fn handle_request(
|
||||||
certificate: record,
|
certificate: record,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ControlRequest::SshCertList => Ok(ControlResponse::SshCertList {
|
ControlRequest::SshCertList { subject } => {
|
||||||
requests: store
|
ensure_subject_authorized(
|
||||||
.list_ssh_cert_requests()?
|
&store,
|
||||||
.into_iter()
|
node,
|
||||||
.map(ssh_cert_request_from_stored)
|
subject.as_deref(),
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
"resource:ssh:certs",
|
||||||
certificates: store
|
"ssh_cert.read",
|
||||||
.list_ssh_certificates()?
|
)?;
|
||||||
.into_iter()
|
Ok(ControlResponse::SshCertList {
|
||||||
.map(ssh_certificate_from_stored)
|
requests: store
|
||||||
.collect(),
|
.list_ssh_cert_requests()?
|
||||||
}),
|
.into_iter()
|
||||||
|
.map(ssh_cert_request_from_stored)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
certificates: store
|
||||||
|
.list_ssh_certificates()?
|
||||||
|
.into_iter()
|
||||||
|
.map(ssh_certificate_from_stored)
|
||||||
|
.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 } => {
|
||||||
revocations: store
|
ensure_subject_authorized(
|
||||||
.list_ssh_revocations()?
|
&store,
|
||||||
.into_iter()
|
node,
|
||||||
.map(ssh_revocation_from_stored)
|
subject.as_deref(),
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
"resource:ssh:revocations",
|
||||||
}),
|
"ssh_revocation.read",
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::SshRevocationList {
|
||||||
|
revocations: store
|
||||||
|
.list_ssh_revocations()?
|
||||||
|
.into_iter()
|
||||||
|
.map(ssh_revocation_from_stored)
|
||||||
|
.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");
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue