Implement keychain view reducer
This commit is contained in:
parent
373ff53d5c
commit
2a31cc3168
4 changed files with 358 additions and 22 deletions
|
|
@ -108,7 +108,8 @@ Roadmap items should be actionable and checkable:
|
||||||
types, untrusted discovery-backend trait, custom relay-map config, and Iroh
|
types, untrusted discovery-backend trait, custom relay-map config, and Iroh
|
||||||
local-network discovery toggle exist.
|
local-network discovery toggle exist.
|
||||||
- Canonical signed-operation envelopes exist for keychain/auth signature
|
- Canonical signed-operation envelopes exist for keychain/auth signature
|
||||||
payloads. Reducers and enforcement remain separate roadmap work.
|
payloads. The keychain reducer builds an active identity view for admin keys,
|
||||||
|
users, devices, nodes, agents, and endpoint bindings.
|
||||||
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
|
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
|
||||||
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
|
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
|
||||||
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
|
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use geth_types::{AgentId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
|
use geth_types::{AgentId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
|
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
|
||||||
|
|
||||||
|
|
@ -93,36 +94,156 @@ pub enum KeychainOpKind {
|
||||||
pub struct KeychainView {
|
pub struct KeychainView {
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
pub admin_keys: Vec<KeyId>,
|
pub admin_keys: Vec<KeyId>,
|
||||||
pub users: Vec<UserId>,
|
pub users: BTreeMap<UserId, UserRecord>,
|
||||||
pub devices: Vec<DeviceId>,
|
pub devices: BTreeMap<DeviceId, DeviceRecord>,
|
||||||
pub nodes: Vec<NodeId>,
|
pub nodes: BTreeMap<NodeId, NodeRecord>,
|
||||||
|
pub agents: BTreeMap<AgentId, NodeId>,
|
||||||
|
pub endpoints: BTreeMap<String, NodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UserRecord {
|
||||||
|
pub id: UserId,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceRecord {
|
||||||
|
pub id: DeviceId,
|
||||||
|
pub user: UserId,
|
||||||
|
pub keys: Vec<KeyId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NodeRecord {
|
||||||
|
pub id: NodeId,
|
||||||
|
pub device: DeviceId,
|
||||||
|
pub name: String,
|
||||||
|
pub endpoints: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
||||||
let mut view = KeychainView::default();
|
let mut initialized = false;
|
||||||
|
let mut admin_keys = BTreeSet::new();
|
||||||
|
let mut users = BTreeMap::<UserId, UserRecord>::new();
|
||||||
|
let mut revoked_users = BTreeSet::new();
|
||||||
|
let mut devices = BTreeMap::<DeviceId, DeviceRecord>::new();
|
||||||
|
let mut revoked_devices = BTreeSet::new();
|
||||||
|
let mut nodes = BTreeMap::<NodeId, NodeRecord>::new();
|
||||||
|
let mut revoked_nodes = BTreeSet::new();
|
||||||
|
let mut agent_bindings = BTreeMap::<AgentId, NodeId>::new();
|
||||||
|
|
||||||
for op in ops {
|
for op in ops {
|
||||||
match &op.kind {
|
match &op.kind {
|
||||||
KeychainOpKind::KeychainInit => view.initialized = true,
|
KeychainOpKind::KeychainInit => initialized = true,
|
||||||
KeychainOpKind::AdminKeyAdd { key } if !view.admin_keys.contains(key) => {
|
KeychainOpKind::AdminKeyAdd { key } => {
|
||||||
view.admin_keys.push(key.clone());
|
admin_keys.insert(key.clone());
|
||||||
}
|
}
|
||||||
KeychainOpKind::AdminKeyRevoke { key } => view.admin_keys.retain(|item| item != key),
|
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||||
KeychainOpKind::UserAdd { user, .. } if !view.users.contains(user) => {
|
admin_keys.remove(key);
|
||||||
view.users.push(user.clone());
|
|
||||||
}
|
}
|
||||||
KeychainOpKind::UserRevoke { user } => view.users.retain(|item| item != user),
|
KeychainOpKind::UserAdd { user, name } => {
|
||||||
KeychainOpKind::DeviceAdd { device, .. } if !view.devices.contains(device) => {
|
revoked_users.remove(user);
|
||||||
view.devices.push(device.clone());
|
users.entry(user.clone()).or_insert_with(|| UserRecord {
|
||||||
|
id: user.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
KeychainOpKind::DeviceRevoke { device } => view.devices.retain(|item| item != device),
|
KeychainOpKind::UserRename { user, name } => {
|
||||||
KeychainOpKind::NodeAdd { node, .. } if !view.nodes.contains(node) => {
|
if let Some(record) = users.get_mut(user) {
|
||||||
view.nodes.push(node.clone());
|
record.name.clone_from(name);
|
||||||
}
|
|
||||||
KeychainOpKind::NodeRevoke { node } => view.nodes.retain(|item| item != node),
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view
|
KeychainOpKind::UserRevoke { user } => {
|
||||||
|
revoked_users.insert(user.clone());
|
||||||
|
}
|
||||||
|
KeychainOpKind::DeviceAdd { device, user } => {
|
||||||
|
revoked_devices.remove(device);
|
||||||
|
devices
|
||||||
|
.entry(device.clone())
|
||||||
|
.or_insert_with(|| DeviceRecord {
|
||||||
|
id: device.clone(),
|
||||||
|
user: user.clone(),
|
||||||
|
keys: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KeychainOpKind::DeviceRevoke { device } => {
|
||||||
|
revoked_devices.insert(device.clone());
|
||||||
|
}
|
||||||
|
KeychainOpKind::DeviceKeyAdd { device, key } => {
|
||||||
|
if let Some(record) = devices.get_mut(device) {
|
||||||
|
if !record.keys.contains(key) {
|
||||||
|
record.keys.push(key.clone());
|
||||||
|
record.keys.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeychainOpKind::DeviceKeyRevoke { device, key } => {
|
||||||
|
if let Some(record) = devices.get_mut(device) {
|
||||||
|
record.keys.retain(|item| item != key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeychainOpKind::NodeAdd { node, device, name } => {
|
||||||
|
revoked_nodes.remove(node);
|
||||||
|
nodes.entry(node.clone()).or_insert_with(|| NodeRecord {
|
||||||
|
id: node.clone(),
|
||||||
|
device: device.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
endpoints: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KeychainOpKind::NodeRename { node, name } => {
|
||||||
|
if let Some(record) = nodes.get_mut(node) {
|
||||||
|
record.name.clone_from(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeychainOpKind::NodeRevoke { node } => {
|
||||||
|
revoked_nodes.insert(node.clone());
|
||||||
|
}
|
||||||
|
KeychainOpKind::NodeEndpointAdd { node, endpoint } => {
|
||||||
|
if let Some(record) = nodes.get_mut(node) {
|
||||||
|
if !record.endpoints.contains(endpoint) {
|
||||||
|
record.endpoints.push(endpoint.clone());
|
||||||
|
record.endpoints.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeychainOpKind::NodeEndpointRevoke { node, endpoint } => {
|
||||||
|
if let Some(record) = nodes.get_mut(node) {
|
||||||
|
record.endpoints.retain(|item| item != endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeychainOpKind::AgentBind { agent, node } => {
|
||||||
|
agent_bindings.insert(agent.clone(), node.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users.retain(|user, _| !revoked_users.contains(user));
|
||||||
|
devices.retain(|device, record| {
|
||||||
|
!revoked_devices.contains(device) && users.contains_key(&record.user)
|
||||||
|
});
|
||||||
|
nodes.retain(|node, record| {
|
||||||
|
!revoked_nodes.contains(node) && devices.contains_key(&record.device)
|
||||||
|
});
|
||||||
|
agent_bindings.retain(|_, node| nodes.contains_key(node));
|
||||||
|
|
||||||
|
let mut endpoints = BTreeMap::new();
|
||||||
|
for (node, record) in &nodes {
|
||||||
|
for endpoint in &record.endpoints {
|
||||||
|
endpoints.insert(endpoint.clone(), node.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeychainView {
|
||||||
|
initialized,
|
||||||
|
admin_keys: admin_keys.into_iter().collect(),
|
||||||
|
users,
|
||||||
|
devices,
|
||||||
|
nodes,
|
||||||
|
agents: agent_bindings,
|
||||||
|
endpoints,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -166,4 +287,216 @@ mod tests {
|
||||||
assert_eq!(signed.namespace(), KEYCHAIN_SIGNATURE_NAMESPACE);
|
assert_eq!(signed.namespace(), KEYCHAIN_SIGNATURE_NAMESPACE);
|
||||||
assert_eq!(signed.payload(), &op);
|
assert_eq!(signed.payload(), &op);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn op(sequence: i64, kind: KeychainOpKind) -> KeychainOp {
|
||||||
|
KeychainOp {
|
||||||
|
id: format!("op:{sequence}").into(),
|
||||||
|
created_at: UnixMillis(sequence),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_tracks_active_keychain_view() {
|
||||||
|
let ops = vec![
|
||||||
|
op(1, KeychainOpKind::KeychainInit),
|
||||||
|
op(
|
||||||
|
2,
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: "key:admin-a".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
3,
|
||||||
|
KeychainOpKind::AdminKeyAdd {
|
||||||
|
key: "key:admin-b".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
4,
|
||||||
|
KeychainOpKind::AdminKeyRevoke {
|
||||||
|
key: "key:admin-b".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
5,
|
||||||
|
KeychainOpKind::UserAdd {
|
||||||
|
user: "user:eric".into(),
|
||||||
|
name: "Eric".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
6,
|
||||||
|
KeychainOpKind::UserRename {
|
||||||
|
user: "user:eric".into(),
|
||||||
|
name: "Eric Updated".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
7,
|
||||||
|
KeychainOpKind::DeviceAdd {
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
user: "user:eric".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
8,
|
||||||
|
KeychainOpKind::DeviceKeyAdd {
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
key: "key:device-a".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
9,
|
||||||
|
KeychainOpKind::DeviceKeyAdd {
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
key: "key:device-b".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
10,
|
||||||
|
KeychainOpKind::DeviceKeyRevoke {
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
key: "key:device-b".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
11,
|
||||||
|
KeychainOpKind::NodeAdd {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
name: "laptop".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
12,
|
||||||
|
KeychainOpKind::NodeRename {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
name: "work-laptop".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
13,
|
||||||
|
KeychainOpKind::NodeEndpointAdd {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
endpoint: "endpoint:old".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
14,
|
||||||
|
KeychainOpKind::NodeEndpointAdd {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
endpoint: "endpoint:new".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
15,
|
||||||
|
KeychainOpKind::NodeEndpointRevoke {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
endpoint: "endpoint:old".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
16,
|
||||||
|
KeychainOpKind::AgentBind {
|
||||||
|
agent: "agent:daemon".into(),
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let view = reduce_keychain_ops(&ops);
|
||||||
|
|
||||||
|
assert!(view.initialized);
|
||||||
|
assert_eq!(view.admin_keys, vec![KeyId::from("key:admin-a")]);
|
||||||
|
assert_eq!(
|
||||||
|
view.users.get(&UserId::from("user:eric")),
|
||||||
|
Some(&UserRecord {
|
||||||
|
id: "user:eric".into(),
|
||||||
|
name: "Eric Updated".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.devices.get(&DeviceId::from("device:laptop")),
|
||||||
|
Some(&DeviceRecord {
|
||||||
|
id: "device:laptop".into(),
|
||||||
|
user: "user:eric".into(),
|
||||||
|
keys: vec!["key:device-a".into()],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.nodes.get(&NodeId::from("node:laptop")),
|
||||||
|
Some(&NodeRecord {
|
||||||
|
id: "node:laptop".into(),
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
name: "work-laptop".to_owned(),
|
||||||
|
endpoints: vec!["endpoint:new".to_owned()],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.agents.get(&AgentId::from("agent:daemon")),
|
||||||
|
Some(&NodeId::from("node:laptop"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.endpoints.get("endpoint:new"),
|
||||||
|
Some(&NodeId::from("node:laptop"))
|
||||||
|
);
|
||||||
|
assert!(!view.endpoints.contains_key("endpoint:old"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_excludes_revoked_identity_subtrees() {
|
||||||
|
let ops = vec![
|
||||||
|
op(
|
||||||
|
1,
|
||||||
|
KeychainOpKind::UserAdd {
|
||||||
|
user: "user:eric".into(),
|
||||||
|
name: "Eric".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
2,
|
||||||
|
KeychainOpKind::DeviceAdd {
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
user: "user:eric".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
3,
|
||||||
|
KeychainOpKind::NodeAdd {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
device: "device:laptop".into(),
|
||||||
|
name: "laptop".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
4,
|
||||||
|
KeychainOpKind::NodeEndpointAdd {
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
endpoint: "endpoint:live".to_owned(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
5,
|
||||||
|
KeychainOpKind::AgentBind {
|
||||||
|
agent: "agent:daemon".into(),
|
||||||
|
node: "node:laptop".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
op(
|
||||||
|
6,
|
||||||
|
KeychainOpKind::UserRevoke {
|
||||||
|
user: "user:eric".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let view = reduce_keychain_ops(&ops);
|
||||||
|
|
||||||
|
assert!(view.users.is_empty());
|
||||||
|
assert!(view.devices.is_empty());
|
||||||
|
assert!(view.nodes.is_empty());
|
||||||
|
assert!(view.agents.is_empty());
|
||||||
|
assert!(view.endpoints.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,9 @@ generate OpenSSH KRL binaries or replicate the lists over Iroh.
|
||||||
|
|
||||||
The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents,
|
The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents,
|
||||||
and endpoint bindings. Endpoint rotation must not destroy higher-level node
|
and endpoint bindings. Endpoint rotation must not destroy higher-level node
|
||||||
identity.
|
identity. Keychain operations reduce into an active view containing current
|
||||||
|
admin keys, users, devices, node records, agent bindings, and endpoint-to-node
|
||||||
|
bindings. Revoked identity subtrees are excluded from that active view.
|
||||||
|
|
||||||
The authorization plane is `geth-auth`: resource-local signed operation logs,
|
The authorization plane is `geth-auth`: resource-local signed operation logs,
|
||||||
grants, revocations, groups, and `auth explain`.
|
grants, revocations, groups, and `auth explain`.
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ resource-scoped capability decisions.
|
||||||
- OpenSSH signature namespaces are explicit.
|
- OpenSSH signature namespaces are explicit.
|
||||||
- Missing `ssh-keygen` or unavailable hardware keys produce clear errors.
|
- Missing `ssh-keygen` or unavailable hardware keys produce clear errors.
|
||||||
|
|
||||||
- `[ ]` Keychain operation reducer.
|
- `[x]` Keychain operation reducer.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- Admin keys, users, devices, nodes, agents, and endpoint bindings reduce into
|
- Admin keys, users, devices, nodes, agents, and endpoint bindings reduce into
|
||||||
a current keychain view.
|
a current keychain view.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue