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

@ -218,8 +218,9 @@ Roadmap items should be actionable and checkable:
`resource:ssh:revocations`. The daemon live-syncs known peers using per-peer `resource:ssh:revocations`. The daemon live-syncs known peers using per-peer
cursors; `[sync] live_sync_enabled` and `live_sync_interval_ms` in cursors; `[sync] live_sync_enabled` and `live_sync_interval_ms` in
`config.toml` control that loop. Conflicting records with already-known ids `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 are rejected during sync import, and new cert-flow/revocation records must
signed CRDT/resource-log replication model. 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, - 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.

1
Cargo.lock generated
View file

@ -1253,6 +1253,7 @@ dependencies = [
"geth-ssh-proxy", "geth-ssh-proxy",
"geth-store", "geth-store",
"geth-types", "geth-types",
"hex",
"iroh", "iroh",
"rusqlite", "rusqlite",
"serde", "serde",

View file

@ -34,6 +34,9 @@ payloads through `ssh-keygen -Y sign` using the
admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored
keychain signatures plus how many currently verify with OpenSSH; rejecting keychain signatures plus how many currently verify with OpenSSH; rejecting
unsigned or invalid replicated keychain ops is still future work. 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: The daemon can also install itself as a user service:

View file

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

View file

@ -27,10 +27,13 @@ use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret}; use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret};
use geth_ssh_identity::{ use geth_ssh_identity::{
SSH_CERT_ISSUANCE_NAMESPACE, SSH_CERT_REQUEST_NAMESPACE, SSH_REVOCATION_LIST_NAMESPACE,
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command, SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind,
cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id, build_ssh_cert_sign_command, cert_request_id, certificate_id, openssh_krl_spec,
ssh_public_key_fingerprint, write_openssh_krl, 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_ssh_proxy::SshProxyConnection;
use geth_store::{ use geth_store::{
@ -3304,7 +3307,7 @@ async fn handle_iroh_control_connection(
.list_ssh_certificates_since(since_ms)? .list_ssh_certificates_since(since_ms)?
.into_iter() .into_iter()
.map(ssh_certificate_from_stored) .map(ssh_certificate_from_stored)
.collect(), .collect::<Result<Vec<_>, _>>()?,
) )
} else { } else {
(Vec::new(), Vec::new()) (Vec::new(), Vec::new())
@ -5098,7 +5101,7 @@ pub fn handle_request(
.map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?; .map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?;
let public_key = std::fs::read_to_string(&public_key_path)?; let public_key = std::fs::read_to_string(&public_key_path)?;
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let request = SshCertRequest { let mut request = SshCertRequest {
id: cert_request_id( id: cert_request_id(
&NodeId::new(node.node_id.clone()), &NodeId::new(node.node_id.clone()),
&public_key, &public_key,
@ -5115,7 +5118,9 @@ pub fn handle_request(
reason, reason,
status: SshCertRequestStatus::Pending, status: SshCertRequestStatus::Pending,
created_at, created_at,
provenance: None,
}; };
sign_ssh_cert_request(node, &mut 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 })
} }
@ -5156,7 +5161,8 @@ pub fn handle_request(
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?; .ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
let mut request = ssh_cert_request_from_stored(stored)?; let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Approved; 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(|| { let public_key_path = out.clone().unwrap_or_else(|| {
node.paths node.paths
.home() .home()
@ -5193,18 +5199,19 @@ pub fn handle_request(
.into()); .into());
} }
let certificate = std::fs::read_to_string(&expected_certificate_path)?; let certificate = std::fs::read_to_string(&expected_certificate_path)?;
let record = SshCertificateRecord { let mut record = SshCertificateRecord {
id: certificate_id(&certificate), id: certificate_id(&certificate),
request_id: request.id.clone(), request_id: request.id.clone(),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate), certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate, certificate,
imported_at: UnixMillis(geth_store::now_ms()), 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.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
store.update_ssh_cert_request_status( request.status = SshCertRequestStatus::Signed;
request.id.as_str(), sign_ssh_cert_request(node, &mut request)?;
SshCertRequestStatus::Signed.as_str(), store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
)?;
signed = true; signed = true;
certificate_id_value = Some(record.id); certificate_id_value = Some(record.id);
note = "request approved, signed with ssh-keygen, and imported into local certificate metadata".to_owned(); 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", "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 mut record = SshCertificateRecord {
id: certificate_id(&certificate), id: certificate_id(&certificate),
request_id: SshCertRequestId::new(request_id.clone()), request_id: SshCertRequestId::new(request_id.clone()),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate), certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate, certificate,
imported_at: UnixMillis(geth_store::now_ms()), 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.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
store.update_ssh_cert_request_status( if let Some(stored) = store.get_ssh_cert_request(&request_id)? {
&request_id, let mut request = ssh_cert_request_from_stored(stored)?;
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))?;
}
Ok(ControlResponse::SshCertImported { Ok(ControlResponse::SshCertImported {
certificate: record, certificate: record,
}) })
@ -5271,7 +5282,7 @@ pub fn handle_request(
.list_ssh_certificates()? .list_ssh_certificates()?
.into_iter() .into_iter()
.map(ssh_certificate_from_stored) .map(ssh_certificate_from_stored)
.collect(), .collect::<Result<Vec<_>, _>>()?,
}) })
} }
ControlRequest::SshRevocationAdd { ControlRequest::SshRevocationAdd {
@ -5291,14 +5302,16 @@ pub fn handle_request(
.parse::<SshRevocationKind>() .parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?; .map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let revocation = SshRevocationEntry { let mut revocation = SshRevocationEntry {
id: revocation_id(&kind, &target, created_at), id: revocation_id(&kind, &target, created_at),
kind, kind,
target, target,
reason, reason,
created_at, created_at,
published: true, published: true,
provenance: None,
}; };
sign_ssh_revocation(node, &mut revocation)?;
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 })
} }
@ -5390,7 +5403,7 @@ pub fn handle_request(
)?; )?;
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 mut revocations = match format.as_str() {
"jsonl" => body "jsonl" => body
.lines() .lines()
.filter(|line| !line.trim().is_empty()) .filter(|line| !line.trim().is_empty())
@ -5408,6 +5421,7 @@ pub fn handle_request(
reason: Some(format!("imported from {}", path.display())), reason: Some(format!("imported from {}", path.display())),
created_at, created_at,
published: true, published: true,
provenance: None,
} }
}) })
.collect(), .collect(),
@ -5424,6 +5438,9 @@ pub fn handle_request(
)); ));
} }
}; };
for revocation in &mut revocations {
sign_ssh_revocation(node, revocation)?;
}
for revocation in &revocations { for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?; 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 { fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertRequest {
StoredSshCertRequest { StoredSshCertRequest {
request_id: request.id.to_string(), request_id: request.id.to_string(),
@ -6598,6 +6749,12 @@ fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertReques
reason: request.reason.clone(), reason: request.reason.clone(),
status: request.status.to_string(), status: request.status.to_string(),
created_at_ms: request.created_at.0, 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); return Ok(false);
} }
verify_ssh_cert_request_provenance(request)?;
store.insert_ssh_cert_request(&stored)?; store.insert_ssh_cert_request(&stored)?;
Ok(true) Ok(true)
} }
@ -6640,6 +6798,10 @@ fn ssh_cert_request_from_stored(stored: StoredSshCertRequest) -> Result<SshCertR
reason: stored.reason, reason: stored.reason,
status, status,
created_at: UnixMillis(stored.created_at_ms), 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: certificate.certificate.clone(),
certificate_fingerprint: certificate.certificate_fingerprint.clone(), certificate_fingerprint: certificate.certificate_fingerprint.clone(),
imported_at_ms: certificate.imported_at.0, 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); return Ok(false);
} }
verify_ssh_certificate_provenance(certificate)?;
store.insert_ssh_certificate(&stored)?; store.insert_ssh_certificate(&stored)?;
Ok(true) Ok(true)
} }
fn ssh_certificate_from_stored(stored: StoredSshCertificate) -> SshCertificateRecord { fn ssh_certificate_from_stored(
SshCertificateRecord { stored: StoredSshCertificate,
) -> Result<SshCertificateRecord, NodeError> {
Ok(SshCertificateRecord {
id: SshCertId::new(stored.cert_id), id: SshCertId::new(stored.cert_id),
request_id: SshCertRequestId::new(stored.request_id), request_id: SshCertRequestId::new(stored.request_id),
certificate: stored.certificate, certificate: stored.certificate,
certificate_fingerprint: stored.certificate_fingerprint, certificate_fingerprint: stored.certificate_fingerprint,
imported_at: UnixMillis(stored.imported_at_ms), 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 { fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevocation {
@ -6693,6 +6868,12 @@ fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevoc
reason: revocation.reason.clone(), reason: revocation.reason.clone(),
created_at_ms: revocation.created_at.0, created_at_ms: revocation.created_at.0,
published: revocation.published, 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); return Ok(false);
} }
verify_ssh_revocation_provenance(revocation)?;
store.insert_ssh_revocation(&stored)?; store.insert_ssh_revocation(&stored)?;
Ok(true) Ok(true)
} }
@ -6732,6 +6914,10 @@ fn ssh_revocation_from_stored(
reason: stored.reason, reason: stored.reason,
created_at: UnixMillis(stored.created_at_ms), created_at: UnixMillis(stored.created_at_ms),
published: stored.published, published: stored.published,
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
}) })
} }
@ -6826,7 +7012,7 @@ mod tests {
#[test] #[test]
fn ssh_sync_import_rejects_conflicting_records() { fn ssh_sync_import_rejects_conflicting_records() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");
let request = SshCertRequest { let mut request = SshCertRequest {
id: SshCertRequestId::new("ssh-cert-request:1"), id: SshCertRequestId::new("ssh-cert-request:1"),
requester_node: NodeId::new("node:right"), requester_node: NodeId::new("node:right"),
public_key: "ssh-ed25519 AAAA".to_owned(), public_key: "ssh-ed25519 AAAA".to_owned(),
@ -6838,7 +7024,9 @@ mod tests {
reason: Some("original".to_owned()), reason: Some("original".to_owned()),
status: SshCertRequestStatus::Pending, status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1), created_at: UnixMillis(1),
provenance: None,
}; };
add_test_ssh_cert_request_provenance(&mut request);
assert!( assert!(
insert_ssh_cert_request_if_not_conflicting(&store, &request).expect("insert request") 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) !insert_ssh_cert_request_if_not_conflicting(&store, &conflicting_request)
.expect("reject 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!( assert_eq!(
store store
.get_ssh_cert_request(request.id.as_str()) .get_ssh_cert_request(request.id.as_str())
@ -6858,14 +7050,16 @@ mod tests {
Some("original") Some("original")
); );
let revocation = SshRevocationEntry { let mut revocation = SshRevocationEntry {
id: geth_types::SshRevocationId::new("ssh-revocation:1"), id: geth_types::SshRevocationId::new("ssh-revocation:1"),
kind: SshRevocationKind::KeyId, kind: SshRevocationKind::KeyId,
target: "old-key".to_owned(), target: "old-key".to_owned(),
reason: Some("original".to_owned()), reason: Some("original".to_owned()),
created_at: UnixMillis(2), created_at: UnixMillis(2),
published: true, published: true,
provenance: None,
}; };
add_test_ssh_revocation_provenance(&mut revocation);
assert!( assert!(
insert_ssh_revocation_if_not_conflicting(&store, &revocation) insert_ssh_revocation_if_not_conflicting(&store, &revocation)
.expect("insert revocation") .expect("insert revocation")
@ -6876,6 +7070,10 @@ mod tests {
!insert_ssh_revocation_if_not_conflicting(&store, &conflicting_revocation) !insert_ssh_revocation_if_not_conflicting(&store, &conflicting_revocation)
.expect("reject 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!( assert_eq!(
store store
.list_ssh_revocations() .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] #[test]
fn sync_watermarks_include_only_authorized_streams() { fn sync_watermarks_include_only_authorized_streams() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");

View file

@ -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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshCertRequest { pub struct SshCertRequest {
pub id: SshCertRequestId, pub id: SshCertRequestId,
@ -130,6 +140,7 @@ pub struct SshCertRequest {
pub reason: Option<String>, pub reason: Option<String>,
pub status: SshCertRequestStatus, pub status: SshCertRequestStatus,
pub created_at: UnixMillis, pub created_at: UnixMillis,
pub provenance: Option<SshRecordProvenance>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -154,6 +165,7 @@ pub struct SshCertificateRecord {
pub certificate: String, pub certificate: String,
pub certificate_fingerprint: String, pub certificate_fingerprint: String,
pub imported_at: UnixMillis, pub imported_at: UnixMillis,
pub provenance: Option<SshRecordProvenance>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -205,6 +217,85 @@ pub struct SshRevocationEntry {
pub reason: Option<String>, pub reason: Option<String>,
pub created_at: UnixMillis, pub created_at: UnixMillis,
pub published: bool, pub published: bool,
pub provenance: Option<SshRecordProvenance>,
}
#[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<String>,
pub requested_validity: Option<String>,
pub renewal_of: Option<SshCertId>,
pub reason: Option<String>,
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<String>,
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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -449,6 +540,7 @@ mod tests {
reason: Some("bootstrap".to_owned()), reason: Some("bootstrap".to_owned()),
status: SshCertRequestStatus::Pending, status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1), created_at: UnixMillis(1),
provenance: None,
}; };
let json = serde_json::to_string(&request).expect("json"); let json = serde_json::to_string(&request).expect("json");
let decoded: SshCertRequest = serde_json::from_str(&json).expect("decode"); let decoded: SshCertRequest = serde_json::from_str(&json).expect("decode");
@ -469,6 +561,7 @@ mod tests {
reason: None, reason: None,
status: SshCertRequestStatus::Pending, status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1), created_at: UnixMillis(1),
provenance: None,
}; };
let command = build_ssh_cert_sign_command( let command = build_ssh_cert_sign_command(
&request, &request,
@ -492,6 +585,7 @@ mod tests {
reason: None, reason: None,
created_at: UnixMillis(1), created_at: UnixMillis(1),
published: false, published: false,
provenance: None,
}, },
SshRevocationEntry { SshRevocationEntry {
id: "ssh-revocation:2".into(), id: "ssh-revocation:2".into(),
@ -500,6 +594,7 @@ mod tests {
reason: None, reason: None,
created_at: UnixMillis(2), created_at: UnixMillis(2),
published: false, published: false,
provenance: None,
}, },
SshRevocationEntry { SshRevocationEntry {
id: "ssh-revocation:3".into(), id: "ssh-revocation:3".into(),
@ -508,6 +603,7 @@ mod tests {
reason: None, reason: None,
created_at: UnixMillis(3), created_at: UnixMillis(3),
published: false, published: false,
provenance: None,
}, },
]; ];
@ -552,6 +648,7 @@ mod tests {
reason: None, reason: None,
created_at: UnixMillis(1), created_at: UnixMillis(1),
published: false, published: false,
provenance: None,
}; };
assert!(openssh_krl_spec_line(&entry).is_err()); assert!(openssh_krl_spec_line(&entry).is_err());
@ -586,6 +683,7 @@ mod tests {
reason: Some("test".to_owned()), reason: Some("test".to_owned()),
created_at: UnixMillis(1), created_at: UnixMillis(1),
published: true, published: true,
provenance: None,
}; };
let krl_path = dir.path().join("revoked.krl"); let krl_path = dir.path().join("revoked.krl");
@ -666,6 +764,7 @@ mod tests {
reason: Some("test certificate revocation".to_owned()), reason: Some("test certificate revocation".to_owned()),
created_at: UnixMillis(1), created_at: UnixMillis(1),
published: true, published: true,
provenance: None,
}; };
let krl_path = dir.path().join("revoked-certs.krl"); let krl_path = dir.path().join("revoked-certs.krl");

View file

@ -186,14 +186,16 @@ impl Store {
renewal_of TEXT, renewal_of TEXT,
reason TEXT, reason TEXT,
status TEXT NOT NULL, 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 ( CREATE TABLE IF NOT EXISTS ssh_certificates (
cert_id TEXT PRIMARY KEY, cert_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL, request_id TEXT NOT NULL,
certificate TEXT NOT NULL, certificate TEXT NOT NULL,
certificate_fingerprint 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 ( CREATE TABLE IF NOT EXISTS ssh_revocations (
revocation_id TEXT PRIMARY KEY, revocation_id TEXT PRIMARY KEY,
@ -201,7 +203,8 @@ impl Store {
target TEXT NOT NULL, target TEXT NOT NULL,
reason TEXT, reason TEXT,
created_at_ms INTEGER NOT NULL, 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'); INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1');
"#, "#,
@ -211,6 +214,9 @@ impl Store {
"signer_public_key", "signer_public_key",
"TEXT NOT NULL DEFAULT ''", "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(()) Ok(())
} }
@ -1001,8 +1007,9 @@ impl Store {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_cert_requests( r#"INSERT OR REPLACE INTO ssh_cert_requests(
request_id, requester_node, public_key, public_key_fingerprint, cert_kind, 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,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, provenance_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"#,
params![ params![
request.request_id, request.request_id,
request.requester_node, request.requester_node,
@ -1014,7 +1021,8 @@ impl Store {
request.renewal_of, request.renewal_of,
request.reason, request.reason,
request.status, request.status,
request.created_at_ms request.created_at_ms,
request.provenance_json
], ],
)?; )?;
Ok(()) Ok(())
@ -1026,7 +1034,8 @@ impl Store {
) -> Result<Option<StoredSshCertRequest>, StoreError> { ) -> Result<Option<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, 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"#, FROM ssh_cert_requests WHERE request_id = ?1"#,
)?; )?;
let mut rows = stmt.query(params![request_id])?; let mut rows = stmt.query(params![request_id])?;
@ -1052,7 +1061,8 @@ impl Store {
pub fn list_ssh_cert_requests(&self) -> Result<Vec<StoredSshCertRequest>, StoreError> { pub fn list_ssh_cert_requests(&self) -> Result<Vec<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, 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"#, FROM ssh_cert_requests ORDER BY created_at_ms, request_id"#,
)?; )?;
let rows = stmt.query_map([], stored_ssh_cert_request_from_row)?; let rows = stmt.query_map([], stored_ssh_cert_request_from_row)?;
@ -1066,7 +1076,8 @@ impl Store {
) -> Result<Vec<StoredSshCertRequest>, StoreError> { ) -> Result<Vec<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind, 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"#, 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)?; let rows = stmt.query_map(params![since_ms], stored_ssh_cert_request_from_row)?;
@ -1080,14 +1091,16 @@ impl Store {
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_certificates( r#"INSERT OR REPLACE INTO ssh_certificates(
cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms,
) VALUES (?1, ?2, ?3, ?4, ?5)"#, provenance_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![ params![
certificate.cert_id, certificate.cert_id,
certificate.request_id, certificate.request_id,
certificate.certificate, certificate.certificate,
certificate.certificate_fingerprint, certificate.certificate_fingerprint,
certificate.imported_at_ms certificate.imported_at_ms,
certificate.provenance_json
], ],
)?; )?;
Ok(()) Ok(())
@ -1096,6 +1109,7 @@ impl Store {
pub fn list_ssh_certificates(&self) -> Result<Vec<StoredSshCertificate>, StoreError> { pub fn list_ssh_certificates(&self) -> Result<Vec<StoredSshCertificate>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms
, provenance_json
FROM ssh_certificates ORDER BY imported_at_ms, cert_id"#, FROM ssh_certificates ORDER BY imported_at_ms, cert_id"#,
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
@ -1105,6 +1119,7 @@ impl Store {
certificate: row.get(2)?, certificate: row.get(2)?,
certificate_fingerprint: row.get(3)?, certificate_fingerprint: row.get(3)?,
imported_at_ms: row.get(4)?, imported_at_ms: row.get(4)?,
provenance_json: row.get(5)?,
}) })
})?; })?;
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
@ -1117,6 +1132,7 @@ impl Store {
) -> Result<Vec<StoredSshCertificate>, StoreError> { ) -> Result<Vec<StoredSshCertificate>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms 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"#, FROM ssh_certificates WHERE imported_at_ms >= ?1 ORDER BY imported_at_ms, cert_id"#,
)?; )?;
let rows = stmt.query_map(params![since_ms], |row| { let rows = stmt.query_map(params![since_ms], |row| {
@ -1126,6 +1142,7 @@ impl Store {
certificate: row.get(2)?, certificate: row.get(2)?,
certificate_fingerprint: row.get(3)?, certificate_fingerprint: row.get(3)?,
imported_at_ms: row.get(4)?, imported_at_ms: row.get(4)?,
provenance_json: row.get(5)?,
}) })
})?; })?;
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
@ -1138,15 +1155,16 @@ impl Store {
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_revocations( r#"INSERT OR REPLACE INTO ssh_revocations(
revocation_id, kind, target, reason, created_at_ms, published revocation_id, kind, target, reason, created_at_ms, published, provenance_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
params![ params![
revocation.revocation_id, revocation.revocation_id,
revocation.kind, revocation.kind,
revocation.target, revocation.target,
revocation.reason, revocation.reason,
revocation.created_at_ms, revocation.created_at_ms,
if revocation.published { 1_i64 } else { 0_i64 } if revocation.published { 1_i64 } else { 0_i64 },
revocation.provenance_json
], ],
)?; )?;
Ok(()) Ok(())
@ -1155,6 +1173,7 @@ impl Store {
pub fn list_ssh_revocations(&self) -> Result<Vec<StoredSshRevocation>, StoreError> { pub fn list_ssh_revocations(&self) -> Result<Vec<StoredSshRevocation>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT revocation_id, kind, target, reason, created_at_ms, published r#"SELECT revocation_id, kind, target, reason, created_at_ms, published
, provenance_json
FROM ssh_revocations ORDER BY created_at_ms, revocation_id"#, FROM ssh_revocations ORDER BY created_at_ms, revocation_id"#,
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
@ -1165,6 +1184,7 @@ impl Store {
reason: row.get(3)?, reason: row.get(3)?,
created_at_ms: row.get(4)?, created_at_ms: row.get(4)?,
published: row.get::<_, i64>(5)? != 0, published: row.get::<_, i64>(5)? != 0,
provenance_json: row.get(6)?,
}) })
})?; })?;
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
@ -1177,6 +1197,7 @@ impl Store {
) -> Result<Vec<StoredSshRevocation>, StoreError> { ) -> Result<Vec<StoredSshRevocation>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
r#"SELECT revocation_id, kind, target, reason, created_at_ms, published 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"#, FROM ssh_revocations WHERE created_at_ms >= ?1 ORDER BY created_at_ms, revocation_id"#,
)?; )?;
let rows = stmt.query_map(params![since_ms], |row| { let rows = stmt.query_map(params![since_ms], |row| {
@ -1187,6 +1208,7 @@ impl Store {
reason: row.get(3)?, reason: row.get(3)?,
created_at_ms: row.get(4)?, created_at_ms: row.get(4)?,
published: row.get::<_, i64>(5)? != 0, published: row.get::<_, i64>(5)? != 0,
provenance_json: row.get(6)?,
}) })
})?; })?;
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
@ -1213,6 +1235,7 @@ fn stored_ssh_cert_request_from_row(
reason: row.get(8)?, reason: row.get(8)?,
status: row.get(9)?, status: row.get(9)?,
created_at_ms: row.get(10)?, created_at_ms: row.get(10)?,
provenance_json: row.get(11)?,
}) })
} }
@ -1388,6 +1411,7 @@ pub struct StoredSshCertRequest {
pub reason: Option<String>, pub reason: Option<String>,
pub status: String, pub status: String,
pub created_at_ms: i64, pub created_at_ms: i64,
pub provenance_json: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -1397,6 +1421,7 @@ pub struct StoredSshCertificate {
pub certificate: String, pub certificate: String,
pub certificate_fingerprint: String, pub certificate_fingerprint: String,
pub imported_at_ms: i64, pub imported_at_ms: i64,
pub provenance_json: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -1407,6 +1432,7 @@ pub struct StoredSshRevocation {
pub reason: Option<String>, pub reason: Option<String>,
pub created_at_ms: i64, pub created_at_ms: i64,
pub published: bool, pub published: bool,
pub provenance_json: Option<String>,
} }
#[must_use] #[must_use]
@ -1445,6 +1471,7 @@ mod tests {
reason: Some("renewal".to_owned()), reason: Some("renewal".to_owned()),
status: "pending".to_owned(), status: "pending".to_owned(),
created_at_ms: 1, created_at_ms: 1,
provenance_json: Some(r#"{"test":true}"#.to_owned()),
}; };
store store
.insert_ssh_cert_request(&request) .insert_ssh_cert_request(&request)
@ -1463,6 +1490,7 @@ mod tests {
reason: Some("lost key".to_owned()), reason: Some("lost key".to_owned()),
created_at_ms: 2, created_at_ms: 2,
published: true, published: true,
provenance_json: Some(r#"{"test":true}"#.to_owned()),
}; };
store store
.insert_ssh_revocation(&revocation) .insert_ssh_revocation(&revocation)

View file

@ -270,8 +270,10 @@ resource-scoped capability decisions.
revocation records with ids that already exist locally. revocation records with ids that already exist locally.
- `[x]` Tests verify conflicting SSH cert request and revocation records do - `[x]` Tests verify conflicting SSH cert request and revocation records do
not overwrite local metadata. not overwrite local metadata.
- `[ ]` Future completion requires all accepted SSH cert/revocation records - `[x]` Accepted SSH cert/revocation records carry agent-key signed
to carry signed provenance and reduce cleanly before replication. 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 ## 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. - `[ ]` Replace pull-only metadata sync with a resource log or CRDT model.
- `[x]` Conflicting records with already-known ids are rejected during import - `[x]` Conflicting records with already-known ids are rejected during import
rather than replacing local metadata. rather than replacing local metadata.
- `[ ]` Unsigned records are rejected or quarantined once signed provenance is - `[x]` Unsigned records are rejected during sync import once signed
part of the metadata format. provenance is part of the metadata format.
- `[x]` OpenSSH KRL import/export. - `[x]` OpenSSH KRL import/export.
Acceptance criteria: Acceptance criteria: