Harden signed node authorization flow

This commit is contained in:
Eric Wendland 2026-05-21 18:15:10 +02:00
commit 1336fa38e8
9 changed files with 382 additions and 55 deletions

View file

@ -160,10 +160,30 @@ pub enum NodeCommand {
capability: String,
#[arg(long)]
grant_id: Option<String>,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
RevokeGrant {
resource: String,
grant_id: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
EndpointAdd {
node: String,
endpoint: String,
#[arg(long)]
signing_key: PathBuf,
},
EndpointRevoke {
node: String,
endpoint: String,
#[arg(long)]
signing_key: PathBuf,
},
}
@ -265,10 +285,18 @@ pub enum AuthCommand {
capability: String,
#[arg(long)]
grant_id: Option<String>,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
Revoke {
resource: String,
grant_id: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
}
@ -871,16 +899,55 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
resource,
capability,
grant_id,
signing_key,
admin_key,
},
} => ControlRequest::NodeGrant {
node,
resource,
capability,
grant_id,
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
},
Command::Node {
command: NodeCommand::RevokeGrant { resource, grant_id },
} => ControlRequest::NodeRevokeGrant { resource, grant_id },
command:
NodeCommand::RevokeGrant {
resource,
grant_id,
signing_key,
admin_key,
},
} => ControlRequest::NodeRevokeGrant {
resource,
grant_id,
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
},
Command::Node {
command:
NodeCommand::EndpointAdd {
node,
endpoint,
signing_key,
},
} => ControlRequest::NodeEndpointAdd {
node,
endpoint,
signing_key_path: Some(signing_key),
},
Command::Node {
command:
NodeCommand::EndpointRevoke {
node,
endpoint,
signing_key,
},
} => ControlRequest::NodeEndpointRevoke {
node,
endpoint,
signing_key_path: Some(signing_key),
},
Command::Peer { command } => match command {
PeerCommand::Export { out } => ControlRequest::PeerCardExport { out },
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
@ -940,16 +1007,31 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
resource,
capability,
grant_id,
signing_key,
admin_key,
},
} => ControlRequest::AuthGrant {
subject,
resource,
capability,
grant_id,
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
},
Command::Auth {
command: AuthCommand::Revoke { resource, grant_id },
} => ControlRequest::AuthRevoke { resource, grant_id },
command:
AuthCommand::Revoke {
resource,
grant_id,
signing_key,
admin_key,
},
} => ControlRequest::AuthRevoke {
resource,
grant_id,
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
},
Command::Secret { command } => match command {
SecretCommand::Status => ControlRequest::SecretStatus,
SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource },
@ -1905,9 +1987,15 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops);
}
ControlResponse::AuthOpRecorded { op } => {
ControlResponse::AuthOpRecorded { op, signatures } => {
println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource);
for signature in signatures {
println!(
"signed auth op: {} by {} ({})",
signature.op_id, signature.signer, signature.namespace
);
}
}
ControlResponse::NodeList { nodes, note } => {
if nodes.is_empty() {

View file

@ -132,10 +132,24 @@ pub enum ControlRequest {
resource: String,
capability: String,
grant_id: Option<String>,
signing_key_path: Option<PathBuf>,
admin_key_path: Option<PathBuf>,
},
NodeRevokeGrant {
resource: String,
grant_id: String,
signing_key_path: Option<PathBuf>,
admin_key_path: Option<PathBuf>,
},
NodeEndpointAdd {
node: String,
endpoint: String,
signing_key_path: Option<PathBuf>,
},
NodeEndpointRevoke {
node: String,
endpoint: String,
signing_key_path: Option<PathBuf>,
},
NodeEnrollRequest {
node_name: String,
@ -219,10 +233,14 @@ pub enum ControlRequest {
resource: String,
capability: String,
grant_id: Option<String>,
signing_key_path: Option<PathBuf>,
admin_key_path: Option<PathBuf>,
},
AuthRevoke {
resource: String,
grant_id: String,
signing_key_path: Option<PathBuf>,
admin_key_path: Option<PathBuf>,
},
SshCertRequest {
public_key_path: PathBuf,
@ -601,6 +619,7 @@ pub enum ControlResponse {
AuthExplain(AuthExplanation),
AuthOpRecorded {
op: AuthOp,
signatures: Vec<AuthOpSignature>,
},
NodeList {
nodes: Vec<NodeRecord>,

View file

@ -5662,16 +5662,35 @@ pub fn handle_request(
resource,
capability,
grant_id,
signing_key_path,
admin_key_path,
} => {
let target = resolve_keychain_node(&store, &target)?;
let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?;
let op = node_grant_op(target.as_str(), &resource, &capability, grant_id);
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::NodeGrantUpdated {
op,
signatures: Vec::new(),
note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(),
signatures,
note: "recorded signed resource-scoped node capability grant; auth explain can show the grant path".to_owned(),
})
}
ControlRequest::NodeRevokeGrant { resource, grant_id } => {
ControlRequest::NodeRevokeGrant {
resource,
grant_id,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
@ -5679,11 +5698,82 @@ pub fn handle_request(
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
store_auth_op(&store, &op)?;
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::NodeGrantUpdated {
op,
signatures: Vec::new(),
note: "recorded capability grant revocation".to_owned(),
signatures,
note: "recorded signed capability grant revocation".to_owned(),
})
}
ControlRequest::NodeEndpointAdd {
node: target,
endpoint,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node endpoint add".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-endpoint-add", &endpoint, created_at),
created_at,
kind: KeychainOpKind::NodeEndpointAdd {
node: target,
endpoint,
},
};
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 endpoint binding".to_owned(),
})
}
ControlRequest::NodeEndpointRevoke {
node: target,
endpoint,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node endpoint 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-endpoint-revoke", &endpoint, created_at),
created_at,
kind: KeychainOpKind::NodeEndpointRevoke {
node: target,
endpoint,
},
};
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 endpoint revocation".to_owned(),
})
}
ControlRequest::NodeEnrollRequest {
@ -5985,6 +6075,8 @@ pub fn handle_request(
resource,
capability,
grant_id,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let grant_id =
@ -5999,10 +6091,26 @@ pub fn handle_request(
capabilities: vec![Capability::new(capability)],
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::AuthOpRecorded { op })
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::AuthOpRecorded { op, signatures })
}
ControlRequest::AuthRevoke { resource, grant_id } => {
ControlRequest::AuthRevoke {
resource,
grant_id,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
@ -6010,8 +6118,19 @@ pub fn handle_request(
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::AuthOpRecorded { op })
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::AuthOpRecorded { op, signatures })
}
ControlRequest::SshCertRequest {
public_key_path,
@ -7733,9 +7852,20 @@ fn record_node_grant(
capability: &str,
grant_id: Option<String>,
) -> Result<AuthOp, NodeError> {
let op = node_grant_op(node_id, resource, capability, grant_id);
store_auth_op(store, &op)?;
Ok(op)
}
fn node_grant_op(
node_id: &str,
resource: &str,
capability: &str,
grant_id: Option<String>,
) -> AuthOp {
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 {
AuthOp {
id: generated_auth_op_id("grant-create", resource, &grant_id, created_at),
resource: ResourceId::new(resource.to_owned()),
created_at,
@ -7744,9 +7874,7 @@ fn record_node_grant(
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 {
@ -9447,6 +9575,8 @@ mod tests {
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-fetch".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left peer");
@ -9457,6 +9587,8 @@ mod tests {
resource: "resource:cas-tree:shared".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-tree-fetch".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left file root fetch");
@ -9467,6 +9599,8 @@ mod tests {
resource: "resource:kv:prefs".to_owned(),
capability: "kv.read".to_owned(),
grant_id: Some("grant:left-kv-read".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left kv read");
@ -9477,6 +9611,8 @@ mod tests {
resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.publish".to_owned(),
grant_id: Some("grant:left-pubsub-publish".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pubsub publish");
@ -9487,6 +9623,8 @@ mod tests {
resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.subscribe".to_owned(),
grant_id: Some("grant:left-pubsub-subscribe".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pubsub subscribe");
@ -9497,6 +9635,8 @@ mod tests {
resource: "resource:pipe:inbox".to_owned(),
capability: "pipe.connect".to_owned(),
grant_id: Some("grant:left-pipe-connect".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe connect");
@ -9507,6 +9647,8 @@ mod tests {
resource: "resource:pipe:remote-inbox".to_owned(),
capability: "pipe.listen".to_owned(),
grant_id: Some("grant:left-pipe-listen".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe listen");
@ -9517,6 +9659,8 @@ mod tests {
resource: "resource:ssh-proxy:local".to_owned(),
capability: "ssh_proxy.connect".to_owned(),
grant_id: Some("grant:left-ssh-proxy-connect".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh proxy connect");
@ -9527,6 +9671,8 @@ mod tests {
resource: "resource:ssh-proxy:local".to_owned(),
capability: "ssh_proxy.admin_shell".to_owned(),
grant_id: Some("grant:left-ssh-admin-shell".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh admin shell");
@ -9537,6 +9683,8 @@ mod tests {
resource: "resource:document:notes".to_owned(),
capability: "document.read".to_owned(),
grant_id: Some("grant:left-document-read".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left document read");
@ -9547,6 +9695,8 @@ mod tests {
resource: "resource:db:notes".to_owned(),
capability: "db.sync".to_owned(),
grant_id: Some("grant:left-db-sync".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left db sync");
@ -9562,6 +9712,8 @@ mod tests {
resource: format!("resource:pipe-tcp:{tcp_target}"),
capability: "pipe.forward".to_owned(),
grant_id: Some("grant:left-pipe-tcp-forward".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe tcp forward");
@ -9638,6 +9790,8 @@ mod tests {
resource: format!("resource:pipe-unix:{unix_target_display}"),
capability: "pipe.forward".to_owned(),
grant_id: Some("grant:left-pipe-unix-forward".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe unix forward");
@ -10201,6 +10355,8 @@ mod tests {
resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.sync".to_owned(),
grant_id: Some("grant:left-ssh-cert-sync".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh cert sync");
@ -10211,6 +10367,8 @@ mod tests {
resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.sync".to_owned(),
grant_id: Some("grant:left-ssh-revocation-sync".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh revocation sync");

View file

@ -786,12 +786,15 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:test-fetch".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant capability");
match response {
geth_control::ControlResponse::AuthOpRecorded { op } => {
geth_control::ControlResponse::AuthOpRecorded { op, signatures } => {
assert_eq!(op.resource.to_string(), "resource:cas:local");
assert!(signatures.is_empty());
}
other => panic!("unexpected response: {other:?}"),
}
@ -819,6 +822,8 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
geth_control::ControlRequest::AuthRevoke {
resource: "resource:cas:local".to_owned(),
grant_id: "grant:test-fetch".to_owned(),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("revoke grant");
@ -1006,7 +1011,7 @@ fn init_owned_node_records_signed_owner_device_and_node() {
geth_control::ControlRequest::NodeRename {
node: "laptop".to_owned(),
name: "work-laptop".to_owned(),
signing_key_path: Some(admin_key_path),
signing_key_path: Some(admin_key_path.clone()),
},
)
.expect("rename node");
@ -1017,6 +1022,41 @@ fn init_owned_node_records_signed_owner_device_and_node() {
other => panic!("unexpected response: {other:?}"),
}
let endpoint_added = geth_node::handle_request(
&node,
geth_control::ControlRequest::NodeEndpointAdd {
node: "work-laptop".to_owned(),
endpoint: "endpoint:test-rotated".to_owned(),
signing_key_path: Some(admin_key_path.clone()),
},
)
.expect("add endpoint");
match endpoint_added {
geth_control::ControlResponse::NodeKeychainUpdated { signatures, .. } => {
assert_eq!(signatures.len(), 1);
}
other => panic!("unexpected response: {other:?}"),
}
let grant = geth_node::handle_request(
&node,
geth_control::ControlRequest::NodeGrant {
node: "work-laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:work-laptop-cas-fetch".to_owned()),
signing_key_path: Some(admin_key_path.clone()),
admin_key_path: None,
},
)
.expect("signed node grant");
match grant {
geth_control::ControlResponse::NodeGrantUpdated { signatures, .. } => {
assert_eq!(signatures.len(), 1);
}
other => panic!("unexpected response: {other:?}"),
}
let explained = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
@ -1388,6 +1428,8 @@ fn kv_create_set_get_use_local_store() {
resource: "resource:kv:prefs".to_owned(),
capability: "kv.write_prefix:apps/foo/".to_owned(),
grant_id: None,
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant prefix write");
@ -2134,6 +2176,8 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() {
resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.request".to_owned(),
grant_id: Some("grant:ssh-cert-request".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant cert request");
@ -2171,6 +2215,8 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() {
resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.publish".to_owned(),
grant_id: Some("grant:ssh-revocation-publish".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant revocation publish");