From decff4b9951569b7c8bf8cb3ecda36181fe4c791 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sun, 19 Jul 2026 03:16:14 +0200 Subject: [PATCH] add canonical sigchain bundle format --- README.md | 25 +- crates/geth-cli/src/lib.rs | 98 +- crates/geth-control/src/lib.rs | 30 +- crates/geth-keychain/src/lib.rs | 22 +- crates/geth-keychain/src/sshsigchain.rs | 9 + .../geth-keychain/src/sshsigchain/bundle.rs | 987 ++++++++++++++++++ crates/geth-node/src/lib.rs | 157 ++- crates/geth-node/src/local_control.rs | 2 +- docs/adr/0018-sshsigchain.md | 10 +- docs/architecture.md | 9 + docs/command-stability.md | 9 +- docs/roadmap.md | 27 +- docs/sigchain-keychain.md | 17 + docs/sshsigchain.md | 155 ++- 14 files changed, 1515 insertions(+), 42 deletions(-) create mode 100644 crates/geth-keychain/src/sshsigchain/bundle.rs diff --git a/README.md b/README.md index d559aca..7671233 100644 --- a/README.md +++ b/README.md @@ -650,23 +650,32 @@ operator-pinned chain ID, OpenSSH root public key, and namespace. Parent hashes define order without a redundant sequence counter. Every record carries a public authority transition for devices, keys, causal revocation, scoped permissions, and anchor policy, plus optional profile commitments whose payloads -can be selectively disclosed. Geth already provides a verifier for independently -produced JSONL transport files: +can be selectively disclosed. Geth already provides a verifier for JSONL +interchange and canonical bundles: ```sh geth keychain verify-sigchain \ - --in ./geth.sshsigchain.v1.jsonl \ + --in ./chain.sscb \ --chain-id <64-hex-character-chain-id> \ --root-key ~/.ssh/geth-root.pub + +# Convert the human-friendly record interchange to the canonical snapshot. +geth keychain bundle-create --in ./chain.jsonl --out ./chain.sscb + +# Extract records and available disclosures for inspection. +geth keychain bundle-extract --in ./chain.sscb --out ./chain.jsonl ``` The verifier reports active authority devices/keys, disclosed and incomplete -profiles, the head digest, and current attester/backend anchor thresholds. -SSHSIGCHAIN local -record storage, signing, publication, import, accepted-head persistence, and +profiles, the head digest, and current attester/backend anchor thresholds. It +auto-detects JSONL interchange or the canonical `.sscb` bundle. The bundle is +the deterministic on-disk/full-snapshot distribution format and can be hosted +unchanged at `/.well-known/sshsigchain/v1//chain.sscb` by a static +HTTP server. HTTP and every other distribution backend remain untrusted inputs. +Local append workflows, accepted-head persistence, concrete fetchers, and concrete anchor adapters remain follow-up work. Until they exist, do not -substitute an unpinned checkpoint or a local operation-log view for the -SSHSIGCHAIN trust tuple. +substitute an unpinned checkpoint, HTTP response, or local operation-log view +for the SSHSIGCHAIN trust tuple. Signing is mediated by OpenSSH. `--signing-key` may point at a private key file, a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 5bfc77d..95eab94 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -1048,7 +1048,7 @@ pub enum KeychainCommand { #[arg(long)] principal: Option, }, - /// 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 { 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", diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 1164ed3..f967a2d 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -272,6 +272,15 @@ pub enum ControlRequest { root_key_path: PathBuf, namespace: Option, }, + 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, + 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; diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 263f6bf..e72f37e 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -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::{ diff --git a/crates/geth-keychain/src/sshsigchain.rs b/crates/geth-keychain/src/sshsigchain.rs index 919f6ee..10f06b9 100644 --- a/crates/geth-keychain/src/sshsigchain.rs +++ b/crates/geth-keychain/src/sshsigchain.rs @@ -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"; diff --git a/crates/geth-keychain/src/sshsigchain/bundle.rs b/crates/geth-keychain/src/sshsigchain/bundle.rs new file mode 100644 index 0000000..003dd0b --- /dev/null +++ b/crates/geth-keychain/src/sshsigchain/bundle.rs @@ -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, + pub claims: Vec, + pub receipts: Vec, +} + +/// 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, + class: impl Into, + locator: impl Into, + ) -> Result { + 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, + ) -> Result>, 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( + bundle: &CanonicalSshSigchainBundle, + trust: &SshSigchainTrust, + verifier: &V, +) -> Result { + 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, 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 { + 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, 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 { + 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, 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, 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 { + 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, 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 { + 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, +) -> Result { + 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 { + 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 { + Ok(AuthorityKey { + public_key: cursor.string16()?, + permissions: decode_permissions(cursor)?, + delegable_permissions: decode_permissions(cursor)?, + }) +} + +fn decode_permissions(cursor: &mut Cursor<'_>) -> Result, 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 { + 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 { + Ok(self.take(1)?[0]) + } + + fn boolean(&mut self) -> Result { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(DistributionError::Malformed), + } + } + + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes( + self.take(2)?.try_into().expect("exact length"), + )) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes( + self.take(4)?.try_into().expect("exact length"), + )) + } + + fn count16(&mut self, maximum: usize) -> Result { + let count = usize::from(self.u16()?); + if count > maximum { + Err(DistributionError::TooManyObjects) + } else { + Ok(count) + } + } + + fn count32(&mut self, maximum: usize) -> Result { + 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 { + let length = usize::from(self.u16()?); + String::from_utf8(self.take(length)?.to_vec()).map_err(|_| DistributionError::Malformed) + } + + fn array(&mut self) -> Result<[u8; N], DistributionError> { + self.take(N)? + .try_into() + .map_err(|_| DistributionError::Malformed) + } + + fn optional_digest(&mut self) -> Result, 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, 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" + ) + ); + } +} diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 7ad1644..cb8a58d 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -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, 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, + namespace: Option, + format: &'static str, + disclosures: usize, + claims: usize, + receipts: usize, + bundle_hash: Option, +} + +fn read_sshsigchain_file(input: &Path) -> Result { + 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, 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()); diff --git a/crates/geth-node/src/local_control.rs b/crates/geth-node/src/local_control.rs index 203d065..11fd99b 100644 --- a/crates/geth-node/src/local_control.rs +++ b/crates/geth-node/src/local_control.rs @@ -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", } } diff --git a/docs/adr/0018-sshsigchain.md b/docs/adr/0018-sshsigchain.md index bad5a07..ceda5fc 100644 --- a/docs/adr/0018-sshsigchain.md +++ b/docs/adr/0018-sshsigchain.md @@ -36,7 +36,12 @@ SSHSIGCHAIN v1 is redesigned before deployment: - cached ancestors are never replaced by older heads and incomparable verified histories fail as forks; and - a policy-change head is witnessed under the preceding policy before the new - policy governs descendants. + policy governs descendants; +- canonical `.sscb` bundles deterministically store records, separately + discloseable payloads, claims, and receipts without embedding root trust; +- JSONL is noncanonical human-facing interchange; and +- distribution backends return untrusted bundle bytes through one interface, + with a static HTTP profile that requires no server application. The generic core defines backend interfaces and deterministic policy behavior, not Nostr, HTTP, blockchain, or transparency-log clients. Those adapters belong @@ -60,5 +65,6 @@ draft. No migration parser or version alias is retained. Rollback protection still depends on persistent local accepted heads and the operator's anchor policy. A Nostr relay, mutable HTTP URL, blockchain, or transparency service has only the guarantees its receipt verifier and deployment -actually establish. Concrete publication, durable accepted-head storage, and +actually establish. Canonical bundle conversion and verification exist, while +automatic HTTP/Iroh fetching, durable accepted-head storage, and cross-implementation vectors remain follow-up work. diff --git a/docs/architecture.md b/docs/architecture.md index 180a178..a2cbbe5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -492,6 +492,15 @@ test-only static workflow was removed rather than migrated. Iroh keychain sync remains the current replicated local operation-log path while explicit SSHSIGCHAIN production and import workflows are completed. +SSHSIGCHAIN's canonical `.sscb` bundle is the portable on-disk and full-snapshot +distribution boundary. It deterministically stores signed record objects, +separate disclosures, head claims, and anchor receipts without embedding the +root key. JSONL remains inspection/interchange only. Bundle sources implement a +backend-neutral untrusted fetch interface; the static HTTP profile publishes +the same bytes below `/.well-known/sshsigchain/v1//chain.sscb`. +Fetching never grants trust or selects a head. Geth may use Iroh rather than +HTTP operationally without changing the format or verification path. + New devices can use the node enrollment flow instead of hand-editing keychain state. `geth node enroll join` explicitly imports an owner admin public key as the new node's trust anchor, imports the signed peer card only as untrusted diff --git a/docs/command-stability.md b/docs/command-stability.md index 8987525..04df466 100644 --- a/docs/command-stability.md +++ b/docs/command-stability.md @@ -62,6 +62,7 @@ settles: - `geth cas add-private` - `geth cas get-private` - `geth keychain verify-sigchain` +- `geth keychain bundle-create|bundle-extract` Migration expectation: overlay membership records and authorization resources should remain readable, but packet runtime flags, platform activation details, @@ -72,10 +73,10 @@ properties are explicitly out of scope. Prototype BLAKE3-XOR envelopes from earlier pre-deployment builds are rejected with a clear error and should be recreated from plaintext. -The SSHSIGCHAIN verifier is experimental while SSHSIGCHAIN signing, storage, -publication, import, head persistence, and independently generated wire vectors -are completed. The prior test-only static commands were removed and are not a -compatibility path. +The SSHSIGCHAIN verifier and canonical bundle conversion are experimental while +SSHSIGCHAIN signing, append storage, automatic distribution, import, head +persistence, and independently generated wire vectors are completed. The prior +test-only static commands were removed and are not a compatibility path. ## Adding Commands diff --git a/docs/roadmap.md b/docs/roadmap.md index bee9a31..7617c9f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -576,10 +576,28 @@ resource-scoped capability decisions. backend receipt policies, required backend classes, old-policy witnessing of policy-change heads, cached-head rollback checks, and incomparable-history fork failure. + - `[x]` Define and implement the canonical `.sscb` on-disk/full-snapshot + bundle with deterministic record, disclosure, claim, and receipt objects. + - `[x]` Decoding rejects trailing bytes, noncanonical order, duplicates, + unknown references, mixed chains, and over-limit inputs. + - `[x]` `bundle-create` and `bundle-extract` convert record JSONL while + clearly preserving claims/receipts only in the canonical bundle. + - `[x]` `verify-sigchain` auto-detects JSONL or canonical bundles and binds + a bundle's namespace and chain ID to local trust. + - `[x]` Specify the backend-neutral distribution-source contract and a static + HTTP full-snapshot profile at + `/.well-known/sshsigchain/v1//chain.sscb`. + - `[ ]` Implement automatic distribution fetchers. + - Acceptance criteria: an HTTP fetcher enforces streaming limits, + conditional caching, redirect policy, trust-tuple matching, accepted-head + ancestry, anchor policy, and fork failure. + - Acceptance criteria: the Iroh fetcher returns the identical canonical + bundle/object model and remains the only geth node-to-node transport. - `[~]` Add explicit CLI storage, signing, verification, publication, and import workflows for a pinned SSHSIGCHAIN trust tuple. - - `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file - against an operator-pinned chain ID and OpenSSH root public key. + - `[x]` `geth keychain verify-sigchain` verifies JSONL interchange or a + canonical bundle against an operator-pinned chain ID, namespace, and + OpenSSH root public key. - `[ ]` Local record storage, signing, publication, import, and head-advance workflows are complete. - `[ ]` Persist accepted heads and require proof of extension before a source @@ -602,8 +620,9 @@ resource-scoped capability decisions. - `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies an Authority v1 genesis link. - `[~]` The specification and reference implementation share base signing, - key-proof, commitment, and head-claim vectors. - - `[ ]` Publish the complete vector set in the specification. + canonical-bundle, key-proof, commitment, and head-claim vectors. + - `[x]` Publish outer-link and canonical-bundle framing vectors. + - `[ ]` Publish key-proof, commitment, head-claim, and receipt vectors. - `[ ]` Add independently generated cross-implementation vectors. - `[x]` SSH-admin-rooted keychain initialization. diff --git a/docs/sigchain-keychain.md b/docs/sigchain-keychain.md index 2ed2584..47090ca 100644 --- a/docs/sigchain-keychain.md +++ b/docs/sigchain-keychain.md @@ -103,6 +103,23 @@ generated wire vectors. Those later workflows must preserve the same explicit trust tuple and Authority v1 semantics; they must not introduce a compatibility route for the removed static format. +The verifier also accepts the canonical `.sscb` on-disk bundle. JSONL is a +human-facing record interchange and does not preserve standalone head claims or +anchor receipts. Convert between them with: + +```sh +geth keychain bundle-create --in chain.jsonl --out chain.sscb +geth keychain bundle-extract --in chain.sscb --out chain.jsonl +``` + +The canonical bundle keeps disclosures separate from signed record objects and +sorts every object class deterministically. Adding a disclosure changes the +bundle hash but not the chain head. The same file is the simple static HTTP +distribution artifact at +`/.well-known/sshsigchain/v1//chain.sscb`. Distribution is untrusted: +the configured root, authority replay, anchor policy, locally accepted head, +and fork checks still decide acceptance. + ## Local commands Bootstrap an owner node: diff --git a/docs/sshsigchain.md b/docs/sshsigchain.md index b9f3ede..97467fa 100644 --- a/docs/sshsigchain.md +++ b/docs/sshsigchain.md @@ -334,7 +334,150 @@ A conforming verifier MUST: Records are never reordered by timestamps, transport arrival, identifiers, or record counts. A verifier without all trust inputs fails closed. -## 9. JSONL transport and limits +## 9. Canonical objects and on-disk bundle + +The canonical on-disk and full-snapshot distribution format is a single +SSHSIGCHAIN Canonical Bundle (`.sscb`). It contains records, disclosures, head +claims, and anchor receipts while deliberately omitting the root public key. +The root remains an out-of-band trust input. + +All objects start with four magic bytes and version `0x01`. `bytes32` means a +big-endian `u32` length followed by that many bytes. + +```text +record object: + "SSCR" || 0x01 || record_signing_bytes:bytes32 || signature:bytes32 + +disclosure object: + "SSCD" || 0x01 || record_hash:32 || profile_id:string16 || + nonce:32 || payload:bytes32 + +head-claim object: + "SSHC" || 0x01 || claim_signing_bytes:bytes32 || signature:bytes32 + +anchor-receipt object: + "SSRC" || 0x01 || backend_id:string16 || claim_hash:32 || + evidence:bytes32 +``` + +The bundle grammar is: + +```text +"SSCB" 4 bytes +0x01 bundle version +chain_id 32 bytes +namespace string16 +record_count u32 +records record_count objects, each wrapped as bytes32 +disclosure_count u32 +disclosures disclosure_count objects, each wrapped as bytes32 +claim_count u32 +claims claim_count objects, each wrapped as bytes32 +receipt_count u32 +receipts receipt_count objects, each wrapped as bytes32 +``` + +Canonical ordering and uniqueness rules are: + +- records occur in causal genesis-to-head order and every parent must equal the + preceding record hash; +- disclosures are sorted by `(record_hash, profile_id)` and that pair is unique; +- claims are sorted by claim hash and claim hashes are unique; +- receipts are sorted by `(claim_hash, backend_id)` and that pair is unique; +- disclosures must open a commitment in the named bundled record; +- claims must name a record in the bundled history; and +- receipts must name a bundled claim. + +A decoder MUST reject trailing bytes, out-of-order objects, duplicate objects, +noncanonical embedded signing bytes, mixed chain IDs, unknown references, or a +bundle which does not encode byte-for-byte identically when re-encoded. The +namespace is self-description only and MUST exactly match the locally configured +trust tuple before record verification. + +Bundle identity is: + +```text +BLAKE3("sshsigchain.bundle.v1\0" || canonical_bundle_bytes) +``` + +Bundle identity is not chain identity: adding a valid disclosure or receipt +changes the bundle hash without changing any record hash. A canonical bundle is +therefore suitable as the canonical single-file on-disk snapshot, attachment, +cache object, or static-server artifact. Implementations may maintain indexes +or databases internally, but export/import MUST round-trip through this format. + +The reference maximum bundle size is 256 MiB and the maximum embedded object +size is 5 MiB. Implementations MUST enforce bounds while streaming, before +allocating lengths supplied by an untrusted bundle. + +### 9.1 Base bundle vector + +The following structural vector contains the section 4.1 zero-chain `Noop` +record, signature bytes `01`, namespace `sshsigchain.v1`, and no disclosures, +claims, or receipts: + +```text +53534342010000000000000000000000000000000000000000000000000000000000000000000e737368736967636861696e2e7631000000010000006953534352010000005b5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d6564323535313920415149440800000000000101000000000000000000000000 +``` + +It fixes bundle and record-object framing. Like the section 4.1 outer vector, +it is not a valid verified chain because the first transition is not `Genesis` +and `01` is not an OpenSSH SSHSIG signature. + +## 10. Distribution profiles + +Distribution is untrusted availability and routing. It does not grant +authority, choose a canonical head, replace the root trust tuple, or satisfy an +anchor policy merely because a file was fetched successfully. + +A distribution endpoint has a local/configured `backend_id`, `class`, and +opaque `locator`. Backends conceptually implement: + +```text +fetch_bundle(endpoint, chain_id, accepted_head) -> none | canonical_bundle_bytes +``` + +The `accepted_head` permits an incremental P2P backend to optimize its response, +but every returned result must decode to a complete verifiable snapshot that +contains the accepted head. HTTP, Iroh, removable-media, object-store, IPFS, or +application-specific backends can implement the same interface. Fetch failures +affect availability only. Results enter the same signature, authority, +anchoring, cached-head, and fork checks. + +### 10.1 Static HTTP profile + +The static HTTP profile needs no server application or directory listing. For +chain ID ``, publish the bundle at: + +```text +/.well-known/sshsigchain/v1//chain.sscb +``` + +The response media type SHOULD be: + +```text +application/vnd.sshsigchain.bundle.v1 +``` + +A client performs an ordinary `GET`, accepts a successful full response, caps +bytes while streaming, decodes the canonical bundle, requires its chain ID and +namespace to match local trust, verifies the chain, verifies applicable head +claims and receipts, then applies cached-head/fork rules. `404` means the source +currently has no bundle. Servers SHOULD replace `chain.sscb` atomically. + +Servers MAY expose the lowercase bundle hash as a strong `ETag`; clients MAY use +`If-None-Match`. HTTP cache validators are performance hints, not trust. HTTPS +protects privacy, locators, and availability against network interference, but +SSHSIGCHAIN verification remains mandatory even over authenticated HTTPS. +Plain HTTP remains integrity-safe only in the narrow sense that tampering is +detected; it offers no confidentiality or reliable availability. + +This full-snapshot profile intentionally favors deployment with a static file +server. A later chunked profile may add immutable hash-addressed objects and a +small mutable manifest, but it must preserve the object encodings and all +verification rules above. + +## 11. JSONL interchange The reference transport uses one JSON object per nonblank line with exactly the record fields in section 3. Byte arrays use JSON arrays of octets; enum objects @@ -345,7 +488,11 @@ Reference limits are: 16 KiB canonical key, 64 KiB signature/proof, 1 MiB one profile payload, 1,024 extensions per record, 100,000 records, 5 MiB one JSONL line, and 64 MiB one JSONL input. Implementations may impose smaller limits. -## 10. OpenSSH interoperability +JSONL is a human-facing interchange representation, not the canonical on-disk +format. It carries records and optional disclosures only; canonical bundles are +required to preserve head claims and receipts. + +## 12. OpenSSH interoperability Outer records use commands equivalent to: @@ -358,7 +505,7 @@ ssh-keygen -Y verify -f -I sshsigchain \ The one-key `allowed_signers` file is only an input to cryptographic signature verification. It never establishes authorization or root trust. -## 11. Security considerations +## 13. Security considerations - Use independent random chain IDs and protocol-specific SSHSIG namespaces. - A stolen active key retains its scoped authority until a causally prior @@ -369,6 +516,8 @@ verification. It never establishes authorization or root trust. nonce is reused. Always use a fresh unpredictable nonce. - Anchor strength is no greater than the configured independent backends and receipt verifiers. Cached-head checks remain mandatory. +- Distribution sources and HTTP cache metadata are untrusted. A fetched bundle + must contain the cached head and pass the complete verifier before use. - Resource bearer secrets, discovery records, and profile payloads cannot mutate SSHSIGCHAIN authority. - Implementations should apply a local OpenSSH algorithm policy and reject