Harden signed node authorization flow
This commit is contained in:
parent
27a79768e4
commit
1336fa38e8
9 changed files with 382 additions and 55 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue