add canonical sigchain bundle format

This commit is contained in:
Eric Wendland 2026-07-19 03:16:14 +02:00
commit decff4b995
14 changed files with 1515 additions and 42 deletions

View file

@ -120,6 +120,8 @@ pub enum NodeError {
Keychain(#[from] geth_keychain::KeychainError),
#[error("SSH sigchain error: {0}")]
SshSigchain(#[from] geth_keychain::SshSigchainError),
#[error("SSH sigchain distribution error: {0}")]
SshSigchainDistribution(#[from] geth_keychain::DistributionError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
@ -6502,13 +6504,20 @@ pub fn handle_request(
root_key_path,
namespace,
} => {
let records = read_sshsigchain_jsonl_file(&input)?;
let loaded = read_sshsigchain_file(&input)?;
let trust = geth_keychain::SshSigchainTrust::new(
geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
std::fs::read_to_string(root_key_path)?,
)?;
let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?;
if loaded
.namespace
.as_ref()
.is_some_and(|namespace| namespace != &trust.namespace)
{
return Err(geth_keychain::DistributionError::WrongTrust.into());
}
let verified = verify_keychain_sshsigchain_with_ssh(&loaded.records, &trust)?;
Ok(ControlResponse::KeychainSigchainVerified {
input,
chain_id: trust.chain_id.to_hex(),
@ -6528,11 +6537,67 @@ pub fn handle_request(
.filter(|backend| backend.required)
.count(),
required_anchor_classes: verified.state.anchor_policy.required_classes.len(),
format: loaded.format.to_owned(),
disclosures: loaded.disclosures,
claims: loaded.claims,
receipts: loaded.receipts,
bundle_hash: loaded.bundle_hash,
note:
"verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key"
"verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key; bundled claims and receipts are reported but require anchor-policy verification before head selection"
.to_owned(),
})
}
ControlRequest::KeychainSigchainBundleCreate {
input,
out,
namespace,
} => {
let records = read_sshsigchain_jsonl_file(&input)?;
let chain_id = records
.first()
.ok_or(geth_keychain::SshSigchainError::EmptyChain)?
.chain_id;
let bundle = geth_keychain::CanonicalSshSigchainBundle {
chain_id,
namespace,
records,
claims: vec![],
receipts: vec![],
};
let bytes = geth_keychain::encode_canonical_bundle(&bundle)?;
std::fs::write(&out, &bytes)?;
Ok(ControlResponse::KeychainSigchainBundleWritten {
input,
out,
format: "canonical-bundle-v1".to_owned(),
records: bundle.records.len(),
disclosures: count_sigchain_disclosures(&bundle.records),
claims: 0,
receipts: 0,
bundle_hash: geth_keychain::canonical_bundle_hash(&bytes).to_hex(),
static_http_path: geth_keychain::static_http_bundle_path(chain_id),
note: "wrote the canonical SSHSIGCHAIN bundle; the root public key remains an out-of-band trust input and is intentionally not embedded".to_owned(),
})
}
ControlRequest::KeychainSigchainBundleExtract { input, out } => {
let bytes =
read_bounded_sigchain_file(&input, geth_keychain::MAX_CANONICAL_BUNDLE_BYTES)?;
let bundle = geth_keychain::decode_canonical_bundle(&bytes)?;
let jsonl = geth_keychain::encode_sshsigchain_jsonl(&bundle.records)?;
std::fs::write(&out, jsonl)?;
Ok(ControlResponse::KeychainSigchainBundleWritten {
input,
out,
format: "jsonl-interchange-v1".to_owned(),
records: bundle.records.len(),
disclosures: count_sigchain_disclosures(&bundle.records),
claims: bundle.claims.len(),
receipts: bundle.receipts.len(),
bundle_hash: geth_keychain::canonical_bundle_hash(&bytes).to_hex(),
static_http_path: geth_keychain::static_http_bundle_path(bundle.chain_id),
note: "extracted record JSONL including available disclosures; head claims and anchor receipts remain in the canonical bundle because JSONL carries records only".to_owned(),
})
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?,
@ -9619,12 +9684,73 @@ fn read_sshsigchain_jsonl_file(
input: &Path,
) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
let mut text = String::new();
reader.read_to_string(&mut text)?;
if text.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into());
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if bytes.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(bytes.len()).into());
}
Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?)
let text =
std::str::from_utf8(&bytes).map_err(|_| geth_keychain::DistributionError::Malformed)?;
Ok(geth_keychain::decode_sshsigchain_jsonl(text)?)
}
struct LoadedSshSigchain {
records: Vec<geth_keychain::SshSigchainRecord>,
namespace: Option<String>,
format: &'static str,
disclosures: usize,
claims: usize,
receipts: usize,
bundle_hash: Option<String>,
}
fn read_sshsigchain_file(input: &Path) -> Result<LoadedSshSigchain, NodeError> {
let bytes = read_bounded_sigchain_file(input, geth_keychain::MAX_CANONICAL_BUNDLE_BYTES)?;
if bytes.starts_with(b"SSCB") {
let bundle = geth_keychain::decode_canonical_bundle(&bytes)?;
return Ok(LoadedSshSigchain {
disclosures: count_sigchain_disclosures(&bundle.records),
claims: bundle.claims.len(),
receipts: bundle.receipts.len(),
bundle_hash: Some(geth_keychain::canonical_bundle_hash(&bytes).to_hex()),
records: bundle.records,
namespace: Some(bundle.namespace),
format: "canonical-bundle-v1",
});
}
if bytes.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(bytes.len()).into());
}
let text =
std::str::from_utf8(&bytes).map_err(|_| geth_keychain::DistributionError::Malformed)?;
let records = geth_keychain::decode_sshsigchain_jsonl(text)?;
Ok(LoadedSshSigchain {
disclosures: count_sigchain_disclosures(&records),
records,
namespace: None,
format: "jsonl-interchange-v1",
claims: 0,
receipts: 0,
bundle_hash: None,
})
}
fn read_bounded_sigchain_file(input: &Path, maximum: usize) -> Result<Vec<u8>, NodeError> {
let mut reader = std::fs::File::open(input)?.take((maximum + 1) as u64);
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if bytes.len() > maximum {
return Err(geth_keychain::DistributionError::BundleTooLarge(bytes.len()).into());
}
Ok(bytes)
}
fn count_sigchain_disclosures(records: &[geth_keychain::SshSigchainRecord]) -> usize {
records
.iter()
.flat_map(|record| &record.extensions)
.filter(|extension| extension.disclosure.is_some())
.count()
}
fn verify_keychain_sigchain_with_ssh(
@ -11104,6 +11230,21 @@ mod tests {
assert_eq!(verified.records, 1);
assert_eq!(verified.state.active_key_count(), 1);
let bundle_bytes =
geth_keychain::encode_canonical_bundle(&geth_keychain::CanonicalSshSigchainBundle {
chain_id: trust.chain_id,
namespace: trust.namespace.clone(),
records: vec![init.clone()],
claims: vec![],
receipts: vec![],
})
.expect("encode canonical bundle");
let bundle =
geth_keychain::decode_canonical_bundle(&bundle_bytes).expect("decode canonical bundle");
let bundled = verify_keychain_sshsigchain_with_ssh(&bundle.records, &trust)
.expect("verify canonical bundle records");
assert_eq!(bundled.head, verified.head);
let mut tampered = init;
tampered.chain_id = geth_keychain::SshSigchainChainId([0x43; 32]);
assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err());

View file

@ -547,7 +547,7 @@ pub(crate) fn node_error_code(error: &NodeError) -> &'static str {
NodeError::Store(_) => "store_error",
NodeError::Config(_) => "config_error",
NodeError::Codec(_) => "codec_error",
NodeError::SshSigchain(_) => "sshsigchain_error",
NodeError::SshSigchain(_) | NodeError::SshSigchainDistribution(_) => "sshsigchain_error",
_ => "node_error",
}
}