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

@ -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,