From 6e04e786c243bc0a69ca7fabd7b3d35134d6ba44 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Thu, 21 May 2026 01:29:55 +0200 Subject: [PATCH] Require signed SSH metadata provenance --- AGENTS.md | 5 +- Cargo.lock | 1 + README.md | 3 + crates/geth-node/Cargo.toml | 1 + crates/geth-node/src/lib.rs | 282 +++++++++++++++++++++++++--- crates/geth-ssh-identity/src/lib.rs | 99 ++++++++++ crates/geth-store/src/lib.rs | 58 ++++-- docs/roadmap.md | 10 +- 8 files changed, 414 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cce6fa6..46405b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,8 +218,9 @@ Roadmap items should be actionable and checkable: `resource:ssh:revocations`. The daemon live-syncs known peers using per-peer cursors; `[sync] live_sync_enabled` and `live_sync_interval_ms` in `config.toml` control that loop. Conflicting records with already-known ids - are rejected during sync import; this is pull-only metadata sync, not yet a - signed CRDT/resource-log replication model. + are rejected during sync import, and new cert-flow/revocation records must + carry valid agent-key signed provenance over canonical payloads. This is + pull-only metadata sync, not yet a CRDT/resource-log replication model. - cr-sqlite apply, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items unless implemented later. diff --git a/Cargo.lock b/Cargo.lock index 7a63392..782461a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,7 @@ dependencies = [ "geth-ssh-proxy", "geth-store", "geth-types", + "hex", "iroh", "rusqlite", "serde", diff --git a/README.md b/README.md index 3ef4e2b..78295de 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ payloads through `ssh-keygen -Y sign` using the admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored keychain signatures plus how many currently verify with OpenSSH; rejecting unsigned or invalid replicated keychain ops is still future work. +SSH certificate-flow and revocation records carry agent-key signed provenance +over canonical payloads, and sync import rejects new unsigned or invalidly +signed records. The daemon can also install itself as a user service: diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 986bbf5..6f23fd6 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -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 diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 70a6136..bcc7f7b 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -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::, _>>()?, ) } 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::, _>>()?, }) } ControlRequest::SshRevocationAdd { @@ -5291,14 +5302,16 @@ pub fn handle_request( .parse::() .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( + 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 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 { + 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"); diff --git a/crates/geth-ssh-identity/src/lib.rs b/crates/geth-ssh-identity/src/lib.rs index 7843191..4c2d1ae 100644 --- a/crates/geth-ssh-identity/src/lib.rs +++ b/crates/geth-ssh-identity/src/lib.rs @@ -117,6 +117,16 @@ impl std::str::FromStr for SshCertRequestStatus { } } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshRecordProvenance { + pub namespace: String, + pub signer_node: NodeId, + pub signer_agent: String, + pub signer_public_key: String, + pub signature_hex: String, + pub signed_at: UnixMillis, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SshCertRequest { pub id: SshCertRequestId, @@ -130,6 +140,7 @@ pub struct SshCertRequest { pub reason: Option, pub status: SshCertRequestStatus, pub created_at: UnixMillis, + pub provenance: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -154,6 +165,7 @@ pub struct SshCertificateRecord { pub certificate: String, pub certificate_fingerprint: String, pub imported_at: UnixMillis, + pub provenance: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -205,6 +217,85 @@ pub struct SshRevocationEntry { pub reason: Option, pub created_at: UnixMillis, pub published: bool, + pub provenance: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshCertRequestSigningPayload { + pub id: SshCertRequestId, + pub requester_node: NodeId, + pub public_key: String, + pub public_key_fingerprint: String, + pub cert_kind: SshCertKind, + pub principals: Vec, + pub requested_validity: Option, + pub renewal_of: Option, + pub reason: Option, + pub status: SshCertRequestStatus, + pub created_at: UnixMillis, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshCertificateSigningPayload { + pub id: SshCertId, + pub request_id: SshCertRequestId, + pub certificate: String, + pub certificate_fingerprint: String, + pub imported_at: UnixMillis, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshRevocationSigningPayload { + pub id: SshRevocationId, + pub kind: SshRevocationKind, + pub target: String, + pub reason: Option, + pub created_at: UnixMillis, + pub published: bool, +} + +#[must_use] +pub fn ssh_cert_request_signing_payload(request: &SshCertRequest) -> SshCertRequestSigningPayload { + SshCertRequestSigningPayload { + id: request.id.clone(), + requester_node: request.requester_node.clone(), + public_key: request.public_key.clone(), + public_key_fingerprint: request.public_key_fingerprint.clone(), + cert_kind: request.cert_kind.clone(), + principals: request.principals.clone(), + requested_validity: request.requested_validity.clone(), + renewal_of: request.renewal_of.clone(), + reason: request.reason.clone(), + status: request.status.clone(), + created_at: request.created_at, + } +} + +#[must_use] +pub fn ssh_certificate_signing_payload( + certificate: &SshCertificateRecord, +) -> SshCertificateSigningPayload { + SshCertificateSigningPayload { + id: certificate.id.clone(), + request_id: certificate.request_id.clone(), + certificate: certificate.certificate.clone(), + certificate_fingerprint: certificate.certificate_fingerprint.clone(), + imported_at: certificate.imported_at, + } +} + +#[must_use] +pub fn ssh_revocation_signing_payload( + revocation: &SshRevocationEntry, +) -> SshRevocationSigningPayload { + SshRevocationSigningPayload { + id: revocation.id.clone(), + kind: revocation.kind.clone(), + target: revocation.target.clone(), + reason: revocation.reason.clone(), + created_at: revocation.created_at, + published: revocation.published, + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -449,6 +540,7 @@ mod tests { reason: Some("bootstrap".to_owned()), status: SshCertRequestStatus::Pending, created_at: UnixMillis(1), + provenance: None, }; let json = serde_json::to_string(&request).expect("json"); let decoded: SshCertRequest = serde_json::from_str(&json).expect("decode"); @@ -469,6 +561,7 @@ mod tests { reason: None, status: SshCertRequestStatus::Pending, created_at: UnixMillis(1), + provenance: None, }; let command = build_ssh_cert_sign_command( &request, @@ -492,6 +585,7 @@ mod tests { reason: None, created_at: UnixMillis(1), published: false, + provenance: None, }, SshRevocationEntry { id: "ssh-revocation:2".into(), @@ -500,6 +594,7 @@ mod tests { reason: None, created_at: UnixMillis(2), published: false, + provenance: None, }, SshRevocationEntry { id: "ssh-revocation:3".into(), @@ -508,6 +603,7 @@ mod tests { reason: None, created_at: UnixMillis(3), published: false, + provenance: None, }, ]; @@ -552,6 +648,7 @@ mod tests { reason: None, created_at: UnixMillis(1), published: false, + provenance: None, }; assert!(openssh_krl_spec_line(&entry).is_err()); @@ -586,6 +683,7 @@ mod tests { reason: Some("test".to_owned()), created_at: UnixMillis(1), published: true, + provenance: None, }; let krl_path = dir.path().join("revoked.krl"); @@ -666,6 +764,7 @@ mod tests { reason: Some("test certificate revocation".to_owned()), created_at: UnixMillis(1), published: true, + provenance: None, }; let krl_path = dir.path().join("revoked-certs.krl"); diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 3eabb4a..54d2030 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -186,14 +186,16 @@ impl Store { renewal_of TEXT, reason TEXT, status TEXT NOT NULL, - created_at_ms INTEGER NOT NULL + created_at_ms INTEGER NOT NULL, + provenance_json TEXT ); CREATE TABLE IF NOT EXISTS ssh_certificates ( cert_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, certificate TEXT NOT NULL, certificate_fingerprint TEXT NOT NULL, - imported_at_ms INTEGER NOT NULL + imported_at_ms INTEGER NOT NULL, + provenance_json TEXT ); CREATE TABLE IF NOT EXISTS ssh_revocations ( revocation_id TEXT PRIMARY KEY, @@ -201,7 +203,8 @@ impl Store { target TEXT NOT NULL, reason TEXT, created_at_ms INTEGER NOT NULL, - published INTEGER NOT NULL DEFAULT 0 + published INTEGER NOT NULL DEFAULT 0, + provenance_json TEXT ); INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1'); "#, @@ -211,6 +214,9 @@ impl Store { "signer_public_key", "TEXT NOT NULL DEFAULT ''", )?; + self.add_column_if_missing("ssh_cert_requests", "provenance_json", "TEXT")?; + self.add_column_if_missing("ssh_certificates", "provenance_json", "TEXT")?; + self.add_column_if_missing("ssh_revocations", "provenance_json", "TEXT")?; Ok(()) } @@ -1001,8 +1007,9 @@ impl Store { self.conn.execute( r#"INSERT OR REPLACE INTO ssh_cert_requests( request_id, requester_node, public_key, public_key_fingerprint, cert_kind, - principals_json, requested_validity, renewal_of, reason, status, created_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, + principals_json, requested_validity, renewal_of, reason, status, created_at_ms, + provenance_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"#, params![ request.request_id, request.requester_node, @@ -1014,7 +1021,8 @@ impl Store { request.renewal_of, request.reason, request.status, - request.created_at_ms + request.created_at_ms, + request.provenance_json ], )?; Ok(()) @@ -1026,7 +1034,8 @@ impl Store { ) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, - principals_json, requested_validity, renewal_of, reason, status, created_at_ms + principals_json, requested_validity, renewal_of, reason, status, created_at_ms, + provenance_json FROM ssh_cert_requests WHERE request_id = ?1"#, )?; let mut rows = stmt.query(params![request_id])?; @@ -1052,7 +1061,8 @@ impl Store { pub fn list_ssh_cert_requests(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, - principals_json, requested_validity, renewal_of, reason, status, created_at_ms + principals_json, requested_validity, renewal_of, reason, status, created_at_ms, + provenance_json FROM ssh_cert_requests ORDER BY created_at_ms, request_id"#, )?; let rows = stmt.query_map([], stored_ssh_cert_request_from_row)?; @@ -1066,7 +1076,8 @@ impl Store { ) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, - principals_json, requested_validity, renewal_of, reason, status, created_at_ms + principals_json, requested_validity, renewal_of, reason, status, created_at_ms, + provenance_json FROM ssh_cert_requests WHERE created_at_ms >= ?1 ORDER BY created_at_ms, request_id"#, )?; let rows = stmt.query_map(params![since_ms], stored_ssh_cert_request_from_row)?; @@ -1080,14 +1091,16 @@ impl Store { ) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR REPLACE INTO ssh_certificates( - cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms, + provenance_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ certificate.cert_id, certificate.request_id, certificate.certificate, certificate.certificate_fingerprint, - certificate.imported_at_ms + certificate.imported_at_ms, + certificate.provenance_json ], )?; Ok(()) @@ -1096,6 +1109,7 @@ impl Store { pub fn list_ssh_certificates(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms + , provenance_json FROM ssh_certificates ORDER BY imported_at_ms, cert_id"#, )?; let rows = stmt.query_map([], |row| { @@ -1105,6 +1119,7 @@ impl Store { certificate: row.get(2)?, certificate_fingerprint: row.get(3)?, imported_at_ms: row.get(4)?, + provenance_json: row.get(5)?, }) })?; rows.collect::, _>>() @@ -1117,6 +1132,7 @@ impl Store { ) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms + , provenance_json FROM ssh_certificates WHERE imported_at_ms >= ?1 ORDER BY imported_at_ms, cert_id"#, )?; let rows = stmt.query_map(params![since_ms], |row| { @@ -1126,6 +1142,7 @@ impl Store { certificate: row.get(2)?, certificate_fingerprint: row.get(3)?, imported_at_ms: row.get(4)?, + provenance_json: row.get(5)?, }) })?; rows.collect::, _>>() @@ -1138,15 +1155,16 @@ impl Store { ) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR REPLACE INTO ssh_revocations( - revocation_id, kind, target, reason, created_at_ms, published - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + revocation_id, kind, target, reason, created_at_ms, published, provenance_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, params![ revocation.revocation_id, revocation.kind, revocation.target, revocation.reason, revocation.created_at_ms, - if revocation.published { 1_i64 } else { 0_i64 } + if revocation.published { 1_i64 } else { 0_i64 }, + revocation.provenance_json ], )?; Ok(()) @@ -1155,6 +1173,7 @@ impl Store { pub fn list_ssh_revocations(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT revocation_id, kind, target, reason, created_at_ms, published + , provenance_json FROM ssh_revocations ORDER BY created_at_ms, revocation_id"#, )?; let rows = stmt.query_map([], |row| { @@ -1165,6 +1184,7 @@ impl Store { reason: row.get(3)?, created_at_ms: row.get(4)?, published: row.get::<_, i64>(5)? != 0, + provenance_json: row.get(6)?, }) })?; rows.collect::, _>>() @@ -1177,6 +1197,7 @@ impl Store { ) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#"SELECT revocation_id, kind, target, reason, created_at_ms, published + , provenance_json FROM ssh_revocations WHERE created_at_ms >= ?1 ORDER BY created_at_ms, revocation_id"#, )?; let rows = stmt.query_map(params![since_ms], |row| { @@ -1187,6 +1208,7 @@ impl Store { reason: row.get(3)?, created_at_ms: row.get(4)?, published: row.get::<_, i64>(5)? != 0, + provenance_json: row.get(6)?, }) })?; rows.collect::, _>>() @@ -1213,6 +1235,7 @@ fn stored_ssh_cert_request_from_row( reason: row.get(8)?, status: row.get(9)?, created_at_ms: row.get(10)?, + provenance_json: row.get(11)?, }) } @@ -1388,6 +1411,7 @@ pub struct StoredSshCertRequest { pub reason: Option, pub status: String, pub created_at_ms: i64, + pub provenance_json: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1397,6 +1421,7 @@ pub struct StoredSshCertificate { pub certificate: String, pub certificate_fingerprint: String, pub imported_at_ms: i64, + pub provenance_json: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1407,6 +1432,7 @@ pub struct StoredSshRevocation { pub reason: Option, pub created_at_ms: i64, pub published: bool, + pub provenance_json: Option, } #[must_use] @@ -1445,6 +1471,7 @@ mod tests { reason: Some("renewal".to_owned()), status: "pending".to_owned(), created_at_ms: 1, + provenance_json: Some(r#"{"test":true}"#.to_owned()), }; store .insert_ssh_cert_request(&request) @@ -1463,6 +1490,7 @@ mod tests { reason: Some("lost key".to_owned()), created_at_ms: 2, published: true, + provenance_json: Some(r#"{"test":true}"#.to_owned()), }; store .insert_ssh_revocation(&revocation) diff --git a/docs/roadmap.md b/docs/roadmap.md index 420e3d5..ec4ef84 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -270,8 +270,10 @@ resource-scoped capability decisions. revocation records with ids that already exist locally. - `[x]` Tests verify conflicting SSH cert request and revocation records do not overwrite local metadata. - - `[ ]` Future completion requires all accepted SSH cert/revocation records - to carry signed provenance and reduce cleanly before replication. + - `[x]` Accepted SSH cert/revocation records carry agent-key signed + provenance over deterministic canonical payloads. + - `[x]` Sync import rejects unsigned or invalidly signed cert-flow and + revocation records that are not already-known conflicting ids. ## Phase 3: CAS, KV, And Pubsub @@ -429,8 +431,8 @@ Goal: add authorized stream-oriented management workflows over Iroh. - `[ ]` Replace pull-only metadata sync with a resource log or CRDT model. - `[x]` Conflicting records with already-known ids are rejected during import rather than replacing local metadata. - - `[ ]` Unsigned records are rejected or quarantined once signed provenance is - part of the metadata format. + - `[x]` Unsigned records are rejected during sync import once signed + provenance is part of the metadata format. - `[x]` OpenSSH KRL import/export. Acceptance criteria: