make sshsigchain the only portable sigchain format

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

View file

@ -80,6 +80,7 @@ use runtime::{
PubsubRuntime,
};
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -117,6 +118,8 @@ pub enum NodeError {
Codec(#[from] geth_codec::CodecError),
#[error("keychain error: {0}")]
Keychain(#[from] geth_keychain::KeychainError),
#[error("SSH sigchain error: {0}")]
SshSigchain(#[from] geth_keychain::SshSigchainError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
@ -137,8 +140,6 @@ pub enum NodeError {
InvalidInitGrant(String),
#[error("invalid enrollment capability, expected <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}")]
@ -6495,95 +6496,34 @@ pub fn handle_request(
note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
})
}
ControlRequest::KeychainSigchainExport { out } => {
let ops = load_keychain_ops(&store)?;
let signatures = load_keychain_signatures(&store)?;
let entries = geth_keychain::sigchain_entries(&ops, &signatures);
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?;
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &jsonl)?;
}
Ok(ControlResponse::KeychainSigchainExported {
entries,
jsonl,
out,
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
})
}
ControlRequest::KeychainPublishBundle {
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
} => publish_keychain_bundle(
&store,
node,
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
),
ControlRequest::KeychainVerifySigchain { input } => {
let text = std::fs::read_to_string(&input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok(ControlResponse::KeychainSigchainFileVerified {
input,
report,
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
.to_owned(),
})
}
ControlRequest::KeychainImportSigchain { input } => {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
let imported =
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
Ok(ControlResponse::KeychainSigchainImported {
input,
ops_imported: imported.ops_imported,
signatures_imported: imported.signatures_imported,
invalid_ops_rejected: imported.invalid_ops_rejected,
note: if imported.invalid_ops_rejected == 0 {
"imported verified JSONL sigchain entries".to_owned()
} else {
"sigchain contained rejected operations; nothing imported".to_owned()
},
})
}
ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
ControlRequest::KeychainVerifySigchain {
input,
chain_id,
root_key_path,
namespace,
} => {
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
node,
&checkpoint,
&signature,
&sigchain,
&allowed_signers,
base_url.as_deref(),
principal.as_deref(),
let records = read_sshsigchain_jsonl_file(&input)?;
let trust = geth_keychain::SshSigchainTrust::new(
geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
std::fs::read_to_string(root_key_path)?,
)?;
Ok(ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
.to_owned(),
let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?;
Ok(ControlResponse::KeychainSigchainVerified {
input,
chain_id: trust.chain_id.to_hex(),
records: verified.records,
head: verified.head.to_hex(),
active_admin_keys: verified.view.admin_keys.len(),
users: verified.view.users.len(),
devices: verified.view.devices.len(),
nodes: verified.view.nodes.len(),
note:
"verified linked SSHSIGCHAIN v1 records against the explicitly pinned root key"
.to_owned(),
})
}
ControlRequest::KeychainFetch { url, out, import } => {
fetch_keychain_bundle(&store, node, url, out, import)
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?,
@ -9483,301 +9423,6 @@ fn stored_keychain_op_from_op(op: &KeychainOp) -> Result<StoredKeychainOp, NodeE
})
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StaticKeychainSourceState {
head: Option<AuthOpId>,
ops: usize,
sigchain_hash: String,
generated_at_ms: i64,
}
fn read_and_verify_sigchain_file(
node: &LocalNode,
input: &Path,
) -> Result<
(
Vec<KeychainOp>,
Vec<KeychainOpSignature>,
geth_keychain::KeychainSigchainReport,
),
NodeError,
> {
let text = std::fs::read_to_string(input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok((ops, signatures, report))
}
fn import_verified_sigchain(
store: &Store,
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
rejected_ops: usize,
) -> Result<geth_control::KeychainFetchImportReport, NodeError> {
if rejected_ops != 0 {
return Ok(geth_control::KeychainFetchImportReport {
ops_imported: 0,
signatures_imported: 0,
invalid_ops_rejected: rejected_ops,
});
}
let existing_ops = load_keychain_ops(store)?
.into_iter()
.map(|op| (op.id.clone(), op))
.collect::<BTreeMap<_, _>>();
let existing_signatures = load_keychain_signatures(store)?
.into_iter()
.map(|signature| {
(
(
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
),
signature,
)
})
.collect::<BTreeMap<_, _>>();
for op in ops {
if existing_ops
.get(&op.id)
.is_some_and(|existing| existing != op)
{
return Err(NodeError::Unauthorized(format!(
"refusing keychain operation {} because that ID already names different immutable content",
op.id
)));
}
}
for signature in signatures {
let identity = (
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
);
if existing_signatures
.get(&identity)
.is_some_and(|existing| existing != signature)
{
return Err(NodeError::Unauthorized(format!(
"refusing keychain signature for {} because its signer and namespace already name different immutable content",
signature.op_id
)));
}
}
let mut signatures_imported = 0;
let mut ops_imported = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.map(stored_keychain_signature_from_signature)
.collect::<Vec<_>>();
store
.insert_keychain_op_with_signatures(&stored_keychain_op_from_op(op)?, &op_signatures)?;
if !existing_ops.contains_key(&op.id) {
ops_imported += 1;
}
for signature in signatures
.iter()
.filter(|signature| signature.op_id == op.id)
{
let key = (
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
);
signatures_imported += usize::from(!existing_signatures.contains_key(&key));
}
}
Ok(geth_control::KeychainFetchImportReport {
ops_imported,
signatures_imported,
invalid_ops_rejected: 0,
})
}
fn verify_keychain_checkpoint_files(
node: &LocalNode,
checkpoint_path: &Path,
signature_path: &Path,
sigchain_path: &Path,
allowed_signers_path: &Path,
expected_base_url: Option<&str>,
principal: Option<&str>,
) -> Result<(geth_keychain::KeychainCheckpoint, bool, Option<String>), NodeError> {
let checkpoint_text = std::fs::read_to_string(checkpoint_path)?;
let checkpoint: geth_keychain::KeychainCheckpoint = serde_json::from_str(&checkpoint_text)?;
if let Some(expected) = expected_base_url {
let expected = geth_keychain::normalize_base_url(expected.to_owned());
if checkpoint.base_url != expected {
return Ok((checkpoint, false, None));
}
}
let sigchain_text = std::fs::read_to_string(sigchain_path)?;
let allowed_signers = std::fs::read_to_string(allowed_signers_path)?;
if checkpoint.sigchain_bytes != sigchain_text.len() as u64
|| checkpoint.sigchain_hash != geth_keychain::blake3_tagged_hash(sigchain_text.as_bytes())
|| checkpoint.allowed_signers_hash
!= geth_keychain::blake3_tagged_hash(allowed_signers.as_bytes())
{
return Ok((checkpoint, false, None));
}
let entries = geth_keychain::decode_sigchain_jsonl(&sigchain_text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
if report.rejected_ops != 0 || report.accepted_head != checkpoint.head {
return Ok((checkpoint, false, None));
}
let (verified, matched_principal) = verify_file_with_keychain_signers(
&Store::open(&node.paths.metadata_db())?,
node,
checkpoint_path,
signature_path,
geth_keychain::KEYCHAIN_CHECKPOINT_NAMESPACE,
Some(allowed_signers_path),
principal,
)?;
Ok((checkpoint, verified, matched_principal))
}
fn fetch_keychain_bundle(
store: &Store,
node: &LocalNode,
url: String,
out: Option<PathBuf>,
import: bool,
) -> Result<ControlResponse, NodeError> {
let base_url = geth_keychain::normalize_base_url(url.clone());
let out = out.unwrap_or_else(|| {
node.paths
.home()
.join("keychain-fetch")
.join(geth_crypto::blake3_hex(base_url.as_bytes()))
});
std::fs::create_dir_all(&out)?;
for name in [
"allowed_signers",
"geth.sigchain.jsonl",
"geth.sigchain.checkpoint.json",
"geth.sigchain.checkpoint.json.sig",
] {
fetch_bundle_file(&base_url, name, &out.join(name))?;
}
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
let signature_path = out.join("geth.sigchain.checkpoint.json.sig");
let sigchain_path = out.join("geth.sigchain.jsonl");
let allowed_signers_path = out.join("allowed_signers");
let (checkpoint, verified, _) = verify_keychain_checkpoint_files(
node,
&checkpoint_path,
&signature_path,
&sigchain_path,
&allowed_signers_path,
None,
None,
)?;
if !verified {
return Err(NodeError::Unauthorized(
"fetched keychain checkpoint did not verify".to_owned(),
));
}
check_static_source_rollback(store, &base_url, &checkpoint)?;
let imported = if import {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &sigchain_path)?;
let imported = import_verified_sigchain(store, &ops, &signatures, report.rejected_ops)?;
if imported.invalid_ops_rejected == 0 {
remember_static_source_checkpoint(store, &base_url, &checkpoint)?;
}
Some(imported)
} else {
None
};
let note = format!(
"fetched and verified static SSH sigchain bundle; checkpoint base URL is {}",
checkpoint.base_url
);
Ok(ControlResponse::KeychainFetched {
url: base_url,
out,
checkpoint,
imported,
note,
})
}
fn fetch_bundle_file(base_url: &str, name: &str, out: &Path) -> Result<(), NodeError> {
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(root) = base_url.strip_prefix("file://") {
std::fs::copy(Path::new(root).join(name), out)?;
return Ok(());
}
if base_url.starts_with("http://") || base_url.starts_with("https://") {
let url = format!("{base_url}{name}");
let output = std::process::Command::new("curl")
.arg("-fsSL")
.arg(&url)
.arg("-o")
.arg(out)
.output()?;
if output.status.success() {
return Ok(());
}
return Err(NodeError::IrohPeer(format!(
"curl failed fetching {url}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
std::fs::copy(Path::new(base_url).join(name), out)?;
Ok(())
}
fn static_source_state_key(base_url: &str) -> String {
format!(
"keychain-static-source:{}",
geth_crypto::blake3_hex(base_url.as_bytes())
)
}
fn check_static_source_rollback(
store: &Store,
base_url: &str,
checkpoint: &geth_keychain::KeychainCheckpoint,
) -> Result<(), NodeError> {
let Some(state) = store.get_module_state(&static_source_state_key(base_url))? else {
return Ok(());
};
let previous: StaticKeychainSourceState = serde_json::from_str(&state.state_json)?;
if previous.generated_at_ms > checkpoint.generated_at.0 || previous.ops > checkpoint.ops {
return Err(NodeError::Unauthorized(
"fetched keychain checkpoint is older than the last accepted checkpoint".to_owned(),
));
}
Ok(())
}
fn remember_static_source_checkpoint(
store: &Store,
base_url: &str,
checkpoint: &geth_keychain::KeychainCheckpoint,
) -> Result<(), NodeError> {
let state = StaticKeychainSourceState {
head: checkpoint.head.clone(),
ops: checkpoint.ops,
sigchain_hash: checkpoint.sigchain_hash.clone(),
generated_at_ms: checkpoint.generated_at.0,
};
store.put_module_state(&StoredModuleState {
module: static_source_state_key(base_url),
state_json: serde_json::to_string(&state)?,
updated_at_ms: geth_store::now_ms(),
})?;
Ok(())
}
fn explain_keychain_op(
store: &Store,
node: &LocalNode,
@ -9869,93 +9514,6 @@ fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, Node
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],
@ -9984,34 +9542,80 @@ fn verify_keychain_sigchain_entries_with_ssh(
geth_keychain::verify_sigchain(ops, signatures, &verifier)
}
fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> {
let Some((name, path)) = value.split_once('=') else {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
fn verify_keychain_sshsigchain_with_ssh(
records: &[geth_keychain::SshSigchainRecord],
trust: &geth_keychain::SshSigchainTrust,
) -> Result<geth_keychain::KeychainSshSigchainVerification, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let verify_dir = tempfile::tempdir()?;
struct SshSigchainOpenSshVerifier {
verify_dir: PathBuf,
}
impl geth_keychain::SshSigchainVerifier for SshSigchainOpenSshVerifier {
fn verify(
&self,
namespace: &str,
message: &[u8],
public_key: &str,
signature: &[u8],
) -> bool {
let payload_path = self.verify_dir.join("payload");
let signature_path = self.verify_dir.join("signature");
let allowed_signers_path = self.verify_dir.join("allowed-signers");
if std::fs::write(&payload_path, message).is_err()
|| std::fs::write(&signature_path, signature).is_err()
|| std::fs::write(
&allowed_signers_path,
format!(
"{} {}\n",
geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL,
public_key
),
)
.is_err()
{
return false;
}
let Ok(payload) = std::fs::File::open(payload_path) else {
return false;
};
std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(allowed_signers_path)
.arg("-I")
.arg(geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL)
.arg("-n")
.arg(namespace)
.arg("-s")
.arg(signature_path)
.stdin(std::process::Stdio::from(payload))
.output()
.is_ok_and(|output| output.status.success())
}
}
let verifier = SshSigchainOpenSshVerifier {
verify_dir: verify_dir.path().to_path_buf(),
};
validate_snapshot_name(name)?;
let path = PathBuf::from(path);
if !path.is_file() {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
}
Ok((name.to_owned(), path))
Ok(geth_keychain::verify_keychain_sshsigchain(
records, trust, &verifier,
)?)
}
fn validate_snapshot_name(name: &str) -> Result<(), NodeError> {
let valid = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
if valid {
Ok(())
} else {
Err(NodeError::InvalidKeychainSnapshot(name.to_owned()))
fn read_sshsigchain_jsonl_file(
input: &Path,
) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
let mut text = String::new();
reader.read_to_string(&mut text)?;
if text.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into());
}
}
fn snapshot_namespace(name: &str) -> String {
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?)
}
fn verify_keychain_sigchain_with_ssh(
@ -11390,6 +10994,12 @@ mod tests {
))),
"daemon_already_running"
);
assert_eq!(
local_control::node_error_code(&NodeError::SshSigchain(
geth_keychain::SshSigchainError::EmptyChain
)),
"sshsigchain_error"
);
}
#[test]
@ -11407,6 +11017,108 @@ mod tests {
);
}
#[test]
fn sshsigchain_verifies_real_openssh_sshsig_records() {
if geth_ssh_identity::ensure_ssh_keygen_available().is_err() {
return;
}
let dir = tempfile::tempdir().expect("temporary SSHSIGCHAIN directory");
let private_key = dir.path().join("root");
let generated = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&private_key)
.output()
.expect("run ssh-keygen");
assert!(
generated.status.success(),
"ssh-keygen key generation failed: {}",
String::from_utf8_lossy(&generated.stderr)
);
let generated_public_key =
std::fs::read_to_string(format!("{}.pub", private_key.display()))
.expect("read public key");
let mut fields = generated_public_key.split_ascii_whitespace();
let root_public_key = format!(
"{} {}",
fields.next().expect("key type"),
fields.next().expect("key blob")
);
let trust = geth_keychain::SshSigchainTrust::new(
geth_keychain::SshSigchainChainId([0x42; 32]),
geth_keychain::KEYCHAIN_SSH_SIGCHAIN_PROFILE,
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
&root_public_key,
)
.expect("trust tuple");
let sign = |record: geth_keychain::SshSigchainRecord| {
let payload = dir.path().join(format!("record-{}", record.sequence));
std::fs::write(&payload, record.signing_bytes().expect("signing bytes"))
.expect("write signing payload");
let output = geth_ssh_identity::sign_command(
&private_key,
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
&payload,
)
.output()
.expect("sign SSHSIGCHAIN record");
assert!(
output.status.success(),
"ssh-keygen signing failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let signature =
std::fs::read(format!("{}.sig", payload.display())).expect("read SSHSIG signature");
record.with_signature(signature).expect("signed record")
};
let init_op = KeychainOp {
id: AuthOpId::new("auth-op:sshsigchain-init"),
created_at: UnixMillis(1),
kind: KeychainOpKind::KeychainInit,
};
let init = sign(
geth_keychain::keychain_sshsigchain_unsigned_record(
&trust,
0,
None,
&init_op,
&root_public_key,
)
.expect("unsigned init"),
);
let root_add_op = KeychainOp {
id: AuthOpId::new("auth-op:sshsigchain-root"),
created_at: UnixMillis(2),
kind: KeychainOpKind::AdminKeyAdd {
key: KeyId::new(geth_keychain::admin_key_fingerprint(&root_public_key)),
public_key: Some(root_public_key.clone()),
principal: Some("root".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
};
let root_add = sign(
geth_keychain::keychain_sshsigchain_unsigned_record(
&trust,
1,
Some(init.record_hash().expect("init hash")),
&root_add_op,
&root_public_key,
)
.expect("unsigned root add"),
);
let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone(), root_add], &trust)
.expect("verify real OpenSSH SSHSIG chain");
assert_eq!(verified.records, 2);
assert_eq!(verified.view.admin_keys.len(), 1);
let mut tampered = init;
tampered.payload.push(0);
assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err());
}
#[derive(Debug, PartialEq, Eq)]
enum RemoteGuardKind {
Capability,

View file

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