Require signed SSH metadata provenance

This commit is contained in:
Eric Wendland 2026-05-21 01:29:55 +02:00
commit 6e04e786c2
8 changed files with 414 additions and 45 deletions

View file

@ -32,6 +32,7 @@ geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-ssh-proxy = { path = "../geth-ssh-proxy" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }
hex.workspace = true
iroh.workspace = true
swarm-discovery.workspace = true

View file

@ -27,10 +27,13 @@ use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret};
use geth_ssh_identity::{
SSH_CERT_ISSUANCE_NAMESPACE, SSH_CERT_REQUEST_NAMESPACE, SSH_REVOCATION_LIST_NAMESPACE,
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command,
cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id,
ssh_public_key_fingerprint, write_openssh_krl,
SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind,
build_ssh_cert_sign_command, cert_request_id, certificate_id, openssh_krl_spec,
parse_openssh_krl_spec, revocation_id, ssh_cert_request_signing_payload,
ssh_certificate_signing_payload, ssh_public_key_fingerprint, ssh_revocation_signing_payload,
write_openssh_krl,
};
use geth_ssh_proxy::SshProxyConnection;
use geth_store::{
@ -3304,7 +3307,7 @@ async fn handle_iroh_control_connection(
.list_ssh_certificates_since(since_ms)?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
.collect::<Result<Vec<_>, _>>()?,
)
} else {
(Vec::new(), Vec::new())
@ -5098,7 +5101,7 @@ pub fn handle_request(
.map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?;
let public_key = std::fs::read_to_string(&public_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let request = SshCertRequest {
let mut request = SshCertRequest {
id: cert_request_id(
&NodeId::new(node.node_id.clone()),
&public_key,
@ -5115,7 +5118,9 @@ pub fn handle_request(
reason,
status: SshCertRequestStatus::Pending,
created_at,
provenance: None,
};
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
Ok(ControlResponse::SshCertRequested { request })
}
@ -5156,7 +5161,8 @@ pub fn handle_request(
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Approved;
store.update_ssh_cert_request_status(request.id.as_str(), request.status.as_str())?;
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
let public_key_path = out.clone().unwrap_or_else(|| {
node.paths
.home()
@ -5193,18 +5199,19 @@ pub fn handle_request(
.into());
}
let certificate = std::fs::read_to_string(&expected_certificate_path)?;
let record = SshCertificateRecord {
let mut 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()),
provenance: None,
};
sign_ssh_certificate(node, &mut record)?;
store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
store.update_ssh_cert_request_status(
request.id.as_str(),
SshCertRequestStatus::Signed.as_str(),
)?;
request.status = SshCertRequestStatus::Signed;
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
signed = true;
certificate_id_value = Some(record.id);
note = "request approved, signed with ssh-keygen, and imported into local certificate metadata".to_owned();
@ -5237,18 +5244,22 @@ pub fn handle_request(
"ssh_cert.import",
)?;
let certificate = std::fs::read_to_string(&cert_path)?;
let record = SshCertificateRecord {
let mut record = SshCertificateRecord {
id: certificate_id(&certificate),
request_id: SshCertRequestId::new(request_id.clone()),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate,
imported_at: UnixMillis(geth_store::now_ms()),
provenance: None,
};
sign_ssh_certificate(node, &mut record)?;
store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
store.update_ssh_cert_request_status(
&request_id,
SshCertRequestStatus::Signed.as_str(),
)?;
if let Some(stored) = store.get_ssh_cert_request(&request_id)? {
let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Signed;
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
}
Ok(ControlResponse::SshCertImported {
certificate: record,
})
@ -5271,7 +5282,7 @@ pub fn handle_request(
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
.collect::<Result<Vec<_>, _>>()?,
})
}
ControlRequest::SshRevocationAdd {
@ -5291,14 +5302,16 @@ pub fn handle_request(
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
let created_at = UnixMillis(geth_store::now_ms());
let revocation = SshRevocationEntry {
let mut revocation = SshRevocationEntry {
id: revocation_id(&kind, &target, created_at),
kind,
target,
reason,
created_at,
published: true,
provenance: None,
};
sign_ssh_revocation(node, &mut revocation)?;
store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?;
Ok(ControlResponse::SshRevocationAdded { revocation })
}
@ -5390,7 +5403,7 @@ pub fn handle_request(
)?;
let body = std::fs::read_to_string(&path)?;
let created_at = UnixMillis(geth_store::now_ms());
let revocations = match format.as_str() {
let mut revocations = match format.as_str() {
"jsonl" => body
.lines()
.filter(|line| !line.trim().is_empty())
@ -5408,6 +5421,7 @@ pub fn handle_request(
reason: Some(format!("imported from {}", path.display())),
created_at,
published: true,
provenance: None,
}
})
.collect(),
@ -5424,6 +5438,9 @@ pub fn handle_request(
));
}
};
for revocation in &mut revocations {
sign_ssh_revocation(node, revocation)?;
}
for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
}
@ -6585,6 +6602,140 @@ fn expected_openssh_cert_path(public_key_path: &Path) -> String {
}
}
fn sign_ssh_cert_request(node: &LocalNode, request: &mut SshCertRequest) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
SSH_CERT_REQUEST_NAMESPACE,
&ssh_cert_request_signing_payload(request),
)?;
request.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_REQUEST_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn sign_ssh_certificate(
node: &LocalNode,
certificate: &mut SshCertificateRecord,
) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
SSH_CERT_ISSUANCE_NAMESPACE,
&ssh_certificate_signing_payload(certificate),
)?;
certificate.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_ISSUANCE_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn sign_ssh_revocation(
node: &LocalNode,
revocation: &mut SshRevocationEntry,
) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
SSH_REVOCATION_LIST_NAMESPACE,
&ssh_revocation_signing_payload(revocation),
)?;
revocation.provenance = Some(SshRecordProvenance {
namespace: SSH_REVOCATION_LIST_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn verify_ssh_cert_request_provenance(request: &SshCertRequest) -> Result<(), NodeError> {
let provenance = request.provenance.as_ref().ok_or_else(|| {
NodeError::IrohPeer(format!(
"SSH cert request {} is missing signed provenance",
request.id
))
})?;
if provenance.namespace != SSH_CERT_REQUEST_NAMESPACE {
return Err(NodeError::IrohPeer(format!(
"SSH cert request {} uses invalid provenance namespace {}",
request.id, provenance.namespace
)));
}
verify_record_provenance(
&provenance.signer_public_key,
SSH_CERT_REQUEST_NAMESPACE,
&ssh_cert_request_signing_payload(request),
&provenance.signature_hex,
)
}
fn verify_ssh_certificate_provenance(certificate: &SshCertificateRecord) -> Result<(), NodeError> {
let provenance = certificate.provenance.as_ref().ok_or_else(|| {
NodeError::IrohPeer(format!(
"SSH certificate {} is missing signed provenance",
certificate.id
))
})?;
if provenance.namespace != SSH_CERT_ISSUANCE_NAMESPACE {
return Err(NodeError::IrohPeer(format!(
"SSH certificate {} uses invalid provenance namespace {}",
certificate.id, provenance.namespace
)));
}
verify_record_provenance(
&provenance.signer_public_key,
SSH_CERT_ISSUANCE_NAMESPACE,
&ssh_certificate_signing_payload(certificate),
&provenance.signature_hex,
)
}
fn verify_ssh_revocation_provenance(revocation: &SshRevocationEntry) -> Result<(), NodeError> {
let provenance = revocation.provenance.as_ref().ok_or_else(|| {
NodeError::IrohPeer(format!(
"SSH revocation {} is missing signed provenance",
revocation.id
))
})?;
if provenance.namespace != SSH_REVOCATION_LIST_NAMESPACE {
return Err(NodeError::IrohPeer(format!(
"SSH revocation {} uses invalid provenance namespace {}",
revocation.id, provenance.namespace
)));
}
verify_record_provenance(
&provenance.signer_public_key,
SSH_REVOCATION_LIST_NAMESPACE,
&ssh_revocation_signing_payload(revocation),
&provenance.signature_hex,
)
}
fn verify_record_provenance<T: serde::Serialize + ?Sized>(
public_key_hex: &str,
namespace: &str,
payload: &T,
signature_hex: &str,
) -> Result<(), NodeError> {
let public_key =
hex::decode(public_key_hex).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let signature =
hex::decode(signature_hex).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
geth_crypto::verify_canonical(&public_key, namespace, payload, &signature)?;
Ok(())
}
fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertRequest {
StoredSshCertRequest {
request_id: request.id.to_string(),
@ -6598,6 +6749,12 @@ fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertReques
reason: request.reason.clone(),
status: request.status.to_string(),
created_at_ms: request.created_at.0,
provenance_json: request
.provenance
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("SSH provenance serializes"),
}
}
@ -6615,6 +6772,7 @@ fn insert_ssh_cert_request_if_not_conflicting(
}
return Ok(false);
}
verify_ssh_cert_request_provenance(request)?;
store.insert_ssh_cert_request(&stored)?;
Ok(true)
}
@ -6640,6 +6798,10 @@ fn ssh_cert_request_from_stored(stored: StoredSshCertRequest) -> Result<SshCertR
reason: stored.reason,
status,
created_at: UnixMillis(stored.created_at_ms),
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
})
}
@ -6650,6 +6812,12 @@ fn stored_from_ssh_certificate(certificate: &SshCertificateRecord) -> StoredSshC
certificate: certificate.certificate.clone(),
certificate_fingerprint: certificate.certificate_fingerprint.clone(),
imported_at_ms: certificate.imported_at.0,
provenance_json: certificate
.provenance
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("SSH provenance serializes"),
}
}
@ -6671,18 +6839,25 @@ fn insert_ssh_certificate_if_not_conflicting(
}
return Ok(false);
}
verify_ssh_certificate_provenance(certificate)?;
store.insert_ssh_certificate(&stored)?;
Ok(true)
}
fn ssh_certificate_from_stored(stored: StoredSshCertificate) -> SshCertificateRecord {
SshCertificateRecord {
fn ssh_certificate_from_stored(
stored: StoredSshCertificate,
) -> Result<SshCertificateRecord, NodeError> {
Ok(SshCertificateRecord {
id: SshCertId::new(stored.cert_id),
request_id: SshCertRequestId::new(stored.request_id),
certificate: stored.certificate,
certificate_fingerprint: stored.certificate_fingerprint,
imported_at: UnixMillis(stored.imported_at_ms),
}
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
})
}
fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevocation {
@ -6693,6 +6868,12 @@ fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevoc
reason: revocation.reason.clone(),
created_at_ms: revocation.created_at.0,
published: revocation.published,
provenance_json: revocation
.provenance
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("SSH provenance serializes"),
}
}
@ -6714,6 +6895,7 @@ fn insert_ssh_revocation_if_not_conflicting(
}
return Ok(false);
}
verify_ssh_revocation_provenance(revocation)?;
store.insert_ssh_revocation(&stored)?;
Ok(true)
}
@ -6732,6 +6914,10 @@ fn ssh_revocation_from_stored(
reason: stored.reason,
created_at: UnixMillis(stored.created_at_ms),
published: stored.published,
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
})
}
@ -6826,7 +7012,7 @@ mod tests {
#[test]
fn ssh_sync_import_rejects_conflicting_records() {
let store = Store::open_memory().expect("open");
let request = SshCertRequest {
let mut request = SshCertRequest {
id: SshCertRequestId::new("ssh-cert-request:1"),
requester_node: NodeId::new("node:right"),
public_key: "ssh-ed25519 AAAA".to_owned(),
@ -6838,7 +7024,9 @@ mod tests {
reason: Some("original".to_owned()),
status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1),
provenance: None,
};
add_test_ssh_cert_request_provenance(&mut request);
assert!(
insert_ssh_cert_request_if_not_conflicting(&store, &request).expect("insert request")
);
@ -6848,6 +7036,10 @@ mod tests {
!insert_ssh_cert_request_if_not_conflicting(&store, &conflicting_request)
.expect("reject conflicting request")
);
let mut unsigned_request = request.clone();
unsigned_request.id = SshCertRequestId::new("ssh-cert-request:unsigned");
unsigned_request.provenance = None;
assert!(insert_ssh_cert_request_if_not_conflicting(&store, &unsigned_request).is_err());
assert_eq!(
store
.get_ssh_cert_request(request.id.as_str())
@ -6858,14 +7050,16 @@ mod tests {
Some("original")
);
let revocation = SshRevocationEntry {
let mut revocation = SshRevocationEntry {
id: geth_types::SshRevocationId::new("ssh-revocation:1"),
kind: SshRevocationKind::KeyId,
target: "old-key".to_owned(),
reason: Some("original".to_owned()),
created_at: UnixMillis(2),
published: true,
provenance: None,
};
add_test_ssh_revocation_provenance(&mut revocation);
assert!(
insert_ssh_revocation_if_not_conflicting(&store, &revocation)
.expect("insert revocation")
@ -6876,6 +7070,10 @@ mod tests {
!insert_ssh_revocation_if_not_conflicting(&store, &conflicting_revocation)
.expect("reject conflicting revocation")
);
let mut unsigned_revocation = revocation.clone();
unsigned_revocation.id = geth_types::SshRevocationId::new("ssh-revocation:unsigned");
unsigned_revocation.provenance = None;
assert!(insert_ssh_revocation_if_not_conflicting(&store, &unsigned_revocation).is_err());
assert_eq!(
store
.list_ssh_revocations()
@ -6887,6 +7085,42 @@ mod tests {
);
}
fn add_test_ssh_cert_request_provenance(request: &mut SshCertRequest) {
let key = AgentKey::generate();
let signature = key
.sign_canonical(
SSH_CERT_REQUEST_NAMESPACE,
&ssh_cert_request_signing_payload(request),
)
.expect("sign request");
request.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_REQUEST_NAMESPACE.to_owned(),
signer_node: NodeId::new("node:right"),
signer_agent: key.agent_id().to_string(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(1),
});
}
fn add_test_ssh_revocation_provenance(revocation: &mut SshRevocationEntry) {
let key = AgentKey::generate();
let signature = key
.sign_canonical(
SSH_REVOCATION_LIST_NAMESPACE,
&ssh_revocation_signing_payload(revocation),
)
.expect("sign revocation");
revocation.provenance = Some(SshRecordProvenance {
namespace: SSH_REVOCATION_LIST_NAMESPACE.to_owned(),
signer_node: NodeId::new("node:right"),
signer_agent: key.agent_id().to_string(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(2),
});
}
#[test]
fn sync_watermarks_include_only_authorized_streams() {
let store = Store::open_memory().expect("open");