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

View file

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

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

View file

@ -4,8 +4,8 @@ use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::{
KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport,
NodeEnrollmentRequest, NodeRecord,
KeychainAllowedSigner, KeychainCheckpoint, KeychainOp, KeychainOpSignature,
KeychainSigchainEntry, KeychainSigchainReport, NodeEnrollmentRequest, NodeRecord,
};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_overlay::{
@ -241,7 +241,58 @@ pub enum ControlRequest {
signing_key_path: PathBuf,
admin_key_path: Option<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,
KeychainSync {
node: String,
@ -687,8 +738,70 @@ pub enum ControlResponse {
KeychainAllowedSigners {
entries: Vec<KeychainAllowedSigner>,
allowed_signers: String,
out: Option<PathBuf>,
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 {
report: KeychainSigchainReport,
},
@ -1021,6 +1134,22 @@ pub enum ControlResponse {
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainFetchImportReport {
pub ops_imported: usize,
pub signatures_imported: usize,
pub invalid_ops_rejected: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainPublishedSnapshot {
pub name: String,
pub source: PathBuf,
pub path: PathBuf,
pub signature_path: PathBuf,
pub namespace: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResponse {
pub home: PathBuf,

View file

@ -23,7 +23,11 @@ use std::collections::{BTreeMap, BTreeSet};
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local";
pub const AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev";
pub const KEYCHAIN_CHECKPOINT_NAMESPACE: &str = "geth.sigchain-checkpoint.v1@eric.wendland.dev";
pub const DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL: &str = "https://example.com/.well-known/sshsigchain/";
pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1;
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
@ -194,6 +198,21 @@ pub struct KeychainSigchainEntry {
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 {
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
}
@ -299,6 +318,8 @@ pub enum KeychainError {
InvalidNamespaceComponent { field: String, value: String },
#[error("invalid keychain principal: {0}")]
InvalidPrincipal(String),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("sigchain JSONL line {line}: {source}")]
SigchainJsonl {
line: usize,
@ -763,6 +784,44 @@ pub fn flatten_sigchain_entries(
(ops, signatures)
}
pub fn keychain_checkpoint(
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
sigchain_jsonl: &str,
allowed_signers: &str,
base_url: impl Into<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> {
let has_single_domain_separator = value.matches('@').count() == 1;
let valid = has_single_domain_separator
@ -1085,6 +1144,53 @@ mod tests {
assert_eq!(decoded_signatures, signatures);
}
#[test]
fn checkpoint_records_static_publication_hashes() {
let ops = vec![
op(1, KeychainOpKind::KeychainInit),
op(
2,
KeychainOpKind::AdminKeyAdd {
key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
),
];
let signatures = Vec::new();
let entries = sigchain_entries(&ops, &signatures);
let jsonl = encode_sigchain_jsonl(&entries).expect("jsonl");
let allowed = render_allowed_signers(&allowed_signers(&ops, &signatures));
let checkpoint = keychain_checkpoint(
&ops,
&signatures,
&jsonl,
&allowed,
"https://example.com/.well-known/sshsigchain",
UnixMillis(10),
)
.expect("checkpoint");
assert_eq!(checkpoint.version, KEYCHAIN_CHECKPOINT_VERSION);
assert_eq!(
checkpoint.base_url,
"https://example.com/.well-known/sshsigchain/"
);
assert_eq!(checkpoint.ops, 2);
assert_eq!(checkpoint.sigchain_bytes, jsonl.len() as u64);
assert_eq!(
checkpoint.sigchain_hash,
blake3_tagged_hash(jsonl.as_bytes())
);
assert_eq!(
checkpoint.allowed_signers_hash,
blake3_tagged_hash(allowed.as_bytes())
);
assert!(!checkpoint.reduced_view_hash.is_empty());
}
#[test]
fn sigchain_verification_replays_against_prior_admin_view() {
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();

View file

@ -81,6 +81,8 @@ pub enum NodeError {
Control(#[from] geth_control::ControlError),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("keychain error: {0}")]
Keychain(#[from] geth_keychain::KeychainError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
@ -99,6 +101,8 @@ pub enum NodeError {
InvalidInitGrant(String),
#[error("invalid enrollment capability, expected <resource>=<capability>: {0}")]
InvalidEnrollmentCapability(String),
#[error("invalid keychain snapshot, expected <name>=<path>: {0}")]
InvalidKeychainSnapshot(String),
#[error("node enrollment request not found: {0}")]
NodeEnrollmentRequestNotFound(String),
#[error("node enrollment request has invalid provenance: {0}")]
@ -7680,18 +7684,181 @@ pub fn handle_request(
note: "recorded signed admin key revocation in the keychain sigchain".to_owned(),
})
}
ControlRequest::KeychainAllowedSigners => {
ControlRequest::KeychainAllowedSigners { out } => {
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?,
);
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &allowed_signers)?;
}
Ok(ControlResponse::KeychainAllowedSigners {
entries,
allowed_signers,
out,
note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(),
})
}
ControlRequest::KeychainSignFile {
input,
out,
namespace,
signing_key_path,
admin_key_path,
} => {
let signing_key_path = signing_key_path
.as_ref()
.ok_or_else(|| NodeError::SigningKeyRequired("keychain sign-file".to_owned()))?;
let namespace =
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?,
);
let (signer, _) =
keychain_signer_from_paths(signing_key_path, admin_key_path.as_deref())?;
if !entries.iter().any(|entry| entry.key == signer) {
return Err(NodeError::Unauthorized(format!(
"signing key {signer} is not an active keychain admin signer"
)));
}
let signature_out = sign_file_with_ssh(signing_key_path, &namespace, &input, out)?;
Ok(ControlResponse::KeychainFileSigned {
input,
out: Some(signature_out),
namespace,
signer: signer.to_string(),
note: "signed file with an active keychain admin signer; publish the file, signature, and allowed_signers projection together".to_owned(),
})
}
ControlRequest::KeychainVerifyFile {
input,
signature,
namespace,
allowed_signers_path,
principal,
} => {
let namespace =
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
let (verified, matched_principal) = verify_file_with_keychain_signers(
&store,
node,
&input,
&signature,
&namespace,
allowed_signers_path.as_deref(),
principal.as_deref(),
)?;
Ok(ControlResponse::KeychainFileVerified {
input,
signature,
namespace,
verified,
principal: matched_principal,
note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
})
}
ControlRequest::KeychainSigchainExport { out } => {
let ops = load_keychain_ops(&store)?;
let signatures = load_keychain_signatures(&store)?;
let entries = geth_keychain::sigchain_entries(&ops, &signatures);
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?;
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &jsonl)?;
}
Ok(ControlResponse::KeychainSigchainExported {
entries,
jsonl,
out,
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
})
}
ControlRequest::KeychainPublishBundle {
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
} => publish_keychain_bundle(
&store,
node,
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
),
ControlRequest::KeychainVerifySigchain { input } => {
let text = std::fs::read_to_string(&input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok(ControlResponse::KeychainSigchainFileVerified {
input,
report,
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
.to_owned(),
})
}
ControlRequest::KeychainImportSigchain { input } => {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
let imported =
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
Ok(ControlResponse::KeychainSigchainImported {
input,
ops_imported: imported.ops_imported,
signatures_imported: imported.signatures_imported,
invalid_ops_rejected: imported.invalid_ops_rejected,
note: if imported.invalid_ops_rejected == 0 {
"imported verified JSONL sigchain entries".to_owned()
} else {
"sigchain contained rejected operations; nothing imported".to_owned()
},
})
}
ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
} => {
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
node,
&checkpoint,
&signature,
&sigchain,
&allowed_signers,
base_url.as_deref(),
principal.as_deref(),
)?;
Ok(ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
.to_owned(),
})
}
ControlRequest::KeychainFetch { url, out, import } => {
fetch_keychain_bundle(&store, node, url, out, import)
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?,
}),
ControlRequest::KeychainExplainSigner { key } => Ok(ControlResponse::KeychainExplained {
subject: key.clone(),
lines: explain_keychain_signer(&store, &key)?,
}),
ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified {
report: verify_keychain_sigchain_with_ssh(&store, node)?,
}),
@ -10501,6 +10668,510 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
Ok(())
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StaticKeychainSourceState {
head: Option<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(
store: &Store,
node: &LocalNode,
@ -10562,9 +11233,16 @@ fn keychain_signer_from_paths(
signing_key_path: &Path,
admin_key_path: Option<&Path>,
) -> Result<(KeyId, String), NodeError> {
let public_key_path = admin_key_path
.map(Path::to_path_buf)
.unwrap_or_else(|| Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf());
let public_key_path = admin_key_path.map(Path::to_path_buf).unwrap_or_else(|| {
if signing_key_path
.extension()
.is_some_and(|extension| extension == "pub")
{
signing_key_path.to_path_buf()
} else {
Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf()
}
});
let public_key = std::fs::read_to_string(public_key_path)?;
Ok((
KeyId::new(ssh_public_key_fingerprint(&public_key)),
@ -10572,6 +11250,131 @@ fn keychain_signer_from_paths(
))
}
fn sign_file_with_ssh(
signing_key_path: &Path,
namespace: &str,
input_path: &Path,
out: Option<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(
store: &Store,
node: &LocalNode,
@ -11167,15 +11970,18 @@ async fn start_daemon_iroh_endpoint(
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?;
}
let blob_store =
iroh_blobs::store::fs::FsStore::load(node.paths.cas_dir().join("iroh-blobs"))
.await
.map_err(|error| {
NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}"))
})?;
let iroh_blobs_path = node.paths.cas_dir().join("iroh-blobs");
std::fs::create_dir_all(&iroh_blobs_path)?;
let iroh_docs_path = node.paths.home().join("iroh-docs");
std::fs::create_dir_all(&iroh_docs_path)?;
let blob_store = iroh_blobs::store::fs::FsStore::load(iroh_blobs_path)
.await
.map_err(|error| {
NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}"))
})?;
let gossip = iroh_gossip::net::Gossip::builder().spawn(endpoint.endpoint());
let blob_api: iroh_blobs::api::Store = blob_store.clone().into();
let docs = iroh_docs::protocol::Docs::persistent(node.paths.home().join("iroh-docs"))
let docs = iroh_docs::protocol::Docs::persistent(iroh_docs_path)
.spawn(endpoint.endpoint(), blob_api, gossip.clone())
.await
.map_err(|error| {

View file

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