make sshsigchain the only portable sigchain format
This commit is contained in:
parent
9e5871d02a
commit
73500e1944
15 changed files with 812 additions and 1603 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue