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

@ -1048,7 +1048,7 @@ pub enum KeychainCommand {
#[arg(long)]
principal: Option<String>,
},
/// Verify a linked SSHSIGCHAIN JSONL file against an explicit root key
/// Verify SSHSIGCHAIN JSONL or a canonical bundle against an explicit root key
VerifySigchain {
#[arg(long = "in")]
input: PathBuf,
@ -1059,6 +1059,22 @@ pub enum KeychainCommand {
#[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
namespace: String,
},
/// Convert an SSHSIGCHAIN JSONL interchange file to the canonical binary bundle
BundleCreate {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
namespace: String,
},
/// Extract records from a canonical SSHSIGCHAIN bundle as JSONL
BundleExtract {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
out: PathBuf,
},
/// Explain why one keychain operation was accepted or rejected
Explain { op_id: String },
/// Explain the current trust state of one signer
@ -2646,6 +2662,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
root_key_path: root_key,
namespace: Some(namespace),
},
Command::Keychain {
command:
KeychainCommand::BundleCreate {
input,
out,
namespace,
},
} => ControlRequest::KeychainSigchainBundleCreate {
input,
out,
namespace,
},
Command::Keychain {
command: KeychainCommand::BundleExtract { input, out },
} => ControlRequest::KeychainSigchainBundleExtract { input, out },
Command::Keychain {
command: KeychainCommand::Explain { op_id },
} => ControlRequest::KeychainExplain { op_id },
@ -4117,6 +4148,11 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
anchor_backend_threshold,
required_anchor_backends,
required_anchor_classes,
format,
disclosures,
claims,
receipts,
bundle_hash,
note,
} => {
println!("sigchain: {}", input.display());
@ -4131,6 +4167,34 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
println!("anchor_backend_threshold: {anchor_backend_threshold}");
println!("required_anchor_backends: {required_anchor_backends}");
println!("required_anchor_classes: {required_anchor_classes}");
println!("format: {format}");
println!("disclosures: {disclosures}");
println!("claims: {claims}");
println!("receipts: {receipts}");
println!("bundle_hash: {}", bundle_hash.as_deref().unwrap_or("none"));
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainBundleWritten {
input,
out,
format,
records,
disclosures,
claims,
receipts,
bundle_hash,
static_http_path,
note,
} => {
println!("input: {}", input.display());
println!("out: {}", out.display());
println!("format: {format}");
println!("records: {records}");
println!("disclosures: {disclosures}");
println!("claims: {claims}");
println!("receipts: {receipts}");
println!("bundle_hash: {bundle_hash}");
println!("static_http_path: {static_http_path}");
eprintln!("note: {note}");
}
ControlResponse::KeychainExplained { subject, lines } => {
@ -5346,6 +5410,38 @@ mod tests {
command: KeychainCommand::VerifySigchain { .. }
}
));
assert!(matches!(
Cli::try_parse_from([
"geth",
"keychain",
"bundle-create",
"--in",
"chain.jsonl",
"--out",
"chain.sscb",
])
.expect("parse canonical bundle creation")
.command,
Command::Keychain {
command: KeychainCommand::BundleCreate { .. }
}
));
assert!(matches!(
Cli::try_parse_from([
"geth",
"keychain",
"bundle-extract",
"--in",
"chain.sscb",
"--out",
"chain.jsonl",
])
.expect("parse canonical bundle extraction")
.command,
Command::Keychain {
command: KeychainCommand::BundleExtract { .. }
}
));
for removed in [
"sigchain",
"publish-bundle",

View file

@ -272,6 +272,15 @@ pub enum ControlRequest {
root_key_path: PathBuf,
namespace: Option<String>,
},
KeychainSigchainBundleCreate {
input: PathBuf,
out: PathBuf,
namespace: String,
},
KeychainSigchainBundleExtract {
input: PathBuf,
out: PathBuf,
},
KeychainExplain {
op_id: String,
},
@ -754,6 +763,23 @@ pub enum ControlResponse {
anchor_backend_threshold: u16,
required_anchor_backends: usize,
required_anchor_classes: usize,
format: String,
disclosures: usize,
claims: usize,
receipts: usize,
bundle_hash: Option<String>,
note: String,
},
KeychainSigchainBundleWritten {
input: PathBuf,
out: PathBuf,
format: String,
records: usize,
disclosures: usize,
claims: usize,
receipts: usize,
bundle_hash: String,
static_http_path: String,
note: String,
},
KeychainExplained {
@ -1768,8 +1794,8 @@ mod tests {
use super::*;
use serde_json::{Map, Value, json};
const CONTROL_REQUEST_VARIANTS: usize = 118;
const CONTROL_RESPONSE_VARIANTS: usize = 110;
const CONTROL_REQUEST_VARIANTS: usize = 120;
const CONTROL_RESPONSE_VARIANTS: usize = 111;
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;

View file

@ -15,15 +15,19 @@ mod sshsigchain;
pub use sshsigchain::{
AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier,
AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState,
AuthorityState, AuthorityTransition, ChainId as SshSigchainChainId,
Digest as SshSigchainDigest, HeadClaim, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES,
MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, ProfileExtension,
SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, SSH_SIGCHAIN_NAMESPACE,
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainRecord,
SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory,
authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes,
profile_payload_commitment, select_anchored_head, verify_anchor_receipts,
verify_anchored_history, verify_head_claim, verify_sshsigchain,
AuthorityState, AuthorityTransition, CANONICAL_BUNDLE_EXTENSION, CANONICAL_BUNDLE_MEDIA_TYPE,
CanonicalSshSigchainBundle, ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
DistributionEndpoint, DistributionError, HeadClaim, MAX_CANONICAL_BUNDLE_BYTES,
MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure,
ProfileExtension, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION,
SshSigchainBundleSource, SshSigchainError, SshSigchainRecord, SshSigchainTrust,
SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory, authority_key_id,
canonical_bundle_hash, decode_canonical_bundle, decode_sshsigchain_jsonl,
encode_canonical_bundle, encode_sshsigchain_jsonl, key_proof_signing_bytes,
profile_payload_commitment, select_anchored_head, static_http_bundle_path,
verify_anchor_receipts, verify_anchored_history, verify_canonical_bundle_records,
verify_head_claim, verify_sshsigchain,
};
use geth_types::{

View file

@ -9,6 +9,15 @@ use base64::{Engine as _, engine::general_purpose};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod bundle;
pub use bundle::{
CANONICAL_BUNDLE_EXTENSION, CANONICAL_BUNDLE_MEDIA_TYPE, CanonicalSshSigchainBundle,
DistributionEndpoint, DistributionError, MAX_CANONICAL_BUNDLE_BYTES, SshSigchainBundleSource,
canonical_bundle_hash, decode_canonical_bundle, encode_canonical_bundle,
static_http_bundle_path, verify_canonical_bundle_records,
};
pub const SSH_SIGCHAIN_VERSION: u8 = 1;
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
pub const SSH_SIGCHAIN_KEY_PROOF_NAMESPACE: &str = "sshsigchain.key-proof.v1";

View file

@ -0,0 +1,987 @@
//! Canonical SSHSIGCHAIN object and bundle encoding.
use super::*;
pub const CANONICAL_BUNDLE_EXTENSION: &str = "sscb";
pub const CANONICAL_BUNDLE_MEDIA_TYPE: &str = "application/vnd.sshsigchain.bundle.v1";
pub const MAX_CANONICAL_BUNDLE_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_CANONICAL_OBJECT_BYTES: usize = 5 * 1024 * 1024;
pub const MAX_BUNDLE_DISCLOSURES: usize = 1_000_000;
pub const MAX_BUNDLE_CLAIMS: usize = 100_000;
pub const MAX_BUNDLE_RECEIPTS: usize = 100_000;
const BUNDLE_MAGIC: &[u8; 4] = b"SSCB";
const RECORD_MAGIC: &[u8; 4] = b"SSCR";
const DISCLOSURE_MAGIC: &[u8; 4] = b"SSCD";
const CLAIM_MAGIC: &[u8; 4] = b"SSHC";
const RECEIPT_MAGIC: &[u8; 4] = b"SSRC";
const BUNDLE_HASH_DOMAIN: &[u8] = b"sshsigchain.bundle.v1\0";
/// One canonical, self-contained chain snapshot. The root public key remains
/// an out-of-band trust input and is deliberately absent.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanonicalSshSigchainBundle {
pub chain_id: ChainId,
pub namespace: String,
pub records: Vec<SshSigchainRecord>,
pub claims: Vec<HeadClaim>,
pub receipts: Vec<AnchorReceipt>,
}
/// Locates an untrusted distribution source. It is routing configuration, not
/// a trust anchor; integrity and authority come from SSHSIGCHAIN verification.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DistributionEndpoint {
pub backend_id: String,
pub class: String,
pub locator: String,
}
impl DistributionEndpoint {
pub fn new(
backend_id: impl Into<String>,
class: impl Into<String>,
locator: impl Into<String>,
) -> Result<Self, DistributionError> {
let backend_id = validate_identifier(backend_id.into())?;
let class = validate_identifier(class.into())?;
let locator = locator.into();
if locator.is_empty() || locator.len() > 2048 {
return Err(DistributionError::InvalidEndpoint);
}
Ok(Self {
backend_id,
class,
locator,
})
}
}
/// A distribution backend returns untrusted canonical bundle bytes. Callers
/// MUST decode and verify them against local trust and accepted-head state.
pub trait SshSigchainBundleSource {
type Error: std::error::Error + Send + Sync + 'static;
fn fetch_bundle(
&self,
endpoint: &DistributionEndpoint,
chain_id: ChainId,
accepted_head: Option<Digest>,
) -> Result<Option<Vec<u8>>, Self::Error>;
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DistributionError {
#[error(
"canonical SSHSIGCHAIN bundle is {0} bytes, over the {MAX_CANONICAL_BUNDLE_BYTES}-byte limit"
)]
BundleTooLarge(usize),
#[error(
"canonical SSHSIGCHAIN object is {0} bytes, over the {MAX_CANONICAL_OBJECT_BYTES}-byte limit"
)]
ObjectTooLarge(usize),
#[error("canonical SSHSIGCHAIN object is malformed or truncated")]
Malformed,
#[error("canonical SSHSIGCHAIN object has unsupported version {0}")]
UnsupportedVersion(u8),
#[error("canonical SSHSIGCHAIN bundle is not in its unique encoding")]
NonCanonical,
#[error("canonical SSHSIGCHAIN bundle contains records from another chain")]
MixedChain,
#[error(
"canonical SSHSIGCHAIN bundle does not match the configured chain ID or SSHSIG namespace"
)]
WrongTrust,
#[error("SSHSIGCHAIN distribution endpoint is invalid")]
InvalidEndpoint,
#[error("canonical SSHSIGCHAIN records are empty or not in causal order")]
InvalidRecordOrder,
#[error("canonical SSHSIGCHAIN disclosure does not name a committed profile in its record")]
UnknownDisclosure,
#[error("canonical SSHSIGCHAIN bundle has duplicate disclosures, claims, or receipts")]
DuplicateObject,
#[error("canonical SSHSIGCHAIN claim does not name a record in the bundled history")]
UnknownClaimHead,
#[error("canonical SSHSIGCHAIN receipt does not name a bundled claim")]
UnknownReceiptClaim,
#[error("canonical SSHSIGCHAIN bundle has too many claims or receipts")]
TooManyObjects,
#[error("canonical SSHSIGCHAIN object violates the protocol: {0}")]
Protocol(#[from] SshSigchainError),
}
#[must_use]
pub fn static_http_bundle_path(chain_id: ChainId) -> String {
format!(
"/.well-known/sshsigchain/v1/{}/chain.{CANONICAL_BUNDLE_EXTENSION}",
chain_id.to_hex()
)
}
#[must_use]
pub fn canonical_bundle_hash(bytes: &[u8]) -> Digest {
let mut input = Vec::with_capacity(BUNDLE_HASH_DOMAIN.len() + bytes.len());
input.extend_from_slice(BUNDLE_HASH_DOMAIN);
input.extend_from_slice(bytes);
Digest(*blake3::hash(&input).as_bytes())
}
pub fn verify_canonical_bundle_records<V: SshSigchainVerifier + ?Sized>(
bundle: &CanonicalSshSigchainBundle,
trust: &SshSigchainTrust,
verifier: &V,
) -> Result<SshSigchainVerification, DistributionError> {
if bundle.chain_id != trust.chain_id || bundle.namespace != trust.namespace {
return Err(DistributionError::WrongTrust);
}
verify_sshsigchain(&bundle.records, trust, verifier).map_err(Into::into)
}
pub fn encode_canonical_bundle(
bundle: &CanonicalSshSigchainBundle,
) -> Result<Vec<u8>, DistributionError> {
validate_bundle_records(bundle)?;
let mut out = Vec::new();
out.extend_from_slice(BUNDLE_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
out.extend_from_slice(&bundle.chain_id.0);
let namespace = validate_namespace(bundle.namespace.clone())?;
push_u16_bytes(&mut out, namespace.as_bytes())?;
push_u32_count(&mut out, bundle.records.len())?;
let mut disclosures = Vec::new();
let mut history = BTreeSet::new();
for record in &bundle.records {
let record_hash = record.record_hash()?;
history.insert(record_hash);
let object = encode_record_object(record)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
for extension in &record.extensions {
if let Some(disclosure) = &extension.disclosure {
disclosures.push((
record_hash,
extension.profile_id.clone(),
disclosure.clone(),
));
}
}
}
disclosures.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1)));
if disclosures.len() > MAX_BUNDLE_DISCLOSURES {
return Err(DistributionError::TooManyObjects);
}
if disclosures
.windows(2)
.any(|pair| (pair[0].0, &pair[0].1) == (pair[1].0, &pair[1].1))
{
return Err(DistributionError::DuplicateObject);
}
push_u32_count(&mut out, disclosures.len())?;
for (record_hash, profile_id, disclosure) in disclosures {
let object = encode_disclosure_object(record_hash, &profile_id, &disclosure)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
}
if bundle.claims.len() > MAX_BUNDLE_CLAIMS || bundle.receipts.len() > MAX_BUNDLE_RECEIPTS {
return Err(DistributionError::TooManyObjects);
}
let mut claims = Vec::with_capacity(bundle.claims.len());
for claim in &bundle.claims {
if claim.chain_id != bundle.chain_id || !history.contains(&claim.head) {
return Err(DistributionError::UnknownClaimHead);
}
claims.push((claim.claim_hash()?, claim.clone()));
}
claims.sort_by_key(|(claim_hash, _)| *claim_hash);
let mut claim_hashes = BTreeSet::new();
push_u32_count(&mut out, claims.len())?;
for (claim_hash, claim) in &claims {
if !claim_hashes.insert(*claim_hash) {
return Err(DistributionError::DuplicateObject);
}
let object = encode_claim_object(claim)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
}
let mut receipts = bundle.receipts.clone();
receipts.sort_by(|left, right| {
(&left.claim_hash, &left.backend_id).cmp(&(&right.claim_hash, &right.backend_id))
});
if receipts.windows(2).any(|pair| {
(pair[0].claim_hash, &pair[0].backend_id) == (pair[1].claim_hash, &pair[1].backend_id)
}) {
return Err(DistributionError::DuplicateObject);
}
push_u32_count(&mut out, receipts.len())?;
for receipt in &receipts {
if !claim_hashes.contains(&receipt.claim_hash) {
return Err(DistributionError::UnknownReceiptClaim);
}
let object = encode_receipt_object(receipt)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
}
if out.len() > MAX_CANONICAL_BUNDLE_BYTES {
return Err(DistributionError::BundleTooLarge(out.len()));
}
Ok(out)
}
pub fn decode_canonical_bundle(
bytes: &[u8],
) -> Result<CanonicalSshSigchainBundle, DistributionError> {
if bytes.len() > MAX_CANONICAL_BUNDLE_BYTES {
return Err(DistributionError::BundleTooLarge(bytes.len()));
}
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(BUNDLE_MAGIC)?;
cursor.expect_version()?;
let chain_id = ChainId(cursor.array()?);
let namespace = validate_namespace(cursor.string16()?)?;
let record_count = cursor.count32(MAX_RECORDS)?;
let mut records = Vec::new();
for _ in 0..record_count {
records.push(decode_record_object(cursor.bytes32()?)?);
}
let disclosure_count = cursor.count32(MAX_BUNDLE_DISCLOSURES)?;
let mut disclosures = Vec::new();
for _ in 0..disclosure_count {
disclosures.push(decode_disclosure_object(cursor.bytes32()?)?);
}
let claim_count = cursor.count32(MAX_BUNDLE_CLAIMS)?;
let mut claims = Vec::new();
for _ in 0..claim_count {
claims.push(decode_claim_object(cursor.bytes32()?)?);
}
let receipt_count = cursor.count32(MAX_BUNDLE_RECEIPTS)?;
let mut receipts = Vec::new();
for _ in 0..receipt_count {
receipts.push(decode_receipt_object(cursor.bytes32()?)?);
}
cursor.finish()?;
let mut record_indexes = BTreeMap::new();
for (index, record) in records.iter().enumerate() {
record_indexes.insert(record.record_hash()?, index);
}
for (record_hash, profile_id, disclosure) in disclosures {
let record = records
.get_mut(
*record_indexes
.get(&record_hash)
.ok_or(DistributionError::UnknownDisclosure)?,
)
.ok_or(DistributionError::UnknownDisclosure)?;
let extension = record
.extensions
.iter_mut()
.find(|extension| extension.profile_id == profile_id)
.ok_or(DistributionError::UnknownDisclosure)?;
if extension.disclosure.is_some() {
return Err(DistributionError::DuplicateObject);
}
extension.disclosure = Some(disclosure);
record.validate(true)?;
}
let bundle = CanonicalSshSigchainBundle {
chain_id,
namespace,
records,
claims,
receipts,
};
let canonical = encode_canonical_bundle(&bundle)?;
if canonical != bytes {
return Err(DistributionError::NonCanonical);
}
Ok(bundle)
}
fn validate_bundle_records(bundle: &CanonicalSshSigchainBundle) -> Result<(), DistributionError> {
if bundle.records.is_empty() || bundle.records.len() > MAX_RECORDS {
return Err(DistributionError::InvalidRecordOrder);
}
let mut previous = None;
for record in &bundle.records {
record.validate(true)?;
if record.chain_id != bundle.chain_id {
return Err(DistributionError::MixedChain);
}
if record.previous != previous {
return Err(DistributionError::InvalidRecordOrder);
}
previous = Some(record.record_hash()?);
}
Ok(())
}
fn encode_record_object(record: &SshSigchainRecord) -> Result<Vec<u8>, DistributionError> {
let mut out = Vec::new();
out.extend_from_slice(RECORD_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
push_u32_bytes(&mut out, &record.signing_bytes()?)?;
push_u32_bytes(&mut out, &record.signature)?;
Ok(out)
}
fn decode_record_object(bytes: &[u8]) -> Result<SshSigchainRecord, DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(RECORD_MAGIC)?;
cursor.expect_version()?;
let signing_bytes = cursor.bytes32()?;
let signature = cursor.bytes32()?.to_vec();
cursor.finish()?;
decode_record_signing_bytes(signing_bytes, signature)
}
fn encode_disclosure_object(
record_hash: Digest,
profile_id: &str,
disclosure: &ProfileDisclosure,
) -> Result<Vec<u8>, DistributionError> {
let mut out = Vec::new();
out.extend_from_slice(DISCLOSURE_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
out.extend_from_slice(&record_hash.0);
push_u16_bytes(
&mut out,
validate_identifier(profile_id.to_owned())?.as_bytes(),
)?;
out.extend_from_slice(&disclosure.nonce);
push_u32_bytes(&mut out, &disclosure.payload)?;
Ok(out)
}
fn decode_disclosure_object(
bytes: &[u8],
) -> Result<(Digest, String, ProfileDisclosure), DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(DISCLOSURE_MAGIC)?;
cursor.expect_version()?;
let record_hash = Digest(cursor.array()?);
let profile_id = validate_identifier(cursor.string16()?)?;
let nonce = cursor.array()?;
let payload = cursor.bytes32()?.to_vec();
if payload.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(payload.len()).into());
}
cursor.finish()?;
Ok((
record_hash,
profile_id,
ProfileDisclosure { nonce, payload },
))
}
fn encode_claim_object(claim: &HeadClaim) -> Result<Vec<u8>, DistributionError> {
if claim.signature.is_empty() {
return Err(SshSigchainError::MissingSignature.into());
}
if claim.signature.len() > MAX_SIGNATURE_BYTES {
return Err(SshSigchainError::SignatureTooLarge(claim.signature.len()).into());
}
let mut out = Vec::new();
out.extend_from_slice(CLAIM_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
push_u32_bytes(&mut out, &claim.signing_bytes()?)?;
push_u32_bytes(&mut out, &claim.signature)?;
Ok(out)
}
fn decode_claim_object(bytes: &[u8]) -> Result<HeadClaim, DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(CLAIM_MAGIC)?;
cursor.expect_version()?;
let signing = cursor.bytes32()?;
let signature = cursor.bytes32()?.to_vec();
cursor.finish()?;
let mut signing_cursor = Cursor::new(signing);
signing_cursor.expect_magic(ANCHOR_MAGIC.try_into().expect("fixed magic"))?;
signing_cursor.expect_version()?;
let chain_id = ChainId(signing_cursor.array()?);
let head = Digest(signing_cursor.array()?);
let signer_key_id = AuthorityKeyId(Digest(signing_cursor.array()?));
let signer_public_key = signing_cursor.string16()?;
signing_cursor.finish()?;
HeadClaim {
chain_id,
head,
signer_key_id,
signer_public_key,
signature: Vec::new(),
}
.with_signature(signature)
.map_err(Into::into)
}
fn encode_receipt_object(receipt: &AnchorReceipt) -> Result<Vec<u8>, DistributionError> {
if receipt.evidence.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(receipt.evidence.len()).into());
}
let mut out = Vec::new();
out.extend_from_slice(RECEIPT_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
push_u16_bytes(
&mut out,
validate_identifier(receipt.backend_id.clone())?.as_bytes(),
)?;
out.extend_from_slice(&receipt.claim_hash.0);
push_u32_bytes(&mut out, &receipt.evidence)?;
Ok(out)
}
fn decode_receipt_object(bytes: &[u8]) -> Result<AnchorReceipt, DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(RECEIPT_MAGIC)?;
cursor.expect_version()?;
let backend_id = validate_identifier(cursor.string16()?)?;
let claim_hash = Digest(cursor.array()?);
let evidence = cursor.bytes32()?.to_vec();
if evidence.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(evidence.len()).into());
}
cursor.finish()?;
Ok(AnchorReceipt {
backend_id,
claim_hash,
evidence,
})
}
fn decode_record_signing_bytes(
bytes: &[u8],
signature: Vec<u8>,
) -> Result<SshSigchainRecord, DistributionError> {
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(SIGNING_MAGIC.try_into().expect("fixed magic"))?;
cursor.expect_version()?;
let chain_id = ChainId(cursor.array()?);
let previous = cursor.optional_digest()?;
let signer_key_id = AuthorityKeyId(Digest(cursor.array()?));
let signer_public_key = cursor.string16()?;
let authority = decode_transition(&mut cursor)?;
let extension_count = cursor.count16(MAX_EXTENSIONS)?;
let mut extensions = Vec::with_capacity(extension_count);
for _ in 0..extension_count {
extensions.push(ProfileExtension::withheld(
cursor.string16()?,
Digest(cursor.array()?),
));
}
cursor.finish()?;
let record = SshSigchainRecord {
chain_id,
previous,
signer_key_id,
signer_public_key,
authority,
extensions,
signature,
};
record.validate(true)?;
if record.signing_bytes()? != bytes {
return Err(DistributionError::NonCanonical);
}
Ok(record)
}
fn decode_transition(cursor: &mut Cursor<'_>) -> Result<AuthorityTransition, DistributionError> {
Ok(match cursor.u8()? {
0 => AuthorityTransition::Genesis {
device_id: cursor.string16()?,
root_key: decode_authority_key(cursor)?,
anchor_policy: decode_anchor_policy(cursor)?,
},
1 => AuthorityTransition::DeviceAdd {
device_id: cursor.string16()?,
permission_ceiling: decode_permissions(cursor)?,
},
2 => AuthorityTransition::DeviceRevoke {
device_id: cursor.string16()?,
},
3 => AuthorityTransition::KeyAdd {
device_id: cursor.string16()?,
key: decode_authority_key(cursor)?,
proof: cursor.bytes32()?.to_vec(),
},
4 => AuthorityTransition::KeyRevoke {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
},
5 => AuthorityTransition::PermissionGrant {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
permissions: decode_permissions(cursor)?,
delegable_permissions: decode_permissions(cursor)?,
},
6 => AuthorityTransition::PermissionRevoke {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
permissions: decode_permissions(cursor)?,
delegable_permissions: decode_permissions(cursor)?,
},
7 => AuthorityTransition::AnchorPolicySet {
policy: decode_anchor_policy(cursor)?,
},
8 => AuthorityTransition::Noop,
_ => return Err(DistributionError::Malformed),
})
}
fn decode_authority_key(cursor: &mut Cursor<'_>) -> Result<AuthorityKey, DistributionError> {
Ok(AuthorityKey {
public_key: cursor.string16()?,
permissions: decode_permissions(cursor)?,
delegable_permissions: decode_permissions(cursor)?,
})
}
fn decode_permissions(cursor: &mut Cursor<'_>) -> Result<Vec<Permission>, DistributionError> {
let count = cursor.count16(u16::MAX as usize)?;
let mut permissions = Vec::with_capacity(count);
for _ in 0..count {
permissions.push(match cursor.u8()? {
0 => Permission::All,
1 => Permission::DeviceAdd,
2 => Permission::DeviceRevoke,
3 => Permission::KeyAddSelf,
4 => Permission::KeyAddAny,
5 => Permission::KeyRevokeSelf,
6 => Permission::KeyRevokeAny,
7 => Permission::ManagePermissions,
8 => Permission::AnchorPolicy,
9 => Permission::AnchorAttest,
10 => Permission::ProfileWrite(cursor.string16()?),
11 => Permission::ProfileDelegate(cursor.string16()?),
_ => return Err(DistributionError::Malformed),
});
}
permission_set(&permissions)?;
Ok(permissions)
}
fn decode_anchor_policy(cursor: &mut Cursor<'_>) -> Result<AnchorPolicy, DistributionError> {
let attester_threshold = cursor.u16()?;
let attester_count = cursor.count16(u16::MAX as usize)?;
let mut attesters = Vec::with_capacity(attester_count);
for _ in 0..attester_count {
attesters.push(AnchorAttesterPolicy {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
weight: cursor.u16()?,
required: cursor.boolean()?,
});
}
let required_class_count = cursor.count16(u16::MAX as usize)?;
let mut required_classes = Vec::with_capacity(required_class_count);
for _ in 0..required_class_count {
required_classes.push(cursor.string16()?);
}
let backend_threshold = cursor.u16()?;
let backend_count = cursor.count16(u16::MAX as usize)?;
let mut backends = Vec::with_capacity(backend_count);
for _ in 0..backend_count {
backends.push(AnchorBackendPolicy {
backend_id: cursor.string16()?,
class: cursor.string16()?,
locator: cursor.string16()?,
weight: cursor.u16()?,
required: cursor.boolean()?,
});
}
let policy = AnchorPolicy {
attester_threshold,
attesters,
required_classes,
backend_threshold,
backends,
};
validate_anchor_policy(&policy)?;
Ok(policy)
}
struct Cursor<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn take(&mut self, length: usize) -> Result<&'a [u8], DistributionError> {
let end = self
.offset
.checked_add(length)
.ok_or(DistributionError::Malformed)?;
let value = self
.bytes
.get(self.offset..end)
.ok_or(DistributionError::Malformed)?;
self.offset = end;
Ok(value)
}
fn expect_magic(&mut self, magic: &[u8; 4]) -> Result<(), DistributionError> {
if self.take(4)? == magic {
Ok(())
} else {
Err(DistributionError::Malformed)
}
}
fn expect_version(&mut self) -> Result<(), DistributionError> {
let version = self.u8()?;
if version == SSH_SIGCHAIN_VERSION {
Ok(())
} else {
Err(DistributionError::UnsupportedVersion(version))
}
}
fn u8(&mut self) -> Result<u8, DistributionError> {
Ok(self.take(1)?[0])
}
fn boolean(&mut self) -> Result<bool, DistributionError> {
match self.u8()? {
0 => Ok(false),
1 => Ok(true),
_ => Err(DistributionError::Malformed),
}
}
fn u16(&mut self) -> Result<u16, DistributionError> {
Ok(u16::from_be_bytes(
self.take(2)?.try_into().expect("exact length"),
))
}
fn u32(&mut self) -> Result<u32, DistributionError> {
Ok(u32::from_be_bytes(
self.take(4)?.try_into().expect("exact length"),
))
}
fn count16(&mut self, maximum: usize) -> Result<usize, DistributionError> {
let count = usize::from(self.u16()?);
if count > maximum {
Err(DistributionError::TooManyObjects)
} else {
Ok(count)
}
}
fn count32(&mut self, maximum: usize) -> Result<usize, DistributionError> {
let count = usize::try_from(self.u32()?).map_err(|_| DistributionError::Malformed)?;
if count > maximum {
Err(DistributionError::TooManyObjects)
} else {
Ok(count)
}
}
fn bytes32(&mut self) -> Result<&'a [u8], DistributionError> {
let length = usize::try_from(self.u32()?).map_err(|_| DistributionError::Malformed)?;
self.take(length)
}
fn string16(&mut self) -> Result<String, DistributionError> {
let length = usize::from(self.u16()?);
String::from_utf8(self.take(length)?.to_vec()).map_err(|_| DistributionError::Malformed)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], DistributionError> {
self.take(N)?
.try_into()
.map_err(|_| DistributionError::Malformed)
}
fn optional_digest(&mut self) -> Result<Option<Digest>, DistributionError> {
match self.u8()? {
0 => Ok(None),
1 => Ok(Some(Digest(self.array()?))),
_ => Err(DistributionError::Malformed),
}
}
fn finish(self) -> Result<(), DistributionError> {
if self.offset == self.bytes.len() {
Ok(())
} else {
Err(DistributionError::NonCanonical)
}
}
}
fn push_u32_count(out: &mut Vec<u8>, count: usize) -> Result<(), DistributionError> {
out.extend_from_slice(
&u32::try_from(count)
.map_err(|_| SshSigchainError::LengthOverflow)?
.to_be_bytes(),
);
Ok(())
}
fn validate_object_size(bytes: &[u8]) -> Result<(), DistributionError> {
if bytes.len() > MAX_CANONICAL_OBJECT_BYTES {
Err(DistributionError::ObjectTooLarge(bytes.len()))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const ROOT_KEY: &str = "ssh-ed25519 AQID";
fn signed_genesis() -> SshSigchainRecord {
SshSigchainRecord::unsigned(
ChainId([7; 32]),
None,
ROOT_KEY,
AuthorityTransition::Genesis {
device_id: "device:root".to_owned(),
root_key: AuthorityKey {
public_key: ROOT_KEY.to_owned(),
permissions: vec![Permission::All],
delegable_permissions: vec![Permission::All],
},
anchor_policy: AnchorPolicy::default(),
},
vec![
ProfileExtension::disclosed("example.profile", [9; 32], b"disclosed".to_vec())
.expect("extension"),
],
)
.expect("record")
.with_signature(vec![1, 2, 3])
.expect("signature")
}
#[test]
fn canonical_bundle_roundtrips_and_preserves_disclosures() {
let record = signed_genesis();
let claim = HeadClaim::unsigned(
record.chain_id,
record.record_hash().expect("head"),
ROOT_KEY,
)
.expect("claim")
.with_signature(vec![4, 5, 6])
.expect("claim signature");
let receipt = AnchorReceipt {
backend_id: "static-http".to_owned(),
claim_hash: claim.claim_hash().expect("claim hash"),
evidence: b"receipt".to_vec(),
};
let bundle = CanonicalSshSigchainBundle {
chain_id: record.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![record.clone()],
claims: vec![claim],
receipts: vec![receipt],
};
let encoded = encode_canonical_bundle(&bundle).expect("encode");
let decoded = decode_canonical_bundle(&encoded).expect("decode");
assert_eq!(decoded, bundle);
assert_eq!(
encode_canonical_bundle(&decoded).expect("reencode"),
encoded
);
}
#[test]
fn disclosure_bytes_are_separate_from_record_identity() {
let disclosed = signed_genesis();
let mut withheld = disclosed.clone();
withheld.extensions[0].disclosure = None;
assert_eq!(
disclosed.record_hash().expect("hash"),
withheld.record_hash().expect("hash")
);
assert_ne!(
encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: disclosed.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![disclosed],
claims: vec![],
receipts: vec![],
})
.expect("disclosed bundle"),
encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: withheld.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![withheld],
claims: vec![],
receipts: vec![],
})
.expect("withheld bundle")
);
}
#[test]
fn decoder_rejects_trailing_and_noncanonical_bytes() {
let record = signed_genesis();
let mut encoded = encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: record.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![record],
claims: vec![],
receipts: vec![],
})
.expect("encode");
encoded.push(0);
assert_eq!(
decode_canonical_bundle(&encoded),
Err(DistributionError::NonCanonical)
);
}
#[test]
fn canonical_bundle_roundtrips_every_authority_transition() {
let chain_id = ChainId([5; 32]);
let key_id = authority_key_id(ROOT_KEY).expect("key ID");
let transitions = vec![
AuthorityTransition::Genesis {
device_id: "device:root".to_owned(),
root_key: AuthorityKey {
public_key: ROOT_KEY.to_owned(),
permissions: vec![Permission::All],
delegable_permissions: vec![Permission::All],
},
anchor_policy: AnchorPolicy::default(),
},
AuthorityTransition::DeviceAdd {
device_id: "device:second".to_owned(),
permission_ceiling: vec![Permission::DeviceAdd],
},
AuthorityTransition::DeviceRevoke {
device_id: "device:second".to_owned(),
},
AuthorityTransition::KeyAdd {
device_id: "device:root".to_owned(),
key: AuthorityKey {
public_key: ROOT_KEY.to_owned(),
permissions: vec![],
delegable_permissions: vec![],
},
proof: vec![1],
},
AuthorityTransition::KeyRevoke { key_id },
AuthorityTransition::PermissionGrant {
key_id,
permissions: vec![Permission::DeviceAdd],
delegable_permissions: vec![Permission::AnchorPolicy],
},
AuthorityTransition::PermissionRevoke {
key_id,
permissions: vec![Permission::DeviceAdd],
delegable_permissions: vec![Permission::AnchorPolicy],
},
AuthorityTransition::AnchorPolicySet {
policy: AnchorPolicy {
attester_threshold: 1,
attesters: vec![AnchorAttesterPolicy {
key_id,
weight: 1,
required: true,
}],
required_classes: vec!["http".to_owned()],
backend_threshold: 1,
backends: vec![AnchorBackendPolicy {
backend_id: "static-http".to_owned(),
class: "http".to_owned(),
locator: "https://example.test".to_owned(),
weight: 1,
required: true,
}],
},
},
AuthorityTransition::Noop,
];
let mut records = Vec::new();
for (index, transition) in transitions.into_iter().enumerate() {
let previous = records
.last()
.map(SshSigchainRecord::record_hash)
.transpose()
.expect("previous hash");
records.push(
SshSigchainRecord::unsigned(chain_id, previous, ROOT_KEY, transition, vec![])
.expect("record")
.with_signature(vec![u8::try_from(index + 1).expect("small index")])
.expect("signature"),
);
}
let bundle = CanonicalSshSigchainBundle {
chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records,
claims: vec![],
receipts: vec![],
};
let bytes = encode_canonical_bundle(&bundle).expect("encode");
assert_eq!(decode_canonical_bundle(&bytes).expect("decode"), bundle);
}
#[test]
fn static_http_path_is_stable_and_contains_only_lowercase_chain_id() {
assert_eq!(
static_http_bundle_path(ChainId([0xab; 32])),
concat!(
"/.well-known/sshsigchain/v1/",
"abababababababababababababababababababababababababababababababab/",
"chain.sscb"
)
);
}
#[test]
fn canonical_bundle_matches_published_base_vector() {
let record = SshSigchainRecord::unsigned(
ChainId([0; 32]),
None,
ROOT_KEY,
AuthorityTransition::Noop,
vec![],
)
.expect("record")
.with_signature(vec![1])
.expect("signature");
let bytes = encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: ChainId([0; 32]),
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![record],
claims: vec![],
receipts: vec![],
})
.expect("bundle");
assert_eq!(
hex::encode(bytes),
concat!(
"5353434201",
"0000000000000000000000000000000000000000000000000000000000000000",
"000e737368736967636861696e2e7631",
"00000001",
"00000069",
"5353435201",
"0000005b",
"5353435301000000000000000000000000000000000000000000000000000000000000000000",
"eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2",
"00107373682d656432353531392041514944080000",
"0000000101",
"00000000",
"00000000",
"00000000"
)
);
}
}

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",
}
}