Add owner-rooted node management

This commit is contained in:
Eric Wendland 2026-05-21 11:29:29 +02:00
commit b941037652
9 changed files with 1001 additions and 52 deletions

View file

@ -122,13 +122,16 @@ Roadmap items should be actionable and checkable:
and uses them for `auth explain`. `kv set --subject <principal>` enforces and uses them for `auth explain`. `kv set --subject <principal>` enforces
local KV write grants for non-local test callers. Signature validation and local KV write grants for non-local test callers. Signature validation and
broader daemon-side module enforcement are still roadmap work. broader daemon-side module enforcement are still roadmap work.
- The daemon persists local keychain init/admin-key ops and reduces them for - The daemon persists local keychain ops and reduces them for `keychain status`.
`keychain status`. `keychain init --signing-key <path>` signs recorded `geth init --admin-key <pub> --signing-key <key> --node-name <name>` records
keychain ops with `ssh-keygen -Y sign` under the signed owner/user/device/node/agent binding operations. `keychain
`geth.keychain.v1@geth.local` namespace and stores signatures locally. init --signing-key <path>` signs recorded keychain ops with `ssh-keygen -Y
`keychain status` verifies stored signatures with OpenSSH when public key sign` under the `geth.keychain.v1@geth.local` namespace and stores signatures
material is available. Verification before accepting replicated keychain ops locally. `keychain status` verifies stored signatures with OpenSSH when public
is still roadmap work. key material is available. `geth keychain sync <node>` imports only keychain
ops with valid OpenSSH signatures from currently trusted admin keys. `geth
node list/rename/revoke/grant` are the current node-management surface over
that reduced keychain/auth view; rename and revoke require `--signing-key`.
- Local CAS supports pin/unpin metadata, surfaced through `cas list`, and - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and
`cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon `cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon
can fetch CAS blobs from an imported signed peer card over Iroh when the peer can fetch CAS blobs from an imported signed peer card over Iroh when the peer

View file

@ -27,13 +27,17 @@ registry, module router, local metadata store, and synchronized data structures.
Most non-daemon commands talk to the daemon through a local Unix socket at Most non-daemon commands talk to the daemon through a local Unix socket at
`$GETH_HOME/run/geth.sock`. `$GETH_HOME/run/geth.sock`.
`geth keychain init --admin-key <public-key> --signing-key <private-key>` records `geth init --admin-key <public-key> --signing-key <private-key> --node-name
the keychain initialization/admin-key operations and signs their canonical <name>` records an owner/admin keychain, the local user/device/node binding, and
payloads through `ssh-keygen -Y sign` using the signs canonical keychain payloads through `ssh-keygen -Y sign` using the
`geth.keychain.v1@geth.local` namespace. This is the bootstrap path for `geth.keychain.v1@geth.local` namespace. This is the bootstrap path for
admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored admin/YubiKey-rooted trust. `geth keychain sync <node>` pulls the signed
keychain signatures plus how many currently verify with OpenSSH; rejecting keychain operation log from an imported peer and imports only operations with
unsigned or invalid replicated keychain ops is still future work. 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.
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.
@ -82,10 +86,16 @@ metadata from an authorized peer over Iroh.
The bootstrap implementation provides: The bootstrap implementation provides:
- `geth init` - `geth init`
- `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>`
- `geth daemon run` - `geth daemon run`
- `geth daemon service install|uninstall|start|stop|status|print` - `geth daemon service install|uninstall|start|stop|status|print`
- `geth status` - `geth status`
- `geth node id` - `geth node id`
- `geth node list`
- `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 peer export [--out <path>]` - `geth peer export [--out <path>]`
- `geth peer import <path>` - `geth peer import <path>`
- `geth peer list` - `geth peer list`
@ -95,6 +105,7 @@ The bootstrap implementation provides:
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>] [--signing-key <path>]` - `geth keychain init [--admin-key <path>] [--signing-key <path>]`
- `geth keychain status` - `geth keychain status`
- `geth keychain sync <node-id-or-name>`
- `geth secret status` - `geth secret status`
- `geth secret create <resource>` - `geth secret create <resource>`
- `geth secret rotate <resource>` - `geth secret rotate <resource>`
@ -327,6 +338,40 @@ cargo run -p geth -- cas add /tmp/hello-geth.txt
cargo run -p geth -- cas list cargo run -p geth -- cas list
``` ```
## Owner And Node Management
The intended owner setup is SSH-admin-rooted:
```sh
geth init \
--admin-key ~/.ssh/id_ed25519_sk.pub \
--signing-key ~/.ssh/id_ed25519_sk \
--node-name laptop \
--capability resource:ssh-proxy:local=ssh_proxy.admin_shell
```
When any owner setup option is used, both `--admin-key` and `--signing-key` are
required. This prevents accidentally creating an unsigned owner/device/node
statement that cannot be accepted by another node during keychain sync.
This records signed keychain operations for `KeychainInit`, `AdminKeyAdd`,
`UserAdd`, `DeviceAdd`, `NodeAdd`, and `AgentBind`. The current node identity is
stable above endpoint rotation: future endpoint bindings should attach to the
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 revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
```
`geth keychain sync <node>` pulls signed keychain operations from an imported
peer over Iroh and rejects operations that do not have a valid OpenSSH signature
from a currently trusted admin key over the canonical keychain payload. This is
the current replicated device-management substrate. It is still a pull-based
operation log, not yet a CRDT or Keyhive-style convergent authority.
## Authorization Direction ## Authorization Direction
The MVP defines the split between: The MVP defines the split between:

View file

@ -20,7 +20,18 @@ pub struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum Command { pub enum Command {
Init, Init {
#[arg(long)]
admin_key: Option<PathBuf>,
#[arg(long)]
signing_key: Option<PathBuf>,
#[arg(long, default_value = "owner")]
owner: String,
#[arg(long, default_value = "local")]
node_name: String,
#[arg(long = "capability")]
capabilities: Vec<String>,
},
Daemon { Daemon {
#[command(subcommand)] #[command(subcommand)]
command: DaemonCommand, command: DaemonCommand,
@ -127,6 +138,29 @@ pub enum ServiceCommand {
pub enum NodeCommand { pub enum NodeCommand {
Id, Id,
Status, Status,
List,
Rename {
node: String,
name: String,
#[arg(long)]
signing_key: Option<PathBuf>,
},
Revoke {
node: String,
#[arg(long)]
signing_key: Option<PathBuf>,
},
Grant {
node: String,
resource: String,
capability: String,
#[arg(long)]
grant_id: Option<String>,
},
RevokeGrant {
resource: String,
grant_id: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -164,6 +198,9 @@ pub enum KeychainCommand {
signing_key: Option<PathBuf>, signing_key: Option<PathBuf>,
}, },
Status, Status,
Sync {
node: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -616,8 +653,24 @@ pub async fn run() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let paths = GethPaths::resolve().context("resolve geth paths")?; let paths = GethPaths::resolve().context("resolve geth paths")?;
match cli.command { match cli.command {
Command::Init => { Command::Init {
let node = geth_node::init_node(&paths).context("initialize geth node")?; admin_key,
signing_key,
owner,
node_name,
capabilities,
} => {
let node = geth_node::init_owned_node(
&paths,
geth_node::InitOwnerOptions {
admin_key_path: admin_key,
signing_key_path: signing_key,
owner_name: owner,
node_name,
capabilities,
},
)
.context("initialize geth node")?;
println!("initialized geth home: {}", node.paths.home().display()); println!("initialized geth home: {}", node.paths.home().display());
println!("agent: {}", node.agent_id); println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id); println!("node: {}", node.node_id);
@ -701,6 +754,44 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Node { Command::Node {
command: NodeCommand::Status, command: NodeCommand::Status,
} => ControlRequest::Status, } => ControlRequest::Status,
Command::Node {
command: NodeCommand::List,
} => ControlRequest::NodeList,
Command::Node {
command:
NodeCommand::Rename {
node,
name,
signing_key,
},
} => ControlRequest::NodeRename {
node,
name,
signing_key_path: signing_key,
},
Command::Node {
command: NodeCommand::Revoke { node, signing_key },
} => ControlRequest::NodeRevoke {
node,
signing_key_path: signing_key,
},
Command::Node {
command:
NodeCommand::Grant {
node,
resource,
capability,
grant_id,
},
} => ControlRequest::NodeGrant {
node,
resource,
capability,
grant_id,
},
Command::Node {
command: NodeCommand::RevokeGrant { resource, grant_id },
} => ControlRequest::NodeRevokeGrant { resource, grant_id },
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 },
@ -735,6 +826,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Keychain { Command::Keychain {
command: KeychainCommand::Status, command: KeychainCommand::Status,
} => ControlRequest::KeychainStatus, } => ControlRequest::KeychainStatus,
Command::Keychain {
command: KeychainCommand::Sync { node },
} => ControlRequest::KeychainSync { node },
Command::Auth { Command::Auth {
command: command:
AuthCommand::Explain { AuthCommand::Explain {
@ -1163,7 +1257,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
}, },
}, },
}, },
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), Command::Init { .. } | Command::Daemon { .. } => bail!("command is handled directly"),
}) })
} }
@ -1576,6 +1670,23 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
); );
} }
} }
ControlResponse::KeychainSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
note,
} => {
println!("synced keychain from: {peer_node_id}");
println!("agent: {peer_agent_id}");
println!("endpoint: {endpoint_id}");
println!("ops_imported: {ops_imported}");
println!("signatures_imported: {signatures_imported}");
println!("invalid_ops_rejected: {invalid_ops_rejected}");
println!("note: {note}");
}
ControlResponse::SecretStatus { secrets } => { ControlResponse::SecretStatus { secrets } => {
if secrets.is_empty() { if secrets.is_empty() {
println!("no resource secrets"); println!("no resource secrets");
@ -1689,6 +1800,43 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("recorded auth op: {}", op.id); println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource); println!("resource: {}", op.resource);
} }
ControlResponse::NodeList { nodes, note } => {
if nodes.is_empty() {
println!("no enrolled nodes");
} else {
for node in nodes {
println!(
"{}\t{}\tdevice {}\t{} endpoints",
node.name,
node.id,
node.device,
node.endpoints.len()
);
}
}
println!("note: {note}");
}
ControlResponse::NodeKeychainUpdated {
ops,
signatures,
note,
} => {
for op in ops {
println!("recorded keychain op: {}", op.id);
}
for signature in signatures {
println!(
"signed keychain op: {} by {} ({})",
signature.op_id, signature.signer, signature.namespace
);
}
println!("note: {note}");
}
ControlResponse::NodeGrantUpdated { op, note } => {
println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource);
println!("note: {note}");
}
ControlResponse::SshCertRequested { request } => { ControlResponse::SshCertRequested { request } => {
println!("ssh cert request: {}", request.id); println!("ssh cert request: {}", request.id);
println!("status: {}", request.status); println!("status: {}", request.status);

View file

@ -3,7 +3,7 @@ use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_keychain::{KeychainOp, KeychainOpSignature}; use geth_keychain::{KeychainOp, KeychainOpSignature, NodeRecord};
use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage};
use geth_pubsub::PubsubMessage; use geth_pubsub::PubsubMessage;
@ -117,11 +117,34 @@ pub enum ControlRequest {
resolution: String, resolution: String,
note: Option<String>, note: Option<String>,
}, },
NodeList,
NodeRename {
node: String,
name: String,
signing_key_path: Option<PathBuf>,
},
NodeRevoke {
node: String,
signing_key_path: Option<PathBuf>,
},
NodeGrant {
node: String,
resource: String,
capability: String,
grant_id: Option<String>,
},
NodeRevokeGrant {
resource: String,
grant_id: String,
},
KeychainInit { KeychainInit {
admin_key_path: Option<PathBuf>, admin_key_path: Option<PathBuf>,
signing_key_path: Option<PathBuf>, signing_key_path: Option<PathBuf>,
}, },
KeychainStatus, KeychainStatus,
KeychainSync {
node: String,
},
SecretStatus, SecretStatus,
SecretCreate { SecretCreate {
resource: String, resource: String,
@ -498,6 +521,15 @@ pub enum ControlResponse {
ops: Vec<KeychainOp>, ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>, signatures: Vec<KeychainOpSignature>,
}, },
KeychainSynced {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
ops_imported: usize,
signatures_imported: usize,
invalid_ops_rejected: usize,
note: String,
},
SecretStatus { SecretStatus {
secrets: Vec<ResourceMasterSecret>, secrets: Vec<ResourceMasterSecret>,
}, },
@ -531,6 +563,19 @@ pub enum ControlResponse {
AuthOpRecorded { AuthOpRecorded {
op: AuthOp, op: AuthOp,
}, },
NodeList {
nodes: Vec<NodeRecord>,
note: String,
},
NodeKeychainUpdated {
ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>,
note: String,
},
NodeGrantUpdated {
op: AuthOp,
note: String,
},
SshCertRequested { SshCertRequested {
request: SshCertRequest, request: SshCertRequest,
}, },
@ -811,6 +856,10 @@ pub enum PeerControlRequest {
peer_card: PeerCard, peer_card: PeerCard,
nonce: String, nonce: String,
}, },
KeychainSync {
peer_card: PeerCard,
nonce: String,
},
CasFetch { CasFetch {
peer_card: PeerCard, peer_card: PeerCard,
hash: BlobHash, hash: BlobHash,
@ -929,6 +978,16 @@ pub enum PeerControlResponse {
nonce: String, nonce: String,
note: String, note: String,
}, },
KeychainSynced {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>,
nonce: String,
note: String,
},
CasFetched { CasFetched {
node_id: String, node_id: String,
agent_id: String, agent_id: String,
@ -1249,6 +1308,24 @@ mod tests {
request request
); );
let request = ControlRequest::NodeRename {
node: "laptop".to_owned(),
name: "work-laptop".to_owned(),
signing_key_path: Some(PathBuf::from("admin")),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let request = ControlRequest::KeychainSync {
node: "work-laptop".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let request = ControlRequest::CasHas { let request = ControlRequest::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
}; };
@ -1266,6 +1343,34 @@ mod tests {
response response
); );
let response = ControlResponse::NodeList {
nodes: vec![NodeRecord {
id: geth_types::NodeId::new("node:local"),
device: geth_types::DeviceId::new("device:local"),
name: "work-laptop".to_owned(),
endpoints: Vec::new(),
}],
note: "reduced keychain view".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let response = ControlResponse::KeychainSynced {
peer_node_id: "node:peer".to_owned(),
peer_agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
ops_imported: 2,
signatures_imported: 2,
invalid_ops_rejected: 1,
note: "trusted admin signatures only".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let request = ControlRequest::SecretBearerVerify { let request = ControlRequest::SecretBearerVerify {
secret: "bearer:test".to_owned(), secret: "bearer:test".to_owned(),
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),

View file

@ -43,8 +43,8 @@ use geth_store::{
StoredSshCertificate, StoredSshRevocation, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
ResourceName, SshCertId, SshCertRequestId, UnixMillis, ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId,
}; };
use std::collections::{BTreeMap, VecDeque}; use std::collections::{BTreeMap, VecDeque};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
@ -75,6 +75,16 @@ pub enum NodeError {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("invalid resource kind: {0}")] #[error("invalid resource kind: {0}")]
InvalidResourceKind(String), InvalidResourceKind(String),
#[error("owner init requires --admin-key so the owner trust anchor is recorded")]
OwnerInitRequiresAdminKey,
#[error("owner init requires --signing-key so the owner statement is signed")]
OwnerInitRequiresSigningKey,
#[error(
"{0} requires --signing-key so the keychain operation can replicate as a verified admin statement"
)]
SigningKeyRequired(String),
#[error("invalid init capability grant, expected <resource>=<capability>: {0}")]
InvalidInitGrant(String),
#[error("invalid db resource name: {0}")] #[error("invalid db resource name: {0}")]
InvalidDbName(String), InvalidDbName(String),
#[error("db path does not exist or is not a file: {0}")] #[error("db path does not exist or is not a file: {0}")]
@ -180,6 +190,37 @@ struct PipeUnixConnectWire {
bearer_proof: Option<BearerProof>, bearer_proof: Option<BearerProof>,
} }
#[derive(Clone, Debug)]
pub struct InitOwnerOptions {
pub admin_key_path: Option<PathBuf>,
pub signing_key_path: Option<PathBuf>,
pub owner_name: String,
pub node_name: String,
pub capabilities: Vec<String>,
}
impl Default for InitOwnerOptions {
fn default() -> Self {
Self {
admin_key_path: None,
signing_key_path: None,
owner_name: "owner".to_owned(),
node_name: "local".to_owned(),
capabilities: Vec::new(),
}
}
}
impl InitOwnerOptions {
fn requests_owner_keychain(&self) -> bool {
self.admin_key_path.is_some()
|| self.signing_key_path.is_some()
|| self.node_name != "local"
|| self.owner_name != "owner"
|| !self.capabilities.is_empty()
}
}
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> { pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?; paths.ensure_base_dirs()?;
if !paths.config_file().exists() { if !paths.config_file().exists() {
@ -210,10 +251,121 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
}) })
} }
pub fn init_owned_node(
paths: &GethPaths,
options: InitOwnerOptions,
) -> Result<LocalNode, NodeError> {
let initialize_owner = options.requests_owner_keychain();
if initialize_owner && options.admin_key_path.is_none() {
return Err(NodeError::OwnerInitRequiresAdminKey);
}
if initialize_owner && options.signing_key_path.is_none() {
return Err(NodeError::OwnerInitRequiresSigningKey);
}
let node = init_node(paths)?;
if initialize_owner {
initialize_owner_keychain(&node, options)?;
}
Ok(node)
}
pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> { pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
init_node(paths) init_node(paths)
} }
fn initialize_owner_keychain(
node: &LocalNode,
options: InitOwnerOptions,
) -> Result<Vec<KeychainOp>, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let owner = UserId::new(format!("user:{}", stable_slug(&options.owner_name)));
let device = DeviceId::new(format!("device:{}", node.node_id));
let node_id = NodeId::new(node.node_id.clone());
let agent = node.agent_id.clone().into();
let now = UnixMillis(geth_store::now_ms());
let mut ops = vec![
KeychainOp {
id: generated_keychain_op_id("keychain-init", "owner", now),
created_at: now,
kind: KeychainOpKind::KeychainInit,
},
KeychainOp {
id: generated_keychain_op_id("user-add", owner.as_str(), now),
created_at: now,
kind: KeychainOpKind::UserAdd {
user: owner.clone(),
name: options.owner_name.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("device-add", device.as_str(), now),
created_at: now,
kind: KeychainOpKind::DeviceAdd {
device: device.clone(),
user: owner.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("node-add", node_id.as_str(), now),
created_at: now,
kind: KeychainOpKind::NodeAdd {
node: node_id.clone(),
device: device.clone(),
name: options.node_name.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("agent-bind", &node.agent_id, now),
created_at: now,
kind: KeychainOpKind::AgentBind {
agent,
node: node_id.clone(),
},
},
];
if let Some(admin_key_path) = options.admin_key_path.as_ref() {
let public_key = std::fs::read_to_string(admin_key_path)?;
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
ops.push(KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
created_at: now,
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
});
}
let signatures = store_and_sign_keychain_ops(
&store,
node,
&ops,
options.signing_key_path.as_deref(),
options.admin_key_path.as_deref(),
)?;
tracing::debug!(
ops = ops.len(),
signatures = signatures.len(),
"initialized owner keychain"
);
for grant in options.capabilities {
let (resource, capability) = grant
.split_once('=')
.ok_or_else(|| NodeError::InvalidInitGrant(grant.clone()))?;
record_node_grant(
&store,
node_id.as_str(),
resource,
capability,
Some(format!(
"grant:init:{}:{}",
stable_slug(node_id.as_str()),
stable_slug(capability)
)),
)?;
}
Ok(ops)
}
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> { pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
let mut node = init_node(&paths)?; let mut node = init_node(&paths)?;
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?; let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
@ -486,6 +638,9 @@ pub async fn handle_request_async(
resource, resource,
capability, capability,
} => peer_auth_check(node, &peer_node, resource, capability).await, } => peer_auth_check(node, &peer_node, resource, capability).await,
ControlRequest::KeychainSync { node: peer_node } => {
keychain_sync_from_peer(node, &peer_node).await
}
ControlRequest::CasFetch { ControlRequest::CasFetch {
node: peer_node, node: peer_node,
hash, hash,
@ -864,6 +1019,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
"peer returned auth-check response to ping request".to_owned(), "peer returned auth-check response to ping request".to_owned(),
)), )),
PeerControlResponse::SyncStatus { .. } PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::CasFetched { .. } | PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. } | PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. } | PeerControlResponse::SshCertSynced { .. }
@ -982,6 +1138,7 @@ async fn peer_auth_check(
"peer returned pong to auth-check request".to_owned(), "peer returned pong to auth-check request".to_owned(),
)), )),
PeerControlResponse::SyncStatus { .. } PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::CasFetched { .. } | PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. } | PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. } | PeerControlResponse::SshCertSynced { .. }
@ -1133,6 +1290,7 @@ async fn cas_fetch_from_peer(
PeerControlResponse::Pong { .. } PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. } | PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. } | PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::CasRootSynced { .. } | PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. } | PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } | PeerControlResponse::SshRevocationSynced { .. }
@ -2398,6 +2556,101 @@ async fn sync_status_from_peer(
} }
} }
async fn keychain_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(node, peer_node, "keychain-sync", |peer_card, nonce| {
PeerControlRequest::KeychainSync { peer_card, nonce }
})
.await?;
match response {
PeerControlResponse::KeychainSynced {
node_id,
agent_id,
endpoint_id,
ops,
signatures,
note,
..
} => {
let store = Store::open(&node.paths.metadata_db())?;
let mut local_ops = load_keychain_ops(&store)?;
let mut trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
let mut ops_imported = 0;
let mut signatures_imported = 0;
let mut invalid_ops_rejected = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect::<Vec<_>>();
if op_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let valid_signatures = op_signatures
.iter()
.filter(|signature| {
if !trusted_admins.contains(&signature.signer)
|| !keychain_signature_uses_claimed_key(signature)
{
return false;
}
let stored = stored_keychain_signature_from_signature(signature);
verify_keychain_signature_with_ssh(node, &op, &stored).unwrap_or(false)
})
.cloned()
.collect::<Vec<_>>();
if valid_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let existing = load_keychain_ops(&store)?
.into_iter()
.find(|existing| existing.id == op.id);
if existing.as_ref().is_some_and(|existing| existing != &op) {
invalid_ops_rejected += 1;
continue;
}
store_keychain_op(&store, &op)?;
if existing.is_none() {
local_ops.push(op.clone());
trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
ops_imported += 1;
}
for signature in valid_signatures {
store.insert_keychain_signature(&StoredKeychainSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
})?;
signatures_imported += 1;
}
}
Ok(ControlResponse::KeychainSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
note: format!(
"{note}; imported only keychain ops signed by currently trusted admin SSH keys"
),
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to keychain sync".to_owned(),
)),
}
}
async fn db_sync_from_peer( async fn db_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
@ -2789,9 +3042,10 @@ async fn request_peer_control(
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest, build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
) -> Result<PeerControlResponse, NodeError> { ) -> Result<PeerControlResponse, NodeError> {
let store = Store::open(&node.paths.metadata_db())?; let store = Store::open(&node.paths.metadata_db())?;
let peer_node = resolve_peer_node_for_control(&store, peer_node);
let stored = store let stored = store
.get_peer_card(peer_node)? .get_peer_card(&peer_node)?
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?; .ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?;
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?; let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
let candidate = peer_card let candidate = peer_card
@ -2844,6 +3098,10 @@ async fn request_peer_control(
nonce: response_nonce, nonce: response_nonce,
.. ..
} }
| PeerControlResponse::KeychainSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::SshRevocationSynced { | PeerControlResponse::SshRevocationSynced {
nonce: response_nonce, nonce: response_nonce,
.. ..
@ -3174,6 +3432,31 @@ async fn handle_iroh_control_connection(
note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(), note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(),
} }
} }
PeerControlRequest::KeychainSync { peer_card, nonce } => {
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
PeerControlResponse::KeychainSynced {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
ops: load_keychain_ops(&store)?,
signatures: load_keychain_signatures(&store)?,
nonce,
note: "keychain sync returns signed operation-log data; receiver must verify OpenSSH signatures before import".to_owned(),
}
}
PeerControlRequest::AuthCheck { PeerControlRequest::AuthCheck {
peer_card, peer_card,
resource, resource,
@ -5011,24 +5294,13 @@ pub fn handle_request(
ops.push(op); ops.push(op);
} }
let signatures = if let Some(signing_key_path) = signing_key_path { let signatures = store_and_sign_keychain_ops(
let (signer, signer_public_key) = &store,
keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?; node,
let mut signatures = Vec::new(); &ops,
for op in &ops { signing_key_path.as_deref(),
signatures.push(sign_keychain_op_with_ssh( admin_key_path.as_deref(),
&store, )?;
node,
op,
&signing_key_path,
&signer,
&signer_public_key,
)?);
}
signatures
} else {
Vec::new()
};
Ok(ControlResponse::KeychainInitialized { ops, signatures }) Ok(ControlResponse::KeychainInitialized { ops, signatures })
} }
@ -5046,6 +5318,93 @@ pub fn handle_request(
nodes: view.nodes.len(), nodes: view.nodes.len(),
})) }))
} }
ControlRequest::NodeList => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
Ok(ControlResponse::NodeList {
nodes: view.nodes.into_values().collect(),
note: "nodes are reduced from the signed keychain operation log; revoked devices and nodes are omitted".to_owned(),
})
}
ControlRequest::NodeRename {
node: target,
name,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node rename".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-rename", target.as_str(), created_at),
created_at,
kind: KeychainOpKind::NodeRename { node: target, name },
};
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 rename as a keychain operation".to_owned(),
})
}
ControlRequest::NodeRevoke {
node: target,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node 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-revoke", target.as_str(), created_at),
created_at,
kind: KeychainOpKind::NodeRevoke { node: target },
};
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 node revocation as a keychain operation; replicated peers will omit the node after verified keychain sync".to_owned(),
})
}
ControlRequest::NodeGrant {
node: target,
resource,
capability,
grant_id,
} => {
let target = resolve_keychain_node(&store, &target)?;
let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?;
Ok(ControlResponse::NodeGrantUpdated {
op,
note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(),
})
}
ControlRequest::NodeRevokeGrant { resource, grant_id } => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::NodeGrantUpdated {
op,
note: "recorded capability grant revocation".to_owned(),
})
}
ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus { ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus {
secrets: store secrets: store
.list_resource_secrets()? .list_resource_secrets()?
@ -5945,7 +6304,8 @@ pub fn handle_request(
} }
ControlRequest::SshProxyConnect { .. } ControlRequest::SshProxyConnect { .. }
| ControlRequest::SshProxyStream { .. } | ControlRequest::SshProxyStream { .. }
| ControlRequest::SshAdminShell { .. } => Err(NodeError::IrohEndpointUnavailable), | ControlRequest::SshAdminShell { .. }
| ControlRequest::KeychainSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ModuleStub { module, command } => { ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command }) Ok(ControlResponse::NotImplemented { module, command })
} }
@ -6625,6 +6985,34 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
Ok(()) Ok(())
} }
fn store_and_sign_keychain_ops(
store: &Store,
node: &LocalNode,
ops: &[KeychainOp],
signing_key_path: Option<&Path>,
admin_key_path: Option<&Path>,
) -> Result<Vec<KeychainOpSignature>, NodeError> {
for op in ops {
store_keychain_op(store, op)?;
}
let Some(signing_key_path) = signing_key_path else {
return Ok(Vec::new());
};
let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?;
ops.iter()
.map(|op| {
sign_keychain_op_with_ssh(
store,
node,
op,
signing_key_path,
&signer,
&signer_public_key,
)
})
.collect()
}
fn keychain_signer_from_paths( fn keychain_signer_from_paths(
signing_key_path: &Path, signing_key_path: &Path,
admin_key_path: Option<&Path>, admin_key_path: Option<&Path>,
@ -6689,6 +7077,23 @@ fn sign_keychain_op_with_ssh(
Ok(signature) Ok(signature)
} }
fn stored_keychain_signature_from_signature(
signature: &KeychainOpSignature,
) -> StoredKeychainSignature {
StoredKeychainSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
}
}
fn keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct KeychainSignatureStatus { struct KeychainSignatureStatus {
total: usize, total: usize,
@ -6787,6 +7192,83 @@ fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
.collect() .collect()
} }
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {
Ok(store
.list_keychain_signatures()?
.into_iter()
.map(|stored| KeychainOpSignature {
op_id: AuthOpId::new(stored.op_id),
signer: KeyId::new(stored.signer),
signer_public_key: stored.signer_public_key,
namespace: stored.namespace,
signature: stored.signature,
created_at: UnixMillis(stored.created_at_ms),
})
.collect())
}
fn resolve_keychain_node(store: &Store, node_or_name: &str) -> Result<NodeId, NodeError> {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?);
if let Some((node_id, _)) = view
.nodes
.iter()
.find(|(node_id, record)| node_id.as_str() == node_or_name || record.name == node_or_name)
{
Ok(node_id.clone())
} else {
Err(NodeError::ResourceNotFound(format!("node:{node_or_name}")))
}
}
fn resolve_peer_node_for_control(store: &Store, node_or_name: &str) -> String {
resolve_keychain_node(store, node_or_name)
.map(|node| node.to_string())
.unwrap_or_else(|_| node_or_name.to_owned())
}
fn record_node_grant(
store: &Store,
node_id: &str,
resource: &str,
capability: &str,
grant_id: Option<String>,
) -> Result<AuthOp, NodeError> {
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 {
id: generated_auth_op_id("grant-create", resource, &grant_id, created_at),
resource: ResourceId::new(resource.to_owned()),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id,
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 {
let slug = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_owned();
if slug.is_empty() {
"unnamed".to_owned()
} else {
slug
}
}
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String { fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!( format!(
"grant:{}", "grant:{}",

View file

@ -71,6 +71,19 @@ fn geth_init_in_temp_home() {
); );
} }
#[test]
fn owner_init_requires_admin_key_and_signing_key() {
let home = tempfile::tempdir().expect("tempdir");
let output = run_geth(home.path(), &["init", "--node-name", "laptop"]);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("owner init requires --admin-key"),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!home.path().join("geth.sqlite").exists());
}
#[test] #[test]
fn geth_status_against_running_daemon() { fn geth_status_against_running_daemon() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");
@ -929,6 +942,99 @@ fn keychain_init_can_record_openssh_signatures() {
} }
} }
#[test]
fn init_owned_node_records_signed_owner_device_and_node() {
if Command::new("ssh-keygen").arg("-?").output().is_err() {
return;
}
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let admin_key_path = home.path().join("owner_ed25519");
let status = Command::new("ssh-keygen")
.arg("-q")
.arg("-t")
.arg("ed25519")
.arg("-N")
.arg("")
.arg("-f")
.arg(&admin_key_path)
.status()
.expect("generate owner ssh key");
assert!(status.success());
let node = geth_node::init_owned_node(
&paths,
geth_node::InitOwnerOptions {
admin_key_path: Some(admin_key_path.with_extension("pub")),
signing_key_path: Some(admin_key_path.clone()),
owner_name: "Eric".to_owned(),
node_name: "laptop".to_owned(),
capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.admin_shell".to_owned()],
},
)
.expect("init owned node");
match geth_node::handle_request(&node, geth_control::ControlRequest::KeychainStatus)
.expect("keychain status")
{
geth_control::ControlResponse::KeychainStatus(status) => {
assert!(status.initialized);
assert_eq!(status.admin_keys, 1);
assert_eq!(status.users, 1);
assert_eq!(status.devices, 1);
assert_eq!(status.nodes, 1);
assert_eq!(status.verified_signatures, status.signatures);
assert!(status.signatures >= 6);
}
other => panic!("unexpected response: {other:?}"),
}
match geth_node::handle_request(&node, geth_control::ControlRequest::NodeList)
.expect("node list")
{
geth_control::ControlResponse::NodeList { nodes, .. } => {
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].name, "laptop");
assert_eq!(nodes[0].id.as_str(), node.node_id);
}
other => panic!("unexpected response: {other:?}"),
}
let renamed = geth_node::handle_request(
&node,
geth_control::ControlRequest::NodeRename {
node: "laptop".to_owned(),
name: "work-laptop".to_owned(),
signing_key_path: Some(admin_key_path),
},
)
.expect("rename node");
match renamed {
geth_control::ControlResponse::NodeKeychainUpdated { signatures, .. } => {
assert_eq!(signatures.len(), 1);
}
other => panic!("unexpected response: {other:?}"),
}
let explained = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: node.node_id.clone(),
resource: "resource:ssh-proxy:local".to_owned(),
capability: "ssh_proxy.admin_shell".to_owned(),
},
)
.expect("explain init grant");
match explained {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(explanation.allowed);
assert!(explanation.reason.contains("direct grant"));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test] #[test]
fn db_add_and_status_register_local_db_metadata() { fn db_add_and_status_register_local_db_metadata() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -0,0 +1,42 @@
# ADR 0016: Owner-Rooted Node Enrollment
## Status
Accepted.
## Context
Geth needs a practical device-management flow: initialize a mesh with an
admin/owner identity, bind local daemon instances to named nodes/devices, list
devices by name, grant resource capabilities, and revoke devices. This must not
be a mutable device table because the project needs to evolve toward replicated
local-first authorization.
## Decision
Owner and node management is represented as signed keychain operations. `geth
init --admin-key <pub> --signing-key <key> --node-name <name>` records
`KeychainInit`, `AdminKeyAdd`, `UserAdd`, `DeviceAdd`, `NodeAdd`, and
`AgentBind` operations. When owner setup options are used, both `--admin-key`
and `--signing-key` are required. Geth signs canonical keychain payloads with
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 keychain sync <node>` pulls keychain operations and signatures over Iroh
from an imported peer. The receiver imports only operations with valid OpenSSH
signatures from currently trusted admin keys over the canonical keychain
payload. Discovery and peer cards still grant no trust by themselves.
## Consequences
Node names are convenience labels over stable node IDs. Endpoint rotation should
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, does not implement advanced group
cryptography, and does not yet sign/verify replicated auth operations. Those are
future roadmap items.

View file

@ -275,15 +275,18 @@ 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. Keychain operations reduce into an active view containing current identity. Keychain operations reduce into an active view containing current
admin keys, users, devices, node records, agent bindings, and endpoint-to-node admin keys, users, devices, node records, agent bindings, and endpoint-to-node
bindings. Revoked identity subtrees are excluded from that active view. The bindings. Revoked identity subtrees are excluded from that active view. `geth
daemon persists local keychain init/admin-key operations and `keychain status` init --admin-key <pub> --signing-key <key> --node-name <name>` records an
reports the reduced local view. `keychain init --signing-key <path>` writes the owner/admin key, user, device, node, and agent binding as keychain operations
canonical keychain signing payloads, runs `ssh-keygen -Y sign` with the explicit and signs them with OpenSSH under `geth.keychain.v1@geth.local`. Both keys are
`geth.keychain.v1@geth.local` namespace, and stores the resulting OpenSSH required when owner setup options are used, so the node does not create unsigned
signatures in local SQLite. `keychain status` reports the stored signature owner statements by accident. `geth node list` shows the active reduced node
count and verifies stored signatures against their canonical payloads with view. `geth node rename` and `geth node revoke` record signed keychain
OpenSSH when possible. Rejection of unsigned or invalid replicated keychain operations and require `--signing-key`. `geth keychain sync <node>` pulls
operations is still future work. 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.
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`. Auth operations reduce into a grants, revocations, groups, and `auth explain`. Auth operations reduce into a

View file

@ -182,8 +182,12 @@ resource-scoped capability decisions.
- `[x]` Tests cover signed keychain init with a generated local OpenSSH key - `[x]` Tests cover signed keychain init with a generated local OpenSSH key
when `ssh-keygen` is available. when `ssh-keygen` is available.
- `[x]` Tests cover local OpenSSH verification of stored keychain signatures. - `[x]` Tests cover local OpenSSH verification of stored keychain signatures.
- `[ ]` Future completion verifies signatures before accepting replicated - `[x]` `geth init --admin-key --signing-key --node-name` records signed
keychain ops. owner/user/device/node/agent binding operations.
- `[x]` `geth node list/rename/revoke` operate on the reduced keychain view.
- `[x]` `geth node rename/revoke` require an admin signing key.
- `[x]` `geth keychain sync <node>` verifies signatures from currently
trusted admin keys before accepting keychain ops.
- `[x]` Keychain operation reducer. - `[x]` Keychain operation reducer.
Acceptance criteria: Acceptance criteria:
@ -192,6 +196,17 @@ 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.
Acceptance criteria:
- `[x]` `geth node grant <node> <resource> <capability>` records a
resource-scoped capability grant for a known node.
- `[x]` `geth node revoke-grant <resource> <grant-id>` records grant
revocation.
- `[x]` Node names can be used for management commands where the keychain view
has a unique active node name.
- `[ ]` Future completion signs auth ops and verifies signed auth ops before
accepting replicated permission changes.
- `[x]` Resource auth operation reducer. - `[x]` Resource auth operation reducer.
Acceptance criteria: Acceptance criteria:
- Resource create, authority set, grants, revocations, and groups reduce into - Resource create, authority set, grants, revocations, and groups reduce into