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
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.

1
Cargo.lock generated
View file

@ -1253,6 +1253,7 @@ dependencies = [
"geth-ssh-proxy",
"geth-store",
"geth-types",
"hex",
"iroh",
"rusqlite",
"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
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:

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");

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)]
pub struct SshCertRequest {
pub id: SshCertRequestId,
@ -130,6 +140,7 @@ pub struct SshCertRequest {
pub reason: Option<String>,
pub status: SshCertRequestStatus,
pub created_at: UnixMillis,
pub provenance: Option<SshRecordProvenance>,
}
#[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<SshRecordProvenance>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -205,6 +217,85 @@ pub struct SshRevocationEntry {
pub reason: Option<String>,
pub created_at: UnixMillis,
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)]
@ -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");

View file

@ -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<Option<StoredSshCertRequest>, 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<Vec<StoredSshCertRequest>, 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<Vec<StoredSshCertRequest>, 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<Vec<StoredSshCertificate>, 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::<Result<Vec<_>, _>>()
@ -1117,6 +1132,7 @@ impl Store {
) -> Result<Vec<StoredSshCertificate>, 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::<Result<Vec<_>, _>>()
@ -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<Vec<StoredSshRevocation>, 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::<Result<Vec<_>, _>>()
@ -1177,6 +1197,7 @@ impl Store {
) -> Result<Vec<StoredSshRevocation>, 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::<Result<Vec<_>, _>>()
@ -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<String>,
pub status: String,
pub created_at_ms: i64,
pub provenance_json: Option<String>,
}
#[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<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -1407,6 +1432,7 @@ pub struct StoredSshRevocation {
pub reason: Option<String>,
pub created_at_ms: i64,
pub published: bool,
pub provenance_json: Option<String>,
}
#[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)

View file

@ -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: