Add owner-rooted node management

This commit is contained in:
Eric Wendland 2026-05-21 11:29:29 +02:00
commit b941037652
9 changed files with 1001 additions and 52 deletions

View file

@ -43,8 +43,8 @@ use geth_store::{
StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
ResourceName, SshCertId, SshCertRequestId, UnixMillis,
AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId,
};
use std::collections::{BTreeMap, VecDeque};
use std::path::{Component, Path, PathBuf};
@ -75,6 +75,16 @@ pub enum NodeError {
Io(#[from] std::io::Error),
#[error("invalid resource kind: {0}")]
InvalidResourceKind(String),
#[error("owner init requires --admin-key so the owner trust anchor is recorded")]
OwnerInitRequiresAdminKey,
#[error("owner init requires --signing-key so the owner statement is signed")]
OwnerInitRequiresSigningKey,
#[error(
"{0} requires --signing-key so the keychain operation can replicate as a verified admin statement"
)]
SigningKeyRequired(String),
#[error("invalid init capability grant, expected <resource>=<capability>: {0}")]
InvalidInitGrant(String),
#[error("invalid db resource name: {0}")]
InvalidDbName(String),
#[error("db path does not exist or is not a file: {0}")]
@ -180,6 +190,37 @@ struct PipeUnixConnectWire {
bearer_proof: Option<BearerProof>,
}
#[derive(Clone, Debug)]
pub struct InitOwnerOptions {
pub admin_key_path: Option<PathBuf>,
pub signing_key_path: Option<PathBuf>,
pub owner_name: String,
pub node_name: String,
pub capabilities: Vec<String>,
}
impl Default for InitOwnerOptions {
fn default() -> Self {
Self {
admin_key_path: None,
signing_key_path: None,
owner_name: "owner".to_owned(),
node_name: "local".to_owned(),
capabilities: Vec::new(),
}
}
}
impl InitOwnerOptions {
fn requests_owner_keychain(&self) -> bool {
self.admin_key_path.is_some()
|| self.signing_key_path.is_some()
|| self.node_name != "local"
|| self.owner_name != "owner"
|| !self.capabilities.is_empty()
}
}
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?;
if !paths.config_file().exists() {
@ -210,10 +251,121 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
})
}
pub fn init_owned_node(
paths: &GethPaths,
options: InitOwnerOptions,
) -> Result<LocalNode, NodeError> {
let initialize_owner = options.requests_owner_keychain();
if initialize_owner && options.admin_key_path.is_none() {
return Err(NodeError::OwnerInitRequiresAdminKey);
}
if initialize_owner && options.signing_key_path.is_none() {
return Err(NodeError::OwnerInitRequiresSigningKey);
}
let node = init_node(paths)?;
if initialize_owner {
initialize_owner_keychain(&node, options)?;
}
Ok(node)
}
pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
init_node(paths)
}
fn initialize_owner_keychain(
node: &LocalNode,
options: InitOwnerOptions,
) -> Result<Vec<KeychainOp>, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let owner = UserId::new(format!("user:{}", stable_slug(&options.owner_name)));
let device = DeviceId::new(format!("device:{}", node.node_id));
let node_id = NodeId::new(node.node_id.clone());
let agent = node.agent_id.clone().into();
let now = UnixMillis(geth_store::now_ms());
let mut ops = vec![
KeychainOp {
id: generated_keychain_op_id("keychain-init", "owner", now),
created_at: now,
kind: KeychainOpKind::KeychainInit,
},
KeychainOp {
id: generated_keychain_op_id("user-add", owner.as_str(), now),
created_at: now,
kind: KeychainOpKind::UserAdd {
user: owner.clone(),
name: options.owner_name.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("device-add", device.as_str(), now),
created_at: now,
kind: KeychainOpKind::DeviceAdd {
device: device.clone(),
user: owner.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("node-add", node_id.as_str(), now),
created_at: now,
kind: KeychainOpKind::NodeAdd {
node: node_id.clone(),
device: device.clone(),
name: options.node_name.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("agent-bind", &node.agent_id, now),
created_at: now,
kind: KeychainOpKind::AgentBind {
agent,
node: node_id.clone(),
},
},
];
if let Some(admin_key_path) = options.admin_key_path.as_ref() {
let public_key = std::fs::read_to_string(admin_key_path)?;
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
ops.push(KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
created_at: now,
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
});
}
let signatures = store_and_sign_keychain_ops(
&store,
node,
&ops,
options.signing_key_path.as_deref(),
options.admin_key_path.as_deref(),
)?;
tracing::debug!(
ops = ops.len(),
signatures = signatures.len(),
"initialized owner keychain"
);
for grant in options.capabilities {
let (resource, capability) = grant
.split_once('=')
.ok_or_else(|| NodeError::InvalidInitGrant(grant.clone()))?;
record_node_grant(
&store,
node_id.as_str(),
resource,
capability,
Some(format!(
"grant:init:{}:{}",
stable_slug(node_id.as_str()),
stable_slug(capability)
)),
)?;
}
Ok(ops)
}
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
let mut node = init_node(&paths)?;
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
@ -486,6 +638,9 @@ pub async fn handle_request_async(
resource,
capability,
} => peer_auth_check(node, &peer_node, resource, capability).await,
ControlRequest::KeychainSync { node: peer_node } => {
keychain_sync_from_peer(node, &peer_node).await
}
ControlRequest::CasFetch {
node: peer_node,
hash,
@ -864,6 +1019,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
"peer returned auth-check response to ping request".to_owned(),
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
@ -982,6 +1138,7 @@ async fn peer_auth_check(
"peer returned pong to auth-check request".to_owned(),
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
@ -1133,6 +1290,7 @@ async fn cas_fetch_from_peer(
PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
@ -2398,6 +2556,101 @@ async fn sync_status_from_peer(
}
}
async fn keychain_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(node, peer_node, "keychain-sync", |peer_card, nonce| {
PeerControlRequest::KeychainSync { peer_card, nonce }
})
.await?;
match response {
PeerControlResponse::KeychainSynced {
node_id,
agent_id,
endpoint_id,
ops,
signatures,
note,
..
} => {
let store = Store::open(&node.paths.metadata_db())?;
let mut local_ops = load_keychain_ops(&store)?;
let mut trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
let mut ops_imported = 0;
let mut signatures_imported = 0;
let mut invalid_ops_rejected = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect::<Vec<_>>();
if op_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let valid_signatures = op_signatures
.iter()
.filter(|signature| {
if !trusted_admins.contains(&signature.signer)
|| !keychain_signature_uses_claimed_key(signature)
{
return false;
}
let stored = stored_keychain_signature_from_signature(signature);
verify_keychain_signature_with_ssh(node, &op, &stored).unwrap_or(false)
})
.cloned()
.collect::<Vec<_>>();
if valid_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let existing = load_keychain_ops(&store)?
.into_iter()
.find(|existing| existing.id == op.id);
if existing.as_ref().is_some_and(|existing| existing != &op) {
invalid_ops_rejected += 1;
continue;
}
store_keychain_op(&store, &op)?;
if existing.is_none() {
local_ops.push(op.clone());
trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
ops_imported += 1;
}
for signature in valid_signatures {
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(ControlResponse::KeychainSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
note: format!(
"{note}; imported only keychain ops signed by currently trusted admin SSH keys"
),
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to keychain sync".to_owned(),
)),
}
}
async fn db_sync_from_peer(
node: &LocalNode,
peer_node: &str,
@ -2789,9 +3042,10 @@ async fn request_peer_control(
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
) -> Result<PeerControlResponse, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let peer_node = resolve_peer_node_for_control(&store, peer_node);
let stored = store
.get_peer_card(peer_node)?
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
.get_peer_card(&peer_node)?
.ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?;
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
peer_card.validate_candidate()?;
let candidate = peer_card
@ -2844,6 +3098,10 @@ async fn request_peer_control(
nonce: response_nonce,
..
}
| PeerControlResponse::KeychainSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::SshRevocationSynced {
nonce: response_nonce,
..
@ -3174,6 +3432,31 @@ async fn handle_iroh_control_connection(
note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(),
}
}
PeerControlRequest::KeychainSync { peer_card, nonce } => {
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
PeerControlResponse::KeychainSynced {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
ops: load_keychain_ops(&store)?,
signatures: load_keychain_signatures(&store)?,
nonce,
note: "keychain sync returns signed operation-log data; receiver must verify OpenSSH signatures before import".to_owned(),
}
}
PeerControlRequest::AuthCheck {
peer_card,
resource,
@ -5011,24 +5294,13 @@ pub fn handle_request(
ops.push(op);
}
let signatures = if let Some(signing_key_path) = signing_key_path {
let (signer, signer_public_key) =
keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
let mut signatures = Vec::new();
for op in &ops {
signatures.push(sign_keychain_op_with_ssh(
&store,
node,
op,
&signing_key_path,
&signer,
&signer_public_key,
)?);
}
signatures
} else {
Vec::new()
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
&ops,
signing_key_path.as_deref(),
admin_key_path.as_deref(),
)?;
Ok(ControlResponse::KeychainInitialized { ops, signatures })
}
@ -5046,6 +5318,93 @@ pub fn handle_request(
nodes: view.nodes.len(),
}))
}
ControlRequest::NodeList => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
Ok(ControlResponse::NodeList {
nodes: view.nodes.into_values().collect(),
note: "nodes are reduced from the signed keychain operation log; revoked devices and nodes are omitted".to_owned(),
})
}
ControlRequest::NodeRename {
node: target,
name,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node rename".to_owned()))?;
let target = resolve_keychain_node(&store, &target)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("node-rename", target.as_str(), created_at),
created_at,
kind: KeychainOpKind::NodeRename { node: target, name },
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(signing_key_path.as_path()),
None,
)?;
Ok(ControlResponse::NodeKeychainUpdated {
ops: vec![op],
signatures,
note: "recorded signed node rename as a keychain operation".to_owned(),
})
}
ControlRequest::NodeRevoke {
node: target,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node revoke".to_owned()))?;
let target = resolve_keychain_node(&store, &target)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("node-revoke", target.as_str(), created_at),
created_at,
kind: KeychainOpKind::NodeRevoke { node: target },
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(signing_key_path.as_path()),
None,
)?;
Ok(ControlResponse::NodeKeychainUpdated {
ops: vec![op],
signatures,
note: "recorded node revocation as a keychain operation; replicated peers will omit the node after verified keychain sync".to_owned(),
})
}
ControlRequest::NodeGrant {
node: target,
resource,
capability,
grant_id,
} => {
let target = resolve_keychain_node(&store, &target)?;
let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?;
Ok(ControlResponse::NodeGrantUpdated {
op,
note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(),
})
}
ControlRequest::NodeRevokeGrant { resource, grant_id } => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::NodeGrantUpdated {
op,
note: "recorded capability grant revocation".to_owned(),
})
}
ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus {
secrets: store
.list_resource_secrets()?
@ -5945,7 +6304,8 @@ pub fn handle_request(
}
ControlRequest::SshProxyConnect { .. }
| ControlRequest::SshProxyStream { .. }
| ControlRequest::SshAdminShell { .. } => Err(NodeError::IrohEndpointUnavailable),
| ControlRequest::SshAdminShell { .. }
| ControlRequest::KeychainSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command })
}
@ -6625,6 +6985,34 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
Ok(())
}
fn store_and_sign_keychain_ops(
store: &Store,
node: &LocalNode,
ops: &[KeychainOp],
signing_key_path: Option<&Path>,
admin_key_path: Option<&Path>,
) -> Result<Vec<KeychainOpSignature>, NodeError> {
for op in ops {
store_keychain_op(store, op)?;
}
let Some(signing_key_path) = signing_key_path else {
return Ok(Vec::new());
};
let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?;
ops.iter()
.map(|op| {
sign_keychain_op_with_ssh(
store,
node,
op,
signing_key_path,
&signer,
&signer_public_key,
)
})
.collect()
}
fn keychain_signer_from_paths(
signing_key_path: &Path,
admin_key_path: Option<&Path>,
@ -6689,6 +7077,23 @@ fn sign_keychain_op_with_ssh(
Ok(signature)
}
fn stored_keychain_signature_from_signature(
signature: &KeychainOpSignature,
) -> StoredKeychainSignature {
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,
}
}
fn keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct KeychainSignatureStatus {
total: usize,
@ -6787,6 +7192,83 @@ fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
.collect()
}
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {
Ok(store
.list_keychain_signatures()?
.into_iter()
.map(|stored| KeychainOpSignature {
op_id: AuthOpId::new(stored.op_id),
signer: KeyId::new(stored.signer),
signer_public_key: stored.signer_public_key,
namespace: stored.namespace,
signature: stored.signature,
created_at: UnixMillis(stored.created_at_ms),
})
.collect())
}
fn resolve_keychain_node(store: &Store, node_or_name: &str) -> Result<NodeId, NodeError> {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?);
if let Some((node_id, _)) = view
.nodes
.iter()
.find(|(node_id, record)| node_id.as_str() == node_or_name || record.name == node_or_name)
{
Ok(node_id.clone())
} else {
Err(NodeError::ResourceNotFound(format!("node:{node_or_name}")))
}
}
fn resolve_peer_node_for_control(store: &Store, node_or_name: &str) -> String {
resolve_keychain_node(store, node_or_name)
.map(|node| node.to_string())
.unwrap_or_else(|_| node_or_name.to_owned())
}
fn record_node_grant(
store: &Store,
node_id: &str,
resource: &str,
capability: &str,
grant_id: Option<String>,
) -> Result<AuthOp, NodeError> {
let created_at = UnixMillis(geth_store::now_ms());
let grant_id = grant_id.unwrap_or_else(|| generated_grant_id(node_id, resource, capability));
let op = AuthOp {
id: generated_auth_op_id("grant-create", resource, &grant_id, created_at),
resource: ResourceId::new(resource.to_owned()),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id,
principal: PrincipalId::new(node_id.to_owned()),
capabilities: vec![Capability::new(capability.to_owned())],
},
};
store_auth_op(store, &op)?;
Ok(op)
}
fn stable_slug(value: &str) -> String {
let slug = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_owned();
if slug.is_empty() {
"unnamed".to_owned()
} else {
slug
}
}
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!(
"grant:{}",