diff --git a/Cargo.lock b/Cargo.lock index 1bbb99e..62a7d5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1525,6 +1525,7 @@ dependencies = [ "geth-cas", "geth-config", "geth-control", + "geth-keychain", "geth-node", "geth-pipe", "serde_json", diff --git a/README.md b/README.md index d97a601..ed42cd0 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,29 @@ geth keychain admin-add \ --signing-key ~/.ssh/id_ed25519_sk \ --principal admin geth keychain allowed-signers > /tmp/geth.allowed_signers +geth keychain allowed-signers --out /tmp/geth.allowed_signers +geth keychain sign-file \ + --in /tmp/authorized_keys \ + --out /tmp/authorized_keys.sig \ + --signing-key ~/.ssh/id_ed25519_sk +geth keychain verify-file \ + --in /tmp/authorized_keys \ + --signature /tmp/authorized_keys.sig +geth keychain sigchain --out /tmp/geth.sigchain.jsonl +geth keychain publish-bundle \ + --out ./public/.well-known/sshsigchain \ + --signing-key ~/.ssh/id_ed25519_sk \ + --snapshot authorized_keys=/tmp/authorized_keys +geth keychain verify-checkpoint \ + --checkpoint /tmp/geth.sigchain.checkpoint.json \ + --signature /tmp/geth.sigchain.checkpoint.json.sig \ + --sigchain /tmp/geth.sigchain.jsonl \ + --allowed-signers /tmp/geth.allowed_signers +geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import +geth keychain verify-sigchain --in /tmp/geth.sigchain.jsonl +geth keychain import-sigchain --in /tmp/geth.sigchain.jsonl +geth keychain explain +geth keychain explain-signer geth keychain verify ``` @@ -459,7 +482,23 @@ trusted state. The reusable mechanics live in the `geth-keychain` crate, including application-specific signature profiles, allowed-signers projection, replay verification through a caller-provided verifier trait, and an appendable JSONL sigchain file format suitable for static hosting with HTTP caching/range -requests. +requests. The default discovery/publication base is +`https://example.com/.well-known/sshsigchain/`; publish bundles contain +`allowed_signers`, `geth.sigchain.jsonl`, `geth.sigchain.checkpoint.json`, and +`geth.sigchain.checkpoint.json.sig`. Clients can verify checkpoints, fetch +bundles, import verified sigchains, and remember the last accepted checkpoint to +reject older static bundles from the same source. `keychain fetch --url` is the +retrieval location, so local `file://` mirrors work for testing; the signed +checkpoint still records the advertised publication base URL, and +`verify-checkpoint --base-url` can pin that value when needed. + +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 +available in `ssh-agent`. For encrypted private keys, the recommended workflow +is to unlock the key with `ssh-add` and pass the public key path. For PKCS#11 +tokens, load the key into `ssh-agent` with `ssh-add -s ` and use the +exported public key path; direct PKCS#11 signing is not exposed by +`ssh-keygen -Y sign` in a portable way. The enrollment flow for a new node is: diff --git a/crates/geth-cli/Cargo.toml b/crates/geth-cli/Cargo.toml index 71b5cc9..a0f3c78 100644 --- a/crates/geth-cli/Cargo.toml +++ b/crates/geth-cli/Cargo.toml @@ -14,6 +14,7 @@ serde_json.workspace = true tokio.workspace = true geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } +geth-keychain = { path = "../geth-keychain" } geth-cas = { path = "../geth-cas" } geth-node = { path = "../geth-node" } geth-pipe = { path = "../geth-pipe" } diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index edf4f9d..ffa3778 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -153,6 +153,24 @@ const GUIDE_KEYS: &str = r#"Key terminology: shells out to ssh-keygen -Y sign with explicit namespaces to sign canonical geth keychain/auth operations. The private key is not copied into geth state. +Signing sources: + Local key file: + --signing-key ~/.ssh/id_ed25519 --admin-key ~/.ssh/id_ed25519.pub + + Encrypted key file: + Load it into ssh-agent with `ssh-add ~/.ssh/id_ed25519`, then sign through + the agent by passing the public key path: + --signing-key ~/.ssh/id_ed25519.pub + + FIDO/YubiKey OpenSSH key: + Use the security-key stub or load it into ssh-agent: + --signing-key ~/.ssh/id_ed25519_sk --admin-key ~/.ssh/id_ed25519_sk.pub + + PKCS#11: + Direct ssh-keygen -Y signing does not expose a portable -D provider option. + Load the token key into ssh-agent with `ssh-add -s `, then pass + the public key path with --signing-key. + Examples: Software key: --admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519 @@ -160,6 +178,15 @@ Examples: YubiKey/FIDO OpenSSH key: --admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk +Generate the active OpenSSH allowed_signers projection: + geth keychain allowed-signers --out ~/.config/geth/allowed_signers + +Sign an arbitrary authorized_keys snapshot with an active admin key: + geth keychain sign-file --in ~/.ssh/authorized_keys --out ~/.ssh/authorized_keys.sig --signing-key ~/.ssh/id_ed25519_sk + +Verify the snapshot signature against the current keychain trust root: + geth keychain verify-file --in ~/.ssh/authorized_keys --signature ~/.ssh/authorized_keys.sig + If you use --owner, --node-name, or --capability during init, geth requires both key options because those fields create signed owner/device/node statements. "#; @@ -654,7 +681,86 @@ pub enum KeychainCommand { #[arg(long)] admin_key: Option, }, - AllowedSigners, + AllowedSigners { + #[arg(long)] + out: Option, + }, + SignFile { + #[arg(long = "in")] + input: PathBuf, + #[arg(long)] + out: Option, + #[arg(long)] + namespace: Option, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, + }, + VerifyFile { + #[arg(long = "in")] + input: PathBuf, + #[arg(long)] + signature: PathBuf, + #[arg(long)] + namespace: Option, + #[arg(long)] + allowed_signers: Option, + #[arg(long)] + principal: Option, + }, + Sigchain { + #[arg(long)] + out: Option, + }, + PublishBundle { + #[arg(long)] + out: PathBuf, + #[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)] + base_url: String, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, + #[arg(long = "snapshot")] + snapshots: Vec, + }, + VerifySigchain { + #[arg(long = "in")] + input: PathBuf, + }, + ImportSigchain { + #[arg(long = "in")] + input: PathBuf, + }, + VerifyCheckpoint { + #[arg(long)] + checkpoint: PathBuf, + #[arg(long)] + signature: PathBuf, + #[arg(long)] + sigchain: PathBuf, + #[arg(long)] + allowed_signers: PathBuf, + #[arg(long)] + base_url: Option, + #[arg(long)] + principal: Option, + }, + Fetch { + #[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)] + url: String, + #[arg(long)] + out: Option, + #[arg(long)] + import: bool, + }, + Explain { + op_id: String, + }, + ExplainSigner { + key: String, + }, Verify, Sync { node: String, @@ -1482,8 +1588,92 @@ fn request_for_command(command: Command) -> Result { admin_key_path: admin_key, }, Command::Keychain { - command: KeychainCommand::AllowedSigners, - } => ControlRequest::KeychainAllowedSigners, + command: KeychainCommand::AllowedSigners { out }, + } => ControlRequest::KeychainAllowedSigners { out }, + Command::Keychain { + command: + KeychainCommand::SignFile { + input, + out, + namespace, + signing_key, + admin_key, + }, + } => ControlRequest::KeychainSignFile { + input, + out, + namespace, + signing_key_path: Some(signing_key), + admin_key_path: admin_key, + }, + Command::Keychain { + command: + KeychainCommand::VerifyFile { + input, + signature, + namespace, + allowed_signers, + principal, + }, + } => ControlRequest::KeychainVerifyFile { + input, + signature, + namespace, + allowed_signers_path: allowed_signers, + principal, + }, + Command::Keychain { + command: KeychainCommand::Sigchain { out }, + } => ControlRequest::KeychainSigchainExport { out }, + Command::Keychain { + command: + KeychainCommand::PublishBundle { + out, + base_url, + signing_key, + admin_key, + snapshots, + }, + } => ControlRequest::KeychainPublishBundle { + out, + base_url: Some(base_url), + signing_key_path: signing_key, + admin_key_path: admin_key, + snapshots, + }, + Command::Keychain { + command: KeychainCommand::VerifySigchain { input }, + } => ControlRequest::KeychainVerifySigchain { input }, + Command::Keychain { + command: KeychainCommand::ImportSigchain { input }, + } => ControlRequest::KeychainImportSigchain { input }, + Command::Keychain { + command: + KeychainCommand::VerifyCheckpoint { + checkpoint, + signature, + sigchain, + allowed_signers, + base_url, + principal, + }, + } => ControlRequest::KeychainVerifyCheckpoint { + checkpoint, + signature, + sigchain, + allowed_signers, + base_url, + principal, + }, + Command::Keychain { + command: KeychainCommand::Fetch { url, out, import }, + } => ControlRequest::KeychainFetch { url, out, import }, + Command::Keychain { + command: KeychainCommand::Explain { op_id }, + } => ControlRequest::KeychainExplain { op_id }, + Command::Keychain { + command: KeychainCommand::ExplainSigner { key }, + } => ControlRequest::KeychainExplainSigner { key }, Command::Keychain { command: KeychainCommand::Verify, } => ControlRequest::KeychainVerify, @@ -1990,6 +2180,23 @@ fn service_executable(bin: Option) -> Result { .context("resolve current geth executable") } +fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) { + println!("ops: {}", report.ops); + println!("signatures: {}", report.signatures); + println!("accepted_ops: {}", report.accepted_ops); + println!("rejected_ops: {}", report.rejected_ops); + println!("active_admin_keys: {}", report.active_admin_keys); + println!( + "accepted_head: {}", + report + .accepted_head + .as_ref() + .map(|head| head.as_str()) + .unwrap_or("none") + ); + println!("note: {}", report.note); +} + fn print_response(response: ControlResponse, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(&response)?); @@ -2529,30 +2736,168 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } ControlResponse::KeychainAllowedSigners { allowed_signers, + out, note, .. } => { - print!("{allowed_signers}"); - if allowed_signers.is_empty() { - println!("no active admin public keys available"); + if let Some(out) = out { + println!("wrote allowed_signers: {}", out.display()); + if allowed_signers.is_empty() { + println!("warning: generated file has no active admin public keys"); + } + } else { + print!("{allowed_signers}"); + if allowed_signers.is_empty() { + println!("no active admin public keys available"); + } } eprintln!("note: {note}"); } - ControlResponse::KeychainVerified { report } => { - println!("ops: {}", report.ops); - println!("signatures: {}", report.signatures); - println!("accepted_ops: {}", report.accepted_ops); - println!("rejected_ops: {}", report.rejected_ops); - println!("active_admin_keys: {}", report.active_admin_keys); + ControlResponse::KeychainFileSigned { + input, + out, + namespace, + signer, + note, + } => { + println!("signed file: {}", input.display()); println!( - "accepted_head: {}", - report - .accepted_head + "signature: {}", + out.map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_owned()) + ); + println!("namespace: {namespace}"); + println!("signer: {signer}"); + eprintln!("note: {note}"); + } + ControlResponse::KeychainFileVerified { + input, + signature, + namespace, + verified, + principal, + note, + } => { + println!("file: {}", input.display()); + println!("signature: {}", signature.display()); + println!("namespace: {namespace}"); + println!("principal: {}", principal.as_deref().unwrap_or("none")); + println!("verified: {verified}"); + eprintln!("note: {note}"); + } + ControlResponse::KeychainSigchainExported { + jsonl, out, note, .. + } => { + if let Some(out) = out { + println!("wrote keychain sigchain: {}", out.display()); + } else { + print!("{jsonl}"); + } + eprintln!("note: {note}"); + } + ControlResponse::KeychainBundlePublished { + out, + base_url, + allowed_signers_path, + sigchain_path, + checkpoint_path, + checkpoint_signature_path, + snapshots, + note, + .. + } => { + println!("bundle: {}", out.display()); + println!("base_url: {base_url}"); + println!("allowed_signers: {}", allowed_signers_path.display()); + println!("sigchain: {}", sigchain_path.display()); + println!("checkpoint: {}", checkpoint_path.display()); + println!( + "checkpoint_signature: {}", + checkpoint_signature_path.display() + ); + for snapshot in snapshots { + println!( + "snapshot: {} {} {}", + snapshot.name, + snapshot.path.display(), + snapshot.signature_path.display() + ); + } + eprintln!("note: {note}"); + } + ControlResponse::KeychainSigchainFileVerified { + input, + report, + note, + } => { + println!("sigchain: {}", input.display()); + print_keychain_sigchain_report(&report); + eprintln!("note: {note}"); + } + ControlResponse::KeychainSigchainImported { + input, + ops_imported, + signatures_imported, + invalid_ops_rejected, + note, + } => { + println!("sigchain: {}", input.display()); + println!("ops_imported: {ops_imported}"); + println!("signatures_imported: {signatures_imported}"); + println!("invalid_ops_rejected: {invalid_ops_rejected}"); + eprintln!("note: {note}"); + } + ControlResponse::KeychainCheckpointVerified { + checkpoint, + verified, + principal, + note, + } => { + println!( + "checkpoint_head: {}", + checkpoint + .head .as_ref() - .map(|head| head.as_str()) + .map(|h| h.as_str()) .unwrap_or("none") ); - println!("note: {}", report.note); + println!("base_url: {}", checkpoint.base_url); + println!("verified: {verified}"); + println!("principal: {}", principal.as_deref().unwrap_or("none")); + eprintln!("note: {note}"); + } + ControlResponse::KeychainFetched { + url, + out, + checkpoint, + imported, + note, + } => { + println!("url: {url}"); + println!("out: {}", out.display()); + println!( + "checkpoint_head: {}", + checkpoint + .head + .as_ref() + .map(|h| h.as_str()) + .unwrap_or("none") + ); + if let Some(imported) = imported { + println!("ops_imported: {}", imported.ops_imported); + println!("signatures_imported: {}", imported.signatures_imported); + println!("invalid_ops_rejected: {}", imported.invalid_ops_rejected); + } + eprintln!("note: {note}"); + } + ControlResponse::KeychainExplained { subject, lines } => { + println!("subject: {subject}"); + for line in lines { + println!("{line}"); + } + } + ControlResponse::KeychainVerified { report } => { + print_keychain_sigchain_report(&report); } ControlResponse::KeychainSynced { peer_node_id, diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 856fb09..60acdf4 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -4,8 +4,8 @@ use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::{ - KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport, - NodeEnrollmentRequest, NodeRecord, + KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature, + KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord, }; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_overlay::{ @@ -241,7 +241,58 @@ pub enum ControlRequest { signing_key_path: PathBuf, admin_key_path: Option, }, - KeychainAllowedSigners, + KeychainAllowedSigners { + out: Option, + }, + KeychainSignFile { + input: PathBuf, + out: Option, + namespace: Option, + signing_key_path: Option, + admin_key_path: Option, + }, + KeychainVerifyFile { + input: PathBuf, + signature: PathBuf, + namespace: Option, + allowed_signers_path: Option, + principal: Option, + }, + KeychainSigchainExport { + out: Option, + }, + KeychainPublishBundle { + out: PathBuf, + base_url: Option, + signing_key_path: PathBuf, + admin_key_path: Option, + snapshots: Vec, + }, + KeychainVerifySigchain { + input: PathBuf, + }, + KeychainImportSigchain { + input: PathBuf, + }, + KeychainVerifyCheckpoint { + checkpoint: PathBuf, + signature: PathBuf, + sigchain: PathBuf, + allowed_signers: PathBuf, + base_url: Option, + principal: Option, + }, + KeychainFetch { + url: String, + out: Option, + import: bool, + }, + KeychainExplain { + op_id: String, + }, + KeychainExplainSigner { + key: String, + }, KeychainVerify, KeychainSync { node: String, @@ -687,8 +738,70 @@ pub enum ControlResponse { KeychainAllowedSigners { entries: Vec, allowed_signers: String, + out: Option, note: String, }, + KeychainFileSigned { + input: PathBuf, + out: Option, + namespace: String, + signer: String, + note: String, + }, + KeychainFileVerified { + input: PathBuf, + signature: PathBuf, + namespace: String, + verified: bool, + principal: Option, + note: String, + }, + KeychainSigchainExported { + entries: Vec, + jsonl: String, + out: Option, + note: String, + }, + KeychainBundlePublished { + out: PathBuf, + base_url: String, + allowed_signers_path: PathBuf, + sigchain_path: PathBuf, + checkpoint_path: PathBuf, + checkpoint_signature_path: PathBuf, + checkpoint: KeychainCheckpoint, + snapshots: Vec, + note: String, + }, + KeychainSigchainFileVerified { + input: PathBuf, + report: KeychainSigchainReport, + note: String, + }, + KeychainSigchainImported { + input: PathBuf, + ops_imported: usize, + signatures_imported: usize, + invalid_ops_rejected: usize, + note: String, + }, + KeychainCheckpointVerified { + checkpoint: KeychainCheckpoint, + verified: bool, + principal: Option, + note: String, + }, + KeychainFetched { + url: String, + out: PathBuf, + checkpoint: KeychainCheckpoint, + imported: Option, + note: String, + }, + KeychainExplained { + subject: String, + lines: Vec, + }, KeychainVerified { report: KeychainSigchainReport, }, @@ -1021,6 +1134,22 @@ pub enum ControlResponse { }, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainFetchImportReport { + pub ops_imported: usize, + pub signatures_imported: usize, + pub invalid_ops_rejected: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainPublishedSnapshot { + pub name: String, + pub source: PathBuf, + pub path: PathBuf, + pub signature_path: PathBuf, + pub namespace: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StatusResponse { pub home: PathBuf, diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 2fd91c4..97234bc 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -23,7 +23,11 @@ use std::collections::{BTreeMap, BTreeSet}; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local"; +pub const AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev"; +pub const KEYCHAIN_CHECKPOINT_NAMESPACE: &str = "geth.sigchain-checkpoint.v1@eric.wendland.dev"; +pub const DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL: &str = "https://example.com/.well-known/sshsigchain/"; pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin"; +pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1; pub type SignedKeychainOp = geth_codec::SignedEnvelope; @@ -194,6 +198,21 @@ pub struct KeychainSigchainEntry { pub signatures: Vec, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainCheckpoint { + pub version: u16, + pub profile: KeychainProfile, + pub base_url: String, + pub head: Option, + pub ops: usize, + pub signatures: usize, + pub sigchain_bytes: u64, + pub sigchain_hash: String, + pub allowed_signers_hash: String, + pub reduced_view_hash: String, + pub generated_at: UnixMillis, +} + pub trait KeychainSignatureVerifier { fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool; } @@ -299,6 +318,8 @@ pub enum KeychainError { InvalidNamespaceComponent { field: String, value: String }, #[error("invalid keychain principal: {0}")] InvalidPrincipal(String), + #[error("codec error: {0}")] + Codec(#[from] geth_codec::CodecError), #[error("sigchain JSONL line {line}: {source}")] SigchainJsonl { line: usize, @@ -763,6 +784,44 @@ pub fn flatten_sigchain_entries( (ops, signatures) } +pub fn keychain_checkpoint( + ops: &[KeychainOp], + signatures: &[KeychainOpSignature], + sigchain_jsonl: &str, + allowed_signers: &str, + base_url: impl Into, + generated_at: UnixMillis, +) -> Result { + let sorted_ops = sorted_keychain_ops(ops.to_vec()); + let view = reduce_keychain_ops(&sorted_ops); + Ok(KeychainCheckpoint { + version: KEYCHAIN_CHECKPOINT_VERSION, + profile: KeychainProfile::geth(), + base_url: normalize_base_url(base_url.into()), + head: sorted_ops.last().map(|op| op.id.clone()), + ops: sorted_ops.len(), + signatures: signatures.len(), + sigchain_bytes: sigchain_jsonl.len() as u64, + sigchain_hash: blake3_tagged_hash(sigchain_jsonl.as_bytes()), + allowed_signers_hash: blake3_tagged_hash(allowed_signers.as_bytes()), + reduced_view_hash: geth_codec::hash_canonical(&view)?.to_string(), + generated_at, + }) +} + +#[must_use] +pub fn blake3_tagged_hash(bytes: &[u8]) -> String { + format!("blake3:{}", blake3::hash(bytes)) +} + +#[must_use] +pub fn normalize_base_url(mut value: String) -> String { + if !value.ends_with('/') { + value.push('/'); + } + value +} + fn validate_namespace(value: &str) -> Result<(), KeychainError> { let has_single_domain_separator = value.matches('@').count() == 1; let valid = has_single_domain_separator @@ -1085,6 +1144,53 @@ mod tests { assert_eq!(decoded_signatures, signatures); } + #[test] + fn checkpoint_records_static_publication_hashes() { + let ops = vec![ + op(1, KeychainOpKind::KeychainInit), + op( + 2, + KeychainOpKind::AdminKeyAdd { + key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(), + public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), + principal: Some("admin".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, + ), + ]; + let signatures = Vec::new(); + let entries = sigchain_entries(&ops, &signatures); + let jsonl = encode_sigchain_jsonl(&entries).expect("jsonl"); + let allowed = render_allowed_signers(&allowed_signers(&ops, &signatures)); + let checkpoint = keychain_checkpoint( + &ops, + &signatures, + &jsonl, + &allowed, + "https://example.com/.well-known/sshsigchain", + UnixMillis(10), + ) + .expect("checkpoint"); + + assert_eq!(checkpoint.version, KEYCHAIN_CHECKPOINT_VERSION); + assert_eq!( + checkpoint.base_url, + "https://example.com/.well-known/sshsigchain/" + ); + assert_eq!(checkpoint.ops, 2); + assert_eq!(checkpoint.sigchain_bytes, jsonl.len() as u64); + assert_eq!( + checkpoint.sigchain_hash, + blake3_tagged_hash(jsonl.as_bytes()) + ); + assert_eq!( + checkpoint.allowed_signers_hash, + blake3_tagged_hash(allowed.as_bytes()) + ); + assert!(!checkpoint.reduced_view_hash.is_empty()); + } + #[test] fn sigchain_verification_replays_against_prior_admin_view() { let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(); diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index fbf2492..68d1acd 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -81,6 +81,8 @@ pub enum NodeError { Control(#[from] geth_control::ControlError), #[error("codec error: {0}")] Codec(#[from] geth_codec::CodecError), + #[error("keychain error: {0}")] + Keychain(#[from] geth_keychain::KeychainError), #[error("json error: {0}")] Json(#[from] serde_json::Error), #[error("io error: {0}")] @@ -99,6 +101,8 @@ pub enum NodeError { InvalidInitGrant(String), #[error("invalid enrollment capability, expected =: {0}")] InvalidEnrollmentCapability(String), + #[error("invalid keychain snapshot, expected =: {0}")] + InvalidKeychainSnapshot(String), #[error("node enrollment request not found: {0}")] NodeEnrollmentRequestNotFound(String), #[error("node enrollment request has invalid provenance: {0}")] @@ -7680,18 +7684,181 @@ pub fn handle_request( note: "recorded signed admin key revocation in the keychain sigchain".to_owned(), }) } - ControlRequest::KeychainAllowedSigners => { + ControlRequest::KeychainAllowedSigners { out } => { let entries = geth_keychain::allowed_signers( &load_keychain_ops(&store)?, &load_keychain_signatures(&store)?, ); let allowed_signers = geth_keychain::render_allowed_signers(&entries); + if let Some(path) = &out { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, &allowed_signers)?; + } Ok(ControlResponse::KeychainAllowedSigners { entries, allowed_signers, + out, note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(), }) } + ControlRequest::KeychainSignFile { + input, + out, + namespace, + signing_key_path, + admin_key_path, + } => { + let signing_key_path = signing_key_path + .as_ref() + .ok_or_else(|| NodeError::SigningKeyRequired("keychain sign-file".to_owned()))?; + let namespace = + namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned()); + let entries = geth_keychain::allowed_signers( + &load_keychain_ops(&store)?, + &load_keychain_signatures(&store)?, + ); + let (signer, _) = + keychain_signer_from_paths(signing_key_path, admin_key_path.as_deref())?; + if !entries.iter().any(|entry| entry.key == signer) { + return Err(NodeError::Unauthorized(format!( + "signing key {signer} is not an active keychain admin signer" + ))); + } + let signature_out = sign_file_with_ssh(signing_key_path, &namespace, &input, out)?; + Ok(ControlResponse::KeychainFileSigned { + input, + out: Some(signature_out), + namespace, + signer: signer.to_string(), + note: "signed file with an active keychain admin signer; publish the file, signature, and allowed_signers projection together".to_owned(), + }) + } + ControlRequest::KeychainVerifyFile { + input, + signature, + namespace, + allowed_signers_path, + principal, + } => { + let namespace = + namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned()); + let (verified, matched_principal) = verify_file_with_keychain_signers( + &store, + node, + &input, + &signature, + &namespace, + allowed_signers_path.as_deref(), + principal.as_deref(), + )?; + Ok(ControlResponse::KeychainFileVerified { + input, + signature, + namespace, + verified, + principal: matched_principal, + note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(), + }) + } + ControlRequest::KeychainSigchainExport { out } => { + let ops = load_keychain_ops(&store)?; + let signatures = load_keychain_signatures(&store)?; + let entries = geth_keychain::sigchain_entries(&ops, &signatures); + let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?; + if let Some(path) = &out { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, &jsonl)?; + } + Ok(ControlResponse::KeychainSigchainExported { + entries, + jsonl, + out, + note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(), + }) + } + ControlRequest::KeychainPublishBundle { + out, + base_url, + signing_key_path, + admin_key_path, + snapshots, + } => publish_keychain_bundle( + &store, + node, + out, + base_url, + signing_key_path, + admin_key_path, + snapshots, + ), + ControlRequest::KeychainVerifySigchain { input } => { + let text = std::fs::read_to_string(&input)?; + let entries = geth_keychain::decode_sigchain_jsonl(&text)?; + let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries); + let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures); + Ok(ControlResponse::KeychainSigchainFileVerified { + input, + report, + note: "verified JSONL sigchain by replaying operations and OpenSSH signatures" + .to_owned(), + }) + } + ControlRequest::KeychainImportSigchain { input } => { + let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?; + let imported = + import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?; + Ok(ControlResponse::KeychainSigchainImported { + input, + ops_imported: imported.ops_imported, + signatures_imported: imported.signatures_imported, + invalid_ops_rejected: imported.invalid_ops_rejected, + note: if imported.invalid_ops_rejected == 0 { + "imported verified JSONL sigchain entries".to_owned() + } else { + "sigchain contained rejected operations; nothing imported".to_owned() + }, + }) + } + ControlRequest::KeychainVerifyCheckpoint { + checkpoint, + signature, + sigchain, + allowed_signers, + base_url, + principal, + } => { + let (checkpoint, verified, principal) = verify_keychain_checkpoint_files( + node, + &checkpoint, + &signature, + &sigchain, + &allowed_signers, + base_url.as_deref(), + principal.as_deref(), + )?; + Ok(ControlResponse::KeychainCheckpointVerified { + checkpoint, + verified, + principal, + note: "verified checkpoint signature, hashes, base URL, and sigchain head" + .to_owned(), + }) + } + ControlRequest::KeychainFetch { url, out, import } => { + fetch_keychain_bundle(&store, node, url, out, import) + } + ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained { + subject: op_id.clone(), + lines: explain_keychain_op(&store, node, &op_id)?, + }), + ControlRequest::KeychainExplainSigner { key } => Ok(ControlResponse::KeychainExplained { + subject: key.clone(), + lines: explain_keychain_signer(&store, &key)?, + }), ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified { report: verify_keychain_sigchain_with_ssh(&store, node)?, }), @@ -10501,6 +10668,510 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> { Ok(()) } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct StaticKeychainSourceState { + head: Option, + ops: usize, + sigchain_hash: String, + generated_at_ms: i64, +} + +fn read_and_verify_sigchain_file( + node: &LocalNode, + input: &Path, +) -> Result< + ( + Vec, + Vec, + geth_keychain::KeychainSigchainReport, + ), + NodeError, +> { + let text = std::fs::read_to_string(input)?; + let entries = geth_keychain::decode_sigchain_jsonl(&text)?; + let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries); + let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures); + Ok((ops, signatures, report)) +} + +fn import_verified_sigchain( + store: &Store, + ops: &[KeychainOp], + signatures: &[KeychainOpSignature], + rejected_ops: usize, +) -> Result { + if rejected_ops != 0 { + return Ok(geth_control::KeychainFetchImportReport { + ops_imported: 0, + signatures_imported: 0, + invalid_ops_rejected: rejected_ops, + }); + } + let existing_ops = load_keychain_ops(store)? + .into_iter() + .map(|op| op.id) + .collect::>(); + let existing_signatures = load_keychain_signatures(store)? + .into_iter() + .map(|signature| { + ( + signature.op_id, + signature.signer, + signature.namespace, + signature.signature, + ) + }) + .collect::>(); + let mut ops_imported = 0; + for op in ops { + if !existing_ops.contains(&op.id) { + store_keychain_op(store, op)?; + ops_imported += 1; + } + } + let mut signatures_imported = 0; + for signature in signatures { + let key = ( + signature.op_id.clone(), + signature.signer.clone(), + signature.namespace.clone(), + signature.signature.clone(), + ); + if !existing_signatures.contains(&key) { + store.insert_keychain_signature(&StoredKeychainSignature { + op_id: signature.op_id.to_string(), + signer: signature.signer.to_string(), + signer_public_key: signature.signer_public_key.clone(), + namespace: signature.namespace.clone(), + signature: signature.signature.clone(), + created_at_ms: signature.created_at.0, + })?; + signatures_imported += 1; + } + } + Ok(geth_control::KeychainFetchImportReport { + ops_imported, + signatures_imported, + invalid_ops_rejected: 0, + }) +} + +fn verify_keychain_checkpoint_files( + node: &LocalNode, + checkpoint_path: &Path, + signature_path: &Path, + sigchain_path: &Path, + allowed_signers_path: &Path, + expected_base_url: Option<&str>, + principal: Option<&str>, +) -> Result<(geth_keychain::KeychainCheckpoint, bool, Option), NodeError> { + let checkpoint_text = std::fs::read_to_string(checkpoint_path)?; + let checkpoint: geth_keychain::KeychainCheckpoint = serde_json::from_str(&checkpoint_text)?; + if let Some(expected) = expected_base_url { + let expected = geth_keychain::normalize_base_url(expected.to_owned()); + if checkpoint.base_url != expected { + return Ok((checkpoint, false, None)); + } + } + let sigchain_text = std::fs::read_to_string(sigchain_path)?; + let allowed_signers = std::fs::read_to_string(allowed_signers_path)?; + if checkpoint.sigchain_bytes != sigchain_text.len() as u64 + || checkpoint.sigchain_hash != geth_keychain::blake3_tagged_hash(sigchain_text.as_bytes()) + || checkpoint.allowed_signers_hash + != geth_keychain::blake3_tagged_hash(allowed_signers.as_bytes()) + { + return Ok((checkpoint, false, None)); + } + let entries = geth_keychain::decode_sigchain_jsonl(&sigchain_text)?; + let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries); + let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures); + if report.rejected_ops != 0 || report.accepted_head != checkpoint.head { + return Ok((checkpoint, false, None)); + } + let (verified, matched_principal) = verify_file_with_keychain_signers( + &Store::open(&node.paths.metadata_db())?, + node, + checkpoint_path, + signature_path, + geth_keychain::KEYCHAIN_CHECKPOINT_NAMESPACE, + Some(allowed_signers_path), + principal, + )?; + Ok((checkpoint, verified, matched_principal)) +} + +fn fetch_keychain_bundle( + store: &Store, + node: &LocalNode, + url: String, + out: Option, + import: bool, +) -> Result { + let base_url = geth_keychain::normalize_base_url(url.clone()); + let out = out.unwrap_or_else(|| { + node.paths + .home() + .join("keychain-fetch") + .join(geth_crypto::blake3_hex(base_url.as_bytes())) + }); + std::fs::create_dir_all(&out)?; + for name in [ + "allowed_signers", + "geth.sigchain.jsonl", + "geth.sigchain.checkpoint.json", + "geth.sigchain.checkpoint.json.sig", + ] { + fetch_bundle_file(&base_url, name, &out.join(name))?; + } + let checkpoint_path = out.join("geth.sigchain.checkpoint.json"); + let signature_path = out.join("geth.sigchain.checkpoint.json.sig"); + let sigchain_path = out.join("geth.sigchain.jsonl"); + let allowed_signers_path = out.join("allowed_signers"); + let (checkpoint, verified, _) = verify_keychain_checkpoint_files( + node, + &checkpoint_path, + &signature_path, + &sigchain_path, + &allowed_signers_path, + None, + None, + )?; + if !verified { + return Err(NodeError::Unauthorized( + "fetched keychain checkpoint did not verify".to_owned(), + )); + } + check_static_source_rollback(store, &base_url, &checkpoint)?; + let imported = if import { + let (ops, signatures, report) = read_and_verify_sigchain_file(node, &sigchain_path)?; + let imported = import_verified_sigchain(store, &ops, &signatures, report.rejected_ops)?; + if imported.invalid_ops_rejected == 0 { + remember_static_source_checkpoint(store, &base_url, &checkpoint)?; + } + Some(imported) + } else { + None + }; + let note = format!( + "fetched and verified static SSH sigchain bundle; checkpoint base URL is {}", + checkpoint.base_url + ); + Ok(ControlResponse::KeychainFetched { + url: base_url, + out, + checkpoint, + imported, + note, + }) +} + +fn fetch_bundle_file(base_url: &str, name: &str, out: &Path) -> Result<(), NodeError> { + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + if let Some(root) = base_url.strip_prefix("file://") { + std::fs::copy(Path::new(root).join(name), out)?; + return Ok(()); + } + if base_url.starts_with("http://") || base_url.starts_with("https://") { + let url = format!("{base_url}{name}"); + let output = std::process::Command::new("curl") + .arg("-fsSL") + .arg(&url) + .arg("-o") + .arg(out) + .output()?; + if output.status.success() { + return Ok(()); + } + return Err(NodeError::IrohPeer(format!( + "curl failed fetching {url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + std::fs::copy(Path::new(base_url).join(name), out)?; + Ok(()) +} + +fn static_source_state_key(base_url: &str) -> String { + format!( + "keychain-static-source:{}", + geth_crypto::blake3_hex(base_url.as_bytes()) + ) +} + +fn check_static_source_rollback( + store: &Store, + base_url: &str, + checkpoint: &geth_keychain::KeychainCheckpoint, +) -> Result<(), NodeError> { + let Some(state) = store.get_module_state(&static_source_state_key(base_url))? else { + return Ok(()); + }; + let previous: StaticKeychainSourceState = serde_json::from_str(&state.state_json)?; + if previous.generated_at_ms > checkpoint.generated_at.0 || previous.ops > checkpoint.ops { + return Err(NodeError::Unauthorized( + "fetched keychain checkpoint is older than the last accepted checkpoint".to_owned(), + )); + } + Ok(()) +} + +fn remember_static_source_checkpoint( + store: &Store, + base_url: &str, + checkpoint: &geth_keychain::KeychainCheckpoint, +) -> Result<(), NodeError> { + let state = StaticKeychainSourceState { + head: checkpoint.head.clone(), + ops: checkpoint.ops, + sigchain_hash: checkpoint.sigchain_hash.clone(), + generated_at_ms: checkpoint.generated_at.0, + }; + store.put_module_state(&StoredModuleState { + module: static_source_state_key(base_url), + state_json: serde_json::to_string(&state)?, + updated_at_ms: geth_store::now_ms(), + })?; + Ok(()) +} + +fn explain_keychain_op( + store: &Store, + node: &LocalNode, + op_id: &str, +) -> Result, NodeError> { + let ops = load_keychain_ops(store)?; + let signatures = load_keychain_signatures(store)?; + let Some(op) = ops.iter().find(|op| op.id.as_str() == op_id) else { + return Ok(vec![format!("operation not found: {op_id}")]); + }; + let sorted = geth_keychain::sorted_keychain_ops(ops.clone()); + let position = sorted.iter().position(|candidate| candidate.id == op.id); + let op_signatures = signatures + .iter() + .filter(|signature| signature.op_id == op.id) + .collect::>(); + let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures); + let mut lines = Vec::new(); + lines.push(format!("op_id: {}", op.id)); + lines.push(format!("created_at_ms: {}", op.created_at.0)); + lines.push(format!("kind: {:?}", op.kind)); + if let Some(position) = position { + lines.push(format!("position: {position}")); + } + lines.push(format!("signatures: {}", op_signatures.len())); + for signature in op_signatures { + let claimed = geth_keychain::signature_uses_claimed_key(signature); + let verified = verify_keychain_signature_with_ssh( + node, + op, + &stored_keychain_signature_from_signature(signature), + ) + .unwrap_or(false); + lines.push(format!( + "signature signer={} namespace={} claimed_key={} cryptographic_verify={}", + signature.signer, signature.namespace, claimed, verified + )); + } + lines.push(format!( + "replay_accepted_head: {}", + report + .accepted_head + .as_ref() + .map(|head| head.as_str()) + .unwrap_or("none") + )); + lines.push(format!("replay_rejected_ops: {}", report.rejected_ops)); + let accepted = report.accepted_head.as_ref().is_some_and(|_| { + let accepted_ops = report.accepted_ops; + sorted + .iter() + .take(accepted_ops) + .any(|accepted_op| accepted_op.id == op.id) + }); + lines.push(format!("accepted_by_replay: {accepted}")); + Ok(lines) +} + +fn explain_keychain_signer(store: &Store, key: &str) -> Result, NodeError> { + let ops = load_keychain_ops(store)?; + let signatures = load_keychain_signatures(store)?; + let key_id = KeyId::new(key.to_owned()); + let allowed = geth_keychain::allowed_signers(&ops, &signatures); + let mut lines = Vec::new(); + lines.push(format!("key: {key}")); + if let Some(entry) = allowed.iter().find(|entry| entry.key == key_id) { + lines.push("active_admin_signer: true".to_owned()); + lines.push(format!("principal: {}", entry.principal)); + lines.push(format!("public_key: {}", entry.public_key.trim())); + } else { + lines.push("active_admin_signer: false".to_owned()); + } + for op in geth_keychain::sorted_keychain_ops(ops) { + match &op.kind { + KeychainOpKind::AdminKeyAdd { key: op_key, .. } if op_key == &key_id => { + lines.push(format!("added_by_op: {} at {}", op.id, op.created_at.0)); + } + KeychainOpKind::AdminKeyRevoke { key: op_key } if op_key == &key_id => { + lines.push(format!("revoked_by_op: {} at {}", op.id, op.created_at.0)); + } + _ => {} + } + } + let signed = signatures + .iter() + .filter(|signature| signature.signer == key_id) + .count(); + lines.push(format!("signed_operations: {signed}")); + Ok(lines) +} + +fn publish_keychain_bundle( + store: &Store, + _node: &LocalNode, + out: PathBuf, + base_url: Option, + signing_key_path: PathBuf, + admin_key_path: Option, + snapshots: Vec, +) -> Result { + let base_url = base_url + .map(geth_keychain::normalize_base_url) + .unwrap_or_else(|| geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned()); + let entries = geth_keychain::allowed_signers( + &load_keychain_ops(store)?, + &load_keychain_signatures(store)?, + ); + let (signer, _) = keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?; + if !entries.iter().any(|entry| entry.key == signer) { + return Err(NodeError::Unauthorized(format!( + "signing key {signer} is not an active keychain admin signer" + ))); + } + + std::fs::create_dir_all(&out)?; + let ops = load_keychain_ops(store)?; + let signatures = load_keychain_signatures(store)?; + let sigchain_entries = geth_keychain::sigchain_entries(&ops, &signatures); + let sigchain_jsonl = geth_keychain::encode_sigchain_jsonl(&sigchain_entries)?; + let allowed_signers = geth_keychain::render_allowed_signers(&entries); + + let allowed_signers_path = out.join("allowed_signers"); + let sigchain_path = out.join("geth.sigchain.jsonl"); + let checkpoint_path = out.join("geth.sigchain.checkpoint.json"); + std::fs::write(&allowed_signers_path, &allowed_signers)?; + std::fs::write(&sigchain_path, &sigchain_jsonl)?; + let checkpoint = geth_keychain::keychain_checkpoint( + &ops, + &signatures, + &sigchain_jsonl, + &allowed_signers, + base_url.clone(), + UnixMillis(geth_store::now_ms()), + )?; + std::fs::write(&checkpoint_path, serde_json::to_vec_pretty(&checkpoint)?)?; + let checkpoint_signature_path = sign_file_with_ssh( + &signing_key_path, + "geth.sigchain-checkpoint.v1@eric.wendland.dev", + &checkpoint_path, + Some(out.join("geth.sigchain.checkpoint.json.sig")), + )?; + + let mut published_snapshots = Vec::new(); + for snapshot in snapshots { + let (name, source) = parse_snapshot_arg(&snapshot)?; + let path = out.join(&name); + std::fs::copy(&source, &path)?; + let namespace = snapshot_namespace(&name); + let signature_path = sign_file_with_ssh( + &signing_key_path, + &namespace, + &path, + Some(out.join(format!("{name}.sig"))), + )?; + published_snapshots.push(geth_control::KeychainPublishedSnapshot { + name, + source, + path, + signature_path, + namespace, + }); + } + + Ok(ControlResponse::KeychainBundlePublished { + out, + base_url, + allowed_signers_path, + sigchain_path, + checkpoint_path, + checkpoint_signature_path, + checkpoint, + snapshots: published_snapshots, + note: + "published static SSH sigchain bundle; serve this directory at the discovery base URL" + .to_owned(), + }) +} + +fn verify_keychain_sigchain_entries_with_ssh( + node: &LocalNode, + ops: &[KeychainOp], + signatures: &[KeychainOpSignature], +) -> geth_keychain::KeychainSigchainReport { + struct SshKeychainVerifier<'a> { + node: &'a LocalNode, + } + + impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> { + fn verify_keychain_signature( + &self, + op: &KeychainOp, + signature: &KeychainOpSignature, + ) -> bool { + verify_keychain_signature_with_ssh( + self.node, + op, + &stored_keychain_signature_from_signature(signature), + ) + .unwrap_or(false) + } + } + + let verifier = SshKeychainVerifier { node }; + geth_keychain::verify_sigchain(ops, signatures, &verifier) +} + +fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> { + let Some((name, path)) = value.split_once('=') else { + return Err(NodeError::InvalidKeychainSnapshot(value.to_owned())); + }; + validate_snapshot_name(name)?; + let path = PathBuf::from(path); + if !path.is_file() { + return Err(NodeError::InvalidKeychainSnapshot(value.to_owned())); + } + Ok((name.to_owned(), path)) +} + +fn validate_snapshot_name(name: &str) -> Result<(), NodeError> { + let valid = !name.is_empty() + && name != "." + && name != ".." + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + if valid { + Ok(()) + } else { + Err(NodeError::InvalidKeychainSnapshot(name.to_owned())) + } +} + +fn snapshot_namespace(name: &str) -> String { + format!("geth.snapshot.{name}.v1@eric.wendland.dev") +} + fn verify_keychain_sigchain_with_ssh( store: &Store, node: &LocalNode, @@ -10562,9 +11233,16 @@ fn keychain_signer_from_paths( signing_key_path: &Path, admin_key_path: Option<&Path>, ) -> Result<(KeyId, String), NodeError> { - let public_key_path = admin_key_path - .map(Path::to_path_buf) - .unwrap_or_else(|| Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf()); + let public_key_path = admin_key_path.map(Path::to_path_buf).unwrap_or_else(|| { + if signing_key_path + .extension() + .is_some_and(|extension| extension == "pub") + { + signing_key_path.to_path_buf() + } else { + Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf() + } + }); let public_key = std::fs::read_to_string(public_key_path)?; Ok(( KeyId::new(ssh_public_key_fingerprint(&public_key)), @@ -10572,6 +11250,131 @@ fn keychain_signer_from_paths( )) } +fn sign_file_with_ssh( + signing_key_path: &Path, + namespace: &str, + input_path: &Path, + out: Option, +) -> Result { + geth_ssh_identity::ensure_ssh_keygen_available()?; + let signature_path = Path::new(&format!("{}.sig", input_path.display())).to_path_buf(); + if signature_path.exists() { + std::fs::remove_file(&signature_path)?; + } + let output = + geth_ssh_identity::sign_command(signing_key_path, namespace, input_path).output()?; + if !output.status.success() { + return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed( + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + ) + .into()); + } + let Some(out) = out else { + return Ok(signature_path); + }; + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + if out != signature_path { + if out.exists() { + std::fs::remove_file(&out)?; + } + match std::fs::rename(&signature_path, &out) { + Ok(()) => {} + Err(_) => { + std::fs::copy(&signature_path, &out)?; + std::fs::remove_file(&signature_path)?; + } + } + } + Ok(out) +} + +fn verify_file_with_keychain_signers( + store: &Store, + node: &LocalNode, + input_path: &Path, + signature_path: &Path, + namespace: &str, + allowed_signers_path: Option<&Path>, + principal: Option<&str>, +) -> Result<(bool, Option), NodeError> { + geth_ssh_identity::ensure_ssh_keygen_available()?; + let verify_dir = node + .paths + .home() + .join("keychain-signatures") + .join("file-verify"); + std::fs::create_dir_all(&verify_dir)?; + let stable_id = geth_crypto::blake3_hex( + format!( + "{}\0{}\0{}\0{}", + input_path.display(), + signature_path.display(), + namespace, + principal.unwrap_or("") + ) + .as_bytes(), + ); + let owned_allowed_signers_path; + let allowed_signers_path = if let Some(path) = allowed_signers_path { + path + } else { + let entries = geth_keychain::allowed_signers( + &load_keychain_ops(store)?, + &load_keychain_signatures(store)?, + ); + let allowed_signers = geth_keychain::render_allowed_signers(&entries); + owned_allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers")); + std::fs::write(&owned_allowed_signers_path, allowed_signers)?; + &owned_allowed_signers_path + }; + let principals = if let Some(principal) = principal { + vec![principal.to_owned()] + } else { + read_allowed_signer_principals(allowed_signers_path)? + }; + for principal in principals { + let payload = std::fs::File::open(input_path)?; + let output = std::process::Command::new("ssh-keygen") + .arg("-Y") + .arg("verify") + .arg("-f") + .arg(allowed_signers_path) + .arg("-I") + .arg(&principal) + .arg("-n") + .arg(namespace) + .arg("-s") + .arg(signature_path) + .stdin(std::process::Stdio::from(payload)) + .output()?; + if output.status.success() { + return Ok((true, Some(principal))); + } + } + Ok((false, None)) +} + +fn read_allowed_signer_principals(path: &Path) -> Result, NodeError> { + let text = std::fs::read_to_string(path)?; + let mut principals = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(field) = line.split_whitespace().next() { + for principal in field.split(',') { + if !principal.is_empty() && !principals.iter().any(|item| item == principal) { + principals.push(principal.to_owned()); + } + } + } + } + Ok(principals) +} + fn sign_keychain_op_with_ssh( store: &Store, node: &LocalNode, @@ -11167,15 +11970,18 @@ async fn start_daemon_iroh_endpoint( let store = Store::open(&node.paths.metadata_db())?; store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?; } - let blob_store = - iroh_blobs::store::fs::FsStore::load(node.paths.cas_dir().join("iroh-blobs")) - .await - .map_err(|error| { - NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}")) - })?; + let iroh_blobs_path = node.paths.cas_dir().join("iroh-blobs"); + std::fs::create_dir_all(&iroh_blobs_path)?; + let iroh_docs_path = node.paths.home().join("iroh-docs"); + std::fs::create_dir_all(&iroh_docs_path)?; + let blob_store = iroh_blobs::store::fs::FsStore::load(iroh_blobs_path) + .await + .map_err(|error| { + NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}")) + })?; let gossip = iroh_gossip::net::Gossip::builder().spawn(endpoint.endpoint()); let blob_api: iroh_blobs::api::Store = blob_store.clone().into(); - let docs = iroh_docs::protocol::Docs::persistent(node.paths.home().join("iroh-docs")) + let docs = iroh_docs::protocol::Docs::persistent(iroh_docs_path) .spawn(endpoint.endpoint(), blob_api, gossip.clone()) .await .map_err(|error| { diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 682b4aa..1518468 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -2627,9 +2627,11 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { other => panic!("unexpected response: {other:?}"), } - let response = - geth_node::handle_request(&node, geth_control::ControlRequest::KeychainAllowedSigners) - .expect("allowed signers"); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainAllowedSigners { out: None }, + ) + .expect("allowed signers"); match response { geth_control::ControlResponse::KeychainAllowedSigners { entries, @@ -2642,6 +2644,278 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { other => panic!("unexpected response: {other:?}"), } + let allowed_signers_path = home.path().join("allowed_signers"); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainAllowedSigners { + out: Some(allowed_signers_path.clone()), + }, + ) + .expect("allowed signers file"); + match response { + geth_control::ControlResponse::KeychainAllowedSigners { out, .. } => { + assert_eq!(out.as_deref(), Some(allowed_signers_path.as_path())); + let allowed_signers_file = + std::fs::read_to_string(&allowed_signers_path).expect("read allowed_signers"); + assert!(allowed_signers_file.contains("second-admin ssh-ed25519 ")); + } + other => panic!("unexpected response: {other:?}"), + } + + let authorized_keys_path = home.path().join("authorized_keys"); + std::fs::write( + &authorized_keys_path, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGexample external-login-key\n", + ) + .expect("write authorized_keys snapshot"); + let authorized_keys_signature_path = home.path().join("authorized_keys.sig"); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainSignFile { + input: authorized_keys_path.clone(), + out: Some(authorized_keys_signature_path.clone()), + namespace: None, + signing_key_path: Some(admin_key_path.clone()), + admin_key_path: Some(admin_key_path.with_extension("pub")), + }, + ) + .expect("sign authorized_keys snapshot"); + match response { + geth_control::ControlResponse::KeychainFileSigned { + input, + out, + namespace, + signer, + .. + } => { + assert_eq!(input, authorized_keys_path); + assert_eq!( + out.as_deref(), + Some(authorized_keys_signature_path.as_path()) + ); + assert_eq!(namespace, geth_keychain::AUTHORIZED_KEYS_NAMESPACE); + assert!(!signer.is_empty()); + assert!(authorized_keys_signature_path.exists()); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainVerifyFile { + input: authorized_keys_path.clone(), + signature: authorized_keys_signature_path.clone(), + namespace: None, + allowed_signers_path: Some(allowed_signers_path.clone()), + principal: None, + }, + ) + .expect("verify authorized_keys snapshot"); + match response { + geth_control::ControlResponse::KeychainFileVerified { + verified, + principal, + .. + } => { + assert!(verified); + assert_eq!(principal.as_deref(), Some("admin")); + } + other => panic!("unexpected response: {other:?}"), + } + + let sigchain_path = home.path().join("keychain.sigchain.jsonl"); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainSigchainExport { + out: Some(sigchain_path.clone()), + }, + ) + .expect("sigchain export"); + match response { + geth_control::ControlResponse::KeychainSigchainExported { entries, out, .. } => { + assert_eq!(out.as_deref(), Some(sigchain_path.as_path())); + assert!(entries.len() >= 3); + let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain"); + let decoded = geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain"); + assert_eq!(decoded, entries); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainVerifySigchain { + input: sigchain_path.clone(), + }, + ) + .expect("verify sigchain file"); + match response { + geth_control::ControlResponse::KeychainSigchainFileVerified { report, .. } => { + assert_eq!(report.rejected_ops, 0); + assert_eq!(report.active_admin_keys, 2); + } + other => panic!("unexpected response: {other:?}"), + } + + let bundle_dir = home.path().join("public-bundle"); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainPublishBundle { + out: bundle_dir.clone(), + base_url: None, + signing_key_path: admin_key_path.clone(), + admin_key_path: Some(admin_key_path.with_extension("pub")), + snapshots: vec![format!( + "authorized_keys={}", + authorized_keys_path.display() + )], + }, + ) + .expect("publish bundle"); + match response { + geth_control::ControlResponse::KeychainBundlePublished { + base_url, + allowed_signers_path, + sigchain_path: bundle_sigchain_path, + checkpoint_path, + checkpoint_signature_path, + checkpoint, + snapshots, + .. + } => { + assert_eq!(base_url, geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL); + assert!(allowed_signers_path.exists()); + assert!(bundle_sigchain_path.exists()); + assert!(checkpoint_path.exists()); + assert!(checkpoint_signature_path.exists()); + assert_eq!( + checkpoint.base_url, + geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL + ); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].name, "authorized_keys"); + assert!(snapshots[0].path.exists()); + assert!(snapshots[0].signature_path.exists()); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainVerifyCheckpoint { + checkpoint: bundle_dir.join("geth.sigchain.checkpoint.json"), + signature: bundle_dir.join("geth.sigchain.checkpoint.json.sig"), + sigchain: bundle_dir.join("geth.sigchain.jsonl"), + allowed_signers: bundle_dir.join("allowed_signers"), + base_url: Some(geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned()), + principal: None, + }, + ) + .expect("verify checkpoint"); + match response { + geth_control::ControlResponse::KeychainCheckpointVerified { + verified, + principal, + .. + } => { + assert!(verified); + assert_eq!(principal.as_deref(), Some("admin")); + } + other => panic!("unexpected response: {other:?}"), + } + + let fetched_home = tempfile::tempdir().expect("fetch tempdir"); + let fetched_paths = geth_config::GethPaths::from_home(fetched_home.path()); + let fetched_node = geth_node::init_node(&fetched_paths).expect("init fetched node"); + let response = geth_node::handle_request( + &fetched_node, + geth_control::ControlRequest::KeychainFetch { + url: format!("file://{}", bundle_dir.display()), + out: Some(fetched_home.path().join("bundle")), + import: true, + }, + ) + .expect("fetch bundle"); + match response { + geth_control::ControlResponse::KeychainFetched { + imported: Some(imported), + checkpoint, + .. + } => { + assert!(imported.ops_imported >= 3); + assert!(imported.signatures_imported >= 3); + assert_eq!(imported.invalid_ops_rejected, 0); + assert_eq!( + checkpoint.base_url, + geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL + ); + } + other => panic!("unexpected response: {other:?}"), + } + + let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain for explain"); + let decoded = + geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain for explain"); + let explain_op_id = decoded.last().expect("last sigchain op").op.id.to_string(); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainExplain { + op_id: explain_op_id.clone(), + }, + ) + .expect("explain op"); + match response { + geth_control::ControlResponse::KeychainExplained { subject, lines } => { + assert_eq!(subject, explain_op_id); + assert!(lines.iter().any(|line| line.contains("accepted_by_replay"))); + } + other => panic!("unexpected response: {other:?}"), + } + + let admin_public_key = + std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin pub"); + let admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainExplainSigner { key: admin_key }, + ) + .expect("explain signer"); + match response { + geth_control::ControlResponse::KeychainExplained { lines, .. } => { + assert!(lines.iter().any(|line| line == "active_admin_signer: true")); + assert!( + lines + .iter() + .any(|line| line.starts_with("signed_operations:")) + ); + } + other => panic!("unexpected response: {other:?}"), + } + + let import_home = tempfile::tempdir().expect("import tempdir"); + let import_paths = geth_config::GethPaths::from_home(import_home.path()); + let import_node = geth_node::init_node(&import_paths).expect("init import node"); + let response = geth_node::handle_request( + &import_node, + geth_control::ControlRequest::KeychainImportSigchain { + input: sigchain_path.clone(), + }, + ) + .expect("import sigchain file"); + match response { + geth_control::ControlResponse::KeychainSigchainImported { + ops_imported, + signatures_imported, + invalid_ops_rejected, + .. + } => { + assert!(ops_imported >= 3); + assert!(signatures_imported >= 3); + assert_eq!(invalid_ops_rejected, 0); + } + other => panic!("unexpected response: {other:?}"), + } + let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify) .expect("verify sigchain"); match response { diff --git a/docs/architecture.md b/docs/architecture.md index 3948dee..3d06244 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -356,12 +356,25 @@ pattern of verifying key-registry changes from a prior trusted state. The transport-neutral replay rules, application-specific signature namespaces, allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain` so other applications can reuse the same identity-log model without depending -on the daemon, SQLite, Iroh, or local control. `geth keychain sync ` pulls -keychain operations and signatures from an imported peer over Iroh and imports -only operations with a valid OpenSSH signature from a currently trusted admin -key over the canonical payload. See `docs/sigchain-keychain.md` for the -detailed sigchain design. This is currently a pull-based signed operation log, -not a CRDT or Keyhive-style convergent authority. +on the daemon, SQLite, Iroh, or local control. The CLI can export the same +reduced key registry as OpenSSH `allowed_signers` or as appendable JSONL +sigchain data for website publication. It can also sign and verify arbitrary +snapshots, such as externally managed `authorized_keys`, with an active +keychain signer under an explicit OpenSSH namespace. `geth keychain +publish-bundle` writes a website-ready bundle for +`https://example.com/.well-known/sshsigchain/`, including `allowed_signers`, +`geth.sigchain.jsonl`, a signed checkpoint, and optional signed snapshots. +`geth keychain fetch --import` verifies the checkpoint and records the last +accepted checkpoint per retrieval source URL to reject older bundles. The +retrieval source may be a local mirror; the checkpoint still carries the signed +advertised publication base URL, and explicit checkpoint verification can pin it. `geth keychain +explain` and `explain-signer` provide basic auditability for why a keychain +operation or signer is trusted. `geth keychain sync ` pulls keychain +operations and signatures from an imported peer over Iroh and imports only +operations with a valid OpenSSH signature from a currently trusted admin key +over the canonical payload. See `docs/sigchain-keychain.md` for the detailed +sigchain design. This is currently a pull-based signed operation log, not a +CRDT or Keyhive-style convergent authority. New devices can use the node enrollment flow instead of hand-editing keychain state. `geth node enroll request` creates a canonical, agent-key-signed request diff --git a/docs/roadmap.md b/docs/roadmap.md index fe4626a..d93dd1e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -325,6 +325,37 @@ resource-scoped capability decisions. signed admin-key registry operations. - `[x]` `geth keychain allowed-signers` exports the active admin key view in OpenSSH `allowed_signers` format. + - `[x]` `geth keychain allowed-signers --out ` writes the active + OpenSSH `allowed_signers` projection directly to a file. + - `[x]` `geth keychain sign-file --in --out ` signs arbitrary + snapshots, such as externally managed `authorized_keys`, with an active + admin key under an explicit namespace. + - `[x]` `geth keychain verify-file --in --signature ` verifies a + snapshot signature against the current keychain-derived `allowed_signers` + projection or a supplied `--allowed-signers` file. + - `[x]` `geth keychain sigchain --out ` writes the appendable JSONL + sigchain suitable for static website publication. + - `[x]` `geth keychain publish-bundle --out ` writes a static website + bundle rooted at `https://example.com/.well-known/sshsigchain/` by default. + - `[x]` Publication bundles include `allowed_signers`, `geth.sigchain.jsonl`, + `geth.sigchain.checkpoint.json`, and a detached checkpoint signature. + - `[x]` Publication bundles can copy and sign external snapshots with + `--snapshot =` without making the keychain own their contents. + - `[x]` `geth keychain verify-sigchain --in ` verifies a JSONL sigchain + file by replaying operations and signatures. + - `[x]` `geth keychain import-sigchain --in ` imports a JSONL sigchain + only if replay verification rejects no operations. + - `[x]` `geth keychain verify-checkpoint` verifies checkpoint signatures, + checkpoint hashes, base URL, and sigchain head consistency. + - `[x]` `geth keychain fetch --url --import` fetches static bundles + with `curl` for HTTP(S) or filesystem reads for local/file URLs. + - `[x]` Static fetch/import records the last accepted checkpoint per source + URL and rejects older checkpoints for rollback resistance. + - `[x]` `geth keychain explain ` and `explain-signer ` provide + basic audit output for operations and admin signers. + - `[x]` Agent/FIDO signing is supported through OpenSSH by passing a public + key or security-key stub to `--signing-key`; PKCS#11 is documented as an + ssh-agent-backed flow when loaded with `ssh-add -s `. - `[x]` `geth keychain verify` replays the keychain sigchain against the previously accepted admin-key view. - `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon diff --git a/docs/sigchain-keychain.md b/docs/sigchain-keychain.md index bec757c..0501b37 100644 --- a/docs/sigchain-keychain.md +++ b/docs/sigchain-keychain.md @@ -75,6 +75,20 @@ Signatures are produced with: ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f ``` +`` can be: + +- a local private OpenSSH key file, +- a FIDO/YubiKey OpenSSH security-key stub, +- a public key whose private half is already loaded in `ssh-agent`, or +- a public key backed by a PKCS#11 token loaded into `ssh-agent` with + `ssh-add -s `. + +Encrypted private key files should normally be unlocked into `ssh-agent` before +running geth control commands. The daemon never stores private keys or +passphrases. Direct PKCS#11 signing is intentionally not a first-class geth +backend because portable `ssh-keygen -Y sign` flows do not expose the same +provider flag as OpenSSH certificate signing. + Verification uses: ```sh @@ -168,10 +182,17 @@ and cacheable: The crate provides helpers to encode/decode JSONL and flatten entries back into `KeychainOp[]` plus `KeychainOpSignature[]`. -Future work should add explicit checkpoint records containing the accepted head -ID, byte offset, reduced view hash, and log hash. That would let static clients -resume verification without replaying the entire file while still detecting -rollback or truncation. +geth writes checkpoint records with `geth keychain publish-bundle`. The +checkpoint contains the accepted head, operation/signature counts, byte length, +BLAKE3 hashes for the sigchain and allowed signers projection, a reduced-view +hash, generation time, and the discovery base URL. The checkpoint is signed as +`geth.sigchain.checkpoint.json.sig` so static clients can detect rollback or +truncation before importing updates. `geth keychain fetch --import` stores the +last accepted checkpoint for a source URL and rejects older checkpoints from the +same source. The fetch URL is a retrieval location and may be a `file://` mirror +for testing; the checkpoint's signed `base_url` remains the advertised static +publication location. Use `geth keychain verify-checkpoint --base-url ` when +a consumer needs to pin that advertised value explicitly. ## Commands @@ -205,6 +226,47 @@ format: ```sh geth keychain allowed-signers > allowed_signers +geth keychain allowed-signers --out allowed_signers +geth keychain sign-file \ + --in authorized_keys \ + --out authorized_keys.sig \ + --signing-key ~/.ssh/id_ed25519_sk +geth keychain verify-file \ + --in authorized_keys \ + --signature authorized_keys.sig +geth keychain sigchain --out geth.sigchain.jsonl +geth keychain publish-bundle \ + --out public/.well-known/sshsigchain \ + --signing-key ~/.ssh/id_ed25519_sk \ + --snapshot authorized_keys=authorized_keys +geth keychain verify-checkpoint \ + --checkpoint geth.sigchain.checkpoint.json \ + --signature geth.sigchain.checkpoint.json.sig \ + --sigchain geth.sigchain.jsonl \ + --allowed-signers allowed_signers +geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import +geth keychain verify-sigchain --in geth.sigchain.jsonl +geth keychain import-sigchain --in geth.sigchain.jsonl +geth keychain explain +geth keychain explain-signer +``` + +The default static discovery base URL used by `publish-bundle` is +`https://example.com/.well-known/sshsigchain/`. The keychain does not manage +`authorized_keys` policy. It signs an arbitrary +snapshot you provide, which lets other projects keep their own SSH login policy +while rooting snapshot approval in the keychain. By default `sign-file` uses the +personal identity namespace `geth.authorized-keys.v1@eric.wendland.dev`; pass +`--namespace` for project-specific snapshots. The signer must be an active key +in the current `allowed_signers` projection, so website consumers can fetch the +snapshot, signature, and allowed signers and verify: + +```sh +ssh-keygen -Y verify \ + -f allowed_signers \ + -I admin \ + -n geth.authorized-keys.v1@eric.wendland.dev \ + -s authorized_keys.sig < authorized_keys ``` Replay and verify the local sigchain: @@ -221,7 +283,6 @@ locally initialized owner/admin key as the bootstrap trust anchor. The current geth prototype does not yet provide: -- signed checkpoint objects equivalent to `skm.last-verified-commit` - transparency-log style append proofs - anti-rollback protection beyond local state, HTTP cache validators, and sync conflict checks