Add OpenSSH certificate signing path
This commit is contained in:
parent
e145bb47cd
commit
91c65e367d
9 changed files with 160 additions and 13 deletions
|
|
@ -177,7 +177,10 @@ 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. Local SSH certificate and revocation commands accept optional
|
available. `geth ssh cert approve` emits the OpenSSH signing command by
|
||||||
|
default; `approve --sign` runs `ssh-keygen`, imports the resulting
|
||||||
|
certificate, and marks the request signed when local signing succeeds. Local
|
||||||
|
SSH certificate and revocation commands accept optional
|
||||||
`--subject` principals and enforce `ssh_cert.*` capabilities on
|
`--subject` principals and enforce `ssh_cert.*` capabilities on
|
||||||
`resource:ssh:certs` plus `ssh_revocation.*` capabilities on
|
`resource:ssh:certs` plus `ssh_revocation.*` capabilities on
|
||||||
`resource:ssh:revocations` for non-owner subjects. Authorized peers can pull
|
`resource:ssh:revocations` for non-owner subjects. Authorized peers can pull
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -58,10 +58,11 @@ over authorized Iroh streams, but the geth transport remains Iroh.
|
||||||
SSH certificate request and renewal flows are managed as geth metadata. A node
|
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
|
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,
|
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
|
or pass `--sign` to run `ssh-keygen` immediately and import the resulting
|
||||||
key revocation entries are tracked locally and can be exported as JSONL or as an
|
`-cert.pub` for distribution. Certificate and key revocation entries are tracked
|
||||||
OpenSSH KRL specification file or a binary OpenSSH KRL generated through
|
locally and can be exported as JSONL or as an OpenSSH KRL specification file or
|
||||||
`ssh-keygen -k`. `geth ssh cert sync <node-id>` and
|
a binary OpenSSH KRL generated through `ssh-keygen -k`. `geth ssh cert sync
|
||||||
|
<node-id>` and
|
||||||
`geth ssh revocation sync <node-id>` pull certificate-flow and revocation
|
`geth ssh revocation sync <node-id>` pull certificate-flow and revocation
|
||||||
metadata from an authorized peer over Iroh.
|
metadata from an authorized peer over Iroh.
|
||||||
|
|
||||||
|
|
@ -114,7 +115,7 @@ The bootstrap implementation provides:
|
||||||
- SSH certificate flow metadata:
|
- SSH certificate flow metadata:
|
||||||
- `geth ssh cert request --public-key <path> --principal <name> [--subject <principal>]`
|
- `geth ssh cert request --public-key <path> --principal <name> [--subject <principal>]`
|
||||||
- `geth ssh cert requests [--subject <principal>]`
|
- `geth ssh cert requests [--subject <principal>]`
|
||||||
- `geth ssh cert approve <request-id> --ca-key <path> [--subject <principal>]`
|
- `geth ssh cert approve <request-id> --ca-key <path> [--sign] [--subject <principal>]`
|
||||||
- `geth ssh cert import <request-id> --cert <path> [--subject <principal>]`
|
- `geth ssh cert import <request-id> --cert <path> [--subject <principal>]`
|
||||||
- `geth ssh cert list [--subject <principal>]`
|
- `geth ssh cert list [--subject <principal>]`
|
||||||
- `geth ssh cert sync <node-id>`
|
- `geth ssh cert sync <node-id>`
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,8 @@ pub enum SshCertCommand {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
sign: bool,
|
||||||
|
#[arg(long)]
|
||||||
subject: Option<String>,
|
subject: Option<String>,
|
||||||
},
|
},
|
||||||
Import {
|
Import {
|
||||||
|
|
@ -736,6 +738,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
valid_for,
|
valid_for,
|
||||||
serial,
|
serial,
|
||||||
out,
|
out,
|
||||||
|
sign,
|
||||||
subject,
|
subject,
|
||||||
} => ControlRequest::SshCertApprove {
|
} => ControlRequest::SshCertApprove {
|
||||||
request_id,
|
request_id,
|
||||||
|
|
@ -743,6 +746,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
valid_for,
|
valid_for,
|
||||||
serial,
|
serial,
|
||||||
out,
|
out,
|
||||||
|
sign,
|
||||||
subject,
|
subject,
|
||||||
},
|
},
|
||||||
SshCertCommand::Import {
|
SshCertCommand::Import {
|
||||||
|
|
@ -1201,6 +1205,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
}
|
}
|
||||||
println!("signing_command:");
|
println!("signing_command:");
|
||||||
println!("{}", shell_quote_command(&approval.signing_command));
|
println!("{}", shell_quote_command(&approval.signing_command));
|
||||||
|
println!("signed: {}", approval.signed);
|
||||||
|
if let Some(certificate_id) = approval.certificate_id {
|
||||||
|
println!("certificate_id: {certificate_id}");
|
||||||
|
}
|
||||||
println!("note: {}", approval.note);
|
println!("note: {}", approval.note);
|
||||||
}
|
}
|
||||||
ControlResponse::SshCertImported { certificate } => {
|
ControlResponse::SshCertImported { certificate } => {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ pub enum ControlRequest {
|
||||||
valid_for: Option<String>,
|
valid_for: Option<String>,
|
||||||
serial: Option<u64>,
|
serial: Option<u64>,
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
|
sign: bool,
|
||||||
subject: Option<String>,
|
subject: Option<String>,
|
||||||
},
|
},
|
||||||
SshCertImport {
|
SshCertImport {
|
||||||
|
|
|
||||||
|
|
@ -3117,6 +3117,7 @@ pub fn handle_request(
|
||||||
valid_for,
|
valid_for,
|
||||||
serial,
|
serial,
|
||||||
out,
|
out,
|
||||||
|
sign,
|
||||||
subject,
|
subject,
|
||||||
} => {
|
} => {
|
||||||
ensure_subject_authorized(
|
ensure_subject_authorized(
|
||||||
|
|
@ -3152,6 +3153,38 @@ pub fn handle_request(
|
||||||
&valid_for,
|
&valid_for,
|
||||||
serial,
|
serial,
|
||||||
)?;
|
)?;
|
||||||
|
let expected_certificate_path = expected_openssh_cert_path(&public_key_path);
|
||||||
|
let mut signed = false;
|
||||||
|
let mut certificate_id_value = None;
|
||||||
|
let mut note = "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned();
|
||||||
|
if sign {
|
||||||
|
geth_ssh_identity::ensure_ssh_keygen_available()?;
|
||||||
|
let output = std::process::Command::new(&signing_command[0])
|
||||||
|
.args(&signing_command[1..])
|
||||||
|
.output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let certificate = std::fs::read_to_string(&expected_certificate_path)?;
|
||||||
|
let record = SshCertificateRecord {
|
||||||
|
id: certificate_id(&certificate),
|
||||||
|
request_id: 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.as_str(),
|
||||||
|
SshCertRequestStatus::Signed.as_str(),
|
||||||
|
)?;
|
||||||
|
signed = true;
|
||||||
|
certificate_id_value = Some(record.id);
|
||||||
|
note = "request approved, signed with ssh-keygen, and imported into local certificate metadata".to_owned();
|
||||||
|
}
|
||||||
let approval = SshCertApproval {
|
let approval = SshCertApproval {
|
||||||
request_id: request.id,
|
request_id: request.id,
|
||||||
approved_by_node: NodeId::new(node.node_id.clone()),
|
approved_by_node: NodeId::new(node.node_id.clone()),
|
||||||
|
|
@ -3159,9 +3192,11 @@ pub fn handle_request(
|
||||||
key_id: request_id,
|
key_id: request_id,
|
||||||
valid_for,
|
valid_for,
|
||||||
serial,
|
serial,
|
||||||
output_path: Some(expected_openssh_cert_path(&public_key_path)),
|
output_path: Some(expected_certificate_path),
|
||||||
signing_command,
|
signing_command,
|
||||||
note: "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned(),
|
signed,
|
||||||
|
certificate_id: certificate_id_value,
|
||||||
|
note,
|
||||||
};
|
};
|
||||||
Ok(ControlResponse::SshCertApproved { approval })
|
Ok(ControlResponse::SshCertApproved { approval })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ pub struct SshCertApproval {
|
||||||
pub serial: Option<u64>,
|
pub serial: Option<u64>,
|
||||||
pub output_path: Option<String>,
|
pub output_path: Option<String>,
|
||||||
pub signing_command: Vec<String>,
|
pub signing_command: Vec<String>,
|
||||||
|
pub signed: bool,
|
||||||
|
pub certificate_id: Option<SshCertId>,
|
||||||
pub note: String,
|
pub note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1392,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,
|
||||||
|
sign: false,
|
||||||
subject: None,
|
subject: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -1591,6 +1592,92 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() {
|
||||||
.expect("authorized revocation add");
|
.expect("authorized revocation add");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_cert_approve_can_sign_and_import_with_openssh_key() {
|
||||||
|
if Command::new("ssh-keygen").arg("-?").output().is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ca_key_path = home.path().join("ca_ed25519");
|
||||||
|
let user_key_path = home.path().join("user_ed25519");
|
||||||
|
for key_path in [&ca_key_path, &user_key_path] {
|
||||||
|
let status = Command::new("ssh-keygen")
|
||||||
|
.arg("-q")
|
||||||
|
.arg("-t")
|
||||||
|
.arg("ed25519")
|
||||||
|
.arg("-N")
|
||||||
|
.arg("")
|
||||||
|
.arg("-f")
|
||||||
|
.arg(key_path)
|
||||||
|
.status()
|
||||||
|
.expect("generate ssh key");
|
||||||
|
assert!(status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::SshCertRequest {
|
||||||
|
public_key_path: user_key_path.with_extension("pub"),
|
||||||
|
cert_kind: "user".to_owned(),
|
||||||
|
principals: vec!["eric".to_owned()],
|
||||||
|
requested_validity: Some("+1w".to_owned()),
|
||||||
|
renewal_of: None,
|
||||||
|
reason: Some("sign now".to_owned()),
|
||||||
|
subject: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
valid_for: Some("+1w".to_owned()),
|
||||||
|
serial: Some(7),
|
||||||
|
out: None,
|
||||||
|
sign: true,
|
||||||
|
subject: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("approve and sign cert");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::SshCertApproved { approval } => {
|
||||||
|
assert!(approval.signed);
|
||||||
|
assert!(approval.certificate_id.is_some());
|
||||||
|
assert!(approval.note.contains("signed with ssh-keygen"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::SshCertList { subject: None },
|
||||||
|
)
|
||||||
|
.expect("list certs");
|
||||||
|
match response {
|
||||||
|
geth_control::ControlResponse::SshCertList {
|
||||||
|
requests,
|
||||||
|
certificates,
|
||||||
|
} => {
|
||||||
|
assert_eq!(certificates.len(), 1);
|
||||||
|
assert_eq!(certificates[0].request_id.to_string(), request_id);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].status,
|
||||||
|
geth_ssh_identity::SshCertRequestStatus::Signed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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() {
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,11 @@ shell remain future work, and will not make SSH a geth transport backend.
|
||||||
SSH certificate flows use the same split. Nodes can request new OpenSSH
|
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
|
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,
|
YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command,
|
||||||
then import the resulting certificate for distribution. Certificate and key
|
or pass `--sign` to execute that command immediately and import the resulting
|
||||||
revocations are stored as signed-list-ready records. The bootstrap can pull
|
certificate into local metadata for distribution. This relies on the local
|
||||||
|
OpenSSH ecosystem, so hardware-backed keys remain mediated by `ssh-keygen` and
|
||||||
|
the host's agent/security-key flow. Certificate and key revocations are stored
|
||||||
|
as signed-list-ready records. The bootstrap can pull
|
||||||
certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the
|
certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the
|
||||||
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
|
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
|
||||||
`geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync`
|
`geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync`
|
||||||
|
|
@ -204,9 +207,12 @@ forward bytes or connect to sshd/admin shell.
|
||||||
`geth-ssh-identity` defines SSH trust namespaces plus certificate request,
|
`geth-ssh-identity` defines SSH trust namespaces plus certificate request,
|
||||||
approval, certificate import, and revocation-list data models. The bootstrap
|
approval, certificate import, and revocation-list data models. The bootstrap
|
||||||
persists these flows locally and exports revocations as JSONL or OpenSSH KRL
|
persists these flows locally and exports revocations as JSONL or OpenSSH KRL
|
||||||
specification text. It can also invoke `ssh-keygen -k` to produce a binary
|
specification text. Certificate approval normally emits the exact
|
||||||
OpenSSH KRL; serial and key-ID KRL entries require a CA public key via
|
`ssh-keygen -s ...` command, and `approve --sign` can run that command, import
|
||||||
`--ca-public`, matching OpenSSH behavior. It can import geth JSONL revocation
|
the resulting OpenSSH certificate, and mark the request signed. It can also
|
||||||
|
invoke `ssh-keygen -k` to produce a binary OpenSSH KRL; serial and key-ID KRL
|
||||||
|
entries require a CA public key via `--ca-public`, matching OpenSSH behavior.
|
||||||
|
It can import geth JSONL revocation
|
||||||
exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are
|
exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are
|
||||||
not enumerable through OpenSSH tooling, so geth treats binary import as
|
not enumerable through OpenSSH tooling, so geth treats binary import as
|
||||||
unsupported and asks for JSONL or the spec source. Revocation lists are not yet
|
unsupported and asks for JSONL or the spec source. Revocation lists are not yet
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,10 @@ resource-scoped capability decisions.
|
||||||
metadata.
|
metadata.
|
||||||
- `[x]` Approval emits an explicit `ssh-keygen -s ...` command for
|
- `[x]` Approval emits an explicit `ssh-keygen -s ...` command for
|
||||||
CA/YubiKey use.
|
CA/YubiKey use.
|
||||||
|
- `[x]` `geth ssh cert approve --sign` can run `ssh-keygen`, import the
|
||||||
|
resulting OpenSSH certificate, and mark the request signed.
|
||||||
|
- `[x]` Tests cover signing with a generated local OpenSSH CA key when
|
||||||
|
`ssh-keygen` is available.
|
||||||
- `[x]` `geth ssh revocation add/list/export` persists and exports
|
- `[x]` `geth ssh revocation add/list/export` persists and exports
|
||||||
revocations.
|
revocations.
|
||||||
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
|
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue