Add static keychain publication workflow
This commit is contained in:
parent
b6ffcde54c
commit
cfd41522d1
11 changed files with 1852 additions and 46 deletions
|
|
@ -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| {
|
||||
|
|
|
|||
Loading…
Reference in a new issue