Add static keychain publication workflow

This commit is contained in:
Eric Wendland 2026-05-27 00:59:52 +02:00
commit cfd41522d1
11 changed files with 1852 additions and 46 deletions

1
Cargo.lock generated
View file

@ -1525,6 +1525,7 @@ dependencies = [
"geth-cas", "geth-cas",
"geth-config", "geth-config",
"geth-control", "geth-control",
"geth-keychain",
"geth-node", "geth-node",
"geth-pipe", "geth-pipe",
"serde_json", "serde_json",

View file

@ -448,6 +448,29 @@ geth keychain admin-add \
--signing-key ~/.ssh/id_ed25519_sk \ --signing-key ~/.ssh/id_ed25519_sk \
--principal admin --principal admin
geth keychain allowed-signers > /tmp/geth.allowed_signers geth keychain allowed-signers > /tmp/geth.allowed_signers
geth keychain allowed-signers --out /tmp/geth.allowed_signers
geth keychain sign-file \
--in /tmp/authorized_keys \
--out /tmp/authorized_keys.sig \
--signing-key ~/.ssh/id_ed25519_sk
geth keychain verify-file \
--in /tmp/authorized_keys \
--signature /tmp/authorized_keys.sig
geth keychain sigchain --out /tmp/geth.sigchain.jsonl
geth keychain publish-bundle \
--out ./public/.well-known/sshsigchain \
--signing-key ~/.ssh/id_ed25519_sk \
--snapshot authorized_keys=/tmp/authorized_keys
geth keychain verify-checkpoint \
--checkpoint /tmp/geth.sigchain.checkpoint.json \
--signature /tmp/geth.sigchain.checkpoint.json.sig \
--sigchain /tmp/geth.sigchain.jsonl \
--allowed-signers /tmp/geth.allowed_signers
geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import
geth keychain verify-sigchain --in /tmp/geth.sigchain.jsonl
geth keychain import-sigchain --in /tmp/geth.sigchain.jsonl
geth keychain explain <op-id>
geth keychain explain-signer <key-id>
geth keychain verify geth keychain verify
``` ```
@ -459,7 +482,23 @@ trusted state. The reusable mechanics live in the `geth-keychain` crate,
including application-specific signature profiles, allowed-signers projection, including application-specific signature profiles, allowed-signers projection,
replay verification through a caller-provided verifier trait, and an appendable replay verification through a caller-provided verifier trait, and an appendable
JSONL sigchain file format suitable for static hosting with HTTP caching/range JSONL sigchain file format suitable for static hosting with HTTP caching/range
requests. requests. The default discovery/publication base is
`https://example.com/.well-known/sshsigchain/`; publish bundles contain
`allowed_signers`, `geth.sigchain.jsonl`, `geth.sigchain.checkpoint.json`, and
`geth.sigchain.checkpoint.json.sig`. Clients can verify checkpoints, fetch
bundles, import verified sigchains, and remember the last accepted checkpoint to
reject older static bundles from the same source. `keychain fetch --url` is the
retrieval location, so local `file://` mirrors work for testing; the signed
checkpoint still records the advertised publication base URL, and
`verify-checkpoint --base-url` can pin that value when needed.
Signing is mediated by OpenSSH. `--signing-key` may point at a private key file,
a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is
available in `ssh-agent`. For encrypted private keys, the recommended workflow
is to unlock the key with `ssh-add` and pass the public key path. For PKCS#11
tokens, load the key into `ssh-agent` with `ssh-add -s <provider>` and use the
exported public key path; direct PKCS#11 signing is not exposed by
`ssh-keygen -Y sign` in a portable way.
The enrollment flow for a new node is: The enrollment flow for a new node is:

View file

@ -14,6 +14,7 @@ serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
geth-config = { path = "../geth-config" } geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }
geth-keychain = { path = "../geth-keychain" }
geth-cas = { path = "../geth-cas" } geth-cas = { path = "../geth-cas" }
geth-node = { path = "../geth-node" } geth-node = { path = "../geth-node" }
geth-pipe = { path = "../geth-pipe" } geth-pipe = { path = "../geth-pipe" }

View file

@ -153,6 +153,24 @@ const GUIDE_KEYS: &str = r#"Key terminology:
shells out to ssh-keygen -Y sign with explicit namespaces to sign canonical shells out to ssh-keygen -Y sign with explicit namespaces to sign canonical
geth keychain/auth operations. The private key is not copied into geth state. geth keychain/auth operations. The private key is not copied into geth state.
Signing sources:
Local key file:
--signing-key ~/.ssh/id_ed25519 --admin-key ~/.ssh/id_ed25519.pub
Encrypted key file:
Load it into ssh-agent with `ssh-add ~/.ssh/id_ed25519`, then sign through
the agent by passing the public key path:
--signing-key ~/.ssh/id_ed25519.pub
FIDO/YubiKey OpenSSH key:
Use the security-key stub or load it into ssh-agent:
--signing-key ~/.ssh/id_ed25519_sk --admin-key ~/.ssh/id_ed25519_sk.pub
PKCS#11:
Direct ssh-keygen -Y signing does not expose a portable -D provider option.
Load the token key into ssh-agent with `ssh-add -s <provider>`, then pass
the public key path with --signing-key.
Examples: Examples:
Software key: Software key:
--admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519 --admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519
@ -160,6 +178,15 @@ Examples:
YubiKey/FIDO OpenSSH key: YubiKey/FIDO OpenSSH key:
--admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk --admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk
Generate the active OpenSSH allowed_signers projection:
geth keychain allowed-signers --out ~/.config/geth/allowed_signers
Sign an arbitrary authorized_keys snapshot with an active admin key:
geth keychain sign-file --in ~/.ssh/authorized_keys --out ~/.ssh/authorized_keys.sig --signing-key ~/.ssh/id_ed25519_sk
Verify the snapshot signature against the current keychain trust root:
geth keychain verify-file --in ~/.ssh/authorized_keys --signature ~/.ssh/authorized_keys.sig
If you use --owner, --node-name, or --capability during init, geth requires both If you use --owner, --node-name, or --capability during init, geth requires both
key options because those fields create signed owner/device/node statements. key options because those fields create signed owner/device/node statements.
"#; "#;
@ -654,7 +681,86 @@ pub enum KeychainCommand {
#[arg(long)] #[arg(long)]
admin_key: Option<PathBuf>, admin_key: Option<PathBuf>,
}, },
AllowedSigners, AllowedSigners {
#[arg(long)]
out: Option<PathBuf>,
},
SignFile {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
VerifyFile {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
signature: PathBuf,
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
allowed_signers: Option<PathBuf>,
#[arg(long)]
principal: Option<String>,
},
Sigchain {
#[arg(long)]
out: Option<PathBuf>,
},
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>,
},
VerifySigchain {
#[arg(long = "in")]
input: PathBuf,
},
ImportSigchain {
#[arg(long = "in")]
input: PathBuf,
},
VerifyCheckpoint {
#[arg(long)]
checkpoint: PathBuf,
#[arg(long)]
signature: PathBuf,
#[arg(long)]
sigchain: PathBuf,
#[arg(long)]
allowed_signers: PathBuf,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
principal: Option<String>,
},
Fetch {
#[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)]
url: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
import: bool,
},
Explain {
op_id: String,
},
ExplainSigner {
key: String,
},
Verify, Verify,
Sync { Sync {
node: String, node: String,
@ -1482,8 +1588,92 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
admin_key_path: admin_key, admin_key_path: admin_key,
}, },
Command::Keychain { Command::Keychain {
command: KeychainCommand::AllowedSigners, command: KeychainCommand::AllowedSigners { out },
} => ControlRequest::KeychainAllowedSigners, } => ControlRequest::KeychainAllowedSigners { out },
Command::Keychain {
command:
KeychainCommand::SignFile {
input,
out,
namespace,
signing_key,
admin_key,
},
} => ControlRequest::KeychainSignFile {
input,
out,
namespace,
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
},
Command::Keychain {
command:
KeychainCommand::VerifyFile {
input,
signature,
namespace,
allowed_signers,
principal,
},
} => ControlRequest::KeychainVerifyFile {
input,
signature,
namespace,
allowed_signers_path: allowed_signers,
principal,
},
Command::Keychain {
command: KeychainCommand::Sigchain { out },
} => ControlRequest::KeychainSigchainExport { out },
Command::Keychain {
command:
KeychainCommand::PublishBundle {
out,
base_url,
signing_key,
admin_key,
snapshots,
},
} => ControlRequest::KeychainPublishBundle {
out,
base_url: Some(base_url),
signing_key_path: signing_key,
admin_key_path: admin_key,
snapshots,
},
Command::Keychain {
command: KeychainCommand::VerifySigchain { input },
} => ControlRequest::KeychainVerifySigchain { input },
Command::Keychain {
command: KeychainCommand::ImportSigchain { input },
} => ControlRequest::KeychainImportSigchain { input },
Command::Keychain {
command:
KeychainCommand::VerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
},
} => ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
},
Command::Keychain {
command: KeychainCommand::Fetch { url, out, import },
} => ControlRequest::KeychainFetch { url, out, import },
Command::Keychain {
command: KeychainCommand::Explain { op_id },
} => ControlRequest::KeychainExplain { op_id },
Command::Keychain {
command: KeychainCommand::ExplainSigner { key },
} => ControlRequest::KeychainExplainSigner { key },
Command::Keychain { Command::Keychain {
command: KeychainCommand::Verify, command: KeychainCommand::Verify,
} => ControlRequest::KeychainVerify, } => ControlRequest::KeychainVerify,
@ -1990,6 +2180,23 @@ fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
.context("resolve current geth executable") .context("resolve current geth executable")
} }
fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) {
println!("ops: {}", report.ops);
println!("signatures: {}", report.signatures);
println!("accepted_ops: {}", report.accepted_ops);
println!("rejected_ops: {}", report.rejected_ops);
println!("active_admin_keys: {}", report.active_admin_keys);
println!(
"accepted_head: {}",
report
.accepted_head
.as_ref()
.map(|head| head.as_str())
.unwrap_or("none")
);
println!("note: {}", report.note);
}
fn print_response(response: ControlResponse, json: bool) -> Result<()> { fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json { if json {
println!("{}", serde_json::to_string_pretty(&response)?); println!("{}", serde_json::to_string_pretty(&response)?);
@ -2529,30 +2736,168 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
ControlResponse::KeychainAllowedSigners { ControlResponse::KeychainAllowedSigners {
allowed_signers, allowed_signers,
out,
note, note,
.. ..
} => { } => {
print!("{allowed_signers}"); if let Some(out) = out {
if allowed_signers.is_empty() { println!("wrote allowed_signers: {}", out.display());
println!("no active admin public keys available"); if allowed_signers.is_empty() {
println!("warning: generated file has no active admin public keys");
}
} else {
print!("{allowed_signers}");
if allowed_signers.is_empty() {
println!("no active admin public keys available");
}
} }
eprintln!("note: {note}"); eprintln!("note: {note}");
} }
ControlResponse::KeychainVerified { report } => { ControlResponse::KeychainFileSigned {
println!("ops: {}", report.ops); input,
println!("signatures: {}", report.signatures); out,
println!("accepted_ops: {}", report.accepted_ops); namespace,
println!("rejected_ops: {}", report.rejected_ops); signer,
println!("active_admin_keys: {}", report.active_admin_keys); note,
} => {
println!("signed file: {}", input.display());
println!( println!(
"accepted_head: {}", "signature: {}",
report out.map(|path| path.display().to_string())
.accepted_head .unwrap_or_else(|| "none".to_owned())
);
println!("namespace: {namespace}");
println!("signer: {signer}");
eprintln!("note: {note}");
}
ControlResponse::KeychainFileVerified {
input,
signature,
namespace,
verified,
principal,
note,
} => {
println!("file: {}", input.display());
println!("signature: {}", signature.display());
println!("namespace: {namespace}");
println!("principal: {}", principal.as_deref().unwrap_or("none"));
println!("verified: {verified}");
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainExported {
jsonl, out, note, ..
} => {
if let Some(out) = out {
println!("wrote keychain sigchain: {}", out.display());
} else {
print!("{jsonl}");
}
eprintln!("note: {note}");
}
ControlResponse::KeychainBundlePublished {
out,
base_url,
allowed_signers_path,
sigchain_path,
checkpoint_path,
checkpoint_signature_path,
snapshots,
note,
..
} => {
println!("bundle: {}", out.display());
println!("base_url: {base_url}");
println!("allowed_signers: {}", allowed_signers_path.display());
println!("sigchain: {}", sigchain_path.display());
println!("checkpoint: {}", checkpoint_path.display());
println!(
"checkpoint_signature: {}",
checkpoint_signature_path.display()
);
for snapshot in snapshots {
println!(
"snapshot: {} {} {}",
snapshot.name,
snapshot.path.display(),
snapshot.signature_path.display()
);
}
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainFileVerified {
input,
report,
note,
} => {
println!("sigchain: {}", input.display());
print_keychain_sigchain_report(&report);
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainImported {
input,
ops_imported,
signatures_imported,
invalid_ops_rejected,
note,
} => {
println!("sigchain: {}", input.display());
println!("ops_imported: {ops_imported}");
println!("signatures_imported: {signatures_imported}");
println!("invalid_ops_rejected: {invalid_ops_rejected}");
eprintln!("note: {note}");
}
ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note,
} => {
println!(
"checkpoint_head: {}",
checkpoint
.head
.as_ref() .as_ref()
.map(|head| head.as_str()) .map(|h| h.as_str())
.unwrap_or("none") .unwrap_or("none")
); );
println!("note: {}", report.note); println!("base_url: {}", checkpoint.base_url);
println!("verified: {verified}");
println!("principal: {}", principal.as_deref().unwrap_or("none"));
eprintln!("note: {note}");
}
ControlResponse::KeychainFetched {
url,
out,
checkpoint,
imported,
note,
} => {
println!("url: {url}");
println!("out: {}", out.display());
println!(
"checkpoint_head: {}",
checkpoint
.head
.as_ref()
.map(|h| h.as_str())
.unwrap_or("none")
);
if let Some(imported) = imported {
println!("ops_imported: {}", imported.ops_imported);
println!("signatures_imported: {}", imported.signatures_imported);
println!("invalid_ops_rejected: {}", imported.invalid_ops_rejected);
}
eprintln!("note: {note}");
}
ControlResponse::KeychainExplained { subject, lines } => {
println!("subject: {subject}");
for line in lines {
println!("{line}");
}
}
ControlResponse::KeychainVerified { report } => {
print_keychain_sigchain_report(&report);
} }
ControlResponse::KeychainSynced { ControlResponse::KeychainSynced {
peer_node_id, peer_node_id,

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, KeychainOp, KeychainOpSignature, KeychainSigchainReport, KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature,
NodeEnrollmentRequest, NodeRecord, KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord,
}; };
use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_overlay::{ use geth_overlay::{
@ -241,7 +241,58 @@ pub enum ControlRequest {
signing_key_path: PathBuf, signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>, admin_key_path: Option<PathBuf>,
}, },
KeychainAllowedSigners, KeychainAllowedSigners {
out: Option<PathBuf>,
},
KeychainSignFile {
input: PathBuf,
out: Option<PathBuf>,
namespace: Option<String>,
signing_key_path: Option<PathBuf>,
admin_key_path: Option<PathBuf>,
},
KeychainVerifyFile {
input: PathBuf,
signature: PathBuf,
namespace: Option<String>,
allowed_signers_path: Option<PathBuf>,
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 {
input: PathBuf,
},
KeychainImportSigchain {
input: PathBuf,
},
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 {
op_id: String,
},
KeychainExplainSigner {
key: String,
},
KeychainVerify, KeychainVerify,
KeychainSync { KeychainSync {
node: String, node: String,
@ -687,8 +738,70 @@ pub enum ControlResponse {
KeychainAllowedSigners { KeychainAllowedSigners {
entries: Vec<KeychainAllowedSigner>, entries: Vec<KeychainAllowedSigner>,
allowed_signers: String, allowed_signers: String,
out: Option<PathBuf>,
note: String, note: String,
}, },
KeychainFileSigned {
input: PathBuf,
out: Option<PathBuf>,
namespace: String,
signer: String,
note: String,
},
KeychainFileVerified {
input: PathBuf,
signature: PathBuf,
namespace: String,
verified: bool,
principal: Option<String>,
note: String,
},
KeychainSigchainExported {
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,
report: KeychainSigchainReport,
note: String,
},
KeychainSigchainImported {
input: PathBuf,
ops_imported: usize,
signatures_imported: usize,
invalid_ops_rejected: usize,
note: String,
},
KeychainCheckpointVerified {
checkpoint: KeychainCheckpoint,
verified: bool,
principal: Option<String>,
note: String,
},
KeychainFetched {
url: String,
out: PathBuf,
checkpoint: KeychainCheckpoint,
imported: Option<KeychainFetchImportReport>,
note: String,
},
KeychainExplained {
subject: String,
lines: Vec<String>,
},
KeychainVerified { KeychainVerified {
report: KeychainSigchainReport, report: KeychainSigchainReport,
}, },
@ -1021,6 +1134,22 @@ pub enum ControlResponse {
}, },
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainFetchImportReport {
pub ops_imported: usize,
pub signatures_imported: usize,
pub invalid_ops_rejected: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainPublishedSnapshot {
pub name: String,
pub source: PathBuf,
pub path: PathBuf,
pub signature_path: PathBuf,
pub namespace: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResponse { pub struct StatusResponse {
pub home: PathBuf, pub home: PathBuf,

View file

@ -23,7 +23,11 @@ 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 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>;
@ -194,6 +198,21 @@ pub struct KeychainSigchainEntry {
pub signatures: Vec<KeychainOpSignature>, 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;
} }
@ -299,6 +318,8 @@ pub enum KeychainError {
InvalidNamespaceComponent { field: String, value: String }, InvalidNamespaceComponent { field: String, value: String },
#[error("invalid keychain principal: {0}")] #[error("invalid keychain principal: {0}")]
InvalidPrincipal(String), InvalidPrincipal(String),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("sigchain JSONL line {line}: {source}")] #[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl { SigchainJsonl {
line: usize, line: usize,
@ -763,6 +784,44 @@ pub fn flatten_sigchain_entries(
(ops, signatures) (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
@ -1085,6 +1144,53 @@ mod tests {
assert_eq!(decoded_signatures, signatures); 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

@ -81,6 +81,8 @@ pub enum NodeError {
Control(#[from] geth_control::ControlError), Control(#[from] geth_control::ControlError),
#[error("codec error: {0}")] #[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError), Codec(#[from] geth_codec::CodecError),
#[error("keychain error: {0}")]
Keychain(#[from] geth_keychain::KeychainError),
#[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}")]
@ -99,6 +101,8 @@ 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}")]
@ -7680,18 +7684,181 @@ pub fn handle_request(
note: "recorded signed admin key revocation in the keychain sigchain".to_owned(), note: "recorded signed admin key revocation in the keychain sigchain".to_owned(),
}) })
} }
ControlRequest::KeychainAllowedSigners => { ControlRequest::KeychainAllowedSigners { out } => {
let entries = geth_keychain::allowed_signers( let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?, &load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?, &load_keychain_signatures(&store)?,
); );
let allowed_signers = geth_keychain::render_allowed_signers(&entries); let allowed_signers = geth_keychain::render_allowed_signers(&entries);
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &allowed_signers)?;
}
Ok(ControlResponse::KeychainAllowedSigners { Ok(ControlResponse::KeychainAllowedSigners {
entries, entries,
allowed_signers, allowed_signers,
out,
note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(), note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(),
}) })
} }
ControlRequest::KeychainSignFile {
input,
out,
namespace,
signing_key_path,
admin_key_path,
} => {
let signing_key_path = signing_key_path
.as_ref()
.ok_or_else(|| NodeError::SigningKeyRequired("keychain sign-file".to_owned()))?;
let namespace =
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?,
);
let (signer, _) =
keychain_signer_from_paths(signing_key_path, admin_key_path.as_deref())?;
if !entries.iter().any(|entry| entry.key == signer) {
return Err(NodeError::Unauthorized(format!(
"signing key {signer} is not an active keychain admin signer"
)));
}
let signature_out = sign_file_with_ssh(signing_key_path, &namespace, &input, out)?;
Ok(ControlResponse::KeychainFileSigned {
input,
out: Some(signature_out),
namespace,
signer: signer.to_string(),
note: "signed file with an active keychain admin signer; publish the file, signature, and allowed_signers projection together".to_owned(),
})
}
ControlRequest::KeychainVerifyFile {
input,
signature,
namespace,
allowed_signers_path,
principal,
} => {
let namespace =
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
let (verified, matched_principal) = verify_file_with_keychain_signers(
&store,
node,
&input,
&signature,
&namespace,
allowed_signers_path.as_deref(),
principal.as_deref(),
)?;
Ok(ControlResponse::KeychainFileVerified {
input,
signature,
namespace,
verified,
principal: matched_principal,
note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
})
}
ControlRequest::KeychainSigchainExport { out } => {
let ops = load_keychain_ops(&store)?;
let signatures = load_keychain_signatures(&store)?;
let entries = geth_keychain::sigchain_entries(&ops, &signatures);
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?;
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &jsonl)?;
}
Ok(ControlResponse::KeychainSigchainExported {
entries,
jsonl,
out,
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
})
}
ControlRequest::KeychainPublishBundle {
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
} => publish_keychain_bundle(
&store,
node,
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
),
ControlRequest::KeychainVerifySigchain { input } => {
let text = std::fs::read_to_string(&input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok(ControlResponse::KeychainSigchainFileVerified {
input,
report,
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
.to_owned(),
})
}
ControlRequest::KeychainImportSigchain { input } => {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
let imported =
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
Ok(ControlResponse::KeychainSigchainImported {
input,
ops_imported: imported.ops_imported,
signatures_imported: imported.signatures_imported,
invalid_ops_rejected: imported.invalid_ops_rejected,
note: if imported.invalid_ops_rejected == 0 {
"imported verified JSONL sigchain entries".to_owned()
} else {
"sigchain contained rejected operations; nothing imported".to_owned()
},
})
}
ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
} => {
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
node,
&checkpoint,
&signature,
&sigchain,
&allowed_signers,
base_url.as_deref(),
principal.as_deref(),
)?;
Ok(ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
.to_owned(),
})
}
ControlRequest::KeychainFetch { url, out, import } => {
fetch_keychain_bundle(&store, node, url, out, import)
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?,
}),
ControlRequest::KeychainExplainSigner { key } => Ok(ControlResponse::KeychainExplained {
subject: key.clone(),
lines: explain_keychain_signer(&store, &key)?,
}),
ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified { ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified {
report: verify_keychain_sigchain_with_ssh(&store, node)?, report: verify_keychain_sigchain_with_ssh(&store, node)?,
}), }),
@ -10501,6 +10668,510 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
Ok(()) Ok(())
} }
#[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)
.collect::<BTreeSet<_>>();
let existing_signatures = load_keychain_signatures(store)?
.into_iter()
.map(|signature| {
(
signature.op_id,
signature.signer,
signature.namespace,
signature.signature,
)
})
.collect::<BTreeSet<_>>();
let mut ops_imported = 0;
for op in ops {
if !existing_ops.contains(&op.id) {
store_keychain_op(store, op)?;
ops_imported += 1;
}
}
let mut signatures_imported = 0;
for signature in signatures {
let key = (
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
signature.signature.clone(),
);
if !existing_signatures.contains(&key) {
store.insert_keychain_signature(&StoredKeychainSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
})?;
signatures_imported += 1;
}
}
Ok(geth_control::KeychainFetchImportReport {
ops_imported,
signatures_imported,
invalid_ops_rejected: 0,
})
}
fn verify_keychain_checkpoint_files(
node: &LocalNode,
checkpoint_path: &Path,
signature_path: &Path,
sigchain_path: &Path,
allowed_signers_path: &Path,
expected_base_url: Option<&str>,
principal: Option<&str>,
) -> Result<(geth_keychain::KeychainCheckpoint, bool, Option<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(
store: &Store,
node: &LocalNode,
op_id: &str,
) -> Result<Vec<String>, NodeError> {
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let Some(op) = ops.iter().find(|op| op.id.as_str() == op_id) else {
return Ok(vec![format!("operation not found: {op_id}")]);
};
let sorted = geth_keychain::sorted_keychain_ops(ops.clone());
let position = sorted.iter().position(|candidate| candidate.id == op.id);
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.collect::<Vec<_>>();
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
let mut lines = Vec::new();
lines.push(format!("op_id: {}", op.id));
lines.push(format!("created_at_ms: {}", op.created_at.0));
lines.push(format!("kind: {:?}", op.kind));
if let Some(position) = position {
lines.push(format!("position: {position}"));
}
lines.push(format!("signatures: {}", op_signatures.len()));
for signature in op_signatures {
let claimed = geth_keychain::signature_uses_claimed_key(signature);
let verified = verify_keychain_signature_with_ssh(
node,
op,
&stored_keychain_signature_from_signature(signature),
)
.unwrap_or(false);
lines.push(format!(
"signature signer={} namespace={} claimed_key={} cryptographic_verify={}",
signature.signer, signature.namespace, claimed, verified
));
}
lines.push(format!(
"replay_accepted_head: {}",
report
.accepted_head
.as_ref()
.map(|head| head.as_str())
.unwrap_or("none")
));
lines.push(format!("replay_rejected_ops: {}", report.rejected_ops));
let accepted = report.accepted_head.as_ref().is_some_and(|_| {
let accepted_ops = report.accepted_ops;
sorted
.iter()
.take(accepted_ops)
.any(|accepted_op| accepted_op.id == op.id)
});
lines.push(format!("accepted_by_replay: {accepted}"));
Ok(lines)
}
fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, NodeError> {
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let key_id = KeyId::new(key.to_owned());
let allowed = geth_keychain::allowed_signers(&ops, &signatures);
let mut lines = Vec::new();
lines.push(format!("key: {key}"));
if let Some(entry) = allowed.iter().find(|entry| entry.key == key_id) {
lines.push("active_admin_signer: true".to_owned());
lines.push(format!("principal: {}", entry.principal));
lines.push(format!("public_key: {}", entry.public_key.trim()));
} else {
lines.push("active_admin_signer: false".to_owned());
}
for op in geth_keychain::sorted_keychain_ops(ops) {
match &op.kind {
KeychainOpKind::AdminKeyAdd { key: op_key, .. } if op_key == &key_id => {
lines.push(format!("added_by_op: {} at {}", op.id, op.created_at.0));
}
KeychainOpKind::AdminKeyRevoke { key: op_key } if op_key == &key_id => {
lines.push(format!("revoked_by_op: {} at {}", op.id, op.created_at.0));
}
_ => {}
}
}
let signed = signatures
.iter()
.filter(|signature| signature.signer == key_id)
.count();
lines.push(format!("signed_operations: {signed}"));
Ok(lines)
}
fn publish_keychain_bundle(
store: &Store,
_node: &LocalNode,
out: PathBuf,
base_url: Option<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(
node: &LocalNode,
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
) -> geth_keychain::KeychainSigchainReport {
struct SshKeychainVerifier<'a> {
node: &'a LocalNode,
}
impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> {
fn verify_keychain_signature(
&self,
op: &KeychainOp,
signature: &KeychainOpSignature,
) -> bool {
verify_keychain_signature_with_ssh(
self.node,
op,
&stored_keychain_signature_from_signature(signature),
)
.unwrap_or(false)
}
}
let verifier = SshKeychainVerifier { node };
geth_keychain::verify_sigchain(ops, signatures, &verifier)
}
fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> {
let Some((name, path)) = value.split_once('=') else {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
};
validate_snapshot_name(name)?;
let path = PathBuf::from(path);
if !path.is_file() {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
}
Ok((name.to_owned(), path))
}
fn validate_snapshot_name(name: &str) -> Result<(), NodeError> {
let valid = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
if valid {
Ok(())
} else {
Err(NodeError::InvalidKeychainSnapshot(name.to_owned()))
}
}
fn snapshot_namespace(name: &str) -> String {
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
}
fn verify_keychain_sigchain_with_ssh( fn verify_keychain_sigchain_with_ssh(
store: &Store, store: &Store,
node: &LocalNode, node: &LocalNode,
@ -10562,9 +11233,16 @@ fn keychain_signer_from_paths(
signing_key_path: &Path, signing_key_path: &Path,
admin_key_path: Option<&Path>, admin_key_path: Option<&Path>,
) -> Result<(KeyId, String), NodeError> { ) -> Result<(KeyId, String), NodeError> {
let public_key_path = admin_key_path let public_key_path = admin_key_path.map(Path::to_path_buf).unwrap_or_else(|| {
.map(Path::to_path_buf) if signing_key_path
.unwrap_or_else(|| Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf()); .extension()
.is_some_and(|extension| extension == "pub")
{
signing_key_path.to_path_buf()
} else {
Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf()
}
});
let public_key = std::fs::read_to_string(public_key_path)?; let public_key = std::fs::read_to_string(public_key_path)?;
Ok(( Ok((
KeyId::new(ssh_public_key_fingerprint(&public_key)), KeyId::new(ssh_public_key_fingerprint(&public_key)),
@ -10572,6 +11250,131 @@ fn keychain_signer_from_paths(
)) ))
} }
fn sign_file_with_ssh(
signing_key_path: &Path,
namespace: &str,
input_path: &Path,
out: Option<PathBuf>,
) -> Result<PathBuf, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let signature_path = Path::new(&format!("{}.sig", input_path.display())).to_path_buf();
if signature_path.exists() {
std::fs::remove_file(&signature_path)?;
}
let output =
geth_ssh_identity::sign_command(signing_key_path, namespace, input_path).output()?;
if !output.status.success() {
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
)
.into());
}
let Some(out) = out else {
return Ok(signature_path);
};
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if out != signature_path {
if out.exists() {
std::fs::remove_file(&out)?;
}
match std::fs::rename(&signature_path, &out) {
Ok(()) => {}
Err(_) => {
std::fs::copy(&signature_path, &out)?;
std::fs::remove_file(&signature_path)?;
}
}
}
Ok(out)
}
fn verify_file_with_keychain_signers(
store: &Store,
node: &LocalNode,
input_path: &Path,
signature_path: &Path,
namespace: &str,
allowed_signers_path: Option<&Path>,
principal: Option<&str>,
) -> Result<(bool, Option<String>), NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let verify_dir = node
.paths
.home()
.join("keychain-signatures")
.join("file-verify");
std::fs::create_dir_all(&verify_dir)?;
let stable_id = geth_crypto::blake3_hex(
format!(
"{}\0{}\0{}\0{}",
input_path.display(),
signature_path.display(),
namespace,
principal.unwrap_or("")
)
.as_bytes(),
);
let owned_allowed_signers_path;
let allowed_signers_path = if let Some(path) = allowed_signers_path {
path
} else {
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(store)?,
&load_keychain_signatures(store)?,
);
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
owned_allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers"));
std::fs::write(&owned_allowed_signers_path, allowed_signers)?;
&owned_allowed_signers_path
};
let principals = if let Some(principal) = principal {
vec![principal.to_owned()]
} else {
read_allowed_signer_principals(allowed_signers_path)?
};
for principal in principals {
let payload = std::fs::File::open(input_path)?;
let output = std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(allowed_signers_path)
.arg("-I")
.arg(&principal)
.arg("-n")
.arg(namespace)
.arg("-s")
.arg(signature_path)
.stdin(std::process::Stdio::from(payload))
.output()?;
if output.status.success() {
return Ok((true, Some(principal)));
}
}
Ok((false, None))
}
fn read_allowed_signer_principals(path: &Path) -> Result<Vec<String>, NodeError> {
let text = std::fs::read_to_string(path)?;
let mut principals = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(field) = line.split_whitespace().next() {
for principal in field.split(',') {
if !principal.is_empty() && !principals.iter().any(|item| item == principal) {
principals.push(principal.to_owned());
}
}
}
}
Ok(principals)
}
fn sign_keychain_op_with_ssh( fn sign_keychain_op_with_ssh(
store: &Store, store: &Store,
node: &LocalNode, node: &LocalNode,
@ -11167,15 +11970,18 @@ async fn start_daemon_iroh_endpoint(
let store = Store::open(&node.paths.metadata_db())?; let store = Store::open(&node.paths.metadata_db())?;
store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?; store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?;
} }
let blob_store = let iroh_blobs_path = node.paths.cas_dir().join("iroh-blobs");
iroh_blobs::store::fs::FsStore::load(node.paths.cas_dir().join("iroh-blobs")) std::fs::create_dir_all(&iroh_blobs_path)?;
.await let iroh_docs_path = node.paths.home().join("iroh-docs");
.map_err(|error| { std::fs::create_dir_all(&iroh_docs_path)?;
NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}")) let blob_store = iroh_blobs::store::fs::FsStore::load(iroh_blobs_path)
})?; .await
.map_err(|error| {
NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}"))
})?;
let gossip = iroh_gossip::net::Gossip::builder().spawn(endpoint.endpoint()); let gossip = iroh_gossip::net::Gossip::builder().spawn(endpoint.endpoint());
let blob_api: iroh_blobs::api::Store = blob_store.clone().into(); let blob_api: iroh_blobs::api::Store = blob_store.clone().into();
let docs = iroh_docs::protocol::Docs::persistent(node.paths.home().join("iroh-docs")) let docs = iroh_docs::protocol::Docs::persistent(iroh_docs_path)
.spawn(endpoint.endpoint(), blob_api, gossip.clone()) .spawn(endpoint.endpoint(), blob_api, gossip.clone())
.await .await
.map_err(|error| { .map_err(|error| {

View file

@ -2627,9 +2627,11 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
} }
let response = let response = geth_node::handle_request(
geth_node::handle_request(&node, geth_control::ControlRequest::KeychainAllowedSigners) &node,
.expect("allowed signers"); geth_control::ControlRequest::KeychainAllowedSigners { out: None },
)
.expect("allowed signers");
match response { match response {
geth_control::ControlResponse::KeychainAllowedSigners { geth_control::ControlResponse::KeychainAllowedSigners {
entries, entries,
@ -2642,6 +2644,278 @@ fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() {
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
} }
let allowed_signers_path = home.path().join("allowed_signers");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainAllowedSigners {
out: Some(allowed_signers_path.clone()),
},
)
.expect("allowed signers file");
match response {
geth_control::ControlResponse::KeychainAllowedSigners { out, .. } => {
assert_eq!(out.as_deref(), Some(allowed_signers_path.as_path()));
let allowed_signers_file =
std::fs::read_to_string(&allowed_signers_path).expect("read allowed_signers");
assert!(allowed_signers_file.contains("second-admin ssh-ed25519 "));
}
other => panic!("unexpected response: {other:?}"),
}
let authorized_keys_path = home.path().join("authorized_keys");
std::fs::write(
&authorized_keys_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGexample external-login-key\n",
)
.expect("write authorized_keys snapshot");
let authorized_keys_signature_path = home.path().join("authorized_keys.sig");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainSignFile {
input: authorized_keys_path.clone(),
out: Some(authorized_keys_signature_path.clone()),
namespace: None,
signing_key_path: Some(admin_key_path.clone()),
admin_key_path: Some(admin_key_path.with_extension("pub")),
},
)
.expect("sign authorized_keys snapshot");
match response {
geth_control::ControlResponse::KeychainFileSigned {
input,
out,
namespace,
signer,
..
} => {
assert_eq!(input, authorized_keys_path);
assert_eq!(
out.as_deref(),
Some(authorized_keys_signature_path.as_path())
);
assert_eq!(namespace, geth_keychain::AUTHORIZED_KEYS_NAMESPACE);
assert!(!signer.is_empty());
assert!(authorized_keys_signature_path.exists());
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainVerifyFile {
input: authorized_keys_path.clone(),
signature: authorized_keys_signature_path.clone(),
namespace: None,
allowed_signers_path: Some(allowed_signers_path.clone()),
principal: None,
},
)
.expect("verify authorized_keys snapshot");
match response {
geth_control::ControlResponse::KeychainFileVerified {
verified,
principal,
..
} => {
assert!(verified);
assert_eq!(principal.as_deref(), Some("admin"));
}
other => panic!("unexpected response: {other:?}"),
}
let sigchain_path = home.path().join("keychain.sigchain.jsonl");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainSigchainExport {
out: Some(sigchain_path.clone()),
},
)
.expect("sigchain export");
match response {
geth_control::ControlResponse::KeychainSigchainExported { entries, out, .. } => {
assert_eq!(out.as_deref(), Some(sigchain_path.as_path()));
assert!(entries.len() >= 3);
let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain");
let decoded = geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain");
assert_eq!(decoded, entries);
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainVerifySigchain {
input: sigchain_path.clone(),
},
)
.expect("verify sigchain file");
match response {
geth_control::ControlResponse::KeychainSigchainFileVerified { report, .. } => {
assert_eq!(report.rejected_ops, 0);
assert_eq!(report.active_admin_keys, 2);
}
other => panic!("unexpected response: {other:?}"),
}
let bundle_dir = home.path().join("public-bundle");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainPublishBundle {
out: bundle_dir.clone(),
base_url: None,
signing_key_path: admin_key_path.clone(),
admin_key_path: Some(admin_key_path.with_extension("pub")),
snapshots: vec![format!(
"authorized_keys={}",
authorized_keys_path.display()
)],
},
)
.expect("publish bundle");
match response {
geth_control::ControlResponse::KeychainBundlePublished {
base_url,
allowed_signers_path,
sigchain_path: bundle_sigchain_path,
checkpoint_path,
checkpoint_signature_path,
checkpoint,
snapshots,
..
} => {
assert_eq!(base_url, geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL);
assert!(allowed_signers_path.exists());
assert!(bundle_sigchain_path.exists());
assert!(checkpoint_path.exists());
assert!(checkpoint_signature_path.exists());
assert_eq!(
checkpoint.base_url,
geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].name, "authorized_keys");
assert!(snapshots[0].path.exists());
assert!(snapshots[0].signature_path.exists());
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainVerifyCheckpoint {
checkpoint: bundle_dir.join("geth.sigchain.checkpoint.json"),
signature: bundle_dir.join("geth.sigchain.checkpoint.json.sig"),
sigchain: bundle_dir.join("geth.sigchain.jsonl"),
allowed_signers: bundle_dir.join("allowed_signers"),
base_url: Some(geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned()),
principal: None,
},
)
.expect("verify checkpoint");
match response {
geth_control::ControlResponse::KeychainCheckpointVerified {
verified,
principal,
..
} => {
assert!(verified);
assert_eq!(principal.as_deref(), Some("admin"));
}
other => panic!("unexpected response: {other:?}"),
}
let fetched_home = tempfile::tempdir().expect("fetch tempdir");
let fetched_paths = geth_config::GethPaths::from_home(fetched_home.path());
let fetched_node = geth_node::init_node(&fetched_paths).expect("init fetched node");
let response = geth_node::handle_request(
&fetched_node,
geth_control::ControlRequest::KeychainFetch {
url: format!("file://{}", bundle_dir.display()),
out: Some(fetched_home.path().join("bundle")),
import: true,
},
)
.expect("fetch bundle");
match response {
geth_control::ControlResponse::KeychainFetched {
imported: Some(imported),
checkpoint,
..
} => {
assert!(imported.ops_imported >= 3);
assert!(imported.signatures_imported >= 3);
assert_eq!(imported.invalid_ops_rejected, 0);
assert_eq!(
checkpoint.base_url,
geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL
);
}
other => panic!("unexpected response: {other:?}"),
}
let jsonl = std::fs::read_to_string(&sigchain_path).expect("read sigchain for explain");
let decoded =
geth_keychain::decode_sigchain_jsonl(&jsonl).expect("decode sigchain for explain");
let explain_op_id = decoded.last().expect("last sigchain op").op.id.to_string();
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainExplain {
op_id: explain_op_id.clone(),
},
)
.expect("explain op");
match response {
geth_control::ControlResponse::KeychainExplained { subject, lines } => {
assert_eq!(subject, explain_op_id);
assert!(lines.iter().any(|line| line.contains("accepted_by_replay")));
}
other => panic!("unexpected response: {other:?}"),
}
let admin_public_key =
std::fs::read_to_string(admin_key_path.with_extension("pub")).expect("admin pub");
let admin_key = geth_keychain::admin_key_fingerprint(&admin_public_key);
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainExplainSigner { key: admin_key },
)
.expect("explain signer");
match response {
geth_control::ControlResponse::KeychainExplained { lines, .. } => {
assert!(lines.iter().any(|line| line == "active_admin_signer: true"));
assert!(
lines
.iter()
.any(|line| line.starts_with("signed_operations:"))
);
}
other => panic!("unexpected response: {other:?}"),
}
let import_home = tempfile::tempdir().expect("import tempdir");
let import_paths = geth_config::GethPaths::from_home(import_home.path());
let import_node = geth_node::init_node(&import_paths).expect("init import node");
let response = geth_node::handle_request(
&import_node,
geth_control::ControlRequest::KeychainImportSigchain {
input: sigchain_path.clone(),
},
)
.expect("import sigchain file");
match response {
geth_control::ControlResponse::KeychainSigchainImported {
ops_imported,
signatures_imported,
invalid_ops_rejected,
..
} => {
assert!(ops_imported >= 3);
assert!(signatures_imported >= 3);
assert_eq!(invalid_ops_rejected, 0);
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify) let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify)
.expect("verify sigchain"); .expect("verify sigchain");
match response { match response {

View file

@ -356,12 +356,25 @@ pattern of verifying key-registry changes from a prior trusted state. The
transport-neutral replay rules, application-specific signature namespaces, transport-neutral replay rules, application-specific signature namespaces,
allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain` allowed-signers projection, and JSONL sigchain helpers live in `geth-keychain`
so other applications can reuse the same identity-log model without depending so other applications can reuse the same identity-log model without depending
on the daemon, SQLite, Iroh, or local control. `geth keychain sync <node>` pulls on the daemon, SQLite, Iroh, or local control. The CLI can export the same
keychain operations and signatures from an imported peer over Iroh and imports reduced key registry as OpenSSH `allowed_signers` or as appendable JSONL
only operations with a valid OpenSSH signature from a currently trusted admin sigchain data for website publication. It can also sign and verify arbitrary
key over the canonical payload. See `docs/sigchain-keychain.md` for the snapshots, such as externally managed `authorized_keys`, with an active
detailed sigchain design. This is currently a pull-based signed operation log, keychain signer under an explicit OpenSSH namespace. `geth keychain
not a CRDT or Keyhive-style convergent authority. publish-bundle` writes a website-ready bundle for
`https://example.com/.well-known/sshsigchain/`, including `allowed_signers`,
`geth.sigchain.jsonl`, a signed checkpoint, and optional signed snapshots.
`geth keychain fetch --import` verifies the checkpoint and records the last
accepted checkpoint per retrieval source URL to reject older bundles. The
retrieval source may be a local mirror; the checkpoint still carries the signed
advertised publication base URL, and explicit checkpoint verification can pin it. `geth keychain
explain` and `explain-signer` provide basic auditability for why a keychain
operation or signer is trusted. `geth keychain sync <node>` pulls keychain
operations and signatures from an imported peer over Iroh and imports only
operations with a valid OpenSSH signature from a currently trusted admin key
over the canonical payload. See `docs/sigchain-keychain.md` for the detailed
sigchain design. This is currently a pull-based signed operation log, not a
CRDT or Keyhive-style convergent authority.
New devices can use the node enrollment flow instead of hand-editing keychain New devices can use the node enrollment flow instead of hand-editing keychain
state. `geth node enroll request` creates a canonical, agent-key-signed request state. `geth node enroll request` creates a canonical, agent-key-signed request

View file

@ -325,6 +325,37 @@ resource-scoped capability decisions.
signed admin-key registry operations. signed admin-key registry operations.
- `[x]` `geth keychain allowed-signers` exports the active admin key view in - `[x]` `geth keychain allowed-signers` exports the active admin key view in
OpenSSH `allowed_signers` format. OpenSSH `allowed_signers` format.
- `[x]` `geth keychain allowed-signers --out <path>` writes the active
OpenSSH `allowed_signers` projection directly to a file.
- `[x]` `geth keychain sign-file --in <path> --out <sig>` signs arbitrary
snapshots, such as externally managed `authorized_keys`, with an active
admin key under an explicit namespace.
- `[x]` `geth keychain verify-file --in <path> --signature <sig>` verifies a
snapshot signature against the current keychain-derived `allowed_signers`
projection or a supplied `--allowed-signers` file.
- `[x]` `geth keychain sigchain --out <path>` writes the appendable JSONL
sigchain suitable for static website publication.
- `[x]` `geth keychain publish-bundle --out <dir>` writes a static website
bundle rooted at `https://example.com/.well-known/sshsigchain/` by default.
- `[x]` Publication bundles include `allowed_signers`, `geth.sigchain.jsonl`,
`geth.sigchain.checkpoint.json`, and a detached checkpoint signature.
- `[x]` Publication bundles can copy and sign external snapshots with
`--snapshot <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
basic audit output for operations and admin signers.
- `[x]` Agent/FIDO signing is supported through OpenSSH by passing a public
key or security-key stub to `--signing-key`; PKCS#11 is documented as an
ssh-agent-backed flow when loaded with `ssh-add -s <provider>`.
- `[x]` `geth keychain verify` replays the keychain sigchain against the - `[x]` `geth keychain verify` replays the keychain sigchain against the
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

View file

@ -75,6 +75,20 @@ Signatures are produced with:
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:
- a local private OpenSSH key file,
- a FIDO/YubiKey OpenSSH security-key stub,
- a public key whose private half is already loaded in `ssh-agent`, or
- a public key backed by a PKCS#11 token loaded into `ssh-agent` with
`ssh-add -s <provider>`.
Encrypted private key files should normally be unlocked into `ssh-agent` before
running geth control commands. The daemon never stores private keys or
passphrases. Direct PKCS#11 signing is intentionally not a first-class geth
backend because portable `ssh-keygen -Y sign` flows do not expose the same
provider flag as OpenSSH certificate signing.
Verification uses: Verification uses:
```sh ```sh
@ -168,10 +182,17 @@ and cacheable:
The crate provides helpers to encode/decode JSONL and flatten entries back into The crate provides helpers to encode/decode JSONL and flatten entries back into
`KeychainOp[]` plus `KeychainOpSignature[]`. `KeychainOp[]` plus `KeychainOpSignature[]`.
Future work should add explicit checkpoint records containing the accepted head geth writes checkpoint records with `geth keychain publish-bundle`. The
ID, byte offset, reduced view hash, and log hash. That would let static clients checkpoint contains the accepted head, operation/signature counts, byte length,
resume verification without replaying the entire file while still detecting BLAKE3 hashes for the sigchain and allowed signers projection, a reduced-view
rollback or truncation. 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 ## Commands
@ -205,6 +226,47 @@ format:
```sh ```sh
geth keychain allowed-signers > allowed_signers geth keychain allowed-signers > allowed_signers
geth keychain allowed-signers --out allowed_signers
geth keychain sign-file \
--in authorized_keys \
--out authorized_keys.sig \
--signing-key ~/.ssh/id_ed25519_sk
geth keychain verify-file \
--in authorized_keys \
--signature authorized_keys.sig
geth keychain sigchain --out geth.sigchain.jsonl
geth keychain publish-bundle \
--out public/.well-known/sshsigchain \
--signing-key ~/.ssh/id_ed25519_sk \
--snapshot authorized_keys=authorized_keys
geth keychain verify-checkpoint \
--checkpoint geth.sigchain.checkpoint.json \
--signature geth.sigchain.checkpoint.json.sig \
--sigchain geth.sigchain.jsonl \
--allowed-signers allowed_signers
geth keychain fetch --url https://example.com/.well-known/sshsigchain/ --import
geth keychain verify-sigchain --in geth.sigchain.jsonl
geth keychain import-sigchain --in geth.sigchain.jsonl
geth keychain explain <op-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: Replay and verify the local sigchain:
@ -221,7 +283,6 @@ locally initialized owner/admin key as the bootstrap trust anchor.
The current geth prototype does not yet provide: The current geth prototype does not yet provide:
- signed checkpoint objects equivalent to `skm.last-verified-commit`
- transparency-log style append proofs - transparency-log style append proofs
- anti-rollback protection beyond local state, HTTP cache validators, and sync - anti-rollback protection beyond local state, HTTP cache validators, and sync
conflict checks conflict checks