From 27a79768e4e2145e08c82368db1eae5642dacbec Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Thu, 21 May 2026 18:01:38 +0200 Subject: [PATCH] Add smooth node enrollment flow --- AGENTS.md | 10 +- README.md | 33 + crates/geth-auth/src/lib.rs | 10 + crates/geth-cli/src/lib.rs | 198 +++++- crates/geth-control/src/lib.rs | 156 ++++- crates/geth-keychain/src/lib.rs | 110 +++- crates/geth-node/src/lib.rs | 805 ++++++++++++++++++++++++- crates/geth-store/src/lib.rs | 181 ++++++ crates/geth/tests/bootstrap.rs | 128 ++++ docs/adr/0016-owner-node-enrollment.md | 23 +- docs/architecture.md | 15 +- docs/roadmap.md | 15 +- 12 files changed, 1662 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bfa4cef..eb26e78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,9 +119,11 @@ 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`. `kv set --subject ` enforces - local KV write grants for non-local test callers. Signature validation and - broader daemon-side module enforcement are still roadmap work. + and uses them for `auth explain`. Enrollment approval signs auth ops, and + `geth auth sync ` imports only auth ops signed by currently trusted + admin keys. `kv set --subject ` 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 --signing-key --node-name ` records signed owner/user/device/node/agent binding operations. `keychain @@ -132,6 +134,8 @@ Roadmap items should be actionable and checkable: 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`. + `geth node enroll request|submit|import|list|approve|sync` is the smooth + new-device flow. - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and `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 diff --git a/README.md b/README.md index 643700a..3d84954 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,12 @@ The bootstrap implementation provides: - `geth status` - `geth node id` - `geth node list` +- `geth node enroll request --node-name --capability [--out ]` +- `geth node enroll submit [--request-id |--path ]` +- `geth node enroll import ` +- `geth node enroll list [--status pending|approved|rejected]` +- `geth node enroll approve --signing-key ` +- `geth node enroll sync ` - `geth node rename --signing-key ` - `geth node revoke --signing-key ` - `geth node grant [--grant-id ]` @@ -106,6 +112,7 @@ The bootstrap implementation provides: - `geth keychain init [--admin-key ] [--signing-key ]` - `geth keychain status` - `geth keychain sync ` +- `geth auth sync ` - `geth secret status` - `geth secret create ` - `geth secret rotate ` @@ -366,6 +373,32 @@ geth node grant work-laptop resource:ssh-proxy:local ssh_proxy.connect geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk ``` +The enrollment flow for a new node is: + +```sh +# On the new node: +geth node enroll request \ + --node-name workstation \ + --capability resource:ssh-proxy:local=ssh_proxy.connect \ + --out /tmp/workstation-enrollment.json + +# Either submit over Iroh to an imported owner peer: +geth node enroll submit owner-laptop --path /tmp/workstation-enrollment.json + +# Or import the JSON on the owner/YubiKey machine: +geth node enroll import /tmp/workstation-enrollment.json +geth node enroll list --status pending +geth node enroll approve --signing-key ~/.ssh/id_ed25519_sk + +# Back on the new node, pull signed identity and authorization state: +geth node enroll sync owner-laptop +``` + +Enrollment requests are signed by the requesting agent key. Approval records +signed keychain operations for the new device/node/agent binding and signed auth +operations for requested resource capabilities. `geth node enroll sync` pulls +both signed logs from the owner node. + `geth keychain sync ` 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 diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index 1ce8a43..3e92d12 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -31,6 +31,16 @@ pub struct AuthOp { pub kind: AuthOpKind, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthOpSignature { + pub op_id: AuthOpId, + pub signer: geth_types::KeyId, + pub signer_public_key: String, + pub namespace: String, + pub signature: Vec, + pub created_at: UnixMillis, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum AuthOpKind { diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 11a1696..d01ce40 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -139,6 +139,10 @@ pub enum NodeCommand { Id, Status, List, + Enroll { + #[command(subcommand)] + command: NodeEnrollCommand, + }, Rename { node: String, name: String, @@ -163,6 +167,48 @@ pub enum NodeCommand { }, } +#[derive(Debug, Subcommand)] +pub enum NodeEnrollCommand { + Request { + #[arg(long)] + node_name: String, + #[arg(long = "capability")] + capabilities: Vec, + #[arg(long)] + reason: Option, + #[arg(long)] + out: Option, + }, + Submit { + owner_node: String, + #[arg(long)] + request_id: Option, + #[arg(long)] + path: Option, + }, + Import { + path: PathBuf, + }, + List { + #[arg(long)] + status: Option, + }, + Approve { + request_id: String, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, + #[arg(long)] + node_name: Option, + #[arg(long = "capability")] + capabilities: Vec, + }, + Sync { + owner_node: String, + }, +} + #[derive(Debug, Subcommand)] pub enum PeerCommand { Export { @@ -210,6 +256,9 @@ pub enum AuthCommand { resource: String, capability: String, }, + Sync { + node: String, + }, Grant { subject: String, resource: String, @@ -757,6 +806,46 @@ fn request_for_command(command: Command) -> Result { Command::Node { command: NodeCommand::List, } => ControlRequest::NodeList, + Command::Node { + command: NodeCommand::Enroll { command }, + } => match command { + NodeEnrollCommand::Request { + node_name, + capabilities, + reason, + out, + } => ControlRequest::NodeEnrollRequest { + node_name, + capabilities, + reason, + out, + }, + NodeEnrollCommand::Submit { + owner_node, + request_id, + path, + } => ControlRequest::NodeEnrollSubmit { + owner_node, + request_id, + path, + }, + NodeEnrollCommand::Import { path } => ControlRequest::NodeEnrollImport { path }, + NodeEnrollCommand::List { status } => ControlRequest::NodeEnrollList { status }, + NodeEnrollCommand::Approve { + request_id, + signing_key, + admin_key, + node_name, + capabilities, + } => ControlRequest::NodeEnrollApprove { + request_id, + signing_key_path: signing_key, + admin_key_path: admin_key, + node_name, + capabilities, + }, + NodeEnrollCommand::Sync { owner_node } => ControlRequest::NodeEnrollSync { owner_node }, + }, Command::Node { command: NodeCommand::Rename { @@ -829,6 +918,9 @@ fn request_for_command(command: Command) -> Result { Command::Keychain { command: KeychainCommand::Sync { node }, } => ControlRequest::KeychainSync { node }, + Command::Auth { + command: AuthCommand::Sync { node }, + } => ControlRequest::AuthSync { node }, Command::Auth { command: AuthCommand::Explain { @@ -1687,6 +1779,23 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("invalid_ops_rejected: {invalid_ops_rejected}"); println!("note: {note}"); } + ControlResponse::AuthSynced { + peer_node_id, + peer_agent_id, + endpoint_id, + ops_imported, + signatures_imported, + invalid_ops_rejected, + note, + } => { + println!("synced auth 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 } => { if secrets.is_empty() { println!("no resource secrets"); @@ -1832,9 +1941,96 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } println!("note: {note}"); } - ControlResponse::NodeGrantUpdated { op, note } => { + ControlResponse::NodeGrantUpdated { + op, + signatures, + note, + } => { 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 + ); + } + println!("note: {note}"); + } + ControlResponse::NodeEnrollmentRequested { request, out, note } => { + println!("node enrollment request: {}", request.id); + println!("node: {}", request.requester_node); + println!("requested_name: {}", request.requested_node_name); + println!("status: {}", request.status); + if let Some(out) = out { + println!("written: {}", out.display()); + } + println!("note: {note}"); + } + ControlResponse::NodeEnrollmentSubmitted { + request_id, + owner_node_id, + accepted, + note, + } => { + println!("submitted enrollment request: {request_id}"); + println!("owner_node: {owner_node_id}"); + println!("accepted: {accepted}"); + println!("note: {note}"); + } + ControlResponse::NodeEnrollmentImported { request, note } => { + println!("imported enrollment request: {}", request.id); + println!("node: {}", request.requester_node); + println!("requested_name: {}", request.requested_node_name); + println!("status: {}", request.status); + println!("note: {note}"); + } + ControlResponse::NodeEnrollmentList { requests, note } => { + if requests.is_empty() { + println!("no node enrollment requests"); + } else { + for request in requests { + println!( + "{}\t{}\t{}\t{} capabilities", + request.id, + request.status, + request.requested_node_name, + request.requested_capabilities.len() + ); + } + } + println!("note: {note}"); + } + ControlResponse::NodeEnrollmentApproved { + request, + keychain_ops, + keychain_signatures, + auth_ops, + auth_signatures, + note, + } => { + println!("approved enrollment request: {}", request.id); + println!("node: {}", request.requester_node); + println!("keychain_ops: {}", keychain_ops.len()); + println!("keychain_signatures: {}", keychain_signatures.len()); + println!("auth_ops: {}", auth_ops.len()); + println!("auth_signatures: {}", auth_signatures.len()); + println!("note: {note}"); + } + ControlResponse::NodeEnrollmentSynced { + owner_node, + keychain_ops_imported, + keychain_signatures_imported, + auth_ops_imported, + auth_signatures_imported, + invalid_ops_rejected, + note, + } => { + println!("synced enrollment from: {owner_node}"); + println!("keychain_ops_imported: {keychain_ops_imported}"); + println!("keychain_signatures_imported: {keychain_signatures_imported}"); + println!("auth_ops_imported: {auth_ops_imported}"); + println!("auth_signatures_imported: {auth_signatures_imported}"); + println!("invalid_ops_rejected: {invalid_ops_rejected}"); println!("note: {note}"); } ControlResponse::SshCertRequested { request } => { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 99dadf6..39db834 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,9 +1,9 @@ -use geth_auth::{AuthExplanation, AuthOp}; +use geth_auth::{AuthExplanation, AuthOp, AuthOpSignature}; use geth_cas::{FileConflict, FileRoot, FileRootScan}; use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; -use geth_keychain::{KeychainOp, KeychainOpSignature, NodeRecord}; +use geth_keychain::{KeychainOp, KeychainOpSignature, NodeEnrollmentRequest, NodeRecord}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pubsub::PubsubMessage; @@ -137,6 +137,33 @@ pub enum ControlRequest { resource: String, grant_id: String, }, + NodeEnrollRequest { + node_name: String, + capabilities: Vec, + reason: Option, + out: Option, + }, + NodeEnrollSubmit { + owner_node: String, + request_id: Option, + path: Option, + }, + NodeEnrollImport { + path: PathBuf, + }, + NodeEnrollList { + status: Option, + }, + NodeEnrollApprove { + request_id: String, + signing_key_path: PathBuf, + admin_key_path: Option, + node_name: Option, + capabilities: Vec, + }, + NodeEnrollSync { + owner_node: String, + }, KeychainInit { admin_key_path: Option, signing_key_path: Option, @@ -145,6 +172,9 @@ pub enum ControlRequest { KeychainSync { node: String, }, + AuthSync { + node: String, + }, SecretStatus, SecretCreate { resource: String, @@ -530,6 +560,15 @@ pub enum ControlResponse { invalid_ops_rejected: usize, note: String, }, + AuthSynced { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + ops_imported: usize, + signatures_imported: usize, + invalid_ops_rejected: usize, + note: String, + }, SecretStatus { secrets: Vec, }, @@ -574,6 +613,43 @@ pub enum ControlResponse { }, NodeGrantUpdated { op: AuthOp, + signatures: Vec, + note: String, + }, + NodeEnrollmentRequested { + request: NodeEnrollmentRequest, + out: Option, + note: String, + }, + NodeEnrollmentSubmitted { + request_id: String, + owner_node_id: String, + accepted: bool, + note: String, + }, + NodeEnrollmentImported { + request: NodeEnrollmentRequest, + note: String, + }, + NodeEnrollmentList { + requests: Vec, + note: String, + }, + NodeEnrollmentApproved { + request: NodeEnrollmentRequest, + keychain_ops: Vec, + keychain_signatures: Vec, + auth_ops: Vec, + auth_signatures: Vec, + note: String, + }, + NodeEnrollmentSynced { + owner_node: String, + keychain_ops_imported: usize, + keychain_signatures_imported: usize, + auth_ops_imported: usize, + auth_signatures_imported: usize, + invalid_ops_rejected: usize, note: String, }, SshCertRequested { @@ -860,6 +936,15 @@ pub enum PeerControlRequest { peer_card: PeerCard, nonce: String, }, + AuthSync { + peer_card: PeerCard, + nonce: String, + }, + NodeEnrollmentSubmit { + peer_card: PeerCard, + request: NodeEnrollmentRequest, + nonce: String, + }, CasFetch { peer_card: PeerCard, hash: BlobHash, @@ -988,6 +1073,26 @@ pub enum PeerControlResponse { nonce: String, note: String, }, + AuthSynced { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + ops: Vec, + signatures: Vec, + nonce: String, + note: String, + }, + NodeEnrollmentSubmitted { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + request_id: String, + accepted: bool, + nonce: String, + note: String, + }, CasFetched { node_id: String, agent_id: String, @@ -1318,6 +1423,25 @@ mod tests { request ); + let request = ControlRequest::NodeEnrollRequest { + node_name: "workstation".to_owned(), + capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.connect".to_owned()], + reason: Some("new machine".to_owned()), + out: Some(PathBuf::from("enrollment.json")), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let request = ControlRequest::NodeEnrollSync { + owner_node: "owner-laptop".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let request = ControlRequest::KeychainSync { node: "work-laptop".to_owned(), }; @@ -1371,6 +1495,34 @@ mod tests { response ); + let response = ControlResponse::NodeEnrollmentSynced { + owner_node: "owner-laptop".to_owned(), + keychain_ops_imported: 3, + keychain_signatures_imported: 3, + auth_ops_imported: 1, + auth_signatures_imported: 1, + invalid_ops_rejected: 0, + note: "synced".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + + let response = ControlResponse::AuthSynced { + peer_node_id: "node:peer".to_owned(), + peer_agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + ops_imported: 1, + signatures_imported: 1, + invalid_ops_rejected: 0, + note: "trusted admin signatures only".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let request = ControlRequest::SecretBearerVerify { secret: "bearer:test".to_owned(), resource: "resource:cas:local".to_owned(), diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 9328e26..1b2064b 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -1,8 +1,11 @@ -use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId}; +use geth_types::{ + AgentId, AuthOpId, Capability, DeviceId, KeyId, NodeId, ResourceId, UnixMillis, UserId, +}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; +pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local"; pub type SignedKeychainOp = geth_codec::SignedEnvelope; @@ -38,6 +41,111 @@ pub struct KeychainOpSignature { pub created_at: UnixMillis, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeEnrollmentRequest { + pub id: AuthOpId, + pub requester_node: NodeId, + pub requester_agent: AgentId, + pub requester_agent_public_key: String, + pub requested_node_name: String, + pub requested_capabilities: Vec, + pub endpoint_id: Option, + pub reason: Option, + pub status: NodeEnrollmentStatus, + pub created_at: UnixMillis, + pub provenance: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeEnrollmentCapability { + pub resource: ResourceId, + pub capability: Capability, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeEnrollmentProvenance { + pub namespace: String, + pub signer_node: NodeId, + pub signer_agent: AgentId, + pub signer_public_key: String, + pub signature_hex: String, + pub signed_at: UnixMillis, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NodeEnrollmentStatus { + Pending, + Approved, + Rejected, +} + +impl NodeEnrollmentStatus { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Rejected => "rejected", + } + } +} + +impl std::fmt::Display for NodeEnrollmentStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for NodeEnrollmentStatus { + type Err = KeychainError; + + fn from_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "approved" => Ok(Self::Approved), + "rejected" => Ok(Self::Rejected), + _ => Err(KeychainError::InvalidEnrollmentStatus(value.to_owned())), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeEnrollmentRequestSigningPayload { + pub id: AuthOpId, + pub requester_node: NodeId, + pub requester_agent: AgentId, + pub requester_agent_public_key: String, + pub requested_node_name: String, + pub requested_capabilities: Vec, + pub endpoint_id: Option, + pub reason: Option, + pub created_at: UnixMillis, +} + +#[derive(Debug, thiserror::Error)] +pub enum KeychainError { + #[error("invalid node enrollment status: {0}")] + InvalidEnrollmentStatus(String), +} + +#[must_use] +pub fn node_enrollment_request_signing_payload( + request: &NodeEnrollmentRequest, +) -> NodeEnrollmentRequestSigningPayload { + NodeEnrollmentRequestSigningPayload { + id: request.id.clone(), + requester_node: request.requester_node.clone(), + requester_agent: request.requester_agent.clone(), + requester_agent_public_key: request.requester_agent_public_key.clone(), + requested_node_name: request.requested_node_name.clone(), + requested_capabilities: request.requested_capabilities.clone(), + endpoint_id: request.endpoint_id.clone(), + reason: request.reason.clone(), + created_at: request.created_at, + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum KeychainOpKind { diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 6d7b698..77425f9 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1,7 +1,7 @@ pub mod service; use base64::Engine; -use geth_auth::{AuthExplanation, AuthOp, AuthOpKind}; +use geth_auth::{AUTH_SIGNATURE_NAMESPACE, AuthExplanation, AuthOp, AuthOpKind, AuthOpSignature}; use geth_cas::{ BlobInfoSummary, CasTreeEntryKind, CasTreeObject, FileConflict, FileConflictKind, FileConflictResolution, FileConflictStatus, FileRoot, FileRootScan, LocalCas, hash_path, @@ -20,7 +20,11 @@ use geth_discovery::{ }; use geth_document::{DocumentResource, DocumentState}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; -use geth_keychain::{KeychainOp, KeychainOpKind, KeychainOpSignature}; +use geth_keychain::{ + KeychainOp, KeychainOpKind, KeychainOpSignature, NODE_ENROLLMENT_REQUEST_NAMESPACE, + NodeEnrollmentCapability, NodeEnrollmentProvenance, NodeEnrollmentRequest, + NodeEnrollmentStatus, node_enrollment_request_signing_payload, +}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pubsub::PubsubMessage; @@ -37,10 +41,10 @@ use geth_ssh_identity::{ }; use geth_ssh_proxy::SshProxyConnection; use geth_store::{ - Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, - StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry, StoredKvStore, - StoredModuleState, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest, - StoredSshCertificate, StoredSshRevocation, + Store, StoredAuthOp, StoredAuthSignature, StoredDbResource, StoredDocumentResource, + StoredFileConflict, StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry, + StoredKvStore, StoredModuleState, StoredNodeEnrollmentRequest, StoredPeerCard, StoredResource, + StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, @@ -85,6 +89,12 @@ pub enum NodeError { SigningKeyRequired(String), #[error("invalid init capability grant, expected =: {0}")] InvalidInitGrant(String), + #[error("invalid enrollment capability, expected =: {0}")] + InvalidEnrollmentCapability(String), + #[error("node enrollment request not found: {0}")] + NodeEnrollmentRequestNotFound(String), + #[error("node enrollment request has invalid provenance: {0}")] + InvalidNodeEnrollment(String), #[error("invalid db resource name: {0}")] InvalidDbName(String), #[error("db path does not exist or is not a file: {0}")] @@ -641,6 +651,15 @@ pub async fn handle_request_async( ControlRequest::KeychainSync { node: peer_node } => { keychain_sync_from_peer(node, &peer_node).await } + ControlRequest::AuthSync { node: peer_node } => auth_sync_from_peer(node, &peer_node).await, + ControlRequest::NodeEnrollSubmit { + owner_node, + request_id, + path, + } => node_enrollment_submit_to_peer(node, &owner_node, request_id, path).await, + ControlRequest::NodeEnrollSync { owner_node } => { + node_enrollment_sync_from_owner(node, &owner_node).await + } ControlRequest::CasFetch { node: peer_node, hash, @@ -1020,6 +1039,8 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result Result { + let response = request_peer_control(node, peer_node, "auth-sync", |peer_card, nonce| { + PeerControlRequest::AuthSync { peer_card, nonce } + }) + .await?; + match response { + PeerControlResponse::AuthSynced { + node_id, + agent_id, + endpoint_id, + ops, + signatures, + note, + .. + } => { + let store = Store::open(&node.paths.metadata_db())?; + let trusted_admins = + geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?).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::>(); + if op_signatures.is_empty() { + invalid_ops_rejected += 1; + continue; + } + let valid_signatures = op_signatures + .iter() + .filter(|signature| { + if !trusted_admins.contains(&signature.signer) + || !auth_signature_uses_claimed_key(signature) + { + return false; + } + let stored = stored_auth_signature_from_signature(signature); + verify_auth_signature_with_ssh(node, &op, &stored).unwrap_or(false) + }) + .cloned() + .collect::>(); + if valid_signatures.is_empty() { + invalid_ops_rejected += 1; + continue; + } + let existing = load_auth_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_auth_op(&store, &op)?; + ops_imported += usize::from(existing.is_none()); + for signature in valid_signatures { + store.insert_auth_signature(&StoredAuthSignature { + 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::AuthSynced { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + ops_imported, + signatures_imported, + invalid_ops_rejected, + note: format!("{note}; imported only auth ops signed by trusted admin SSH keys"), + }) + } + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + _ => Err(NodeError::IrohPeer( + "peer returned wrong response type to auth sync".to_owned(), + )), + } +} + +async fn node_enrollment_submit_to_peer( + node: &LocalNode, + owner_node: &str, + request_id: Option, + path: Option, +) -> Result { + let store = Store::open(&node.paths.metadata_db())?; + let request = if let Some(path) = path { + let request = read_node_enrollment_request_file(&path)?; + insert_node_enrollment_request_if_not_conflicting(&store, &request)?; + request + } else { + let request_id = request_id + .or_else(|| { + latest_pending_node_enrollment_request(&store).map(|request| request.id.to_string()) + }) + .ok_or_else(|| NodeError::NodeEnrollmentRequestNotFound("latest-pending".to_owned()))?; + load_node_enrollment_request(&store, &request_id)? + }; + let response = request_peer_control( + node, + owner_node, + "node-enrollment-submit", + |peer_card, nonce| PeerControlRequest::NodeEnrollmentSubmit { + peer_card, + request: request.clone(), + nonce, + }, + ) + .await?; + match response { + PeerControlResponse::NodeEnrollmentSubmitted { + node_id, + accepted, + note, + .. + } => Ok(ControlResponse::NodeEnrollmentSubmitted { + request_id: request.id.to_string(), + owner_node_id: node_id, + accepted, + note, + }), + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + _ => Err(NodeError::IrohPeer( + "peer returned wrong response type to node enrollment submit".to_owned(), + )), + } +} + +async fn node_enrollment_sync_from_owner( + node: &LocalNode, + owner_node: &str, +) -> Result { + let keychain = keychain_sync_from_peer(node, owner_node).await?; + let auth = auth_sync_from_peer(node, owner_node).await?; + let (keychain_ops_imported, keychain_signatures_imported, mut invalid_ops_rejected) = + match keychain { + ControlResponse::KeychainSynced { + ops_imported, + signatures_imported, + invalid_ops_rejected, + .. + } => (ops_imported, signatures_imported, invalid_ops_rejected), + other => { + return Err(NodeError::IrohPeer(format!( + "unexpected keychain sync response during enrollment sync: {other:?}" + ))); + } + }; + let (auth_ops_imported, auth_signatures_imported, auth_invalid) = match auth { + ControlResponse::AuthSynced { + ops_imported, + signatures_imported, + invalid_ops_rejected, + .. + } => (ops_imported, signatures_imported, invalid_ops_rejected), + other => { + return Err(NodeError::IrohPeer(format!( + "unexpected auth sync response during enrollment sync: {other:?}" + ))); + } + }; + invalid_ops_rejected += auth_invalid; + Ok(ControlResponse::NodeEnrollmentSynced { + owner_node: owner_node.to_owned(), + keychain_ops_imported, + keychain_signatures_imported, + auth_ops_imported, + auth_signatures_imported, + invalid_ops_rejected, + note: + "enrollment sync pulled signed keychain and signed auth operations from the owner node" + .to_owned(), + }) +} + async fn db_sync_from_peer( node: &LocalNode, peer_node: &str, @@ -3102,6 +3312,14 @@ async fn request_peer_control( nonce: response_nonce, .. } + | PeerControlResponse::AuthSynced { + nonce: response_nonce, + .. + } + | PeerControlResponse::NodeEnrollmentSubmitted { + nonce: response_nonce, + .. + } | PeerControlResponse::SshRevocationSynced { nonce: response_nonce, .. @@ -3280,6 +3498,12 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> { None } }; + if let Err(error) = keychain_sync_from_peer(node, &peer.peer_id).await { + tracing::debug!(peer = %peer.peer_id, %error, "live keychain sync failed"); + } + if let Err(error) = auth_sync_from_peer(node, &peer.peer_id).await { + tracing::debug!(peer = %peer.peer_id, %error, "live auth sync failed"); + } if should_live_sync_stream( &store, &peer.peer_id, @@ -3457,6 +3681,61 @@ async fn handle_iroh_control_connection( note: "keychain sync returns signed operation-log data; receiver must verify OpenSSH signatures before import".to_owned(), } } + PeerControlRequest::AuthSync { 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::AuthSynced { + 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_auth_ops(&store)?, + signatures: load_auth_signatures(&store)?, + nonce, + note: "auth sync returns signed resource operation-log data; receiver must verify admin signatures before import".to_owned(), + } + } + PeerControlRequest::NodeEnrollmentSubmit { + peer_card, + request, + 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, + })?; + let accepted = insert_node_enrollment_request_if_not_conflicting(&store, &request)?; + PeerControlResponse::NodeEnrollmentSubmitted { + 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, + request_id: request.id.to_string(), + accepted, + nonce, + note: "stored signed node enrollment request for owner review".to_owned(), + } + } PeerControlRequest::AuthCheck { peer_card, resource, @@ -5388,6 +5667,7 @@ pub fn handle_request( let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?; Ok(ControlResponse::NodeGrantUpdated { op, + signatures: Vec::new(), note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(), }) } @@ -5402,9 +5682,66 @@ pub fn handle_request( store_auth_op(&store, &op)?; Ok(ControlResponse::NodeGrantUpdated { op, + signatures: Vec::new(), note: "recorded capability grant revocation".to_owned(), }) } + ControlRequest::NodeEnrollRequest { + node_name, + capabilities, + reason, + out, + } => { + let mut request = + create_node_enrollment_request(node, node_name, capabilities, reason)?; + sign_node_enrollment_request(node, &mut request)?; + store_node_enrollment_request(&store, &request)?; + if let Some(path) = out.as_ref() { + write_node_enrollment_request_file(path, &request)?; + } + Ok(ControlResponse::NodeEnrollmentRequested { + request, + out, + note: "created signed node enrollment request; submit it to an owner node or move the JSON file to the owner machine".to_owned(), + }) + } + ControlRequest::NodeEnrollImport { path } => { + let request = read_node_enrollment_request_file(&path)?; + insert_node_enrollment_request_if_not_conflicting(&store, &request)?; + Ok(ControlResponse::NodeEnrollmentImported { + request, + note: "imported signed node enrollment request for owner review".to_owned(), + }) + } + ControlRequest::NodeEnrollList { status } => { + let requests = load_node_enrollment_requests(&store)? + .into_iter() + .filter(|request| { + status + .as_ref() + .is_none_or(|status| request.status.as_str() == status) + }) + .collect(); + Ok(ControlResponse::NodeEnrollmentList { + requests, + note: "node enrollment requests are signed by the requesting agent; approve on an owner machine with an admin signing key".to_owned(), + }) + } + ControlRequest::NodeEnrollApprove { + request_id, + signing_key_path, + admin_key_path, + node_name, + capabilities, + } => approve_node_enrollment( + &store, + node, + &request_id, + &signing_key_path, + admin_key_path.as_deref(), + node_name, + capabilities, + ), ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus { secrets: store .list_resource_secrets()? @@ -6305,7 +6642,10 @@ pub fn handle_request( ControlRequest::SshProxyConnect { .. } | ControlRequest::SshProxyStream { .. } | ControlRequest::SshAdminShell { .. } - | ControlRequest::KeychainSync { .. } => Err(NodeError::IrohEndpointUnavailable), + | ControlRequest::KeychainSync { .. } + | ControlRequest::AuthSync { .. } + | ControlRequest::NodeEnrollSubmit { .. } + | ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -6968,6 +7308,78 @@ fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { Ok(()) } +fn store_and_sign_auth_ops( + store: &Store, + node: &LocalNode, + ops: &[AuthOp], + signing_key_path: &Path, + admin_key_path: Option<&Path>, +) -> Result, NodeError> { + for op in ops { + store_auth_op(store, op)?; + } + let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?; + ops.iter() + .map(|op| { + sign_auth_op_with_ssh( + store, + node, + op, + signing_key_path, + &signer, + &signer_public_key, + ) + }) + .collect() +} + +fn sign_auth_op_with_ssh( + store: &Store, + node: &LocalNode, + op: &AuthOp, + signing_key_path: &Path, + signer: &KeyId, + signer_public_key: &str, +) -> Result { + geth_ssh_identity::ensure_ssh_keygen_available()?; + let signature_dir = node.paths.home().join("auth-signatures"); + std::fs::create_dir_all(&signature_dir)?; + let payload_path = signature_dir.join(format!( + "{}.payload", + geth_crypto::blake3_hex(op.id.as_str().as_bytes()) + )); + std::fs::write(&payload_path, geth_auth::auth_signing_payload(op)?)?; + let output = + geth_ssh_identity::sign_command(signing_key_path, AUTH_SIGNATURE_NAMESPACE, &payload_path) + .output()?; + if !output.status.success() { + return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed( + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + ) + .into()); + } + let signature_path = Path::new(&format!("{}.sig", payload_path.display())).to_path_buf(); + let signature_bytes = std::fs::read(signature_path)?; + let created_at = UnixMillis(geth_store::now_ms()); + let signature = AuthOpSignature { + op_id: op.id.clone(), + signer: signer.clone(), + signer_public_key: signer_public_key.to_owned(), + namespace: AUTH_SIGNATURE_NAMESPACE.to_owned(), + signature: signature_bytes, + created_at, + }; + store.insert_auth_signature(&StoredAuthSignature { + 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, + })?; + Ok(signature) +} + fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result, NodeError> { store .list_auth_ops_for_resource(resource)? @@ -6976,6 +7388,94 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result Result, NodeError> { + store + .list_auth_ops()? + .into_iter() + .map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from)) + .collect() +} + +fn load_auth_signatures(store: &Store) -> Result, NodeError> { + Ok(store + .list_auth_signatures()? + .into_iter() + .map(|stored| AuthOpSignature { + 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 stored_auth_signature_from_signature(signature: &AuthOpSignature) -> StoredAuthSignature { + StoredAuthSignature { + 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 auth_signature_uses_claimed_key(signature: &AuthOpSignature) -> bool { + KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer +} + +fn verify_auth_signature_with_ssh( + node: &LocalNode, + op: &AuthOp, + signature: &StoredAuthSignature, +) -> Result { + if signature.signer_public_key.trim().is_empty() { + return Ok(false); + } + if geth_ssh_identity::ensure_ssh_keygen_available().is_err() { + return Ok(false); + } + let verify_dir = node.paths.home().join("auth-signatures").join("verify"); + std::fs::create_dir_all(&verify_dir)?; + let stable_id = geth_crypto::blake3_hex( + format!( + "{}\0{}\0{}\0{}", + op.id, signature.signer, signature.namespace, signature.created_at_ms + ) + .as_bytes(), + ); + let payload_path = verify_dir.join(format!("{stable_id}.payload")); + let signature_path = verify_dir.join(format!("{stable_id}.sig")); + let allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers")); + std::fs::write(&payload_path, geth_auth::auth_signing_payload(op)?)?; + std::fs::write(&signature_path, &signature.signature)?; + std::fs::write( + &allowed_signers_path, + format!( + "{} {}\n", + signature.signer, + signature.signer_public_key.trim() + ), + )?; + let payload = std::fs::File::open(&payload_path)?; + let output = std::process::Command::new("ssh-keygen") + .arg("-Y") + .arg("verify") + .arg("-f") + .arg(&allowed_signers_path) + .arg("-I") + .arg(&signature.signer) + .arg("-n") + .arg(&signature.namespace) + .arg("-s") + .arg(&signature_path) + .stdin(std::process::Stdio::from(payload)) + .output()?; + Ok(output.status.success()) +} + fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> { store.insert_keychain_op(&StoredKeychainOp { op_id: op.id.to_string(), @@ -7269,6 +7769,297 @@ fn stable_slug(value: &str) -> String { } } +fn parse_enrollment_capabilities( + capabilities: Vec, +) -> Result, NodeError> { + capabilities + .into_iter() + .map(|value| { + let (resource, capability) = value + .split_once('=') + .ok_or_else(|| NodeError::InvalidEnrollmentCapability(value.clone()))?; + Ok(NodeEnrollmentCapability { + resource: ResourceId::new(resource.to_owned()), + capability: Capability::new(capability.to_owned()), + }) + }) + .collect() +} + +fn create_node_enrollment_request( + node: &LocalNode, + node_name: String, + capabilities: Vec, + reason: Option, +) -> Result { + let key = AgentKey::load(&node.paths.agent_key())?; + let created_at = UnixMillis(geth_store::now_ms()); + let requested_capabilities = parse_enrollment_capabilities(capabilities)?; + let endpoint_id = node.iroh_status.endpoint_id.clone(); + let stable = format!( + "{}\0{}\0{}\0{}", + node.node_id, node.agent_id, node_name, created_at.0 + ); + Ok(NodeEnrollmentRequest { + id: AuthOpId::new(format!( + "node-enrollment:{}", + geth_crypto::blake3_hex(stable.as_bytes()) + )), + requester_node: NodeId::new(node.node_id.clone()), + requester_agent: node.agent_id.clone().into(), + requester_agent_public_key: key.public_key_hex(), + requested_node_name: node_name, + requested_capabilities, + endpoint_id, + reason, + status: NodeEnrollmentStatus::Pending, + created_at, + provenance: None, + }) +} + +fn sign_node_enrollment_request( + node: &LocalNode, + request: &mut NodeEnrollmentRequest, +) -> Result<(), NodeError> { + let key = AgentKey::load(&node.paths.agent_key())?; + let signature = key.sign_canonical( + NODE_ENROLLMENT_REQUEST_NAMESPACE, + &node_enrollment_request_signing_payload(request), + )?; + request.provenance = Some(NodeEnrollmentProvenance { + namespace: NODE_ENROLLMENT_REQUEST_NAMESPACE.to_owned(), + signer_node: NodeId::new(node.node_id.clone()), + signer_agent: node.agent_id.clone().into(), + signer_public_key: key.public_key_hex(), + signature_hex: hex::encode(signature), + signed_at: UnixMillis(geth_store::now_ms()), + }); + Ok(()) +} + +fn verify_node_enrollment_request(request: &NodeEnrollmentRequest) -> Result<(), NodeError> { + let provenance = request + .provenance + .as_ref() + .ok_or_else(|| NodeError::InvalidNodeEnrollment("missing signed provenance".to_owned()))?; + if provenance.namespace != NODE_ENROLLMENT_REQUEST_NAMESPACE { + return Err(NodeError::InvalidNodeEnrollment(format!( + "invalid namespace {}", + provenance.namespace + ))); + } + if provenance.signer_node != request.requester_node + || provenance.signer_agent != request.requester_agent + || provenance.signer_public_key != request.requester_agent_public_key + { + return Err(NodeError::InvalidNodeEnrollment( + "provenance does not match requesting node/agent".to_owned(), + )); + } + verify_record_provenance( + &provenance.signer_public_key, + NODE_ENROLLMENT_REQUEST_NAMESPACE, + &node_enrollment_request_signing_payload(request), + &provenance.signature_hex, + ) +} + +fn store_node_enrollment_request( + store: &Store, + request: &NodeEnrollmentRequest, +) -> Result<(), NodeError> { + store.insert_node_enrollment_request(&StoredNodeEnrollmentRequest { + request_id: request.id.to_string(), + requester_node: request.requester_node.to_string(), + request_json: serde_json::to_string(request)?, + status: request.status.to_string(), + created_at_ms: request.created_at.0, + })?; + Ok(()) +} + +fn insert_node_enrollment_request_if_not_conflicting( + store: &Store, + request: &NodeEnrollmentRequest, +) -> Result { + verify_node_enrollment_request(request)?; + let stored = StoredNodeEnrollmentRequest { + request_id: request.id.to_string(), + requester_node: request.requester_node.to_string(), + request_json: serde_json::to_string(request)?, + status: request.status.to_string(), + created_at_ms: request.created_at.0, + }; + if let Some(existing) = store.get_node_enrollment_request(&stored.request_id)? { + return Ok(existing == stored); + } + store.insert_node_enrollment_request(&stored)?; + Ok(true) +} + +fn load_node_enrollment_request( + store: &Store, + request_id: &str, +) -> Result { + store + .get_node_enrollment_request(request_id)? + .ok_or_else(|| NodeError::NodeEnrollmentRequestNotFound(request_id.to_owned())) + .and_then(node_enrollment_request_from_stored) +} + +fn load_node_enrollment_requests(store: &Store) -> Result, NodeError> { + store + .list_node_enrollment_requests()? + .into_iter() + .map(node_enrollment_request_from_stored) + .collect() +} + +fn node_enrollment_request_from_stored( + stored: StoredNodeEnrollmentRequest, +) -> Result { + let mut request: NodeEnrollmentRequest = serde_json::from_str(&stored.request_json)?; + request.status = stored + .status + .parse() + .map_err(|error: geth_keychain::KeychainError| { + NodeError::InvalidNodeEnrollment(error.to_string()) + })?; + Ok(request) +} + +fn latest_pending_node_enrollment_request(store: &Store) -> Option { + load_node_enrollment_requests(store) + .ok() + .and_then(|requests| { + requests + .into_iter() + .filter(|request| request.status == NodeEnrollmentStatus::Pending) + .max_by_key(|request| request.created_at.0) + }) +} + +fn write_node_enrollment_request_file( + path: &Path, + request: &NodeEnrollmentRequest, +) -> Result<(), NodeError> { + std::fs::write(path, serde_json::to_vec_pretty(request)?)?; + Ok(()) +} + +fn read_node_enrollment_request_file(path: &Path) -> Result { + let request = serde_json::from_slice(&std::fs::read(path)?)?; + Ok(request) +} + +fn approve_node_enrollment( + store: &Store, + node: &LocalNode, + request_id: &str, + signing_key_path: &Path, + admin_key_path: Option<&Path>, + node_name: Option, + extra_capabilities: Vec, +) -> Result { + let mut request = load_node_enrollment_request(store, request_id)?; + verify_node_enrollment_request(&request)?; + let now = UnixMillis(geth_store::now_ms()); + let owner = first_keychain_user(store).unwrap_or_else(|| UserId::new("user:owner")); + let device = DeviceId::new(format!( + "device:{}", + stable_slug(request.requester_node.as_str()) + )); + let node_id = request.requester_node.clone(); + let name = node_name.unwrap_or_else(|| request.requested_node_name.clone()); + let mut keychain_ops = vec![ + KeychainOp { + id: generated_keychain_op_id("device-add", device.as_str(), now), + created_at: now, + kind: KeychainOpKind::DeviceAdd { + device: device.clone(), + user: owner, + }, + }, + KeychainOp { + id: generated_keychain_op_id("node-add", node_id.as_str(), now), + created_at: now, + kind: KeychainOpKind::NodeAdd { + node: node_id.clone(), + device, + name, + }, + }, + KeychainOp { + id: generated_keychain_op_id("agent-bind", request.requester_agent.as_str(), now), + created_at: now, + kind: KeychainOpKind::AgentBind { + agent: request.requester_agent.clone(), + node: node_id.clone(), + }, + }, + ]; + if let Some(endpoint) = request.endpoint_id.clone() { + keychain_ops.push(KeychainOp { + id: generated_keychain_op_id("node-endpoint-add", &endpoint, now), + created_at: now, + kind: KeychainOpKind::NodeEndpointAdd { + node: node_id.clone(), + endpoint, + }, + }); + } + let keychain_signatures = store_and_sign_keychain_ops( + store, + node, + &keychain_ops, + Some(signing_key_path), + admin_key_path, + )?; + + let mut grant_caps = request.requested_capabilities.clone(); + grant_caps.extend(parse_enrollment_capabilities(extra_capabilities)?); + let auth_ops = grant_caps + .into_iter() + .map(|grant| { + let resource = grant.resource.to_string(); + let capability = grant.capability.to_string(); + let grant_id = generated_grant_id(node_id.as_str(), &resource, &capability); + AuthOp { + id: generated_auth_op_id("grant-create", &resource, &grant_id, now), + resource: grant.resource, + created_at: now, + kind: AuthOpKind::GrantCreate { + grant_id, + principal: PrincipalId::new(node_id.to_string()), + capabilities: vec![grant.capability], + }, + } + }) + .collect::>(); + let auth_signatures = + store_and_sign_auth_ops(store, node, &auth_ops, signing_key_path, admin_key_path)?; + + request.status = NodeEnrollmentStatus::Approved; + store_node_enrollment_request(store, &request)?; + Ok(ControlResponse::NodeEnrollmentApproved { + request, + keychain_ops, + keychain_signatures, + auth_ops, + auth_signatures, + note: "approved enrollment; requester can run keychain sync and auth sync from this owner node".to_owned(), + }) +} + +fn first_keychain_user(store: &Store) -> Option { + geth_keychain::reduce_keychain_ops(&load_keychain_ops(store).ok()?) + .users + .keys() + .next() + .cloned() +} + fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String { format!( "grant:{}", diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 54d2030..e66fab9 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -79,12 +79,28 @@ impl Store { created_at_ms INTEGER NOT NULL, PRIMARY KEY (op_id, signer, namespace) ); + CREATE TABLE IF NOT EXISTS node_enrollment_requests ( + request_id TEXT PRIMARY KEY, + requester_node TEXT NOT NULL, + request_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS auth_ops ( op_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, op_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS auth_signatures ( + op_id TEXT NOT NULL, + signer TEXT NOT NULL, + signer_public_key TEXT NOT NULL DEFAULT '', + namespace TEXT NOT NULL, + signature BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (op_id, signer, namespace) + ); CREATE TABLE IF NOT EXISTS grants ( grant_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, @@ -935,6 +951,43 @@ impl Store { .map_err(StoreError::from) } + pub fn insert_auth_signature(&self, signature: &StoredAuthSignature) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO auth_signatures( + op_id, signer, signer_public_key, namespace, signature, created_at_ms + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![ + signature.op_id, + signature.signer, + signature.signer_public_key, + signature.namespace, + signature.signature, + signature.created_at_ms + ], + )?; + Ok(()) + } + + pub fn list_auth_signatures(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT op_id, signer, signer_public_key, namespace, signature, created_at_ms + FROM auth_signatures ORDER BY created_at_ms, op_id, signer, namespace"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredAuthSignature { + op_id: row.get(0)?, + signer: row.get(1)?, + signer_public_key: row.get(2)?, + namespace: row.get(3)?, + signature: row.get(4)?, + created_at_ms: row.get(5)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms) @@ -1000,6 +1053,68 @@ impl Store { .map_err(StoreError::from) } + pub fn insert_node_enrollment_request( + &self, + request: &StoredNodeEnrollmentRequest, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO node_enrollment_requests( + request_id, requester_node, request_json, status, created_at_ms + ) + VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![ + request.request_id, + request.requester_node, + request.request_json, + request.status, + request.created_at_ms + ], + )?; + Ok(()) + } + + pub fn get_node_enrollment_request( + &self, + request_id: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT request_id, requester_node, request_json, status, created_at_ms + FROM node_enrollment_requests WHERE request_id = ?1"#, + )?; + let mut rows = stmt.query(params![request_id])?; + if let Some(row) = rows.next()? { + Ok(Some(StoredNodeEnrollmentRequest { + request_id: row.get(0)?, + requester_node: row.get(1)?, + request_json: row.get(2)?, + status: row.get(3)?, + created_at_ms: row.get(4)?, + })) + } else { + Ok(None) + } + } + + pub fn list_node_enrollment_requests( + &self, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT request_id, requester_node, request_json, status, created_at_ms + FROM node_enrollment_requests ORDER BY created_at_ms, request_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredNodeEnrollmentRequest { + request_id: row.get(0)?, + requester_node: row.get(1)?, + request_json: row.get(2)?, + status: row.get(3)?, + created_at_ms: row.get(4)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_ssh_cert_request( &self, request: &StoredSshCertRequest, @@ -1381,6 +1496,16 @@ pub struct StoredAuthOp { pub created_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredAuthSignature { + pub op_id: String, + pub signer: String, + pub signer_public_key: String, + pub namespace: String, + pub signature: Vec, + pub created_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredKeychainOp { pub op_id: String, @@ -1398,6 +1523,15 @@ pub struct StoredKeychainSignature { pub created_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredNodeEnrollmentRequest { + pub request_id: String, + pub requester_node: String, + pub request_json: String, + pub status: String, + pub created_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredSshCertRequest { pub request_id: String, @@ -1546,6 +1680,33 @@ mod tests { ); } + #[test] + fn node_enrollment_requests_roundtrip() { + let store = Store::open_memory().expect("open"); + let request = StoredNodeEnrollmentRequest { + request_id: "node-enrollment:1".to_owned(), + requester_node: "node:laptop".to_owned(), + request_json: r#"{"id":"node-enrollment:1"}"#.to_owned(), + status: "pending".to_owned(), + created_at_ms: 7, + }; + store + .insert_node_enrollment_request(&request) + .expect("insert enrollment request"); + assert_eq!( + store + .get_node_enrollment_request("node-enrollment:1") + .expect("get enrollment request"), + Some(request.clone()) + ); + assert_eq!( + store + .list_node_enrollment_requests() + .expect("list enrollment requests"), + vec![request] + ); + } + #[test] fn auth_ops_roundtrip_by_resource() { let store = Store::open_memory().expect("open"); @@ -1574,6 +1735,26 @@ mod tests { assert_eq!(store.list_auth_ops().expect("list all auth ops").len(), 2); } + #[test] + fn auth_signatures_roundtrip() { + let store = Store::open_memory().expect("open"); + let signature = StoredAuthSignature { + op_id: "op:auth:1".to_owned(), + signer: "ssh:blake3:admin".to_owned(), + signer_public_key: "ssh-ed25519 AAAAADMIN eric@geth".to_owned(), + namespace: "geth.auth-op.v1@geth.local".to_owned(), + signature: b"-----BEGIN SSH SIGNATURE-----".to_vec(), + created_at_ms: 3, + }; + store + .insert_auth_signature(&signature) + .expect("insert auth signature"); + assert_eq!( + store.list_auth_signatures().expect("list auth signatures"), + vec![signature] + ); + } + #[test] fn keychain_ops_roundtrip() { let store = Store::open_memory().expect("open"); diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 3d844e6..70725b0 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1035,6 +1035,134 @@ fn init_owned_node_records_signed_owner_device_and_node() { } } +#[test] +fn node_enrollment_request_import_and_approval_records_signed_ops() { + if Command::new("ssh-keygen").arg("-?").output().is_err() { + return; + } + + let owner_home = tempfile::tempdir().expect("owner tempdir"); + let requester_home = tempfile::tempdir().expect("requester tempdir"); + let owner_paths = geth_config::GethPaths::from_home(owner_home.path()); + let requester_paths = geth_config::GethPaths::from_home(requester_home.path()); + let admin_key_path = owner_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 owner = geth_node::init_owned_node( + &owner_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: "owner-laptop".to_owned(), + capabilities: Vec::new(), + }, + ) + .expect("init owner"); + let requester = geth_node::init_node(&requester_paths).expect("init requester"); + let request_path = requester_home.path().join("enrollment.json"); + + let response = geth_node::handle_request( + &requester, + geth_control::ControlRequest::NodeEnrollRequest { + node_name: "workstation".to_owned(), + capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.connect".to_owned()], + reason: Some("new test machine".to_owned()), + out: Some(request_path.clone()), + }, + ) + .expect("create enrollment request"); + let request_id = match response { + geth_control::ControlResponse::NodeEnrollmentRequested { request, .. } => { + assert_eq!(request.requested_node_name, "workstation"); + assert!(request.provenance.is_some()); + request.id.to_string() + } + other => panic!("unexpected response: {other:?}"), + }; + assert!(request_path.exists()); + + let imported = geth_node::handle_request( + &owner, + geth_control::ControlRequest::NodeEnrollImport { path: request_path }, + ) + .expect("import request"); + match imported { + geth_control::ControlResponse::NodeEnrollmentImported { request, .. } => { + assert_eq!(request.id.as_str(), request_id); + assert_eq!(request.status.to_string(), "pending"); + } + other => panic!("unexpected response: {other:?}"), + } + + let approved = geth_node::handle_request( + &owner, + geth_control::ControlRequest::NodeEnrollApprove { + request_id: request_id.clone(), + signing_key_path: admin_key_path, + admin_key_path: None, + node_name: None, + capabilities: vec!["resource:cas:local=cas.fetch".to_owned()], + }, + ) + .expect("approve request"); + match approved { + geth_control::ControlResponse::NodeEnrollmentApproved { + keychain_ops, + keychain_signatures, + auth_ops, + auth_signatures, + .. + } => { + assert!(keychain_ops.len() >= 3); + assert_eq!(keychain_signatures.len(), keychain_ops.len()); + assert_eq!(auth_ops.len(), 2); + assert_eq!(auth_signatures.len(), 2); + } + other => panic!("unexpected response: {other:?}"), + } + + match geth_node::handle_request(&owner, geth_control::ControlRequest::NodeList) + .expect("node list") + { + geth_control::ControlResponse::NodeList { nodes, .. } => { + assert!(nodes.iter().any(|node| node.name == "workstation")); + } + other => panic!("unexpected response: {other:?}"), + } + match geth_node::handle_request( + &owner, + geth_control::ControlRequest::AuthExplain { + subject: requester.node_id.clone(), + resource: "resource:ssh-proxy:local".to_owned(), + capability: "ssh_proxy.connect".to_owned(), + }, + ) + .expect("explain enrollment grant") + { + geth_control::ControlResponse::AuthExplain(explanation) => assert!(explanation.allowed), + other => panic!("unexpected response: {other:?}"), + } + let store = geth_store::Store::open(&owner_paths.metadata_db()).expect("open store"); + assert_eq!( + store + .list_auth_signatures() + .expect("list auth signatures") + .len(), + 2 + ); +} + #[test] fn db_add_and_status_register_local_db_metadata() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/adr/0016-owner-node-enrollment.md b/docs/adr/0016-owner-node-enrollment.md index a08f90c..0d61f45 100644 --- a/docs/adr/0016-owner-node-enrollment.md +++ b/docs/adr/0016-owner-node-enrollment.md @@ -26,10 +26,24 @@ 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`. +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, +requested node name, optional endpoint ID, and requested resource capabilities. +The request can be submitted over Iroh with `geth node enroll submit` or moved +as a JSON file and imported on the owner machine. + +The owner approves with `geth node enroll approve --signing-key `. Approval +records signed keychain operations for the device, node, agent binding, and +endpoint binding, plus signed auth operations for approved capabilities. The +requesting node can run `geth node enroll sync ` to pull both signed +logs. + `geth keychain sync ` 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. +payload. `geth auth sync ` applies the same trusted-admin signature rule +to resource auth operations. Discovery and peer cards still grant no trust by +themselves. ## Consequences @@ -37,6 +51,7 @@ 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. +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. diff --git a/docs/architecture.md b/docs/architecture.md index 6b5c23c..9afeb9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -288,13 +288,24 @@ only operations with a valid OpenSSH signature from a currently trusted admin ke 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 +containing the requesting node ID, agent ID, requested node name, optional Iroh +endpoint, and requested resource capabilities. The request can be submitted over +Iroh to an imported owner peer or moved as a JSON file to the owner machine. +`geth node enroll approve` runs on the owner/YubiKey machine and records signed +keychain operations for the device/node/agent/endpoint binding plus signed auth +operations for approved capabilities. `geth node enroll sync ` pulls +both signed logs so the new node can see its approved identity and permissions. + The authorization plane is `geth-auth`: resource-local signed operation logs, 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. Signature validation, replication, and module enforcement are still future -work. +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. Capability evaluation supports exact matches plus explicit scoped forms. For KV, `kv.write_prefix:` grants writes requested as `kv.write_key:` only diff --git a/docs/roadmap.md b/docs/roadmap.md index 28143a9..014a7e2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -188,6 +188,14 @@ resource-scoped capability decisions. - `[x]` `geth node rename/revoke` require an admin signing key. - `[x]` `geth keychain sync ` verifies signatures from currently trusted admin keys before accepting keychain ops. + - `[x]` `geth node enroll request` creates an agent-key-signed enrollment + request with requested node name and capabilities. + - `[x]` `geth node enroll submit/import/list` moves pending enrollment + requests over Iroh or JSON file for owner review. + - `[x]` `geth node enroll approve --signing-key` records signed keychain ops + for device/node/agent/endpoint enrollment. + - `[x]` `geth node enroll sync ` pulls approved signed keychain + and auth state onto the requesting node. - `[x]` Keychain operation reducer. Acceptance criteria: @@ -204,8 +212,11 @@ resource-scoped capability decisions. 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]` Enrollment approval signs capability grants as auth ops. + - `[x]` `geth auth sync ` 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]` Resource auth operation reducer. Acceptance criteria: