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. users, devices, nodes, agents, and endpoint bindings.
- The auth reducer builds a current permission view for resources, grants, - The auth reducer builds a current permission view for resources, grants,
groups, and bearer access. The daemon persists local auth grant/revoke ops groups, and bearer access. The daemon persists local auth grant/revoke ops
and uses them for `auth explain`. Enrollment approval signs auth ops, and and uses them for `auth explain`. Enrollment approval, `geth node
`geth auth sync <node>` imports only auth ops signed by currently trusted grant/revoke-grant`, and `geth auth grant/revoke` sign auth ops through
admin keys. `kv set --subject <principal>` enforces local KV write grants for OpenSSH when invoked through the CLI. `geth auth sync <node>` imports only
non-local test callers. Broader daemon-side module enforcement is still auth ops signed by currently trusted admin keys. `kv set --subject
roadmap work. <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`. - The daemon persists local keychain ops and reduces them for `keychain status`.
`geth init --admin-key <pub> --signing-key <key> --node-name <name>` records `geth init --admin-key <pub> --signing-key <key> --node-name <name>` records
signed owner/user/device/node/agent binding operations. `keychain 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` valid OpenSSH signatures from currently trusted admin keys. `geth node list`
shows the active reduced node view, and `geth node rename/revoke` require shows the active reduced node view, and `geth node rename/revoke` require
`--signing-key` so device-management changes can replicate as verified admin `--signing-key` so device-management changes can replicate as verified admin
statements. `geth node grant/revoke-grant` records the current resource-scoped statements. `geth node grant/revoke-grant` and `geth auth grant/revoke` also
capability prototype. require `--signing-key` in the CLI and store signed auth operations for
replication.
SSH certificate-flow and revocation records carry agent-key signed provenance SSH certificate-flow and revocation records carry agent-key signed provenance
over canonical payloads, and sync import rejects new unsigned or invalidly over canonical payloads, and sync import rejects new unsigned or invalidly
signed records. signed records.
@ -100,8 +101,10 @@ The bootstrap implementation provides:
- `geth node enroll sync <owner-node>` - `geth node enroll sync <owner-node>`
- `geth node rename <node-or-name> <name> --signing-key <private-key>` - `geth node rename <node-or-name> <name> --signing-key <private-key>`
- `geth node revoke <node-or-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 endpoint-add <node-or-name> <endpoint-id> --signing-key <private-key>`
- `geth node revoke-grant <resource> <grant-id>` - `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 export [--out <path>]`
- `geth peer import <path>` - `geth peer import <path>`
- `geth peer list` - `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 verify <token> <resource> --nonce <nonce> --response <response> --capability <capability>`
- `geth secret bearer revoke <resource> <bearer-id>` - `geth secret bearer revoke <resource> <bearer-id>`
- `geth auth explain <subject> <resource> <capability>` - `geth auth explain <subject> <resource> <capability>`
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]` - `geth auth grant <subject> <resource> <capability> --signing-key <private-key> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id>` - `geth auth revoke <resource> <grant-id> --signing-key <private-key>`
- local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`, - local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`,
`unpin`, `cleanup`, `providers`, `list`; remote fetch accepts `unpin`, `cleanup`, `providers`, `list`; remote fetch accepts
`--bearer-secret <secret>` `--bearer-secret <secret>`
@ -369,7 +372,11 @@ node, not replace it. Node management is done through the reduced keychain view:
```sh ```sh
geth node list geth node list
geth node rename laptop work-laptop --signing-key ~/.ssh/id_ed25519_sk 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 geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
``` ```

View file

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

View file

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

View file

@ -5662,16 +5662,35 @@ pub fn handle_request(
resource, resource,
capability, capability,
grant_id, grant_id,
signing_key_path,
admin_key_path,
} => { } => {
let target = resolve_keychain_node(&store, &target)?; 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 { Ok(ControlResponse::NodeGrantUpdated {
op, op,
signatures: Vec::new(), signatures,
note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(), 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 created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp { let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at), id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
@ -5679,11 +5698,82 @@ pub fn handle_request(
created_at, created_at,
kind: AuthOpKind::GrantRevoke { grant_id }, 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 { Ok(ControlResponse::NodeGrantUpdated {
op, op,
signatures: Vec::new(), signatures,
note: "recorded capability grant revocation".to_owned(), 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 { ControlRequest::NodeEnrollRequest {
@ -5985,6 +6075,8 @@ pub fn handle_request(
resource, resource,
capability, capability,
grant_id, grant_id,
signing_key_path,
admin_key_path,
} => { } => {
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let grant_id = let grant_id =
@ -5999,10 +6091,26 @@ pub fn handle_request(
capabilities: vec![Capability::new(capability)], capabilities: vec![Capability::new(capability)],
}, },
}; };
store_auth_op(&store, &op)?; let signatures = if let Some(signing_key_path) = signing_key_path {
Ok(ControlResponse::AuthOpRecorded { op }) 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 created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp { let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at), id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
@ -6010,8 +6118,19 @@ pub fn handle_request(
created_at, created_at,
kind: AuthOpKind::GrantRevoke { grant_id }, kind: AuthOpKind::GrantRevoke { grant_id },
}; };
store_auth_op(&store, &op)?; let signatures = if let Some(signing_key_path) = signing_key_path {
Ok(ControlResponse::AuthOpRecorded { op }) 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 { ControlRequest::SshCertRequest {
public_key_path, public_key_path,
@ -7733,9 +7852,20 @@ fn record_node_grant(
capability: &str, capability: &str,
grant_id: Option<String>, grant_id: Option<String>,
) -> Result<AuthOp, NodeError> { ) -> 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 created_at = UnixMillis(geth_store::now_ms());
let grant_id = grant_id.unwrap_or_else(|| generated_grant_id(node_id, resource, capability)); 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), id: generated_auth_op_id("grant-create", resource, &grant_id, created_at),
resource: ResourceId::new(resource.to_owned()), resource: ResourceId::new(resource.to_owned()),
created_at, created_at,
@ -7744,9 +7874,7 @@ fn record_node_grant(
principal: PrincipalId::new(node_id.to_owned()), principal: PrincipalId::new(node_id.to_owned()),
capabilities: vec![Capability::new(capability.to_owned())], capabilities: vec![Capability::new(capability.to_owned())],
}, },
}; }
store_auth_op(store, &op)?;
Ok(op)
} }
fn stable_slug(value: &str) -> String { fn stable_slug(value: &str) -> String {
@ -9447,6 +9575,8 @@ mod tests {
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(), capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left peer");
@ -9457,6 +9587,8 @@ mod tests {
resource: "resource:cas-tree:shared".to_owned(), resource: "resource:cas-tree:shared".to_owned(),
capability: "cas.fetch".to_owned(), capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-tree-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"); .expect("grant left file root fetch");
@ -9467,6 +9599,8 @@ mod tests {
resource: "resource:kv:prefs".to_owned(), resource: "resource:kv:prefs".to_owned(),
capability: "kv.read".to_owned(), capability: "kv.read".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left kv read");
@ -9477,6 +9611,8 @@ mod tests {
resource: "resource:pubsub:presence/test".to_owned(), resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.publish".to_owned(), capability: "pubsub.publish".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left pubsub publish");
@ -9487,6 +9623,8 @@ mod tests {
resource: "resource:pubsub:presence/test".to_owned(), resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.subscribe".to_owned(), capability: "pubsub.subscribe".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left pubsub subscribe");
@ -9497,6 +9635,8 @@ mod tests {
resource: "resource:pipe:inbox".to_owned(), resource: "resource:pipe:inbox".to_owned(),
capability: "pipe.connect".to_owned(), capability: "pipe.connect".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left pipe connect");
@ -9507,6 +9647,8 @@ mod tests {
resource: "resource:pipe:remote-inbox".to_owned(), resource: "resource:pipe:remote-inbox".to_owned(),
capability: "pipe.listen".to_owned(), capability: "pipe.listen".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left pipe listen");
@ -9517,6 +9659,8 @@ mod tests {
resource: "resource:ssh-proxy:local".to_owned(), resource: "resource:ssh-proxy:local".to_owned(),
capability: "ssh_proxy.connect".to_owned(), capability: "ssh_proxy.connect".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left ssh proxy connect");
@ -9527,6 +9671,8 @@ mod tests {
resource: "resource:ssh-proxy:local".to_owned(), resource: "resource:ssh-proxy:local".to_owned(),
capability: "ssh_proxy.admin_shell".to_owned(), capability: "ssh_proxy.admin_shell".to_owned(),
grant_id: Some("grant:left-ssh-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"); .expect("grant left ssh admin shell");
@ -9537,6 +9683,8 @@ mod tests {
resource: "resource:document:notes".to_owned(), resource: "resource:document:notes".to_owned(),
capability: "document.read".to_owned(), capability: "document.read".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left document read");
@ -9547,6 +9695,8 @@ mod tests {
resource: "resource:db:notes".to_owned(), resource: "resource:db:notes".to_owned(),
capability: "db.sync".to_owned(), capability: "db.sync".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left db sync");
@ -9562,6 +9712,8 @@ mod tests {
resource: format!("resource:pipe-tcp:{tcp_target}"), resource: format!("resource:pipe-tcp:{tcp_target}"),
capability: "pipe.forward".to_owned(), capability: "pipe.forward".to_owned(),
grant_id: Some("grant:left-pipe-tcp-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"); .expect("grant left pipe tcp forward");
@ -9638,6 +9790,8 @@ mod tests {
resource: format!("resource:pipe-unix:{unix_target_display}"), resource: format!("resource:pipe-unix:{unix_target_display}"),
capability: "pipe.forward".to_owned(), capability: "pipe.forward".to_owned(),
grant_id: Some("grant:left-pipe-unix-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"); .expect("grant left pipe unix forward");
@ -10201,6 +10355,8 @@ mod tests {
resource: "resource:ssh:certs".to_owned(), resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.sync".to_owned(), capability: "ssh_cert.sync".to_owned(),
grant_id: Some("grant:left-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"); .expect("grant left ssh cert sync");
@ -10211,6 +10367,8 @@ mod tests {
resource: "resource:ssh:revocations".to_owned(), resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.sync".to_owned(), capability: "ssh_revocation.sync".to_owned(),
grant_id: Some("grant:left-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"); .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(), resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(), capability: "cas.fetch".to_owned(),
grant_id: Some("grant:test-fetch".to_owned()), grant_id: Some("grant:test-fetch".to_owned()),
signing_key_path: None,
admin_key_path: None,
}, },
) )
.expect("grant capability"); .expect("grant capability");
match response { match response {
geth_control::ControlResponse::AuthOpRecorded { op } => { geth_control::ControlResponse::AuthOpRecorded { op, signatures } => {
assert_eq!(op.resource.to_string(), "resource:cas:local"); assert_eq!(op.resource.to_string(), "resource:cas:local");
assert!(signatures.is_empty());
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
} }
@ -819,6 +822,8 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
geth_control::ControlRequest::AuthRevoke { geth_control::ControlRequest::AuthRevoke {
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),
grant_id: "grant:test-fetch".to_owned(), grant_id: "grant:test-fetch".to_owned(),
signing_key_path: None,
admin_key_path: None,
}, },
) )
.expect("revoke grant"); .expect("revoke grant");
@ -1006,7 +1011,7 @@ fn init_owned_node_records_signed_owner_device_and_node() {
geth_control::ControlRequest::NodeRename { geth_control::ControlRequest::NodeRename {
node: "laptop".to_owned(), node: "laptop".to_owned(),
name: "work-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"); .expect("rename node");
@ -1017,6 +1022,41 @@ fn init_owned_node_records_signed_owner_device_and_node() {
other => panic!("unexpected response: {other:?}"), 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( let explained = geth_node::handle_request(
&node, &node,
geth_control::ControlRequest::AuthExplain { geth_control::ControlRequest::AuthExplain {
@ -1388,6 +1428,8 @@ fn kv_create_set_get_use_local_store() {
resource: "resource:kv:prefs".to_owned(), resource: "resource:kv:prefs".to_owned(),
capability: "kv.write_prefix:apps/foo/".to_owned(), capability: "kv.write_prefix:apps/foo/".to_owned(),
grant_id: None, grant_id: None,
signing_key_path: None,
admin_key_path: None,
}, },
) )
.expect("grant prefix write"); .expect("grant prefix write");
@ -2134,6 +2176,8 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() {
resource: "resource:ssh:certs".to_owned(), resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.request".to_owned(), capability: "ssh_cert.request".to_owned(),
grant_id: Some("grant: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"); .expect("grant cert request");
@ -2171,6 +2215,8 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() {
resource: "resource:ssh:revocations".to_owned(), resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.publish".to_owned(), capability: "ssh_revocation.publish".to_owned(),
grant_id: Some("grant: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"); .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 The active device list is the reduced keychain view, surfaced through `geth node
list`. Renames and revocations are additional keychain operations. Resource list`. Renames and revocations are additional keychain operations. Resource
permissions remain resource-scoped auth operations and can be managed with 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 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, 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 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 Keyhive-style convergent authority and does not implement advanced group
cryptography. Manual grant/revoke commands still need the same signing cryptography. Conflict resolution for concurrent signed operations is still the
requirement as enrollment approval before this becomes a complete authorization deterministic reducer, not a richer collaborative authority protocol.
workflow.

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 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 owner statements by accident. `geth node list` shows the active reduced node
view. `geth node rename` and `geth node revoke` record signed keychain view. `geth node rename` and `geth node revoke` record signed keychain
operations and require `--signing-key`. `geth keychain sync <node>` pulls operations and require `--signing-key`. Endpoint rotation is explicit:
keychain operations and signatures from an imported peer over Iroh and imports `geth node endpoint-add` and `geth node endpoint-revoke` record signed
only operations with a valid OpenSSH signature from a currently trusted admin key `NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. `geth keychain
over the canonical payload. This is currently a pull-based signed operation log, sync <node>` pulls keychain operations and signatures from an imported peer over
not a CRDT or Keyhive-style convergent authority. 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 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 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 current permission view for resources, grants, groups, and bearer access. The
library can explain direct and group grants. The daemon persists local auth library can explain direct and group grants. The daemon persists local auth
grant/revoke operations and `geth auth explain` evaluates that local operation grant/revoke operations and `geth auth explain` evaluates that local operation
log. Enrollment approval and auth sync attach and verify OpenSSH admin log. `geth node grant`, `geth node revoke-grant`, `geth auth grant`, and `geth
signatures for replicated auth operations. Broader delegated authority and auth revoke` require `--signing-key` in the CLI and store OpenSSH-signed auth
module enforcement are still future work. 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, Capability evaluation supports exact matches plus explicit scoped forms. For KV,
`kv.write_prefix:<prefix>` grants writes requested as `kv.write_key:<key>` only `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. for device/node/agent/endpoint enrollment.
- `[x]` `geth node enroll sync <owner-node>` pulls approved signed keychain - `[x]` `geth node enroll sync <owner-node>` pulls approved signed keychain
and auth state onto the requesting node. and auth state onto the requesting node.
- `[x]` `geth node endpoint-add/revoke --signing-key` records signed endpoint
rotation operations.
- `[x]` Keychain operation reducer. - `[x]` Keychain operation reducer.
Acceptance criteria: Acceptance criteria:
@ -204,19 +206,18 @@ resource-scoped capability decisions.
- Revoked keys/devices/nodes are excluded from active views. - Revoked keys/devices/nodes are excluded from active views.
- Tests cover add, rename, revoke, and endpoint rotation. - Tests cover add, rename, revoke, and endpoint rotation.
- `[~]` Node capability management. - `[x]` Node capability management.
Acceptance criteria: Acceptance criteria:
- `[x]` `geth node grant <node> <resource> <capability>` records a - `[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 - `[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 - `[x]` Node names can be used for management commands where the keychain view
has a unique active node name. has a unique active node name.
- `[x]` Enrollment approval signs capability grants as auth ops. - `[x]` Enrollment approval signs capability grants as auth ops.
- `[x]` `geth auth sync <node>` imports only auth ops signed by currently - `[x]` `geth auth sync <node>` imports only auth ops signed by currently
trusted admin keys. trusted admin keys.
- `[ ]` Future completion requires signed auth ops for every manual - `[x]` `geth auth grant/revoke --signing-key` records signed auth ops.
grant/revoke command, not only enrollment approval and replicated imports.
- `[x]` Resource auth operation reducer. - `[x]` Resource auth operation reducer.
Acceptance criteria: Acceptance criteria:
@ -228,13 +229,13 @@ resource-scoped capability decisions.
- `[~]` `auth explain` real decision path. - `[~]` `auth explain` real decision path.
Acceptance criteria: 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 - `[x]` `geth auth explain <subject> <resource> <capability>` reports
allowed/denied from the local auth-op reducer when local ops exist. 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]` Output includes the grant ID or missing grant that caused the result.
- `[x]` JSON output is stable enough for tests and scripts. - `[x]` JSON output is stable enough for tests and scripts.
- `[ ]` Future completion requires signed-op validation before accepting - `[x]` Replicated auth sync requires trusted-admin signatures before import.
replicated auth ops.
- `[~]` Resource secrets and bearer invites. - `[~]` Resource secrets and bearer invites.
Acceptance criteria: Acceptance criteria: