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

@ -119,11 +119,12 @@ Roadmap items should be actionable and checkable:
users, devices, nodes, agents, and endpoint bindings.
- The auth reducer builds a current permission view for resources, grants,
groups, and bearer access. The daemon persists local auth grant/revoke ops
and uses them for `auth explain`. Enrollment approval signs auth ops, and
`geth auth sync <node>` imports only auth ops signed by currently trusted
admin keys. `kv set --subject <principal>` enforces local KV write grants for
non-local test callers. Broader daemon-side module enforcement is still
roadmap work.
and uses them for `auth explain`. Enrollment approval, `geth node
grant/revoke-grant`, and `geth auth grant/revoke` sign auth ops through
OpenSSH when invoked through the CLI. `geth auth sync <node>` imports only
auth ops signed by currently trusted admin keys. `kv set --subject
<principal>` enforces local KV write grants for non-local test callers.
Broader daemon-side module enforcement is still roadmap work.
- The daemon persists local keychain ops and reduces them for `keychain status`.
`geth init --admin-key <pub> --signing-key <key> --node-name <name>` records
signed owner/user/device/node/agent binding operations. `keychain

View file

@ -36,8 +36,9 @@ keychain operation log from an imported peer and imports only operations with
valid OpenSSH signatures from currently trusted admin keys. `geth node list`
shows the active reduced node view, and `geth node rename/revoke` require
`--signing-key` so device-management changes can replicate as verified admin
statements. `geth node grant/revoke-grant` records the current resource-scoped
capability prototype.
statements. `geth node grant/revoke-grant` and `geth auth grant/revoke` also
require `--signing-key` in the CLI and store signed auth operations for
replication.
SSH certificate-flow and revocation records carry agent-key signed provenance
over canonical payloads, and sync import rejects new unsigned or invalidly
signed records.
@ -100,8 +101,10 @@ The bootstrap implementation provides:
- `geth node enroll sync <owner-node>`
- `geth node rename <node-or-name> <name> --signing-key <private-key>`
- `geth node revoke <node-or-name> --signing-key <private-key>`
- `geth node grant <node-or-name> <resource> <capability> [--grant-id <id>]`
- `geth node revoke-grant <resource> <grant-id>`
- `geth node endpoint-add <node-or-name> <endpoint-id> --signing-key <private-key>`
- `geth node endpoint-revoke <node-or-name> <endpoint-id> --signing-key <private-key>`
- `geth node grant <node-or-name> <resource> <capability> --signing-key <private-key> [--grant-id <id>]`
- `geth node revoke-grant <resource> <grant-id> --signing-key <private-key>`
- `geth peer export [--out <path>]`
- `geth peer import <path>`
- `geth peer list`
@ -123,8 +126,8 @@ The bootstrap implementation provides:
- `geth secret bearer verify <token> <resource> --nonce <nonce> --response <response> --capability <capability>`
- `geth secret bearer revoke <resource> <bearer-id>`
- `geth auth explain <subject> <resource> <capability>`
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id>`
- `geth auth grant <subject> <resource> <capability> --signing-key <private-key> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id> --signing-key <private-key>`
- local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`,
`unpin`, `cleanup`, `providers`, `list`; remote fetch accepts
`--bearer-secret <secret>`
@ -369,7 +372,11 @@ node, not replace it. Node management is done through the reduced keychain view:
```sh
geth node list
geth node rename laptop work-laptop --signing-key ~/.ssh/id_ed25519_sk
geth node grant work-laptop resource:ssh-proxy:local ssh_proxy.connect
geth node endpoint-add work-laptop <iroh-endpoint-id> --signing-key ~/.ssh/id_ed25519_sk
geth node grant work-laptop resource:ssh-proxy:local ssh_proxy.connect \
--signing-key ~/.ssh/id_ed25519_sk
geth node revoke-grant resource:ssh-proxy:local <grant-id> \
--signing-key ~/.ssh/id_ed25519_sk
geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
```

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");

View file

@ -24,7 +24,11 @@ OpenSSH using the `geth.keychain.v1@geth.local` namespace.
The active device list is the reduced keychain view, surfaced through `geth node
list`. Renames and revocations are additional keychain operations. Resource
permissions remain resource-scoped auth operations and can be managed with
`geth node grant` and `geth node revoke-grant`.
`geth node grant` and `geth node revoke-grant`; the CLI requires an admin
`--signing-key` so these mutations replicate as signed auth operations.
Endpoint rotation is modeled with signed `NodeEndpointAdd` and
`NodeEndpointRevoke` operations exposed as `geth node endpoint-add` and
`geth node endpoint-revoke`.
New nodes can request enrollment with `geth node enroll request`. The request is
signed by the requesting agent key and includes the stable node ID, agent ID,
@ -52,6 +56,5 @@ add or revoke endpoint bindings without replacing the node identity.
The current sync model is a pull-based signed operation log. It is not yet a
Keyhive-style convergent authority and does not implement advanced group
cryptography. Manual grant/revoke commands still need the same signing
requirement as enrollment approval before this becomes a complete authorization
workflow.
cryptography. Conflict resolution for concurrent signed operations is still the
deterministic reducer, not a richer collaborative authority protocol.

View file

@ -282,11 +282,13 @@ and signs them with OpenSSH under `geth.keychain.v1@geth.local`. Both keys are
required when owner setup options are used, so the node does not create unsigned
owner statements by accident. `geth node list` shows the active reduced node
view. `geth node rename` and `geth node revoke` record signed keychain
operations and require `--signing-key`. `geth keychain sync <node>` pulls
keychain operations and signatures from an imported peer over Iroh and imports
only operations with a valid OpenSSH signature from a currently trusted admin key
over the canonical payload. This is currently a pull-based signed operation log,
not a CRDT or Keyhive-style convergent authority.
operations and require `--signing-key`. Endpoint rotation is explicit:
`geth node endpoint-add` and `geth node endpoint-revoke` record signed
`NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. `geth keychain
sync <node>` pulls keychain operations and signatures from an imported peer over
Iroh and imports only operations with a valid OpenSSH signature from a currently
trusted admin key over the canonical payload. This is currently a pull-based
signed operation log, not a CRDT or Keyhive-style convergent authority.
New devices can use the node enrollment flow instead of hand-editing keychain
state. `geth node enroll request` creates a canonical, agent-key-signed request
@ -303,9 +305,11 @@ grants, revocations, groups, and `auth explain`. Auth operations reduce into a
current permission view for resources, grants, groups, and bearer access. The
library can explain direct and group grants. The daemon persists local auth
grant/revoke operations and `geth auth explain` evaluates that local operation
log. Enrollment approval and auth sync attach and verify OpenSSH admin
signatures for replicated auth operations. Broader delegated authority and
module enforcement are still future work.
log. `geth node grant`, `geth node revoke-grant`, `geth auth grant`, and `geth
auth revoke` require `--signing-key` in the CLI and store OpenSSH-signed auth
operations. Enrollment approval uses the same signed auth operation path. Auth
sync imports only auth operations signed by currently trusted admin keys.
Broader delegated authority and module enforcement are still future work.
Capability evaluation supports exact matches plus explicit scoped forms. For KV,
`kv.write_prefix:<prefix>` grants writes requested as `kv.write_key:<key>` only

View file

@ -196,6 +196,8 @@ resource-scoped capability decisions.
for device/node/agent/endpoint enrollment.
- `[x]` `geth node enroll sync <owner-node>` pulls approved signed keychain
and auth state onto the requesting node.
- `[x]` `geth node endpoint-add/revoke --signing-key` records signed endpoint
rotation operations.
- `[x]` Keychain operation reducer.
Acceptance criteria:
@ -204,19 +206,18 @@ resource-scoped capability decisions.
- Revoked keys/devices/nodes are excluded from active views.
- Tests cover add, rename, revoke, and endpoint rotation.
- `[~]` Node capability management.
- `[x]` Node capability management.
Acceptance criteria:
- `[x]` `geth node grant <node> <resource> <capability>` records a
resource-scoped capability grant for a known node.
signed resource-scoped capability grant for a known node.
- `[x]` `geth node revoke-grant <resource> <grant-id>` records grant
revocation.
revocation as a signed auth op.
- `[x]` Node names can be used for management commands where the keychain view
has a unique active node name.
- `[x]` Enrollment approval signs capability grants as auth ops.
- `[x]` `geth auth sync <node>` imports only auth ops signed by currently
trusted admin keys.
- `[ ]` Future completion requires signed auth ops for every manual
grant/revoke command, not only enrollment approval and replicated imports.
- `[x]` `geth auth grant/revoke --signing-key` records signed auth ops.
- `[x]` Resource auth operation reducer.
Acceptance criteria:
@ -228,13 +229,13 @@ resource-scoped capability decisions.
- `[~]` `auth explain` real decision path.
Acceptance criteria:
- `[x]` `geth auth grant` and `geth auth revoke` persist local auth ops.
- `[x]` `geth auth grant` and `geth auth revoke` persist signed local auth
ops when run through the CLI.
- `[x]` `geth auth explain <subject> <resource> <capability>` reports
allowed/denied from the local auth-op reducer when local ops exist.
- `[x]` Output includes the grant ID or missing grant that caused the result.
- `[x]` JSON output is stable enough for tests and scripts.
- `[ ]` Future completion requires signed-op validation before accepting
replicated auth ops.
- `[x]` Replicated auth sync requires trusted-admin signatures before import.
- `[~]` Resource secrets and bearer invites.
Acceptance criteria: