make sshsigchain the only portable sigchain format

This commit is contained in:
Eric Wendland 2026-07-18 21:00:06 +02:00
commit 73500e1944
15 changed files with 812 additions and 1603 deletions

View file

@ -634,41 +634,33 @@ geth keychain sign-file \
geth keychain verify-file \ geth keychain verify-file \
--in /tmp/authorized_keys \ --in /tmp/authorized_keys \
--signature /tmp/authorized_keys.sig --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 <op-id> geth keychain explain <op-id>
geth keychain explain-signer <key-id> geth keychain explain-signer <key-id>
geth keychain verify geth keychain verify
``` ```
The keychain follows a sigchain model documented in The previous test-only static sigchain commands (`sigchain`, `publish-bundle`,
`docs/sigchain-keychain.md`: each keychain operation is accepted only if it is `import-sigchain`, `verify-checkpoint`, and `fetch`) were removed. Their
signed by an admin key from the previously accepted reduced view. This is the downloaded `allowed_signers` projection could establish the trust that
geth analogue of verifying `git-skm` allowed-signers changes from a prior validated its own chain. There is no compatibility mode for that workflow.
trusted state. The reusable mechanics live in the `geth-keychain` crate,
including application-specific signature profiles, allowed-signers projection, The replacement is the small, transport-neutral
replay verification through a caller-provided verifier trait, and an appendable [`SSHSIGCHAIN v1`](docs/sshsigchain.md) specification. It starts from an
JSONL sigchain file format suitable for static hosting with HTTP caching/range operator-pinned chain ID, OpenSSH root public key, profile, and namespace;
requests. The default discovery/publication base is records are fixed-byte SSHSIG payloads linked by sequence and digest. Geth
`https://example.com/.well-known/sshsigchain/`; publish bundles contain already provides a verifier for independently produced JSONL transport files:
`allowed_signers`, `geth.sigchain.jsonl`, `geth.sigchain.checkpoint.json`, and
`geth.sigchain.checkpoint.json.sig`. Clients can verify checkpoints, fetch ```sh
bundles, import verified sigchains, and remember the last accepted checkpoint to geth keychain verify-sigchain \
reject older static bundles from the same source. `keychain fetch --url` is the --in ./geth.sshsigchain.v1.jsonl \
retrieval location, so local `file://` mirrors work for testing; the signed --chain-id <64-hex-character-chain-id> \
checkpoint still records the advertised publication base URL, and --root-key ~/.ssh/geth-root.pub
`verify-checkpoint --base-url` can pin that value when needed. ```
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, 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 a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is

View file

@ -1048,57 +1048,16 @@ pub enum KeychainCommand {
#[arg(long)] #[arg(long)]
principal: Option<String>, principal: Option<String>,
}, },
/// Export the canonical SSH signature chain /// Verify a linked SSHSIGCHAIN JSONL file against an explicit root key
Sigchain {
#[arg(long)]
out: Option<PathBuf>,
},
/// 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<PathBuf>,
#[arg(long = "snapshot")]
snapshots: Vec<String>,
},
/// Verify a signature chain without importing it
VerifySigchain { VerifySigchain {
#[arg(long = "in")] #[arg(long = "in")]
input: PathBuf, 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)] #[arg(long)]
checkpoint: PathBuf, chain_id: String,
#[arg(long)] #[arg(long)]
signature: PathBuf, root_key: PathBuf,
#[arg(long)] #[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
sigchain: PathBuf, namespace: String,
#[arg(long)]
allowed_signers: PathBuf,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
principal: Option<String>,
},
/// 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<PathBuf>,
#[arg(long)]
import: bool,
}, },
/// Explain why one keychain operation was accepted or rejected /// Explain why one keychain operation was accepted or rejected
Explain { op_id: String }, Explain { op_id: String },
@ -1785,6 +1744,8 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
"signature" => Some("OpenSSH signature file path"), "signature" => Some("OpenSSH signature file path"),
"allowed_signers" => Some("OpenSSH allowed_signers file used for verification"), "allowed_signers" => Some("OpenSSH allowed_signers file used for verification"),
"base_url" => Some("Publication base URL recorded in signed discovery metadata"), "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"), "snapshots" => Some("Named snapshot mapping NAME=PATH; repeatable"),
"checkpoint" => Some("Signed publication checkpoint file"), "checkpoint" => Some("Signed publication checkpoint file"),
"sigchain" => Some("Canonical keychain signature-chain JSONL file"), "sigchain" => Some("Canonical keychain signature-chain JSONL file"),
@ -2671,52 +2632,20 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
allowed_signers_path: allowed_signers, allowed_signers_path: allowed_signers,
principal, principal,
}, },
Command::Keychain {
command: KeychainCommand::Sigchain { out },
} => ControlRequest::KeychainSigchainExport { out },
Command::Keychain { Command::Keychain {
command: command:
KeychainCommand::PublishBundle { KeychainCommand::VerifySigchain {
out, input,
base_url, chain_id,
signing_key, root_key,
admin_key, namespace,
snapshots,
}, },
} => ControlRequest::KeychainPublishBundle { } => ControlRequest::KeychainVerifySigchain {
out, input,
base_url: Some(base_url), chain_id,
signing_key_path: signing_key, root_key_path: root_key,
admin_key_path: admin_key, namespace: Some(namespace),
snapshots,
}, },
Command::Keychain {
command: KeychainCommand::VerifySigchain { input },
} => ControlRequest::KeychainVerifySigchain { input },
Command::Keychain {
command: KeychainCommand::ImportSigchain { input },
} => ControlRequest::KeychainImportSigchain { input },
Command::Keychain {
command:
KeychainCommand::VerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
},
} => ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
},
Command::Keychain {
command: KeychainCommand::Fetch { url, out, import },
} => ControlRequest::KeychainFetch { url, out, import },
Command::Keychain { Command::Keychain {
command: KeychainCommand::Explain { op_id }, command: KeychainCommand::Explain { op_id },
} => ControlRequest::KeychainExplain { op_id }, } => ControlRequest::KeychainExplain { op_id },
@ -4175,109 +4104,25 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
println!("verified: {verified}"); println!("verified: {verified}");
eprintln!("note: {note}"); eprintln!("note: {note}");
} }
ControlResponse::KeychainSigchainExported { ControlResponse::KeychainSigchainVerified {
jsonl, out, note, ..
} => {
if let Some(out) = out {
println!("wrote keychain sigchain: {}", out.display());
} else {
print!("{jsonl}");
}
eprintln!("note: {note}");
}
ControlResponse::KeychainBundlePublished {
out,
base_url,
allowed_signers_path,
sigchain_path,
checkpoint_path,
checkpoint_signature_path,
snapshots,
note,
..
} => {
println!("bundle: {}", out.display());
println!("base_url: {base_url}");
println!("allowed_signers: {}", allowed_signers_path.display());
println!("sigchain: {}", sigchain_path.display());
println!("checkpoint: {}", checkpoint_path.display());
println!(
"checkpoint_signature: {}",
checkpoint_signature_path.display()
);
for snapshot in snapshots {
println!(
"snapshot: {} {} {}",
snapshot.name,
snapshot.path.display(),
snapshot.signature_path.display()
);
}
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainFileVerified {
input, input,
report, chain_id,
records,
head,
active_admin_keys,
users,
devices,
nodes,
note, note,
} => { } => {
println!("sigchain: {}", input.display()); println!("sigchain: {}", input.display());
print_keychain_sigchain_report(&report); println!("chain_id: {chain_id}");
eprintln!("note: {note}"); println!("records: {records}");
} println!("head: {head}");
ControlResponse::KeychainSigchainImported { println!("active_admin_keys: {active_admin_keys}");
input, println!("users: {users}");
ops_imported, println!("devices: {devices}");
signatures_imported, println!("nodes: {nodes}");
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);
}
eprintln!("note: {note}"); eprintln!("note: {note}");
} }
ControlResponse::KeychainExplained { subject, lines } => { ControlResponse::KeychainExplained { subject, lines } => {
@ -5473,6 +5318,41 @@ mod tests {
assert!(Cli::try_parse_from(["geth", "daemon", "logs", "--lines", "0"]).is_err()); 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] #[test]
fn service_install_copies_transient_binaries_unless_explicitly_overridden() { fn service_install_copies_transient_binaries_unless_explicitly_overridden() {
assert!( assert!(

View file

@ -4,8 +4,8 @@ use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_keychain::{ use geth_keychain::{
KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature, KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord, NodeEnrollmentRequest, NodeRecord,
}; };
use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_overlay::{ use geth_overlay::{
@ -266,34 +266,11 @@ pub enum ControlRequest {
allowed_signers_path: Option<PathBuf>, allowed_signers_path: Option<PathBuf>,
principal: Option<String>, principal: Option<String>,
}, },
KeychainSigchainExport {
out: Option<PathBuf>,
},
KeychainPublishBundle {
out: PathBuf,
base_url: Option<String>,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
snapshots: Vec<String>,
},
KeychainVerifySigchain { KeychainVerifySigchain {
input: PathBuf, input: PathBuf,
}, chain_id: String,
KeychainImportSigchain { root_key_path: PathBuf,
input: PathBuf, namespace: Option<String>,
},
KeychainVerifyCheckpoint {
checkpoint: PathBuf,
signature: PathBuf,
sigchain: PathBuf,
allowed_signers: PathBuf,
base_url: Option<String>,
principal: Option<String>,
},
KeychainFetch {
url: String,
out: Option<PathBuf>,
import: bool,
}, },
KeychainExplain { KeychainExplain {
op_id: String, op_id: String,
@ -764,46 +741,15 @@ pub enum ControlResponse {
principal: Option<String>, principal: Option<String>,
note: String, note: String,
}, },
KeychainSigchainExported { KeychainSigchainVerified {
entries: Vec<KeychainSigchainEntry>,
jsonl: String,
out: Option<PathBuf>,
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<KeychainPublishedSnapshot>,
note: String,
},
KeychainSigchainFileVerified {
input: PathBuf, input: PathBuf,
report: KeychainSigchainReport, chain_id: String,
note: String, records: usize,
}, head: String,
KeychainSigchainImported { active_admin_keys: usize,
input: PathBuf, users: usize,
ops_imported: usize, devices: usize,
signatures_imported: usize, nodes: usize,
invalid_ops_rejected: usize,
note: String,
},
KeychainCheckpointVerified {
checkpoint: KeychainCheckpoint,
verified: bool,
principal: Option<String>,
note: String,
},
KeychainFetched {
url: String,
out: PathBuf,
checkpoint: KeychainCheckpoint,
imported: Option<KeychainFetchImportReport>,
note: String, note: String,
}, },
KeychainExplained { 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResponse { pub struct StatusResponse {
pub home: PathBuf, pub home: PathBuf,
@ -1834,8 +1764,8 @@ mod tests {
use super::*; use super::*;
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
const CONTROL_REQUEST_VARIANTS: usize = 123; const CONTROL_REQUEST_VARIANTS: usize = 118;
const CONTROL_RESPONSE_VARIANTS: usize = 115; const CONTROL_RESPONSE_VARIANTS: usize = 110;
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19; const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20; const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3; const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
@ -2126,11 +2056,6 @@ mod tests {
"devices": 1, "devices": 1,
"nodes": 1 "nodes": 1
}), }),
"KeychainFetchImportReport" => json!({
"ops_imported": 1,
"signatures_imported": 1,
"invalid_ops_rejected": 0
}),
"ResourceDescriptor" => json!({ "ResourceDescriptor" => json!({
"id": "resource:sample", "id": "resource:sample",
"kind": "kv", "kind": "kv",
@ -2269,27 +2194,6 @@ mod tests {
"accepted_head": null, "accepted_head": null,
"note": "sample" "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!({ "AuthExplanation" => json!({
"subject": "node:peer", "subject": "node:peer",
"resource": "resource:sample", "resource": "resource:sample",

View file

@ -5,25 +5,23 @@
//! to update them. It intentionally has no dependency on the daemon, SQLite, //! to update them. It intentionally has no dependency on the daemon, SQLite,
//! Iroh, local sockets, or any particular publication mechanism. //! Iroh, local sockets, or any particular publication mechanism.
//! //!
//! Applications can publish `KeychainSigchainEntry` values in an append-only //! The current local operation-log verifier remains local to geth's daemon
//! JSONL file, object store, database row stream, document CRDT, or another //! state. Portable SSHSIGCHAIN records are implemented separately in
//! transport. Consumers decode entries, flatten them into operations and //! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and
//! signatures, then call `verify_sigchain_with_profile` with an application //! a causal hash chain rather than a timestamp-sorted JSONL bundle.
//! 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.
mod sshsigchain; mod sshsigchain;
pub use sshsigchain::{ pub use sshsigchain::{
ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION, ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification, KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, KEYCHAIN_SSH_SIGCHAIN_PROFILE,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy, 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, SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record, decode_keychain_sshsigchain_payload, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl,
verify_keychain_v2_sigchain, verify_sshsigchain, keychain_sshsigchain_payload, keychain_sshsigchain_unsigned_record,
verify_keychain_sshsigchain, verify_sshsigchain,
}; };
use geth_types::{ 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 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 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 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 DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1;
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>; pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
@ -203,27 +198,6 @@ pub struct KeychainSigchainReport {
pub note: String, pub note: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainSigchainEntry {
pub op: KeychainOp,
pub signatures: Vec<KeychainOpSignature>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainCheckpoint {
pub version: u16,
pub profile: KeychainProfile,
pub base_url: String,
pub head: Option<AuthOpId>,
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 { pub trait KeychainSignatureVerifier {
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool; fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
} }
@ -331,11 +305,6 @@ pub enum KeychainError {
InvalidPrincipal(String), InvalidPrincipal(String),
#[error("codec error: {0}")] #[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError), Codec(#[from] geth_codec::CodecError),
#[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl {
line: usize,
source: serde_json::Error,
},
} }
#[must_use] #[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())) format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes()))
} }
#[must_use]
pub fn sigchain_entries(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
) -> Vec<KeychainSigchainEntry> {
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<String, serde_json::Error> {
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<Vec<KeychainSigchainEntry>, KeychainError> {
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str::<KeychainSigchainEntry>(line).map_err(|source| {
KeychainError::SigchainJsonl {
line: index + 1,
source,
}
})
})
.collect()
}
#[must_use]
pub fn flatten_sigchain_entries(
entries: &[KeychainSigchainEntry],
) -> (Vec<KeychainOp>, Vec<KeychainOpSignature>) {
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<String>,
generated_at: UnixMillis,
) -> Result<KeychainCheckpoint, KeychainError> {
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> { fn validate_namespace(value: &str) -> Result<(), KeychainError> {
let has_single_domain_separator = value.matches('@').count() == 1; let has_single_domain_separator = value.matches('@').count() == 1;
let valid = has_single_domain_separator let valid = has_single_domain_separator
@ -1118,90 +993,6 @@ mod tests {
assert!(!view.endpoints.contains_key("endpoint:old")); 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] #[test]
fn sigchain_verification_replays_against_prior_admin_view() { fn sigchain_verification_replays_against_prior_admin_view() {
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(); let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();

View file

@ -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 //! The core deliberately knows nothing about geth's resource or identity
//! model. It verifies one linear, linked sequence against an explicitly //! model. It verifies one linear, linked sequence against an explicitly
//! configured root key, then delegates authorization and state transitions to //! configured root key, then delegates authorization and state transitions to
//! an application profile. `KeychainV2Policy` below is geth's first profile. //! an application profile. `KeychainSshSigchainPolicy` below is geth's first profile.
//! See `docs/sshsigchain-v2.md` for the interoperable format. //! See `docs/sshsigchain.md` for the interoperable format.
use base64::{Engine as _, engine::general_purpose}; use base64::{Engine as _, engine::general_purpose};
use serde::{Deserialize, Serialize}; 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 crate::{KeychainOp, KeychainOpKind, KeychainView, admin_key_fingerprint, reduce_keychain_ops};
use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId}; use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
pub const SSH_SIGCHAIN_VERSION: u8 = 2; pub const SSH_SIGCHAIN_VERSION: u8 = 1;
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v2"; pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
pub const KEYCHAIN_V2_PROFILE: &str = "geth.keychain.v2"; pub const SSH_SIGCHAIN_VERIFIER_PRINCIPAL: &str = "sshsigchain";
pub const KEYCHAIN_V2_PAYLOAD_VERSION: u16 = 1; 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_PROFILE_BYTES: usize = 128;
pub const MAX_NAMESPACE_BYTES: usize = 128;
pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024; pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024;
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024; pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024; pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024;
pub const MAX_RECORDS: usize = 100_000; 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 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ChainId(pub [u8; 32]); pub struct ChainId(pub [u8; 32]);
@ -84,6 +88,7 @@ impl SshSigchainTrust {
/// A JSON-serializable transport envelope. Its JSON representation is not /// A JSON-serializable transport envelope. Its JSON representation is not
/// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are. /// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SshSigchainRecord { pub struct SshSigchainRecord {
pub chain_id: ChainId, pub chain_id: ChainId,
pub profile: String, pub profile: String,
@ -94,6 +99,46 @@ pub struct SshSigchainRecord {
pub signature: Vec<u8>, pub signature: Vec<u8>,
} }
pub fn encode_sshsigchain_jsonl(records: &[SshSigchainRecord]) -> Result<String, SshSigchainError> {
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<Vec<SshSigchainRecord>, 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 { impl SshSigchainRecord {
pub fn unsigned( pub fn unsigned(
chain_id: ChainId, chain_id: ChainId,
@ -276,96 +321,98 @@ where
} }
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KeychainV2Policy; pub struct KeychainSshSigchainPolicy;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeychainV2State { pub struct KeychainSshSigchainState {
initialized: bool, initialized: bool,
admin_public_keys: BTreeMap<KeyId, String>, admin_public_keys: BTreeMap<KeyId, String>,
seen_op_ids: BTreeSet<AuthOpId>,
ops: Vec<KeychainOp>, ops: Vec<KeychainOp>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeychainV2Verification { pub struct KeychainSshSigchainVerification {
pub view: KeychainView, pub view: KeychainView,
pub records: usize, pub records: usize,
pub head: Digest, pub head: Digest,
} }
pub fn keychain_v2_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> { pub fn keychain_sshsigchain_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
geth_codec::encode_canonical(&KeychainV2Payload { geth_codec::encode_canonical(&KeychainSshSigchainPayload {
version: KEYCHAIN_V2_PAYLOAD_VERSION, version: KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION,
op: CanonicalKeychainOp::from(op), op: CanonicalKeychainOp::from(op),
}) })
.map_err(|error| SshSigchainError::PayloadEncoding(error.to_string())) .map_err(|error| SshSigchainError::PayloadEncoding(error.to_string()))
} }
pub fn decode_keychain_v2_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> { pub fn decode_keychain_sshsigchain_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
let decoded = geth_codec::decode_canonical::<KeychainV2Payload>(bytes) let decoded = geth_codec::decode_canonical::<KeychainSshSigchainPayload>(bytes)
.map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?; .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)); return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version));
} }
let op = KeychainOp::from(decoded.op); let op = KeychainOp::from(decoded.op);
let canonical = keychain_v2_payload(&op)?; let canonical = keychain_sshsigchain_payload(&op)?;
if canonical != bytes { if canonical != bytes {
return Err(SshSigchainError::NonCanonicalPayload); return Err(SshSigchainError::NonCanonicalPayload);
} }
Ok(op) Ok(op)
} }
pub fn keychain_v2_unsigned_record( pub fn keychain_sshsigchain_unsigned_record(
trust: &SshSigchainTrust, trust: &SshSigchainTrust,
sequence: u64, sequence: u64,
previous: Option<Digest>, previous: Option<Digest>,
op: &KeychainOp, op: &KeychainOp,
signer_public_key: impl AsRef<str>, signer_public_key: impl AsRef<str>,
) -> Result<SshSigchainRecord, SshSigchainError> { ) -> Result<SshSigchainRecord, SshSigchainError> {
if trust.profile != KEYCHAIN_V2_PROFILE { if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
return Err(SshSigchainError::WrongKeychainProfile( return Err(SshSigchainError::WrongKeychainProfile(
trust.profile.clone(), trust.profile.clone(),
)); ));
} }
SshSigchainRecord::unsigned( SshSigchainRecord::unsigned(
trust.chain_id, trust.chain_id,
KEYCHAIN_V2_PROFILE, KEYCHAIN_SSH_SIGCHAIN_PROFILE,
sequence, sequence,
previous, previous,
keychain_v2_payload(op)?, keychain_sshsigchain_payload(op)?,
signer_public_key, signer_public_key,
) )
} }
pub fn verify_keychain_v2_sigchain<V>( pub fn verify_keychain_sshsigchain<V>(
records: &[SshSigchainRecord], records: &[SshSigchainRecord],
trust: &SshSigchainTrust, trust: &SshSigchainTrust,
verifier: &V, verifier: &V,
) -> Result<KeychainV2Verification, SshSigchainError> ) -> Result<KeychainSshSigchainVerification, SshSigchainError>
where where
V: SshSigchainVerifier + ?Sized, V: SshSigchainVerifier + ?Sized,
{ {
if trust.profile != KEYCHAIN_V2_PROFILE { if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
return Err(SshSigchainError::WrongKeychainProfile( return Err(SshSigchainError::WrongKeychainProfile(
trust.profile.clone(), trust.profile.clone(),
)); ));
} }
let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?; let verified = verify_sshsigchain(records, trust, verifier, &KeychainSshSigchainPolicy)?;
Ok(KeychainV2Verification { Ok(KeychainSshSigchainVerification {
view: reduce_keychain_ops(&verified.state.ops), view: reduce_keychain_ops(&verified.state.ops),
records: verified.records, records: verified.records,
head: verified.head, head: verified.head,
}) })
} }
impl SshSigchainPolicy for KeychainV2Policy { impl SshSigchainPolicy for KeychainSshSigchainPolicy {
type State = KeychainV2State; type State = KeychainSshSigchainState;
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String> { fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String> {
let root = let root =
canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?; canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?;
Ok(KeychainV2State { Ok(KeychainSshSigchainState {
initialized: false, initialized: false,
admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]), admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]),
seen_op_ids: BTreeSet::new(),
ops: Vec::new(), ops: Vec::new(),
}) })
} }
@ -383,7 +430,11 @@ impl SshSigchainPolicy for KeychainV2Policy {
} }
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String> { 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 { match &op.kind {
KeychainOpKind::KeychainInit => { KeychainOpKind::KeychainInit => {
if state.initialized || record.sequence != 0 { 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() { if valid_after_ms.is_some() || valid_before_ms.is_some() {
return Err( 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(), .to_owned(),
); );
} }
let public_key = public_key.as_deref().ok_or_else(|| { 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 = let public_key =
canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?; canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?;
if KeyId::new(admin_key_fingerprint(&public_key)) != *key { if KeyId::new(admin_key_fingerprint(&public_key)) != *key {
return Err("AdminKeyAdd key ID does not match its public key".to_owned()); 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); state.admin_public_keys.insert(key.clone(), public_key);
} }
KeychainOpKind::AdminKeyRevoke { key } => { KeychainOpKind::AdminKeyRevoke { key } => {
if !state.initialized { if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned()); 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); state.admin_public_keys.remove(key);
} }
_ => { _ => {
if !state.initialized { if !state.initialized {
return Err("keychain must start with KeychainInit".to_owned()); 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); state.ops.push(op);
@ -435,13 +504,13 @@ impl SshSigchainPolicy for KeychainV2Policy {
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct KeychainV2Payload { struct KeychainSshSigchainPayload {
version: u16, version: u16,
op: CanonicalKeychainOp, op: CanonicalKeychainOp,
} }
/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard /// `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. /// profile-local externally tagged mirror for its signed binary payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct CanonicalKeychainOp { struct CanonicalKeychainOp {
@ -654,7 +723,9 @@ pub enum SshSigchainError {
InvalidChainId, InvalidChainId,
#[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")] #[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")]
InvalidProfile, 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, InvalidNamespace,
#[error("SSH public key must be a canonical two-field OpenSSH public key")] #[error("SSH public key must be a canonical two-field OpenSSH public key")]
InvalidPublicKey, InvalidPublicKey,
@ -668,6 +739,16 @@ pub enum SshSigchainError {
MissingSignature, MissingSignature,
#[error("SSH sigchain cannot be empty")] #[error("SSH sigchain cannot be empty")]
EmptyChain, 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")] #[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")]
TooManyRecords(usize), TooManyRecords(usize),
#[error("SSH sigchain sequence counter overflow")] #[error("SSH sigchain sequence counter overflow")]
@ -690,15 +771,15 @@ pub enum SshSigchainError {
InvalidSignature { index: usize }, InvalidSignature { index: usize },
#[error("SSH sigchain policy rejected record: {0}")] #[error("SSH sigchain policy rejected record: {0}")]
Policy(String), Policy(String),
#[error("failed to encode keychain v2 payload: {0}")] #[error("failed to encode keychain SSHSIGCHAIN payload: {0}")]
PayloadEncoding(String), PayloadEncoding(String),
#[error("failed to decode keychain v2 payload: {0}")] #[error("failed to decode keychain SSHSIGCHAIN payload: {0}")]
PayloadDecoding(String), PayloadDecoding(String),
#[error("unsupported keychain v2 payload version {0}")] #[error("unsupported keychain SSHSIGCHAIN payload version {0}")]
UnsupportedPayloadVersion(u16), UnsupportedPayloadVersion(u16),
#[error("keychain v2 payload is not its unique canonical encoding")] #[error("keychain SSHSIGCHAIN payload is not its unique canonical encoding")]
NonCanonicalPayload, NonCanonicalPayload,
#[error("expected geth keychain v2 profile, got {0}")] #[error("expected geth keychain SSHSIGCHAIN profile, got {0}")]
WrongKeychainProfile(String), WrongKeychainProfile(String),
#[error("length does not fit the SSH sigchain wire format")] #[error("length does not fit the SSH sigchain wire format")]
LengthOverflow, LengthOverflow,
@ -732,7 +813,7 @@ fn validate_profile(value: String) -> Result<String, SshSigchainError> {
fn validate_namespace(value: String) -> Result<String, SshSigchainError> { fn validate_namespace(value: String) -> Result<String, SshSigchainError> {
if value.is_empty() if value.is_empty()
|| value.len() > MAX_PROFILE_BYTES || value.len() > MAX_NAMESPACE_BYTES
|| !value.bytes().all(|byte| byte.is_ascii_graphic()) || !value.bytes().all(|byte| byte.is_ascii_graphic())
{ {
return Err(SshSigchainError::InvalidNamespace); return Err(SshSigchainError::InvalidNamespace);
@ -804,7 +885,7 @@ mod tests {
fn trust() -> SshSigchainTrust { fn trust() -> SshSigchainTrust {
SshSigchainTrust::new( SshSigchainTrust::new(
ChainId([7; 32]), ChainId([7; 32]),
KEYCHAIN_V2_PROFILE, KEYCHAIN_SSH_SIGCHAIN_PROFILE,
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_NAMESPACE,
ROOT_KEY, ROOT_KEY,
) )
@ -826,7 +907,7 @@ mod tests {
op: &KeychainOp, op: &KeychainOp,
signer: &str, signer: &str,
) -> SshSigchainRecord { ) -> 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"); .expect("unsigned record");
let signature = test_signature( let signature = test_signature(
&trust.namespace, &trust.namespace,
@ -851,7 +932,7 @@ mod tests {
} }
#[test] #[test]
fn v2_accepts_a_linked_rooted_keychain() { fn accepts_a_linked_rooted_keychain() {
let trust = trust(); let trust = trust();
let init = signed_record( let init = signed_record(
&trust, &trust,
@ -882,7 +963,7 @@ mod tests {
ROOT_KEY, 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"); .expect("valid chain");
assert_eq!(verified.records, 3); assert_eq!(verified.records, 3);
assert_eq!( assert_eq!(
@ -893,7 +974,7 @@ mod tests {
} }
#[test] #[test]
fn v2_requires_an_explicit_root_instead_of_self_bootstrap() { fn requires_an_explicit_root_instead_of_self_bootstrap() {
let trust = trust(); let trust = trust();
let init = signed_record( let init = signed_record(
&trust, &trust,
@ -903,13 +984,13 @@ mod tests {
SECOND_KEY, SECOND_KEY,
); );
assert!(matches!( assert!(matches!(
verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier), verify_keychain_sshsigchain(&[init], &trust, &TestVerifier),
Err(SshSigchainError::RootSignerMismatch) Err(SshSigchainError::RootSignerMismatch)
)); ));
} }
#[test] #[test]
fn v2_rejects_a_non_linked_fork() { fn rejects_a_non_linked_fork() {
let trust = trust(); let trust = trust();
let init = signed_record( let init = signed_record(
&trust, &trust,
@ -920,13 +1001,94 @@ mod tests {
); );
let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY); let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY);
assert!(matches!( assert!(matches!(
verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier), verify_keychain_sshsigchain(&[init, add], &trust, &TestVerifier),
Err(SshSigchainError::UnexpectedPrevious { index: 1 }) Err(SshSigchainError::UnexpectedPrevious { index: 1 })
)); ));
} }
#[test] #[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 trust = trust();
let init = signed_record( let init = signed_record(
&trust, &trust,
@ -972,20 +1134,80 @@ mod tests {
ROOT_KEY, ROOT_KEY,
); );
assert!(matches!( assert!(matches!(
verify_keychain_v2_sigchain(&[init, add, revoke, forged], &trust, &TestVerifier), verify_keychain_sshsigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
Err(SshSigchainError::Policy(_)) Err(SshSigchainError::Policy(_))
)); ));
} }
#[test] #[test]
fn v2_payload_rejects_trailing_or_noncanonical_bytes() { fn payload_rejects_trailing_or_noncanonical_bytes() {
let payload = let payload = keychain_sshsigchain_payload(&op("op:init", 1, KeychainOpKind::KeychainInit))
keychain_v2_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)).expect("payload"); .expect("payload");
let mut noncanonical = payload; let mut noncanonical = payload;
noncanonical.push(0); noncanonical.push(0);
assert!(matches!( assert!(matches!(
decode_keychain_v2_payload(&noncanonical), decode_keychain_sshsigchain_payload(&noncanonical),
Err(SshSigchainError::NonCanonicalPayload) 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"
)
);
}
} }

View file

@ -12,6 +12,7 @@ bytes.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
tempfile.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
geth-auth = { path = "../geth-auth" } geth-auth = { path = "../geth-auth" }

View file

@ -80,6 +80,7 @@ use runtime::{
PubsubRuntime, PubsubRuntime,
}; };
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -117,6 +118,8 @@ pub enum NodeError {
Codec(#[from] geth_codec::CodecError), Codec(#[from] geth_codec::CodecError),
#[error("keychain error: {0}")] #[error("keychain error: {0}")]
Keychain(#[from] geth_keychain::KeychainError), Keychain(#[from] geth_keychain::KeychainError),
#[error("SSH sigchain error: {0}")]
SshSigchain(#[from] geth_keychain::SshSigchainError),
#[error("json error: {0}")] #[error("json error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("io error: {0}")] #[error("io error: {0}")]
@ -137,8 +140,6 @@ pub enum NodeError {
InvalidInitGrant(String), InvalidInitGrant(String),
#[error("invalid enrollment capability, expected <resource>=<capability>: {0}")] #[error("invalid enrollment capability, expected <resource>=<capability>: {0}")]
InvalidEnrollmentCapability(String), InvalidEnrollmentCapability(String),
#[error("invalid keychain snapshot, expected <name>=<path>: {0}")]
InvalidKeychainSnapshot(String),
#[error("node enrollment request not found: {0}")] #[error("node enrollment request not found: {0}")]
NodeEnrollmentRequestNotFound(String), NodeEnrollmentRequestNotFound(String),
#[error("node enrollment request has invalid provenance: {0}")] #[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(), note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
}) })
} }
ControlRequest::KeychainSigchainExport { out } => { ControlRequest::KeychainVerifySigchain {
let ops = load_keychain_ops(&store)?; input,
let signatures = load_keychain_signatures(&store)?; chain_id,
let entries = geth_keychain::sigchain_entries(&ops, &signatures); root_key_path,
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?; namespace,
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &jsonl)?;
}
Ok(ControlResponse::KeychainSigchainExported {
entries,
jsonl,
out,
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
})
}
ControlRequest::KeychainPublishBundle {
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
} => publish_keychain_bundle(
&store,
node,
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
),
ControlRequest::KeychainVerifySigchain { input } => {
let text = std::fs::read_to_string(&input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok(ControlResponse::KeychainSigchainFileVerified {
input,
report,
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
.to_owned(),
})
}
ControlRequest::KeychainImportSigchain { input } => {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
let imported =
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
Ok(ControlResponse::KeychainSigchainImported {
input,
ops_imported: imported.ops_imported,
signatures_imported: imported.signatures_imported,
invalid_ops_rejected: imported.invalid_ops_rejected,
note: if imported.invalid_ops_rejected == 0 {
"imported verified JSONL sigchain entries".to_owned()
} else {
"sigchain contained rejected operations; nothing imported".to_owned()
},
})
}
ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
} => { } => {
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files( let records = read_sshsigchain_jsonl_file(&input)?;
node, let trust = geth_keychain::SshSigchainTrust::new(
&checkpoint, geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
&signature, geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
&sigchain, namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
&allowed_signers, std::fs::read_to_string(root_key_path)?,
base_url.as_deref(),
principal.as_deref(),
)?; )?;
Ok(ControlResponse::KeychainCheckpointVerified { let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?;
checkpoint, Ok(ControlResponse::KeychainSigchainVerified {
verified, input,
principal, chain_id: trust.chain_id.to_hex(),
note: "verified checkpoint signature, hashes, base URL, and sigchain head" records: verified.records,
.to_owned(), 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 { ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(), subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?, lines: explain_keychain_op(&store, node, &op_id)?,
@ -9483,301 +9423,6 @@ fn stored_keychain_op_from_op(op: &KeychainOp) -> Result<StoredKeychainOp, NodeE
}) })
} }
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StaticKeychainSourceState {
head: Option<AuthOpId>,
ops: usize,
sigchain_hash: String,
generated_at_ms: i64,
}
fn read_and_verify_sigchain_file(
node: &LocalNode,
input: &Path,
) -> Result<
(
Vec<KeychainOp>,
Vec<KeychainOpSignature>,
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<geth_control::KeychainFetchImportReport, NodeError> {
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::<BTreeMap<_, _>>();
let existing_signatures = load_keychain_signatures(store)?
.into_iter()
.map(|signature| {
(
(
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
),
signature,
)
})
.collect::<BTreeMap<_, _>>();
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::<Vec<_>>();
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<String>), 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<PathBuf>,
import: bool,
) -> Result<ControlResponse, NodeError> {
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( fn explain_keychain_op(
store: &Store, store: &Store,
node: &LocalNode, node: &LocalNode,
@ -9869,93 +9514,6 @@ fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, Node
Ok(lines) Ok(lines)
} }
fn publish_keychain_bundle(
store: &Store,
_node: &LocalNode,
out: PathBuf,
base_url: Option<String>,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
snapshots: Vec<String>,
) -> Result<ControlResponse, NodeError> {
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( fn verify_keychain_sigchain_entries_with_ssh(
node: &LocalNode, node: &LocalNode,
ops: &[KeychainOp], ops: &[KeychainOp],
@ -9984,34 +9542,80 @@ fn verify_keychain_sigchain_entries_with_ssh(
geth_keychain::verify_sigchain(ops, signatures, &verifier) geth_keychain::verify_sigchain(ops, signatures, &verifier)
} }
fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> { fn verify_keychain_sshsigchain_with_ssh(
let Some((name, path)) = value.split_once('=') else { records: &[geth_keychain::SshSigchainRecord],
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned())); trust: &geth_keychain::SshSigchainTrust,
) -> Result<geth_keychain::KeychainSshSigchainVerification, NodeError> {
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)?; Ok(geth_keychain::verify_keychain_sshsigchain(
let path = PathBuf::from(path); records, trust, &verifier,
if !path.is_file() { )?)
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
}
Ok((name.to_owned(), path))
} }
fn validate_snapshot_name(name: &str) -> Result<(), NodeError> { fn read_sshsigchain_jsonl_file(
let valid = !name.is_empty() input: &Path,
&& name != "." ) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
&& name != ".." let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
&& name let mut text = String::new();
.bytes() reader.read_to_string(&mut text)?;
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); if text.len() > geth_keychain::MAX_JSONL_BYTES {
if valid { return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into());
Ok(())
} else {
Err(NodeError::InvalidKeychainSnapshot(name.to_owned()))
} }
} Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?)
fn snapshot_namespace(name: &str) -> String {
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
} }
fn verify_keychain_sigchain_with_ssh( fn verify_keychain_sigchain_with_ssh(
@ -11390,6 +10994,12 @@ mod tests {
))), ))),
"daemon_already_running" "daemon_already_running"
); );
assert_eq!(
local_control::node_error_code(&NodeError::SshSigchain(
geth_keychain::SshSigchainError::EmptyChain
)),
"sshsigchain_error"
);
} }
#[test] #[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)] #[derive(Debug, PartialEq, Eq)]
enum RemoteGuardKind { enum RemoteGuardKind {
Capability, Capability,

View file

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

View file

@ -3079,9 +3079,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
}, },
) )
.expect("admin add"); .expect("admin add");
match response { let added_op_id = match response {
geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => { geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => {
assert_eq!(signatures.len(), 1); assert_eq!(signatures.len(), 1);
let op_id = op.id.to_string();
match op.kind { match op.kind {
geth_keychain::KeychainOpKind::AdminKeyAdd { geth_keychain::KeychainOpKind::AdminKeyAdd {
public_key, public_key,
@ -3093,9 +3094,10 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
} }
other => panic!("unexpected op kind: {other:?}"), other => panic!("unexpected op kind: {other:?}"),
} }
op_id
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
} };
let response = geth_node::handle_request( let response = geth_node::handle_request(
&node, &node,
@ -3193,157 +3195,23 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
other => panic!("unexpected response: {other:?}"), 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( let response = geth_node::handle_request(
&node, &node,
geth_control::ControlRequest::KeychainExplain { geth_control::ControlRequest::KeychainExplain {
op_id: explain_op_id.clone(), op_id: added_op_id.clone(),
}, },
) )
.expect("explain op"); .expect("explain operation");
match response { match response {
geth_control::ControlResponse::KeychainExplained { subject, lines } => { 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"))); assert!(lines.iter().any(|line| line.contains("accepted_by_replay")));
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
} }
let admin_public_key = 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 admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key);
let response = geth_node::handle_request( let response = geth_node::handle_request(
&node, &node,
@ -3353,46 +3221,16 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
match response { match response {
geth_control::ControlResponse::KeychainExplained { lines, .. } => { geth_control::ControlResponse::KeychainExplained { lines, .. } => {
assert!(lines.iter().any(|line| line == "active_admin_signer: true")); 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:?}"), other => panic!("unexpected response: {other:?}"),
} }
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify) let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify)
.expect("verify sigchain"); .expect("verify local keychain");
match response { match response {
geth_control::ControlResponse::KeychainVerified { report } => { geth_control::ControlResponse::KeychainVerified { report } => {
assert_eq!(report.rejected_ops, 0); assert_eq!(report.rejected_ops, 0);
assert_eq!(report.active_admin_keys, 2); assert_eq!(report.active_admin_keys, 2);
assert!(report.note.contains("git-skm"));
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
} }

View file

@ -1,9 +1,10 @@
# ADR 0018: Linked SSHSIGCHAIN v2 # ADR 0018: Linked SSHSIGCHAIN v1
## Status ## Status
Accepted for the new generic core and geth keychain profile. Legacy static Accepted for the generic core and geth keychain profile. The pre-standard
sigchain publication is deprecated pending explicit v2 command migration. test-only static workflow is removed; it is not an alternate format or a
compatibility path.
## Context ## Context
@ -14,7 +15,7 @@ rollback, and fork semantics inadequate for a trust foundation.
## Decision ## 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; - 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 - records are linearly ordered by sequence and linked by a digest over their
@ -31,11 +32,11 @@ not a geth transport.
## Consequences ## 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 geth's resource model. Geth's keychain profile is intentionally narrow and
tested against self-bootstrap, fork, and backdated-revocation attacks. tested against self-bootstrap, fork, and backdated-revocation attacks.
Existing static v1 bundles cannot safely migrate in place. Operators must pin a The old static bundle code and commands are deleted rather than supported beside
new v2 trust tuple and create a new genesis chain. Head persistence and later SSHSIGCHAIN. There is no migration format because the repository has not been
witness/transparency support remain follow-up work; neither is implied by a deployed. Head persistence and later witness/transparency support remain
single signed checkpoint. follow-up work; neither is implied by a single signed checkpoint.

View file

@ -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 to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays
the log against the previously accepted admin-key view, mirroring the `git-skm` 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 pattern of verifying key-registry changes from a prior trusted state. The
transport-neutral replay rules, application-specific signature namespaces, local replay rules, application-specific signature namespaces, and
allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain` allowed-signers projection live in `geth-keychain`. The CLI can export the
so other applications can reuse the same identity-log model without depending reduced key registry as OpenSSH `allowed_signers` and can sign and verify
on the daemon, SQLite, Iroh, or local control. The CLI can export the same arbitrary snapshots, such as externally managed `authorized_keys`, with an
reduced key registry as OpenSSH `allowed_signers` or as appendable JSONL active keychain signer under an explicit OpenSSH namespace. The prior static
sigchain data for website publication. It can also sign and verify arbitrary JSONL export, publication, import, checkpoint, and fetch commands were removed:
snapshots, such as externally managed `authorized_keys`, with an active their downloaded `allowed_signers` projection could bootstrap its own trust.
keychain signer under an explicit OpenSSH namespace. `geth keychain `geth keychain verify-sigchain --in <file> --chain-id <id> --root-key
publish-bundle` writes a website-ready bundle for <public-key>` is the inspection entry point for the standalone SSHSIGCHAIN
`https://example.com/.well-known/sshsigchain/`, including `allowed_signers`, format, verified against a pin supplied by the operator. SSHSIGCHAIN signing,
`geth.sigchain.jsonl`, a signed checkpoint, and optional signed snapshots. persistent heads, publication, and import remain follow-up work rather than a
`geth keychain fetch --import` verifies the checkpoint and records the last compatibility fallback. `geth keychain explain`
accepted checkpoint per retrieval source URL to reject older bundles. The and `explain-signer` provide basic auditability for why a keychain operation or
retrieval source may be a local mirror; the checkpoint still carries the signed signer is trusted. `geth keychain sync <node>` pulls keychain
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 <node>` pulls keychain
operations and signatures from an imported peer over Iroh and imports only operations and signatures from an imported peer over Iroh and imports only
operations with a valid OpenSSH signature from a currently trusted admin key operations with a valid OpenSSH signature from a currently trusted admin key
over the canonical payload. See `docs/sigchain-keychain.md` for the detailed 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 existing identity but different bytes is rejected before it can replace local
trust state; idempotent repeats leave the original bytes unchanged. trust state; idempotent repeats leave the original bytes unchanged.
The replacement static-publication design is specified in The portable signed-chain design is specified in
[`sshsigchain-v2.md`](sshsigchain-v2.md). Its reusable core has an explicit [`sshsigchain.md`](sshsigchain.md). Its reusable core has an explicit
out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust 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 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 that authorizes each record from only the causally preceding state. Geth's
`geth.keychain.v2` profile rejects timestamp validity windows as authorization `geth.keychain.sshsigchain.v1` profile rejects timestamp validity windows as authorization
policy and treats key revocation as a causal record. The legacy static JSONL policy and treats key revocation as a causal record. The prior test-only static
commands have not yet been migrated to this core and are not a safe bootstrap workflow was removed rather than migrated. Iroh keychain sync remains the
for new trust; Iroh keychain sync remains the supported replicated path while current replicated local operation-log path while explicit SSHSIGCHAIN
the explicit v2 command workflow is completed. production and import workflows are completed.
New devices can use the node enrollment flow instead of hand-editing keychain 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 state. `geth node enroll join` explicitly imports an owner admin public key as

View file

@ -33,7 +33,7 @@ The following command families are intended to be stable automation surfaces:
- `geth peer export|import|list|ping|auth-check` - `geth peer export|import|list|ping|auth-check`
- `geth keychain init|status|admin-add|admin-revoke|allowed-signers|verify` - `geth keychain init|status|admin-add|admin-revoke|allowed-signers|verify`
- `geth keychain sign-file|verify-file` - `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 auth explain|grant|revoke|sync`
- `geth sync status|now` - `geth sync status|now`
- `geth wait daemon|peer|sync` - `geth wait daemon|peer|sync`
@ -61,6 +61,7 @@ settles:
- `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv` - `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv`
- `geth cas add-private` - `geth cas add-private`
- `geth cas get-private` - `geth cas get-private`
- `geth keychain verify-sigchain`
Migration expectation: overlay membership records and authorization resources Migration expectation: overlay membership records and authorization resources
should remain readable, but packet runtime flags, platform activation details, 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 earlier pre-deployment builds are rejected with a clear error and should be
recreated from plaintext. 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 ## Adding Commands
New commands should enter this file in the same change that introduces the CLI New commands should enter this file in the same change that introduces the CLI

View file

@ -173,7 +173,7 @@ admin devices without depending on one always-online coordination server.
resource data. resource data.
- `[ ]` The control plane has documented conflict semantics for concurrent - `[ ]` The control plane has documented conflict semantics for concurrent
joins, renames, IP conflicts, route changes, and revocations. 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. and Iroh-connected peers without requiring a central coordinator.
- `[ ]` Stale or partitioned nodes are detectable in `geth overlay status` - `[ ]` Stale or partitioned nodes are detectable in `geth overlay status`
and `geth sync status --json`. 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. - `[ ]` Fault-tolerant peer and path selection.
Acceptance criteria: Acceptance criteria:
- `[ ]` Nodes maintain multiple candidate addresses from Iroh relays, local - `[ ]` 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 - `[ ]` Packet routing retries healthy paths and backs off failed paths
without granting trust from discovery metadata. without granting trust from discovery metadata.
- `[ ]` Relay use, direct connections, LAN discovery, and path failures are - `[ ]` 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. - JSON is not used as the signed representation.
- Tests verify equivalent operations hash/sign identically across runs. - 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: 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 format with explicit trust-anchor, chain-link, size-limit, and
non-claim documentation. non-claim documentation.
- `[x]` Provide a reusable verifier core and a geth keychain profile that - `[x]` Provide a reusable verifier core and a geth keychain profile that
rejects self-bootstrap, non-linked forks, non-canonical payloads, and rejects self-bootstrap, non-linked forks, non-canonical payloads, and
post-revocation timestamp replay. post-revocation timestamp replay.
- `[ ]` Add explicit CLI storage, signing, verification, publication, and - `[~]` Add explicit CLI storage, signing, verification, publication, and
import workflows for a pinned v2 trust tuple. import workflows for a pinned SSHSIGCHAIN trust tuple.
- `[ ]` Persist accepted v2 heads and require proof of extension before a - `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file
static source can advance. against an operator-pinned chain ID and OpenSSH root public key.
- `[ ]` Disable the legacy static publish/fetch/import workflow by default - `[ ]` Local record storage, signing, publication, import, and
and provide an operator-visible migration path that creates a fresh v2 head-advance workflows are complete.
genesis chain. - `[ ]` Persist accepted heads and require proof of extension before a source
- `[ ]` Add OpenSSH integration tests and independently generated wire test 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. 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. - `[x]` SSH-admin-rooted keychain initialization.
Acceptance criteria: Acceptance criteria:
@ -602,24 +612,11 @@ resource-scoped capability decisions.
- `[x]` `geth keychain verify-file --in <path> --signature <sig>` verifies a - `[x]` `geth keychain verify-file --in <path> --signature <sig>` verifies a
snapshot signature against the current keychain-derived `allowed_signers` snapshot signature against the current keychain-derived `allowed_signers`
projection or a supplied `--allowed-signers` file. projection or a supplied `--allowed-signers` file.
- `[x]` `geth keychain sigchain --out <path>` writes the appendable JSONL - `[x]` Previous test-only static JSONL export, publication, verification,
sigchain suitable for static website publication. import, checkpoint, and fetch commands were deleted after review found that
- `[x]` `geth keychain publish-bundle --out <dir>` writes a static website their downloaded allowed-signers projection could self-bootstrap trust and
bundle rooted at `https://example.com/.well-known/sshsigchain/` by default. their timestamp ordering could not prove causal history. They are not a
- `[x]` Publication bundles include `allowed_signers`, `geth.sigchain.jsonl`, compatibility target; SSHSIGCHAIN is the only portable format.
`geth.sigchain.checkpoint.json`, and a detached checkpoint signature.
- `[x]` Publication bundles can copy and sign external snapshots with
`--snapshot <name>=<path>` without making the keychain own their contents.
- `[x]` `geth keychain verify-sigchain --in <path>` verifies a JSONL sigchain
file by replaying operations and signatures.
- `[x]` `geth keychain import-sigchain --in <path>` 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 <base> --import` fetches static bundles
with `curl` for HTTP(S) or filesystem reads for local/file URLs.
- `[x]` Static fetch/import records the last accepted checkpoint per source
URL and rejects older checkpoints for rollback resistance.
- `[x]` `geth keychain explain <op-id>` and `explain-signer <key-id>` provide - `[x]` `geth keychain explain <op-id>` and `explain-signer <key-id>` provide
basic audit output for operations and admin signers. basic audit output for operations and admin signers.
- `[x]` Agent/FIDO signing is supported through OpenSSH by passing a public - `[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. previously accepted admin-key view.
- `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon - `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon
orchestration code. orchestration code.
- `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection, - `[x]` `geth-keychain` exposes a transport-neutral SSHSIGCHAIN verifier,
replay verification with an injected verifier, and appendable JSONL bounded JSONL transport codec, and an explicit trust-tuple model alongside
sigchain encode/decode helpers for static hosting or alternate transports. local allowed-signers projection and replay verification.
- `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications - `[x]` `geth-keychain` exposes `KeychainProfile` so non-geth applications
can use distinct signature namespaces and default principals. can use distinct signature namespaces and default principals.
- `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so - `[x]` `geth-keychain` exposes a `KeychainSignatureVerifier` trait so

View file

@ -1,202 +1,103 @@
# Geth Keychain Sigchain Design # Geth Keychain And SSHSIGCHAIN
## Purpose ## Purpose
The geth keychain is a signed operation log for mesh identity state. It is the The geth keychain is the local identity-plane operation log for admin SSH keys,
source of truth for admin SSH keys, users, devices, nodes, agents, and endpoint users, devices, nodes, agents, and endpoint bindings. The daemon stores these
bindings. Peers do not trust a mutable keychain snapshot. They replay signed operations in SQLite and synchronizes authorized records over Iroh. Reducing
operations and reduce the accepted log into the current keychain view. 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 ## Local keychain model
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.
## 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 Important operation kinds are:
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("<app>", "<domain>")`. 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:
- `KeychainInit` - `KeychainInit`
- `AdminKeyAdd` - `AdminKeyAdd`, `AdminKeyRevoke`
- `AdminKeyRevoke`
- `UserAdd`, `UserRename`, `UserRevoke` - `UserAdd`, `UserRename`, `UserRevoke`
- `DeviceAdd`, `DeviceRevoke` - `DeviceAdd`, `DeviceRevoke`, `DeviceKeyAdd`, `DeviceKeyRevoke`
- `DeviceKeyAdd`, `DeviceKeyRevoke`
- `NodeAdd`, `NodeRename`, `NodeRevoke` - `NodeAdd`, `NodeRename`, `NodeRevoke`
- `NodeEndpointAdd`, `NodeEndpointRevoke` - `NodeEndpointAdd`, `NodeEndpointRevoke`
- `AgentBind` - `AgentBind`
`AdminKeyAdd` records the admin key fingerprint and, for new operations, the `AdminKeyAdd` carries the OpenSSH public key material used to reconstruct the
OpenSSH public key material, optional principal, and optional validity metadata. active `allowed_signers` view. Local operation and signature identities are
The public key is part of the signed operation so the key registry can be append-only: an existing identity with different bytes is rejected rather than
reconstructed from the sigchain itself. Older local data may only have the replaced.
fingerprint; geth can use stored signature public-key material as a fallback
when exporting the current allowed signers view.
## Signature Rules ## Local signatures
Each signed operation has a `KeychainOpSignature`: An operation signature contains its operation ID, signer key fingerprint,
signer public key, SSHSIG namespace, OpenSSH signature bytes, and creation
- `op_id` time. geth signs through OpenSSH, for example:
- signer key fingerprint
- signer OpenSSH public key
- namespace
- OpenSSH signature bytes
- creation time
Signatures are produced with:
```sh ```sh
ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f <signing-key> <payload> ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f <signing-key> <payload>
``` ```
`<signing-key>` can be: `<signing-key>` 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, Local verification accepts an operation only when a signature from an admin
- a FIDO/YubiKey OpenSSH security-key stub, key in the previously accepted local view verifies over the canonical payload.
- a public key whose private half is already loaded in `ssh-agent`, or This is useful for the local Iroh-synchronized operation log, but it is not a
- a public key backed by a PKCS#11 token loaded into `ssh-agent` with portable SSHSIGCHAIN history and MUST NOT be presented as one.
`ssh-add -s <provider>`.
Encrypted private key files should normally be unlocked into `ssh-agent` before ## SSHSIGCHAIN keychain profile
running geth control commands. The daemon never stores private keys or
passphrases. Direct PKCS#11 signing is intentionally not a first-class geth
backend because portable `ssh-keygen -Y sign` flows do not expose the same
provider flag as OpenSSH certificate signing.
Verification uses: 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 ```sh
ssh-keygen -Y verify \ geth keychain verify-sigchain \
-f <allowed-signers> \ --in geth.sshsigchain.v1.jsonl \
-I <signer-principal> \ --chain-id <64-hex-character-chain-id> \
-n geth.keychain.v1@geth.local \ --root-key ~/.ssh/geth-root.pub
-s <signature>
``` ```
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)`: Bootstrap an owner node:
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 <url>` when
a consumer needs to pin that advertised value explicitly.
## Commands
Owner bootstrap:
```sh ```sh
geth init \ geth init \
@ -205,100 +106,25 @@ geth init \
--node-name laptop --node-name laptop
``` ```
Add a new admin key: Manage the current local keychain and its OpenSSH projection:
```sh ```sh
geth keychain admin-add \ geth keychain admin-add \
--admin-key ~/.ssh/new_admin.pub \ --admin-key ~/.ssh/new_admin.pub \
--signing-key ~/.ssh/current_admin_sk \ --signing-key ~/.ssh/current_admin_sk \
--principal admin --principal admin
```
Revoke an admin key:
```sh
geth keychain admin-revoke <key-fingerprint> \ geth keychain admin-revoke <key-fingerprint> \
--signing-key ~/.ssh/current_admin_sk --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 allowed-signers --out allowed_signers
geth keychain sign-file \ geth keychain sign-file --in authorized_keys --out authorized_keys.sig \
--in authorized_keys \
--out authorized_keys.sig \
--signing-key ~/.ssh/id_ed25519_sk --signing-key ~/.ssh/id_ed25519_sk
geth keychain verify-file \ geth keychain verify-file --in authorized_keys --signature authorized_keys.sig
--in authorized_keys \
--signature authorized_keys.sig
geth keychain sigchain --out geth.sigchain.jsonl
geth keychain publish-bundle \
--out public/.well-known/sshsigchain \
--signing-key ~/.ssh/id_ed25519_sk \
--snapshot authorized_keys=authorized_keys
geth keychain verify-checkpoint \
--checkpoint geth.sigchain.checkpoint.json \
--signature geth.sigchain.checkpoint.json.sig \
--sigchain geth.sigchain.jsonl \
--allowed-signers allowed_signers
geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import
geth keychain verify-sigchain --in geth.sigchain.jsonl
geth keychain import-sigchain --in geth.sigchain.jsonl
geth keychain explain <op-id> geth keychain explain <op-id>
geth keychain explain-signer <key-id> geth keychain explain-signer <key-id>
```
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 geth keychain verify
``` ```
## Differences From Git-SKM 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
`git-skm` uses Git commit history as the append-only log and a trusted commit SSH login policy while usefully checking approval against an active local admin
hash as the checkpoint. geth uses `KeychainOp[]` as the append-only log and the key.
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.

View file

@ -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 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 independent: a chain may be carried by a file, object store, HTTP, database, or
mesh protocol. It does not make SSH a transport. mesh protocol. It does not make SSH a transport.
The reference implementation lives in geth's `geth-keychain` crate. Geth uses 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. verification core do not depend on geth data types.
## 1. Goals ## 1. Goals
SSHSIGCHAIN v2 provides: SSHSIGCHAIN v1 provides:
- a fixed, deterministic byte sequence for every signed record; - a fixed, deterministic byte sequence for every signed record;
- an explicit, out-of-band root public key rather than trust bootstrapped from - 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 channel. A fetched `allowed_signers` file, checkpoint, or first record MUST NOT
be used to discover or replace them. 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 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. 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. | | `signature` | byte string | An OpenSSH SSHSIG signature over the signing bytes below. |
The profile identifier is 1128 ASCII bytes containing only letters, digits, The profile identifier is 1128 ASCII bytes containing only letters, digits,
`.`, `-`, and `_`. Canonical public-key text is at most 16 KiB, signatures are `.`, `-`, and `_`. The namespace is 1128 printable ASCII bytes without
at most 64 KiB, and a verifier MUST reject a chain over 100,000 records before 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. performing unbounded work. Implementations may set smaller limits.
Timestamps are intentionally not fields in the generic ordering mechanism. 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 ```text
"SSCS" 4 bytes "SSCS" 4 bytes
0x02 1 byte (protocol version) 0x01 1 byte (protocol version)
chain_id 32 bytes chain_id 32 bytes
sequence u64 sequence u64
has_previous 1 byte: 0 or 1 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: bytes. The record digest, which binds the signature into the next link, is:
```text ```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 `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 JSONL transport envelope is allowed for convenience, but JSON bytes MUST NOT
be signed or hashed as the record representation. 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 ## 6. Verification algorithm
Given the configured trust input and an ordered candidate record list, a Given the configured trust input and an ordered candidate record list, a
@ -167,13 +205,15 @@ bytes.
### geth keychain profile ### 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 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 the human-facing, internally tagged JSON API type so that a decoder can prove a
unique payload byte sequence. Version 1 requires: unique payload byte sequence. Version 1 requires:
- sequence zero to be `KeychainInit`; - 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 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 - each `AdminKeyAdd` to carry a canonical public key whose BLAKE3 key ID
matches the declared key ID; matches the declared key ID;
- `AdminKeyRevoke` to remove that key before any later record is authorized; - `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. verified.
For stronger equivocation evidence, publish heads to independently operated 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 ## 9. Security considerations
@ -217,11 +258,11 @@ witnesses or a transparency log. This is intentionally outside v2's small core.
## 10. Compatibility ## 10. Compatibility
SSHSIGCHAIN v2 has no compatibility mode with the older geth static JSONL This specification defines one format: SSHSIGCHAIN version 1. It has no
sigchain. That format could bootstrap trust from its own download and ordered compatibility mode, downgrade path, or alternate legacy record grammar.
operations by mutable timestamps, so treating it as v2 would silently preserve Implementations MUST reject another protocol version. Geth removed its
the bugs this specification removes. Migration requires an explicitly pinned pre-standard test-only static JSONL workflow rather than treating it as an
v2 trust tuple and a freshly signed v2 genesis sequence. SSHSIGCHAIN variant.
## References ## References