diff --git a/README.md b/README.md index d8fa276..7df3468 100644 --- a/README.md +++ b/README.md @@ -634,41 +634,33 @@ geth keychain sign-file \ 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 ``` -The keychain follows a sigchain model documented in -`docs/sigchain-keychain.md`: each keychain operation is accepted only if it is -signed by an admin key from the previously accepted reduced view. This is the -geth analogue of verifying `git-skm` allowed-signers changes from a prior -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. 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. +The previous test-only static sigchain commands (`sigchain`, `publish-bundle`, +`import-sigchain`, `verify-checkpoint`, and `fetch`) were removed. Their +downloaded `allowed_signers` projection could establish the trust that +validated its own chain. There is no compatibility mode for that workflow. + +The replacement is the small, transport-neutral +[`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an +operator-pinned chain ID, OpenSSH root public key, profile, and namespace; +records are fixed-byte SSHSIG payloads linked by sequence and digest. Geth +already provides a verifier for independently produced JSONL transport files: + +```sh +geth keychain verify-sigchain \ + --in ./geth.sshsigchain.v1.jsonl \ + --chain-id <64-hex-character-chain-id> \ + --root-key ~/.ssh/geth-root.pub +``` + +SSHSIGCHAIN local record storage, signing, publication, import, and accepted-head +persistence remain follow-up work. Until they exist, do not substitute an +unpinned checkpoint or a 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 779023f..2034c60 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -1048,57 +1048,16 @@ pub enum KeychainCommand { #[arg(long)] principal: Option, }, - /// Export the canonical SSH signature chain - Sigchain { - #[arg(long)] - out: Option, - }, - /// Build a signed static-publication bundle - 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, - }, - /// Verify a signature chain without importing it + /// Verify a linked SSHSIGCHAIN JSONL file against an explicit root key VerifySigchain { #[arg(long = "in")] input: PathBuf, - }, - /// Verify and import a signature chain - ImportSigchain { - #[arg(long = "in")] - input: PathBuf, - }, - /// Verify a published checkpoint and its discovery metadata - VerifyCheckpoint { #[arg(long)] - checkpoint: PathBuf, + chain_id: String, #[arg(long)] - signature: PathBuf, - #[arg(long)] - sigchain: PathBuf, - #[arg(long)] - allowed_signers: PathBuf, - #[arg(long)] - base_url: Option, - #[arg(long)] - principal: Option, - }, - /// Fetch a published signature chain over HTTPS - Fetch { - #[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)] - url: String, - #[arg(long)] - out: Option, - #[arg(long)] - import: bool, + root_key: PathBuf, + #[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)] + namespace: String, }, /// Explain why one keychain operation was accepted or rejected Explain { op_id: String }, @@ -1785,6 +1744,8 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> { "signature" => Some("OpenSSH signature file path"), "allowed_signers" => Some("OpenSSH allowed_signers file used for verification"), "base_url" => Some("Publication base URL recorded in signed discovery metadata"), + "chain_id" => Some("Pinned 32-byte SSHSIGCHAIN chain ID as 64 hexadecimal characters"), + "root_key" => Some("Pinned OpenSSH root public-key file for SSHSIGCHAIN verification"), "snapshots" => Some("Named snapshot mapping NAME=PATH; repeatable"), "checkpoint" => Some("Signed publication checkpoint file"), "sigchain" => Some("Canonical keychain signature-chain JSONL file"), @@ -2671,52 +2632,20 @@ fn request_for_command(command: Command) -> Result { 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, + KeychainCommand::VerifySigchain { + input, + chain_id, + root_key, + namespace, }, - } => ControlRequest::KeychainPublishBundle { - out, - base_url: Some(base_url), - signing_key_path: signing_key, - admin_key_path: admin_key, - snapshots, + } => ControlRequest::KeychainVerifySigchain { + input, + chain_id, + root_key_path: root_key, + namespace: Some(namespace), }, - 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 }, @@ -4175,109 +4104,25 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> { 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 { + ControlResponse::KeychainSigchainVerified { input, - report, + chain_id, + records, + head, + active_admin_keys, + users, + devices, + nodes, 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(|h| h.as_str()) - .unwrap_or("none") - ); - 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); - } + println!("chain_id: {chain_id}"); + println!("records: {records}"); + println!("head: {head}"); + println!("active_admin_keys: {active_admin_keys}"); + println!("users: {users}"); + println!("devices: {devices}"); + println!("nodes: {nodes}"); eprintln!("note: {note}"); } ControlResponse::KeychainExplained { subject, lines } => { @@ -5473,6 +5318,41 @@ mod tests { assert!(Cli::try_parse_from(["geth", "daemon", "logs", "--lines", "0"]).is_err()); } + #[test] + fn sshsigchain_exposes_one_pinned_verifier_not_static_compatibility_commands() { + let parsed = Cli::try_parse_from([ + "geth", + "keychain", + "verify-sigchain", + "--in", + "chain.jsonl", + "--chain-id", + "00", + "--root-key", + "root.pub", + ]) + .expect("parse SSHSIGCHAIN verifier"); + assert!(matches!( + parsed.command, + Command::Keychain { + command: KeychainCommand::VerifySigchain { .. } + } + )); + for removed in [ + "sigchain", + "publish-bundle", + "import-sigchain", + "verify-checkpoint", + "fetch", + "verify-sigchain-v1", + ] { + assert!( + Cli::try_parse_from(["geth", "keychain", removed]).is_err(), + "removed static command {removed} must not parse" + ); + } + } + #[test] fn service_install_copies_transient_binaries_unless_explicitly_overridden() { assert!( diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 67936e0..c05b239 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, KeychainCheckpoint, KeychainOp, KeychainOpSignature, - KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord, + KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport, + NodeEnrollmentRequest, NodeRecord, }; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_overlay::{ @@ -266,34 +266,11 @@ pub enum ControlRequest { 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, + chain_id: String, + root_key_path: PathBuf, + namespace: Option, }, KeychainExplain { op_id: String, @@ -764,46 +741,15 @@ pub enum ControlResponse { 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 { + KeychainSigchainVerified { 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, + chain_id: String, + records: usize, + head: String, + active_admin_keys: usize, + users: usize, + devices: usize, + nodes: usize, note: String, }, KeychainExplained { @@ -1151,22 +1097,6 @@ 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, @@ -1834,8 +1764,8 @@ mod tests { use super::*; use serde_json::{Map, Value, json}; - const CONTROL_REQUEST_VARIANTS: usize = 123; - const CONTROL_RESPONSE_VARIANTS: usize = 115; + const CONTROL_REQUEST_VARIANTS: usize = 118; + const CONTROL_RESPONSE_VARIANTS: usize = 110; const PEER_CONTROL_REQUEST_VARIANTS: usize = 19; const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20; const PIPE_WIRE_REQUEST_VARIANTS: usize = 3; @@ -2126,11 +2056,6 @@ mod tests { "devices": 1, "nodes": 1 }), - "KeychainFetchImportReport" => json!({ - "ops_imported": 1, - "signatures_imported": 1, - "invalid_ops_rejected": 0 - }), "ResourceDescriptor" => json!({ "id": "resource:sample", "kind": "kv", @@ -2269,27 +2194,6 @@ mod tests { "accepted_head": null, "note": "sample" }), - "KeychainSigchainEntry" => json!({ - "op": sample_for_type("KeychainOp"), - "signatures": [] - }), - "KeychainCheckpoint" => json!({ - "version": 1, - "profile": { - "keychain_signature_namespace": "geth.keychain.v1@geth.local", - "node_enrollment_request_namespace": "geth.node-enrollment-request.v1@geth.local", - "default_admin_principal": "geth-admin" - }, - "base_url": "https://example.invalid", - "head": null, - "ops": 1, - "signatures": 1, - "sigchain_bytes": 1, - "sigchain_hash": "hash", - "allowed_signers_hash": "hash", - "reduced_view_hash": "hash", - "generated_at": 1 - }), "AuthExplanation" => json!({ "subject": "node:peer", "resource": "resource:sample", diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 4d67c6f..0cf5c3b 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -5,25 +5,23 @@ //! to update them. It intentionally has no dependency on the daemon, SQLite, //! Iroh, local sockets, or any particular publication mechanism. //! -//! Applications can publish `KeychainSigchainEntry` values in an append-only -//! JSONL file, object store, database row stream, document CRDT, or another -//! transport. Consumers decode entries, flatten them into operations and -//! signatures, then call `verify_sigchain_with_profile` with an application -//! profile and a `KeychainSignatureVerifier` implementation. -//! -//! The default `KeychainProfile` is geth-specific. Other applications should -//! create their own profile with `KeychainProfile::for_application` or -//! `KeychainProfile::new` so signed payload namespaces do not overlap. +//! The current local operation-log verifier remains local to geth's daemon +//! state. Portable SSHSIGCHAIN records are implemented separately in +//! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and +//! a causal hash chain rather than a timestamp-sorted JSONL bundle. mod sshsigchain; pub use sshsigchain::{ - ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION, - KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification, - SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy, + ChainId as SshSigchainChainId, Digest as SshSigchainDigest, + KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, KEYCHAIN_SSH_SIGCHAIN_PROFILE, + KeychainSshSigchainPolicy, KeychainSshSigchainState, KeychainSshSigchainVerification, + MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, SSH_SIGCHAIN_NAMESPACE, + SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy, SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, - decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record, - verify_keychain_v2_sigchain, verify_sshsigchain, + decode_keychain_sshsigchain_payload, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, + keychain_sshsigchain_payload, keychain_sshsigchain_unsigned_record, + verify_keychain_sshsigchain, verify_sshsigchain, }; use geth_types::{ @@ -35,10 +33,7 @@ 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; @@ -203,27 +198,6 @@ pub struct KeychainSigchainReport { pub note: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KeychainSigchainEntry { - pub op: KeychainOp, - 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; } @@ -331,11 +305,6 @@ pub enum KeychainError { InvalidPrincipal(String), #[error("codec error: {0}")] Codec(#[from] geth_codec::CodecError), - #[error("sigchain JSONL line {line}: {source}")] - SigchainJsonl { - line: usize, - source: serde_json::Error, - }, } #[must_use] @@ -739,100 +708,6 @@ pub fn admin_key_fingerprint(public_key: &str) -> String { format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes())) } -#[must_use] -pub fn sigchain_entries( - ops: &[KeychainOp], - signatures: &[KeychainOpSignature], -) -> Vec { - sorted_keychain_ops(ops.to_vec()) - .into_iter() - .map(|op| KeychainSigchainEntry { - signatures: signatures - .iter() - .filter(|signature| signature.op_id == op.id) - .cloned() - .collect(), - op, - }) - .collect() -} - -pub fn encode_sigchain_jsonl( - entries: &[KeychainSigchainEntry], -) -> Result { - let mut text = String::new(); - for entry in entries { - text.push_str(&serde_json::to_string(entry)?); - text.push('\n'); - } - Ok(text) -} - -pub fn decode_sigchain_jsonl(text: &str) -> Result, KeychainError> { - text.lines() - .enumerate() - .filter(|(_, line)| !line.trim().is_empty()) - .map(|(index, line)| { - serde_json::from_str::(line).map_err(|source| { - KeychainError::SigchainJsonl { - line: index + 1, - source, - } - }) - }) - .collect() -} - -#[must_use] -pub fn flatten_sigchain_entries( - entries: &[KeychainSigchainEntry], -) -> (Vec, Vec) { - let ops = entries.iter().map(|entry| entry.op.clone()).collect(); - let signatures = entries - .iter() - .flat_map(|entry| entry.signatures.clone()) - .collect(); - (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 @@ -1118,90 +993,6 @@ mod tests { assert!(!view.endpoints.contains_key("endpoint:old")); } - #[test] - fn allowed_signers_and_sigchain_jsonl_are_portable() { - 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-a".to_owned()), - valid_after_ms: None, - valid_before_ms: None, - }, - ), - ]; - let signatures = vec![KeychainOpSignature { - op_id: ops[1].id.clone(), - signer: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(), - signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), - namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), - signature: vec![1], - created_at: UnixMillis(2), - }]; - let allowed = allowed_signers(&ops, &signatures); - assert_eq!(allowed.len(), 1); - assert!(render_allowed_signers(&allowed).contains("admin-a ssh-ed25519")); - - let entries = sigchain_entries(&ops, &signatures); - let jsonl = encode_sigchain_jsonl(&entries).expect("encode jsonl"); - assert_eq!(jsonl.lines().count(), 2); - let decoded = decode_sigchain_jsonl(&jsonl).expect("decode jsonl"); - assert_eq!(decoded, entries); - let (decoded_ops, decoded_signatures) = flatten_sigchain_entries(&decoded); - assert_eq!(decoded_ops, sorted_keychain_ops(ops)); - 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-keychain/src/sshsigchain.rs b/crates/geth-keychain/src/sshsigchain.rs index 176ed63..1648005 100644 --- a/crates/geth-keychain/src/sshsigchain.rs +++ b/crates/geth-keychain/src/sshsigchain.rs @@ -1,30 +1,34 @@ -//! Reference implementation of the small, generic SSHSIGCHAIN v2 core. +//! Reference implementation of the small, generic SSHSIGCHAIN v1 core. //! //! The core deliberately knows nothing about geth's resource or identity //! model. It verifies one linear, linked sequence against an explicitly //! configured root key, then delegates authorization and state transitions to -//! an application profile. `KeychainV2Policy` below is geth's first profile. -//! See `docs/sshsigchain-v2.md` for the interoperable format. +//! an application profile. `KeychainSshSigchainPolicy` below is geth's first profile. +//! See `docs/sshsigchain.md` for the interoperable format. use base64::{Engine as _, engine::general_purpose}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::{KeychainOp, KeychainOpKind, KeychainView, admin_key_fingerprint, reduce_keychain_ops}; use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId}; -pub const SSH_SIGCHAIN_VERSION: u8 = 2; -pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v2"; -pub const KEYCHAIN_V2_PROFILE: &str = "geth.keychain.v2"; -pub const KEYCHAIN_V2_PAYLOAD_VERSION: u16 = 1; +pub const SSH_SIGCHAIN_VERSION: u8 = 1; +pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1"; +pub const SSH_SIGCHAIN_VERIFIER_PRINCIPAL: &str = "sshsigchain"; +pub const KEYCHAIN_SSH_SIGCHAIN_PROFILE: &str = "geth.keychain.sshsigchain.v1"; +pub const KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION: u16 = 1; pub const MAX_PROFILE_BYTES: usize = 128; +pub const MAX_NAMESPACE_BYTES: usize = 128; pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024; pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024; pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024; pub const MAX_RECORDS: usize = 100_000; +pub const MAX_JSONL_LINE_BYTES: usize = 5 * 1024 * 1024; +pub const MAX_JSONL_BYTES: usize = 64 * 1024 * 1024; const SIGNING_MAGIC: &[u8] = b"SSCS"; -const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v2\0"; +const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v1\0"; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ChainId(pub [u8; 32]); @@ -84,6 +88,7 @@ impl SshSigchainTrust { /// A JSON-serializable transport envelope. Its JSON representation is not /// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SshSigchainRecord { pub chain_id: ChainId, pub profile: String, @@ -94,6 +99,46 @@ pub struct SshSigchainRecord { pub signature: Vec, } +pub fn encode_sshsigchain_jsonl(records: &[SshSigchainRecord]) -> Result { + let mut output = String::new(); + for record in records { + record.validate(true)?; + output.push_str( + &serde_json::to_string(record) + .map_err(|error| SshSigchainError::JsonEncoding(error.to_string()))?, + ); + output.push('\n'); + } + Ok(output) +} + +pub fn decode_sshsigchain_jsonl(input: &str) -> Result, SshSigchainError> { + if input.len() > MAX_JSONL_BYTES { + return Err(SshSigchainError::JsonlTooLarge(input.len())); + } + let mut records = Vec::new(); + for (index, line) in input.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + if line.len() > MAX_JSONL_LINE_BYTES { + return Err(SshSigchainError::JsonLineTooLarge { + line: index + 1, + bytes: line.len(), + }); + } + let record = serde_json::from_str(line).map_err(|error| SshSigchainError::JsonLine { + line: index + 1, + detail: error.to_string(), + })?; + records.push(record); + if records.len() > MAX_RECORDS { + return Err(SshSigchainError::TooManyRecords(records.len())); + } + } + Ok(records) +} + impl SshSigchainRecord { pub fn unsigned( chain_id: ChainId, @@ -276,96 +321,98 @@ where } #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct KeychainV2Policy; +pub struct KeychainSshSigchainPolicy; #[derive(Clone, Debug, PartialEq, Eq)] -pub struct KeychainV2State { +pub struct KeychainSshSigchainState { initialized: bool, admin_public_keys: BTreeMap, + seen_op_ids: BTreeSet, ops: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct KeychainV2Verification { +pub struct KeychainSshSigchainVerification { pub view: KeychainView, pub records: usize, pub head: Digest, } -pub fn keychain_v2_payload(op: &KeychainOp) -> Result, SshSigchainError> { - geth_codec::encode_canonical(&KeychainV2Payload { - version: KEYCHAIN_V2_PAYLOAD_VERSION, +pub fn keychain_sshsigchain_payload(op: &KeychainOp) -> Result, SshSigchainError> { + geth_codec::encode_canonical(&KeychainSshSigchainPayload { + version: KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, op: CanonicalKeychainOp::from(op), }) .map_err(|error| SshSigchainError::PayloadEncoding(error.to_string())) } -pub fn decode_keychain_v2_payload(bytes: &[u8]) -> Result { - let decoded = geth_codec::decode_canonical::(bytes) +pub fn decode_keychain_sshsigchain_payload(bytes: &[u8]) -> Result { + let decoded = geth_codec::decode_canonical::(bytes) .map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?; - if decoded.version != KEYCHAIN_V2_PAYLOAD_VERSION { + if decoded.version != KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION { return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version)); } let op = KeychainOp::from(decoded.op); - let canonical = keychain_v2_payload(&op)?; + let canonical = keychain_sshsigchain_payload(&op)?; if canonical != bytes { return Err(SshSigchainError::NonCanonicalPayload); } Ok(op) } -pub fn keychain_v2_unsigned_record( +pub fn keychain_sshsigchain_unsigned_record( trust: &SshSigchainTrust, sequence: u64, previous: Option, op: &KeychainOp, signer_public_key: impl AsRef, ) -> Result { - if trust.profile != KEYCHAIN_V2_PROFILE { + if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE { return Err(SshSigchainError::WrongKeychainProfile( trust.profile.clone(), )); } SshSigchainRecord::unsigned( trust.chain_id, - KEYCHAIN_V2_PROFILE, + KEYCHAIN_SSH_SIGCHAIN_PROFILE, sequence, previous, - keychain_v2_payload(op)?, + keychain_sshsigchain_payload(op)?, signer_public_key, ) } -pub fn verify_keychain_v2_sigchain( +pub fn verify_keychain_sshsigchain( records: &[SshSigchainRecord], trust: &SshSigchainTrust, verifier: &V, -) -> Result +) -> Result where V: SshSigchainVerifier + ?Sized, { - if trust.profile != KEYCHAIN_V2_PROFILE { + if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE { return Err(SshSigchainError::WrongKeychainProfile( trust.profile.clone(), )); } - let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?; - Ok(KeychainV2Verification { + let verified = verify_sshsigchain(records, trust, verifier, &KeychainSshSigchainPolicy)?; + Ok(KeychainSshSigchainVerification { view: reduce_keychain_ops(&verified.state.ops), records: verified.records, head: verified.head, }) } -impl SshSigchainPolicy for KeychainV2Policy { - type State = KeychainV2State; +impl SshSigchainPolicy for KeychainSshSigchainPolicy { + type State = KeychainSshSigchainState; fn initial_state(&self, trust: &SshSigchainTrust) -> Result { let root = canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?; - Ok(KeychainV2State { + Ok(KeychainSshSigchainState { initialized: false, admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]), + seen_op_ids: BTreeSet::new(), ops: Vec::new(), }) } @@ -383,7 +430,11 @@ impl SshSigchainPolicy for KeychainV2Policy { } fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String> { - let op = decode_keychain_v2_payload(&record.payload).map_err(|error| error.to_string())?; + let op = decode_keychain_sshsigchain_payload(&record.payload) + .map_err(|error| error.to_string())?; + if !state.seen_op_ids.insert(op.id.clone()) { + return Err(format!("duplicate keychain operation ID: {}", op.id)); + } match &op.kind { KeychainOpKind::KeychainInit => { if state.initialized || record.sequence != 0 { @@ -403,30 +454,48 @@ impl SshSigchainPolicy for KeychainV2Policy { } if valid_after_ms.is_some() || valid_before_ms.is_some() { return Err( - "v2 does not accept key validity windows as security policy; use a causally ordered revocation record" + "SSHSIGCHAIN does not accept key validity windows as security policy; use a causally ordered revocation record" .to_owned(), ); } let public_key = public_key.as_deref().ok_or_else(|| { - "AdminKeyAdd requires its canonical public key in v2".to_owned() + "AdminKeyAdd requires its canonical public key in SSHSIGCHAIN".to_owned() })?; let public_key = canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?; if KeyId::new(admin_key_fingerprint(&public_key)) != *key { return Err("AdminKeyAdd key ID does not match its public key".to_owned()); } + if record.sequence == 1 && public_key != record.signer_public_key { + return Err( + "sequence 1 must record the configured root signer as an admin key" + .to_owned(), + ); + } state.admin_public_keys.insert(key.clone(), public_key); } KeychainOpKind::AdminKeyRevoke { key } => { if !state.initialized { return Err("keychain must start with KeychainInit".to_owned()); } + if record.sequence == 1 { + return Err( + "sequence 1 must record the configured root signer as an admin key" + .to_owned(), + ); + } state.admin_public_keys.remove(key); } _ => { if !state.initialized { return Err("keychain must start with KeychainInit".to_owned()); } + if record.sequence == 1 { + return Err( + "sequence 1 must record the configured root signer as an admin key" + .to_owned(), + ); + } } } state.ops.push(op); @@ -435,13 +504,13 @@ impl SshSigchainPolicy for KeychainV2Policy { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct KeychainV2Payload { +struct KeychainSshSigchainPayload { version: u16, op: CanonicalKeychainOp, } /// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard -/// deliberately cannot deserialize that representation, so v2 uses this +/// deliberately cannot deserialize that representation, so SSHSIGCHAIN uses this /// profile-local externally tagged mirror for its signed binary payload. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct CanonicalKeychainOp { @@ -654,7 +723,9 @@ pub enum SshSigchainError { InvalidChainId, #[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")] InvalidProfile, - #[error("SSH sigchain namespace must be non-empty printable ASCII without whitespace")] + #[error( + "SSH sigchain namespace must be non-empty printable ASCII without whitespace and at most {MAX_NAMESPACE_BYTES} bytes" + )] InvalidNamespace, #[error("SSH public key must be a canonical two-field OpenSSH public key")] InvalidPublicKey, @@ -668,6 +739,16 @@ pub enum SshSigchainError { MissingSignature, #[error("SSH sigchain cannot be empty")] EmptyChain, + #[error("SSH sigchain JSONL line {line}: {detail}")] + JsonLine { line: usize, detail: String }, + #[error("failed to encode SSH sigchain JSONL: {0}")] + JsonEncoding(String), + #[error("SSH sigchain JSONL input is {0} bytes, over the {MAX_JSONL_BYTES}-byte limit")] + JsonlTooLarge(usize), + #[error( + "SSH sigchain JSONL line {line} is {bytes} bytes, over the {MAX_JSONL_LINE_BYTES}-byte limit" + )] + JsonLineTooLarge { line: usize, bytes: usize }, #[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")] TooManyRecords(usize), #[error("SSH sigchain sequence counter overflow")] @@ -690,15 +771,15 @@ pub enum SshSigchainError { InvalidSignature { index: usize }, #[error("SSH sigchain policy rejected record: {0}")] Policy(String), - #[error("failed to encode keychain v2 payload: {0}")] + #[error("failed to encode keychain SSHSIGCHAIN payload: {0}")] PayloadEncoding(String), - #[error("failed to decode keychain v2 payload: {0}")] + #[error("failed to decode keychain SSHSIGCHAIN payload: {0}")] PayloadDecoding(String), - #[error("unsupported keychain v2 payload version {0}")] + #[error("unsupported keychain SSHSIGCHAIN payload version {0}")] UnsupportedPayloadVersion(u16), - #[error("keychain v2 payload is not its unique canonical encoding")] + #[error("keychain SSHSIGCHAIN payload is not its unique canonical encoding")] NonCanonicalPayload, - #[error("expected geth keychain v2 profile, got {0}")] + #[error("expected geth keychain SSHSIGCHAIN profile, got {0}")] WrongKeychainProfile(String), #[error("length does not fit the SSH sigchain wire format")] LengthOverflow, @@ -732,7 +813,7 @@ fn validate_profile(value: String) -> Result { fn validate_namespace(value: String) -> Result { if value.is_empty() - || value.len() > MAX_PROFILE_BYTES + || value.len() > MAX_NAMESPACE_BYTES || !value.bytes().all(|byte| byte.is_ascii_graphic()) { return Err(SshSigchainError::InvalidNamespace); @@ -804,7 +885,7 @@ mod tests { fn trust() -> SshSigchainTrust { SshSigchainTrust::new( ChainId([7; 32]), - KEYCHAIN_V2_PROFILE, + KEYCHAIN_SSH_SIGCHAIN_PROFILE, SSH_SIGCHAIN_NAMESPACE, ROOT_KEY, ) @@ -826,7 +907,7 @@ mod tests { op: &KeychainOp, signer: &str, ) -> SshSigchainRecord { - let record = keychain_v2_unsigned_record(trust, sequence, previous, op, signer) + let record = keychain_sshsigchain_unsigned_record(trust, sequence, previous, op, signer) .expect("unsigned record"); let signature = test_signature( &trust.namespace, @@ -851,7 +932,7 @@ mod tests { } #[test] - fn v2_accepts_a_linked_rooted_keychain() { + fn accepts_a_linked_rooted_keychain() { let trust = trust(); let init = signed_record( &trust, @@ -882,7 +963,7 @@ mod tests { ROOT_KEY, ); - let verified = verify_keychain_v2_sigchain(&[init, add, user], &trust, &TestVerifier) + let verified = verify_keychain_sshsigchain(&[init, add, user], &trust, &TestVerifier) .expect("valid chain"); assert_eq!(verified.records, 3); assert_eq!( @@ -893,7 +974,7 @@ mod tests { } #[test] - fn v2_requires_an_explicit_root_instead_of_self_bootstrap() { + fn requires_an_explicit_root_instead_of_self_bootstrap() { let trust = trust(); let init = signed_record( &trust, @@ -903,13 +984,13 @@ mod tests { SECOND_KEY, ); assert!(matches!( - verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier), + verify_keychain_sshsigchain(&[init], &trust, &TestVerifier), Err(SshSigchainError::RootSignerMismatch) )); } #[test] - fn v2_rejects_a_non_linked_fork() { + fn rejects_a_non_linked_fork() { let trust = trust(); let init = signed_record( &trust, @@ -920,13 +1001,94 @@ mod tests { ); let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY); assert!(matches!( - verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier), + verify_keychain_sshsigchain(&[init, add], &trust, &TestVerifier), Err(SshSigchainError::UnexpectedPrevious { index: 1 }) )); } #[test] - fn v2_revocation_is_causal_not_timestamp_ordered() { + fn requires_the_root_to_be_recorded_before_other_identity_changes() { + let trust = trust(); + let init = signed_record( + &trust, + 0, + None, + &op("op:init", 1, KeychainOpKind::KeychainInit), + ROOT_KEY, + ); + let user = signed_record( + &trust, + 1, + Some(init.record_hash().expect("hash")), + &op( + "op:user", + 2, + KeychainOpKind::UserAdd { + user: "user:alice".into(), + name: "Alice".to_owned(), + }, + ), + ROOT_KEY, + ); + assert!(matches!( + verify_keychain_sshsigchain(&[init, user], &trust, &TestVerifier), + Err(SshSigchainError::Policy(_)) + )); + } + + #[test] + fn rejects_duplicate_keychain_operation_ids() { + let trust = trust(); + let init = signed_record( + &trust, + 0, + None, + &op("op:init", 1, KeychainOpKind::KeychainInit), + ROOT_KEY, + ); + let add = signed_record( + &trust, + 1, + Some(init.record_hash().expect("hash")), + &root_add_op(), + ROOT_KEY, + ); + let user = signed_record( + &trust, + 2, + Some(add.record_hash().expect("hash")), + &op( + "op:user", + 3, + KeychainOpKind::UserAdd { + user: "user:alice".into(), + name: "Alice".to_owned(), + }, + ), + ROOT_KEY, + ); + let duplicate = signed_record( + &trust, + 3, + Some(user.record_hash().expect("hash")), + &op( + "op:user", + 4, + KeychainOpKind::UserRename { + user: "user:alice".into(), + name: "Mallory".to_owned(), + }, + ), + ROOT_KEY, + ); + assert!(matches!( + verify_keychain_sshsigchain(&[init, add, user, duplicate], &trust, &TestVerifier), + Err(SshSigchainError::Policy(_)) + )); + } + + #[test] + fn revocation_is_causal_not_timestamp_ordered() { let trust = trust(); let init = signed_record( &trust, @@ -972,20 +1134,80 @@ mod tests { ROOT_KEY, ); assert!(matches!( - verify_keychain_v2_sigchain(&[init, add, revoke, forged], &trust, &TestVerifier), + verify_keychain_sshsigchain(&[init, add, revoke, forged], &trust, &TestVerifier), Err(SshSigchainError::Policy(_)) )); } #[test] - fn v2_payload_rejects_trailing_or_noncanonical_bytes() { - let payload = - keychain_v2_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)).expect("payload"); + fn payload_rejects_trailing_or_noncanonical_bytes() { + let payload = keychain_sshsigchain_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)) + .expect("payload"); let mut noncanonical = payload; noncanonical.push(0); assert!(matches!( - decode_keychain_v2_payload(&noncanonical), + decode_keychain_sshsigchain_payload(&noncanonical), Err(SshSigchainError::NonCanonicalPayload) )); } + + #[test] + fn jsonl_transport_roundtrips_without_becoming_signed_data() { + let trust = trust(); + let record = signed_record( + &trust, + 0, + None, + &op("op:init", 1, KeychainOpKind::KeychainInit), + ROOT_KEY, + ); + let jsonl = encode_sshsigchain_jsonl(std::slice::from_ref(&record)).expect("encode JSONL"); + assert_eq!( + decode_sshsigchain_jsonl(&jsonl).expect("decode JSONL"), + vec![record] + ); + } + + #[test] + fn jsonl_transport_rejects_unknown_record_fields() { + let trust = trust(); + let record = signed_record( + &trust, + 0, + None, + &op("op:init", 1, KeychainOpKind::KeychainInit), + ROOT_KEY, + ); + let mut value = serde_json::to_value(record).expect("record JSON"); + value + .as_object_mut() + .expect("record object") + .insert("unrecognized".to_owned(), serde_json::Value::Bool(true)); + assert!(matches!( + decode_sshsigchain_jsonl(&format!("{value}\n")), + Err(SshSigchainError::JsonLine { line: 1, .. }) + )); + } + + #[test] + fn signing_bytes_match_the_published_base_vector() { + let record = SshSigchainRecord::unsigned( + ChainId([0; 32]), + "example.test", + 0, + None, + vec![1, 2], + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2", + ) + .expect("unsigned record"); + assert_eq!( + hex::encode(record.signing_bytes().expect("signing bytes")), + concat!( + "5353435301", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000", + "00000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332" + ) + ); + } } diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 54a0b54..24d1966 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -12,6 +12,7 @@ bytes.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +tempfile.workspace = true tokio.workspace = true tracing.workspace = true geth-auth = { path = "../geth-auth" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index c43917f..9914bc8 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -80,6 +80,7 @@ use runtime::{ PubsubRuntime, }; use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -117,6 +118,8 @@ pub enum NodeError { Codec(#[from] geth_codec::CodecError), #[error("keychain error: {0}")] Keychain(#[from] geth_keychain::KeychainError), + #[error("SSH sigchain error: {0}")] + SshSigchain(#[from] geth_keychain::SshSigchainError), #[error("json error: {0}")] Json(#[from] serde_json::Error), #[error("io error: {0}")] @@ -137,8 +140,6 @@ 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}")] @@ -6495,95 +6496,34 @@ pub fn handle_request( 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, + ControlRequest::KeychainVerifySigchain { + input, + chain_id, + root_key_path, + namespace, } => { - let (checkpoint, verified, principal) = verify_keychain_checkpoint_files( - node, - &checkpoint, - &signature, - &sigchain, - &allowed_signers, - base_url.as_deref(), - principal.as_deref(), + let records = read_sshsigchain_jsonl_file(&input)?; + let trust = geth_keychain::SshSigchainTrust::new( + geth_keychain::SshSigchainChainId::from_hex(&chain_id)?, + geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE, + namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()), + std::fs::read_to_string(root_key_path)?, )?; - Ok(ControlResponse::KeychainCheckpointVerified { - checkpoint, - verified, - principal, - note: "verified checkpoint signature, hashes, base URL, and sigchain head" - .to_owned(), + let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?; + Ok(ControlResponse::KeychainSigchainVerified { + input, + chain_id: trust.chain_id.to_hex(), + records: verified.records, + head: verified.head.to_hex(), + active_admin_keys: verified.view.admin_keys.len(), + users: verified.view.users.len(), + devices: verified.view.devices.len(), + nodes: verified.view.nodes.len(), + note: + "verified linked SSHSIGCHAIN v1 records against the explicitly pinned root key" + .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)?, @@ -9483,301 +9423,6 @@ fn stored_keychain_op_from_op(op: &KeychainOp) -> Result, - 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.clone(), op)) - .collect::>(); - let existing_signatures = load_keychain_signatures(store)? - .into_iter() - .map(|signature| { - ( - ( - signature.op_id.clone(), - signature.signer.clone(), - signature.namespace.clone(), - ), - signature, - ) - }) - .collect::>(); - for op in ops { - if existing_ops - .get(&op.id) - .is_some_and(|existing| existing != op) - { - return Err(NodeError::Unauthorized(format!( - "refusing keychain operation {} because that ID already names different immutable content", - op.id - ))); - } - } - for signature in signatures { - let identity = ( - signature.op_id.clone(), - signature.signer.clone(), - signature.namespace.clone(), - ); - if existing_signatures - .get(&identity) - .is_some_and(|existing| existing != signature) - { - return Err(NodeError::Unauthorized(format!( - "refusing keychain signature for {} because its signer and namespace already name different immutable content", - signature.op_id - ))); - } - } - let mut signatures_imported = 0; - let mut ops_imported = 0; - for op in ops { - let op_signatures = signatures - .iter() - .filter(|signature| signature.op_id == op.id) - .map(stored_keychain_signature_from_signature) - .collect::>(); - store - .insert_keychain_op_with_signatures(&stored_keychain_op_from_op(op)?, &op_signatures)?; - if !existing_ops.contains_key(&op.id) { - ops_imported += 1; - } - for signature in signatures - .iter() - .filter(|signature| signature.op_id == op.id) - { - let key = ( - signature.op_id.clone(), - signature.signer.clone(), - signature.namespace.clone(), - ); - signatures_imported += usize::from(!existing_signatures.contains_key(&key)); - } - } - 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, @@ -9869,93 +9514,6 @@ fn explain_keychain_signer(store: &Store, key: &str) -> Result, Node 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], @@ -9984,34 +9542,80 @@ fn verify_keychain_sigchain_entries_with_ssh( 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())); +fn verify_keychain_sshsigchain_with_ssh( + records: &[geth_keychain::SshSigchainRecord], + trust: &geth_keychain::SshSigchainTrust, +) -> Result { + geth_ssh_identity::ensure_ssh_keygen_available()?; + let verify_dir = tempfile::tempdir()?; + + struct SshSigchainOpenSshVerifier { + verify_dir: PathBuf, + } + + impl geth_keychain::SshSigchainVerifier for SshSigchainOpenSshVerifier { + fn verify( + &self, + namespace: &str, + message: &[u8], + public_key: &str, + signature: &[u8], + ) -> bool { + let payload_path = self.verify_dir.join("payload"); + let signature_path = self.verify_dir.join("signature"); + let allowed_signers_path = self.verify_dir.join("allowed-signers"); + if std::fs::write(&payload_path, message).is_err() + || std::fs::write(&signature_path, signature).is_err() + || std::fs::write( + &allowed_signers_path, + format!( + "{} {}\n", + geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL, + public_key + ), + ) + .is_err() + { + return false; + } + let Ok(payload) = std::fs::File::open(payload_path) else { + return false; + }; + std::process::Command::new("ssh-keygen") + .arg("-Y") + .arg("verify") + .arg("-f") + .arg(allowed_signers_path) + .arg("-I") + .arg(geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL) + .arg("-n") + .arg(namespace) + .arg("-s") + .arg(signature_path) + .stdin(std::process::Stdio::from(payload)) + .output() + .is_ok_and(|output| output.status.success()) + } + } + + let verifier = SshSigchainOpenSshVerifier { + verify_dir: verify_dir.path().to_path_buf(), }; - 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)) + Ok(geth_keychain::verify_keychain_sshsigchain( + records, trust, &verifier, + )?) } -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 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()); } -} - -fn snapshot_namespace(name: &str) -> String { - format!("geth.snapshot.{name}.v1@eric.wendland.dev") + Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?) } fn verify_keychain_sigchain_with_ssh( @@ -11390,6 +10994,12 @@ mod tests { ))), "daemon_already_running" ); + assert_eq!( + local_control::node_error_code(&NodeError::SshSigchain( + geth_keychain::SshSigchainError::EmptyChain + )), + "sshsigchain_error" + ); } #[test] @@ -11407,6 +11017,108 @@ mod tests { ); } + #[test] + fn sshsigchain_verifies_real_openssh_sshsig_records() { + if geth_ssh_identity::ensure_ssh_keygen_available().is_err() { + return; + } + let dir = tempfile::tempdir().expect("temporary SSHSIGCHAIN directory"); + let private_key = dir.path().join("root"); + let generated = std::process::Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f"]) + .arg(&private_key) + .output() + .expect("run ssh-keygen"); + assert!( + generated.status.success(), + "ssh-keygen key generation failed: {}", + String::from_utf8_lossy(&generated.stderr) + ); + let generated_public_key = + std::fs::read_to_string(format!("{}.pub", private_key.display())) + .expect("read public key"); + let mut fields = generated_public_key.split_ascii_whitespace(); + let root_public_key = format!( + "{} {}", + fields.next().expect("key type"), + fields.next().expect("key blob") + ); + let trust = geth_keychain::SshSigchainTrust::new( + geth_keychain::SshSigchainChainId([0x42; 32]), + geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE, + geth_keychain::SSH_SIGCHAIN_NAMESPACE, + &root_public_key, + ) + .expect("trust tuple"); + + let sign = |record: geth_keychain::SshSigchainRecord| { + let payload = dir.path().join(format!("record-{}", record.sequence)); + std::fs::write(&payload, record.signing_bytes().expect("signing bytes")) + .expect("write signing payload"); + let output = geth_ssh_identity::sign_command( + &private_key, + geth_keychain::SSH_SIGCHAIN_NAMESPACE, + &payload, + ) + .output() + .expect("sign SSHSIGCHAIN record"); + assert!( + output.status.success(), + "ssh-keygen signing failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let signature = + std::fs::read(format!("{}.sig", payload.display())).expect("read SSHSIG signature"); + record.with_signature(signature).expect("signed record") + }; + + let init_op = KeychainOp { + id: AuthOpId::new("auth-op:sshsigchain-init"), + created_at: UnixMillis(1), + kind: KeychainOpKind::KeychainInit, + }; + let init = sign( + geth_keychain::keychain_sshsigchain_unsigned_record( + &trust, + 0, + None, + &init_op, + &root_public_key, + ) + .expect("unsigned init"), + ); + let root_add_op = KeychainOp { + id: AuthOpId::new("auth-op:sshsigchain-root"), + created_at: UnixMillis(2), + kind: KeychainOpKind::AdminKeyAdd { + key: KeyId::new(geth_keychain::admin_key_fingerprint(&root_public_key)), + public_key: Some(root_public_key.clone()), + principal: Some("root".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, + }; + let root_add = sign( + geth_keychain::keychain_sshsigchain_unsigned_record( + &trust, + 1, + Some(init.record_hash().expect("init hash")), + &root_add_op, + &root_public_key, + ) + .expect("unsigned root add"), + ); + + let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone(), root_add], &trust) + .expect("verify real OpenSSH SSHSIG chain"); + assert_eq!(verified.records, 2); + assert_eq!(verified.view.admin_keys.len(), 1); + + let mut tampered = init; + tampered.payload.push(0); + assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err()); + } + #[derive(Debug, PartialEq, Eq)] enum RemoteGuardKind { Capability, diff --git a/crates/geth-node/src/local_control.rs b/crates/geth-node/src/local_control.rs index 9572dbd..203d065 100644 --- a/crates/geth-node/src/local_control.rs +++ b/crates/geth-node/src/local_control.rs @@ -547,6 +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", _ => "node_error", } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 7897404..ec18c56 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -3079,9 +3079,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { }, ) .expect("admin add"); - match response { + let added_op_id = match response { geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => { assert_eq!(signatures.len(), 1); + let op_id = op.id.to_string(); match op.kind { geth_keychain::KeychainOpKind::AdminKeyAdd { public_key, @@ -3093,9 +3094,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { } other => panic!("unexpected op kind: {other:?}"), } + op_id } other => panic!("unexpected response: {other:?}"), - } + }; let response = geth_node::handle_request( &node, @@ -3193,157 +3195,23 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { 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(), + op_id: added_op_id.clone(), }, ) - .expect("explain op"); + .expect("explain operation"); match response { geth_control::ControlResponse::KeychainExplained { subject, lines } => { - assert_eq!(subject, explain_op_id); + assert_eq!(subject, added_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"); + std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin public key"); let admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key); let response = geth_node::handle_request( &node, @@ -3353,46 +3221,16 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { 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"); + .expect("verify local keychain"); match response { geth_control::ControlResponse::KeychainVerified { report } => { assert_eq!(report.rejected_ops, 0); assert_eq!(report.active_admin_keys, 2); - assert!(report.note.contains("git-skm")); } other => panic!("unexpected response: {other:?}"), } diff --git a/docs/adr/0018-sshsigchain-v2.md b/docs/adr/0018-sshsigchain.md similarity index 64% rename from docs/adr/0018-sshsigchain-v2.md rename to docs/adr/0018-sshsigchain.md index 19cc69f..3b5e167 100644 --- a/docs/adr/0018-sshsigchain-v2.md +++ b/docs/adr/0018-sshsigchain.md @@ -1,9 +1,10 @@ -# ADR 0018: Linked SSHSIGCHAIN v2 +# ADR 0018: Linked SSHSIGCHAIN v1 ## Status -Accepted for the new generic core and geth keychain profile. Legacy static -sigchain publication is deprecated pending explicit v2 command migration. +Accepted for the generic core and geth keychain profile. The pre-standard +test-only static workflow is removed; it is not an alternate format or a +compatibility path. ## Context @@ -14,7 +15,7 @@ rollback, and fork semantics inadequate for a trust foundation. ## Decision -Geth adopts SSHSIGCHAIN v2 as the replacement design: +Geth adopts SSHSIGCHAIN version 1 as its one portable signed-chain design: - root trust is an explicit `(chain ID, profile, namespace, root key)` tuple; - records are linearly ordered by sequence and linked by a digest over their @@ -31,11 +32,11 @@ not a geth transport. ## Consequences -The v2 core can be published and implemented by applications without importing +The core can be published and implemented by applications without importing geth's resource model. Geth's keychain profile is intentionally narrow and tested against self-bootstrap, fork, and backdated-revocation attacks. -Existing static v1 bundles cannot safely migrate in place. Operators must pin a -new v2 trust tuple and create a new genesis chain. Head persistence and later -witness/transparency support remain follow-up work; neither is implied by a -single signed checkpoint. +The old static bundle code and commands are deleted rather than supported beside +SSHSIGCHAIN. There is no migration format because the repository has not been +deployed. Head persistence and later witness/transparency support remain +follow-up work; neither is implied by a single signed checkpoint. diff --git a/docs/architecture.md b/docs/architecture.md index 5e18cbe..38dc855 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -451,23 +451,20 @@ admin-revoke` operations; `AdminKeyAdd` carries the public key material needed to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays the log against the previously accepted admin-key view, mirroring the `git-skm` 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. 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 +local replay rules, application-specific signature namespaces, and +allowed-signers projection live in `geth-keychain`. The CLI can export the +reduced key registry as OpenSSH `allowed_signers` and can sign and verify +arbitrary snapshots, such as externally managed `authorized_keys`, with an +active keychain signer under an explicit OpenSSH namespace. The prior static +JSONL export, publication, import, checkpoint, and fetch commands were removed: +their downloaded `allowed_signers` projection could bootstrap its own trust. +`geth keychain verify-sigchain --in --chain-id --root-key +` is the inspection entry point for the standalone SSHSIGCHAIN +format, verified against a pin supplied by the operator. SSHSIGCHAIN signing, +persistent heads, publication, and import remain follow-up work rather than a +compatibility fallback. `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 @@ -479,16 +476,16 @@ identities are immutable in local storage. An attempted re-import with an existing identity but different bytes is rejected before it can replace local trust state; idempotent repeats leave the original bytes unchanged. -The replacement static-publication design is specified in -[`sshsigchain-v2.md`](sshsigchain-v2.md). Its reusable core has an explicit +The portable signed-chain design is specified in +[`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust tuple, a strict sequence plus hash link, bounded fields, and a profile reducer that authorizes each record from only the causally preceding state. Geth's -`geth.keychain.v2` profile rejects timestamp validity windows as authorization -policy and treats key revocation as a causal record. The legacy static JSONL -commands have not yet been migrated to this core and are not a safe bootstrap -for new trust; Iroh keychain sync remains the supported replicated path while -the explicit v2 command workflow is completed. +`geth.keychain.sshsigchain.v1` profile rejects timestamp validity windows as authorization +policy and treats key revocation as a causal record. The prior 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. 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 diff --git a/docs/command-stability.md b/docs/command-stability.md index f4f9f78..8987525 100644 --- a/docs/command-stability.md +++ b/docs/command-stability.md @@ -33,7 +33,7 @@ The following command families are intended to be stable automation surfaces: - `geth peer export|import|list|ping|auth-check` - `geth keychain init|status|admin-add|admin-revoke|allowed-signers|verify` - `geth keychain sign-file|verify-file` -- `geth keychain sigchain|verify-sigchain|import-sigchain|verify-checkpoint|fetch|explain|explain-signer` +- `geth keychain explain|explain-signer` - `geth auth explain|grant|revoke|sync` - `geth sync status|now` - `geth wait daemon|peer|sync` @@ -61,6 +61,7 @@ settles: - `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv` - `geth cas add-private` - `geth cas get-private` +- `geth keychain verify-sigchain` Migration expectation: overlay membership records and authorization resources should remain readable, but packet runtime flags, platform activation details, @@ -71,6 +72,11 @@ 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. + ## Adding Commands New commands should enter this file in the same change that introduces the CLI diff --git a/docs/roadmap.md b/docs/roadmap.md index ca790c1..ee2ced9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -173,7 +173,7 @@ admin devices without depending on one always-online coordination server. resource data. - `[ ]` The control plane has documented conflict semantics for concurrent joins, renames, IP conflicts, route changes, and revocations. - - `[ ]` Nodes can converge from static sigchain publication, peer live-sync, + - `[ ]` Nodes can converge from SSHSIGCHAIN publication, peer live-sync, and Iroh-connected peers without requiring a central coordinator. - `[ ]` Stale or partitioned nodes are detectable in `geth overlay status` and `geth sync status --json`. @@ -181,7 +181,7 @@ admin devices without depending on one always-online coordination server. - `[ ]` Fault-tolerant peer and path selection. Acceptance criteria: - `[ ]` Nodes maintain multiple candidate addresses from Iroh relays, local - discovery, imported peer cards, and static publication. + discovery, imported peer cards, and configured bootstrap metadata. - `[ ]` Packet routing retries healthy paths and backs off failed paths without granting trust from discovery metadata. - `[ ]` Relay use, direct connections, LAN discovery, and path failures are @@ -555,23 +555,33 @@ resource-scoped capability decisions. - JSON is not used as the signed representation. - Tests verify equivalent operations hash/sign identically across runs. -- `[~]` Replace the legacy static keychain sigchain with SSHSIGCHAIN v2. +- `[~]` Implement SSHSIGCHAIN version 1 as the only portable keychain + sigchain format. Acceptance criteria: - - `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v2 record + - `[x]` Publish a transport-neutral, deterministic SSHSIGCHAIN v1 record format with explicit trust-anchor, chain-link, size-limit, and non-claim documentation. - `[x]` Provide a reusable verifier core and a geth keychain profile that rejects self-bootstrap, non-linked forks, non-canonical payloads, and post-revocation timestamp replay. - - `[ ]` Add explicit CLI storage, signing, verification, publication, and - import workflows for a pinned v2 trust tuple. - - `[ ]` Persist accepted v2 heads and require proof of extension before a - static source can advance. - - `[ ]` Disable the legacy static publish/fetch/import workflow by default - and provide an operator-visible migration path that creates a fresh v2 - genesis chain. - - `[ ]` Add OpenSSH integration tests and independently generated wire test + - `[~]` 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. + - `[ ]` Local record storage, signing, publication, import, and + head-advance workflows are complete. + - `[ ]` Persist accepted heads and require proof of extension before a source + can advance. + - `[x]` Delete the previous test-only static export, publication, + verification, import, checkpoint, and fetch workflow. It is not a + compatibility target because it was never deployed. + - `[~]` Add OpenSSH integration tests and independently generated wire test vectors for the published standard. + - `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies a + linked root/init chain. + - `[x]` The specification and reference implementation share a base + signing-byte test vector. + - `[ ]` Add independently generated cross-implementation vectors. - `[x]` SSH-admin-rooted keychain initialization. Acceptance criteria: @@ -602,24 +612,11 @@ resource-scoped capability decisions. - `[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]` Previous test-only static JSONL export, publication, verification, + import, checkpoint, and fetch commands were deleted after review found that + their downloaded allowed-signers projection could self-bootstrap trust and + their timestamp ordering could not prove causal history. They are not a + compatibility target; SSHSIGCHAIN is the only portable format. - `[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 @@ -629,9 +626,9 @@ resource-scoped capability decisions. previously accepted admin-key view. - `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon orchestration code. - - `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection, - replay verification with an injected verifier, and appendable JSONL - sigchain encode/decode helpers for static hosting or alternate transports. + - `[x]` `geth-keychain` exposes a transport-neutral SSHSIGCHAIN verifier, + bounded JSONL transport codec, and an explicit trust-tuple model alongside + local allowed-signers projection and replay verification. - `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications can use distinct signature namespaces and default principals. - `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so diff --git a/docs/sigchain-keychain.md b/docs/sigchain-keychain.md index 0501b37..44a5b4a 100644 --- a/docs/sigchain-keychain.md +++ b/docs/sigchain-keychain.md @@ -1,202 +1,103 @@ -# Geth Keychain Sigchain Design +# Geth Keychain And SSHSIGCHAIN ## Purpose -The geth keychain is a signed operation log for mesh identity state. It is the -source of truth for admin SSH keys, users, devices, nodes, agents, and endpoint -bindings. Peers do not trust a mutable keychain snapshot. They replay signed -operations and reduce the accepted log into the current keychain view. +The geth keychain is the local identity-plane operation log for admin SSH keys, +users, devices, nodes, agents, and endpoint bindings. The daemon stores these +operations in SQLite and synchronizes authorized records over Iroh. Reducing +the operation log produces the current keychain view used by node management, +authorization, and OpenSSH `allowed_signers` projection. -This is intentionally similar to the `git-skm` pattern: +[`sshsigchain.md`](sshsigchain.md) specifies SSHSIGCHAIN, the only portable +signed-chain format in this repository. It is deliberately separate from the +current local keychain operation log: SSHSIGCHAIN starts with an explicit +out-of-band trust tuple, has a strict sequence and hash link, and never uses a +downloaded `allowed_signers` file as a trust root. The previous test-only +static JSONL publication format was removed. -- `git-skm` treats a committed `allowed_signers` file as trusted only if every - commit that changed it can be verified from a prior trusted state. -- geth treats a keychain operation as trusted only if it is signed by an admin - key that was trusted in the previously accepted keychain view. +## Local keychain model -## Data Model +The durable local log contains `KeychainOp` records and their +`KeychainOpSignature` records. Every operation has a deterministic canonical +binary signing payload under the `geth.keychain.v1@geth.local` namespace. +JSON is an API and storage representation, never the signed payload. -The reusable model lives in the `geth-keychain` crate. The daemon stores it in -SQLite and syncs it over Iroh, but the crate does not depend on SQLite, Iroh, or -the geth daemon. Other projects can publish the same keychain as a static -sigchain file, append it to object storage, embed it in a document, or transport -it by any other mechanism. - -The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation -has a deterministic canonical signing payload under the -`geth.keychain.v1@geth.local` namespace. - -The namespace is part of a `KeychainProfile`. geth uses: - -- `geth.keychain.v1@geth.local` -- `geth.node-enrollment-request.v1@geth.local` -- default admin principal `admin` - -Other applications should create their own profile with explicit namespaces or -`KeychainProfile::for_application("", "")`. For example, -`for_application("acme-notes", "example.com")` produces -`acme-notes.keychain.v1@example.com`. This prevents signatures from one -application's keychain from being replayed into another application's -keychain. - -Important operation kinds: +Important operation kinds are: - `KeychainInit` -- `AdminKeyAdd` -- `AdminKeyRevoke` +- `AdminKeyAdd`, `AdminKeyRevoke` - `UserAdd`, `UserRename`, `UserRevoke` -- `DeviceAdd`, `DeviceRevoke` -- `DeviceKeyAdd`, `DeviceKeyRevoke` +- `DeviceAdd`, `DeviceRevoke`, `DeviceKeyAdd`, `DeviceKeyRevoke` - `NodeAdd`, `NodeRename`, `NodeRevoke` - `NodeEndpointAdd`, `NodeEndpointRevoke` - `AgentBind` -`AdminKeyAdd` records the admin key fingerprint and, for new operations, the -OpenSSH public key material, optional principal, and optional validity metadata. -The public key is part of the signed operation so the key registry can be -reconstructed from the sigchain itself. Older local data may only have the -fingerprint; geth can use stored signature public-key material as a fallback -when exporting the current allowed signers view. +`AdminKeyAdd` carries the OpenSSH public key material used to reconstruct the +active `allowed_signers` view. Local operation and signature identities are +append-only: an existing identity with different bytes is rejected rather than +replaced. -## Signature Rules +## Local signatures -Each signed operation has a `KeychainOpSignature`: - -- `op_id` -- signer key fingerprint -- signer OpenSSH public key -- namespace -- OpenSSH signature bytes -- creation time - -Signatures are produced with: +An operation signature contains its operation ID, signer key fingerprint, +signer public key, SSHSIG namespace, OpenSSH signature bytes, and creation +time. geth signs through OpenSSH, for example: ```sh ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f ``` -`` can be: +`` may be a local private key, an OpenSSH security-key stub, or a +public key whose private half is available through `ssh-agent`. The daemon does +not store private keys or passphrases. -- 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 `. +Local verification accepts an operation only when a signature from an admin +key in the previously accepted local view verifies over the canonical payload. +This is useful for the local Iroh-synchronized operation log, but it is not a +portable SSHSIGCHAIN history and MUST NOT be presented as one. -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. +## SSHSIGCHAIN keychain profile -Verification uses: +Geth's SSHSIGCHAIN profile identifier is `geth.keychain.sshsigchain.v1` and its +SSHSIG namespace is `sshsigchain.v1`. Its canonical binary payload is a +versioned mirror of a keychain operation; it is separate from the +human-facing, internally tagged JSON API type so a decoder can prove one unique +payload encoding. + +The profile requires: + +- sequence zero to be `KeychainInit` signed by the operator-pinned root key; +- sequence one to be an `AdminKeyAdd` recording that root key; +- every following signer to be an active admin key in the causally prior + profile state; +- every keychain operation ID to occur only once in the chain; +- each added admin key to have canonical key material matching its declared key + fingerprint; and +- causal add/revoke records rather than validity windows or payload timestamps + as authorization policy. + +The root key seeds authorization at genesis only. A causally valid revocation +removes it like any other admin key. + +To inspect an SSHSIGCHAIN JSONL transport file, pin the chain ID and root public +key locally: ```sh -ssh-keygen -Y verify \ - -f \ - -I \ - -n geth.keychain.v1@geth.local \ - -s +geth keychain verify-sigchain \ + --in geth.sshsigchain.v1.jsonl \ + --chain-id <64-hex-character-chain-id> \ + --root-key ~/.ssh/geth-root.pub ``` -The signed payload is canonical binary encoding, not JSON. +This verifier is experimental while geth adds its own record creation, +publication, import, accepted-head persistence, and independently generated +wire vectors. Those later workflows must preserve the same explicit trust +tuple; they must not introduce a compatibility route for the removed static +format. -## Verification Algorithm +## Local commands -For a candidate log ordered by `(created_at_ms, op_id)`: - -1. Start from a local trust anchor. In the bootstrap implementation this is a - locally initialized `KeychainInit` plus an admin key added through - `geth init --admin-key ... --signing-key ...` or `geth keychain init - --admin-key ...`. -2. Maintain an accepted operation prefix and reduce it into the current - keychain view. -3. For each next operation, collect its signatures. -4. Accept the operation only if at least one signature: - - is from a key fingerprint present in the previous accepted view, - - carries public key material that hashes to that fingerprint, - - verifies over the canonical operation payload with OpenSSH, and - - uses the keychain namespace. -5. After accepting the operation, append it to the accepted prefix and reduce - again. This lets a valid `AdminKeyAdd` authorize later operations, and a - valid `AdminKeyRevoke` stop later authorization by that key. -6. Reject unsigned, invalidly signed, conflicting, or out-of-authority - operations. - -This mirrors `git-skm`'s "verify from the previously trusted -allowed_signers" model, but the transport and storage are geth/Iroh/SQLite -instead of Git commits. - -The `geth-keychain` crate exposes this as a transport-neutral verifier. Callers -provide: - -- ordered or unordered `KeychainOp[]` -- `KeychainOpSignature[]` -- a `KeychainProfile` -- a `KeychainSignatureVerifier` - -The verifier is responsible for the cryptographic backend, such as -`ssh-keygen -Y verify`, WebCrypto, an HSM, a service-side verifier, or a test -verifier. The crate owns the replay order, bootstrap rule, profile namespace -check, previous-view authorization rule, `allowed_signers` projection, and -reduced keychain view. - -Minimal reusable Rust shape: - -```rust -let profile = geth_keychain::KeychainProfile::for_application( - "acme-notes", - "example.com", -)?; -let entries = geth_keychain::decode_sigchain_jsonl(sigchain_text)?; -let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries); -let report = geth_keychain::verify_sigchain_with_profile( - &ops, - &signatures, - &profile, - &my_verifier, -); -``` - -## Static Sigchain File - -For static hosting and range-friendly distribution, `geth-keychain` defines a -JSONL sigchain representation: - -```json -{"op":{...},"signatures":[...]} -{"op":{...},"signatures":[...]} -``` - -Each line is a complete `KeychainSigchainEntry`. This keeps the file appendable -and cacheable: - -- A publisher can append new entries to the end of the file. -- HTTP clients can use `ETag`, `Last-Modified`, and byte range requests to fetch - only new bytes. -- A client can decode complete trailing lines, ignore a partial final line until - the next fetch, and replay verification from its last accepted checkpoint. -- The canonical signed payload remains the `KeychainOp`; JSONL is only the - publication container. - -The crate provides helpers to encode/decode JSONL and flatten entries back into -`KeychainOp[]` plus `KeychainOpSignature[]`. - -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 - -Owner bootstrap: +Bootstrap an owner node: ```sh geth init \ @@ -205,100 +106,25 @@ geth init \ --node-name laptop ``` -Add a new admin key: +Manage the current local keychain and its OpenSSH projection: ```sh geth keychain admin-add \ --admin-key ~/.ssh/new_admin.pub \ --signing-key ~/.ssh/current_admin_sk \ --principal admin -``` - -Revoke an admin key: - -```sh geth keychain admin-revoke \ --signing-key ~/.ssh/current_admin_sk -``` - -Export the reduced active admin key registry in OpenSSH `allowed_signers` -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 \ +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 verify-file --in authorized_keys --signature authorized_keys.sig 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: - -```sh geth keychain verify ``` -## Differences From Git-SKM - -`git-skm` uses Git commit history as the append-only log and a trusted commit -hash as the checkpoint. geth uses `KeychainOp[]` as the append-only log and the -locally initialized owner/admin key as the bootstrap trust anchor. - -The current geth prototype does not yet provide: - -- transparency-log style append proofs -- anti-rollback protection beyond local state, HTTP cache validators, and sync - conflict checks -- delegated admin scopes or threshold admin signatures -- strong compromise-recovery semantics - -Those are future hardening items. The current prototype is intended to make the -key registry self-describing, replayable, and testable. - -## Security Invariants - -- The admin SSH private key is never copied into geth state. -- Admin key changes are keychain operations, not mutable ACL edits. -- Public key material needed to reconstruct active admin signers is stored in - the signed operation log. -- Import from peers accepts only operations signed by currently trusted admin - keys. -- A discovered peer card or Iroh EndpointID never grants keychain authority. -- Bearer secrets do not grant keychain mutation rights. +The keychain does not manage host `authorized_keys` policy. It can sign a +snapshot supplied by the operator, allowing another system to retain its own +SSH login policy while usefully checking approval against an active local admin +key. diff --git a/docs/sshsigchain-v2.md b/docs/sshsigchain.md similarity index 75% rename from docs/sshsigchain-v2.md rename to docs/sshsigchain.md index e0a1ab9..d030ea0 100644 --- a/docs/sshsigchain-v2.md +++ b/docs/sshsigchain.md @@ -1,19 +1,20 @@ -# SSHSIGCHAIN v2 +# SSHSIGCHAIN v1 -Status: Draft 0 +Status: Draft 1 (pre-deployment) This document specifies a deliberately small, generic append-only signature -chain for applications that use OpenSSH `sshsig` signatures. It is transport +chain for applications that use OpenSSH `sshsig` signatures. Version 1 is the +first and only defined version of SSHSIGCHAIN. It is transport independent: a chain may be carried by a file, object store, HTTP, database, or mesh protocol. It does not make SSH a transport. The reference implementation lives in geth's `geth-keychain` crate. Geth uses -the `geth.keychain.v2` profile for identity-plane operations, but the format and +the `geth.keychain.sshsigchain.v1` profile for identity-plane operations, but the format and verification core do not depend on geth data types. ## 1. Goals -SSHSIGCHAIN v2 provides: +SSHSIGCHAIN v1 provides: - a fixed, deterministic byte sequence for every signed record; - an explicit, out-of-band root public key rather than trust bootstrapped from @@ -46,7 +47,7 @@ enrollment material, release metadata, or another authenticated out-of-band channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT be used to discover or replace them. -The v2 core accepts a single root key. A threshold or witness scheme is a +The v1 core accepts a single root key. A threshold or witness scheme is a separate protocol extension and must bind the same `chain_id`, profile, and head digest; it is not implied by multiple signatures attached to a record. @@ -80,8 +81,9 @@ A record has these logical fields: | `signature` | byte string | An OpenSSH SSHSIG signature over the signing bytes below. | The profile identifier is 1–128 ASCII bytes containing only letters, digits, -`.`, `-`, and `_`. Canonical public-key text is at most 16 KiB, signatures are -at most 64 KiB, and a verifier MUST reject a chain over 100,000 records before +`.`, `-`, and `_`. The namespace is 1–128 printable ASCII bytes without +whitespace. Canonical public-key text is at most 16 KiB, signatures are at most +64 KiB, and a verifier MUST reject a chain over 100,000 records before performing unbounded work. Implementations may set smaller limits. Timestamps are intentionally not fields in the generic ordering mechanism. @@ -97,7 +99,7 @@ contains exactly `n` bytes; no implicit terminator or alignment is present. ```text "SSCS" 4 bytes -0x02 1 byte (protocol version) +0x01 1 byte (protocol version) chain_id 32 bytes sequence u64 has_previous 1 byte: 0 or 1 @@ -114,13 +116,49 @@ The `signature` field is not in the signing bytes because SSHSIG signs those bytes. The record digest, which binds the signature into the next link, is: ```text -BLAKE3("sshsigchain.record-hash.v2\\0" || signing_bytes || u32(signature_length) || signature) +BLAKE3("sshsigchain.record-hash.v1\\0" || signing_bytes || u32(signature_length) || signature) ``` `previous` in record `n + 1` MUST equal this digest for record `n`. A JSON or JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT be signed or hashed as the record representation. +### JSONL transport + +This specification defines JSONL as a convenient interchange transport. Each +non-empty physical line contains exactly one JSON object with these fields: +`chain_id`, `profile`, `sequence`, `previous`, `payload`, +`signer_public_key`, and `signature`. `chain_id`, `previous` when present, +`payload`, and `signature` are JSON arrays of unsigned octets; `previous` is +`null` when absent. `profile` and `signer_public_key` are JSON strings, and +`sequence` is an unsigned integer. Unknown or duplicate fields MUST be +rejected. Object-member order and JSON whitespace carry no meaning. + +Blank lines MAY be ignored. A JSONL line is limited to 5 MiB and one complete +input to 64 MiB. Implementations reading an arbitrary stream MUST enforce those +limits before buffering an unbounded line or input. These limits are transport +limits in addition to the record limits above. + +### Base test vector + +This unsigned record fixes the field grammar independently of OpenSSH key +generation or signature randomness: + +```text +chain_id 32 zero bytes +profile "example.test" +sequence 0 +previous absent +payload 01 02 +signer_public_key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2" +``` + +Its signing bytes, encoded as lowercase hexadecimal, MUST be: + +```text +53534353010000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332 +``` + ## 6. Verification algorithm Given the configured trust input and an ordered candidate record list, a @@ -167,13 +205,15 @@ bytes. ### geth keychain profile -Geth's identifier is `geth.keychain.v2`. Its payload is a versioned canonical +Geth's identifier is `geth.keychain.sshsigchain.v1`. Its payload is a versioned canonical binary mirror of a keychain operation. The mirror is deliberately separate from the human-facing, internally tagged JSON API type so that a decoder can prove a unique payload byte sequence. Version 1 requires: - sequence zero to be `KeychainInit`; +- sequence one to be an `AdminKeyAdd` that records the configured root signer; - every signer to be an active admin public key in the preceding profile state; +- every keychain operation ID to occur only once in the chain; - each `AdminKeyAdd` to carry a canonical public key whose BLAKE3 key ID matches the declared key ID; - `AdminKeyRevoke` to remove that key before any later record is authorized; @@ -195,7 +235,8 @@ unless its signer is an already pinned trust anchor and its claimed ancestry is verified. For stronger equivocation evidence, publish heads to independently operated -witnesses or a transparency log. This is intentionally outside v2's small core. +witnesses or a transparency log. This is intentionally outside SSHSIGCHAIN's +small core. ## 9. Security considerations @@ -217,11 +258,11 @@ witnesses or a transparency log. This is intentionally outside v2's small core. ## 10. Compatibility -SSHSIGCHAIN v2 has no compatibility mode with the older geth static JSONL -sigchain. That format could bootstrap trust from its own download and ordered -operations by mutable timestamps, so treating it as v2 would silently preserve -the bugs this specification removes. Migration requires an explicitly pinned -v2 trust tuple and a freshly signed v2 genesis sequence. +This specification defines one format: SSHSIGCHAIN version 1. It has no +compatibility mode, downgrade path, or alternate legacy record grammar. +Implementations MUST reject another protocol version. Geth removed its +pre-standard test-only static JSONL workflow rather than treating it as an +SSHSIGCHAIN variant. ## References