Add smooth node enrollment flow

This commit is contained in:
Eric Wendland 2026-05-21 18:01:38 +02:00
commit 27a79768e4
12 changed files with 1662 additions and 22 deletions

View file

@ -119,9 +119,11 @@ Roadmap items should be actionable and checkable:
users, devices, nodes, agents, and endpoint bindings. users, devices, nodes, agents, and endpoint bindings.
- The auth reducer builds a current permission view for resources, grants, - The auth reducer builds a current permission view for resources, grants,
groups, and bearer access. The daemon persists local auth grant/revoke ops groups, and bearer access. The daemon persists local auth grant/revoke ops
and uses them for `auth explain`. `kv set --subject <principal>` enforces and uses them for `auth explain`. Enrollment approval signs auth ops, and
local KV write grants for non-local test callers. Signature validation and `geth auth sync <node>` imports only auth ops signed by currently trusted
broader daemon-side module enforcement are still roadmap work. admin keys. `kv set --subject <principal>` enforces local KV write grants for
non-local test callers. Broader daemon-side module enforcement is still
roadmap work.
- The daemon persists local keychain ops and reduces them for `keychain status`. - The daemon persists local keychain ops and reduces them for `keychain status`.
`geth init --admin-key <pub> --signing-key <key> --node-name <name>` records `geth init --admin-key <pub> --signing-key <key> --node-name <name>` records
signed owner/user/device/node/agent binding operations. `keychain signed owner/user/device/node/agent binding operations. `keychain
@ -132,6 +134,8 @@ Roadmap items should be actionable and checkable:
ops with valid OpenSSH signatures from currently trusted admin keys. `geth ops with valid OpenSSH signatures from currently trusted admin keys. `geth
node list/rename/revoke/grant` are the current node-management surface over node list/rename/revoke/grant` are the current node-management surface over
that reduced keychain/auth view; rename and revoke require `--signing-key`. 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 - 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

@ -92,6 +92,12 @@ The bootstrap implementation provides:
- `geth status` - `geth status`
- `geth node id` - `geth node id`
- `geth node list` - `geth node list`
- `geth node enroll request --node-name <name> --capability <resource=capability> [--out <path>]`
- `geth node enroll submit <owner-node> [--request-id <id>|--path <path>]`
- `geth node enroll import <path>`
- `geth node enroll list [--status pending|approved|rejected]`
- `geth node enroll approve <request-id> --signing-key <private-key>`
- `geth node enroll sync <owner-node>`
- `geth node rename <node-or-name> <name> --signing-key <private-key>` - `geth node rename <node-or-name> <name> --signing-key <private-key>`
- `geth node revoke <node-or-name> --signing-key <private-key>` - `geth node revoke <node-or-name> --signing-key <private-key>`
- `geth node grant <node-or-name> <resource> <capability> [--grant-id <id>]` - `geth node grant <node-or-name> <resource> <capability> [--grant-id <id>]`
@ -106,6 +112,7 @@ The bootstrap implementation provides:
- `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 keychain sync <node-id-or-name>`
- `geth auth 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>`
@ -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 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 <request-id> --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 <node>` pulls signed keychain operations from an imported `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 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 from a currently trusted admin key over the canonical keychain payload. This is

View file

@ -31,6 +31,16 @@ pub struct AuthOp {
pub kind: AuthOpKind, 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<u8>,
pub created_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")] #[serde(tag = "kind", rename_all = "kebab-case")]
pub enum AuthOpKind { pub enum AuthOpKind {

View file

@ -139,6 +139,10 @@ pub enum NodeCommand {
Id, Id,
Status, Status,
List, List,
Enroll {
#[command(subcommand)]
command: NodeEnrollCommand,
},
Rename { Rename {
node: String, node: String,
name: 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<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
},
Submit {
owner_node: String,
#[arg(long)]
request_id: Option<String>,
#[arg(long)]
path: Option<PathBuf>,
},
Import {
path: PathBuf,
},
List {
#[arg(long)]
status: Option<String>,
},
Approve {
request_id: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
#[arg(long)]
node_name: Option<String>,
#[arg(long = "capability")]
capabilities: Vec<String>,
},
Sync {
owner_node: String,
},
}
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum PeerCommand { pub enum PeerCommand {
Export { Export {
@ -210,6 +256,9 @@ pub enum AuthCommand {
resource: String, resource: String,
capability: String, capability: String,
}, },
Sync {
node: String,
},
Grant { Grant {
subject: String, subject: String,
resource: String, resource: String,
@ -757,6 +806,46 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Node { Command::Node {
command: NodeCommand::List, command: NodeCommand::List,
} => ControlRequest::NodeList, } => 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::Node {
command: command:
NodeCommand::Rename { NodeCommand::Rename {
@ -829,6 +918,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Keychain { Command::Keychain {
command: KeychainCommand::Sync { node }, command: KeychainCommand::Sync { node },
} => ControlRequest::KeychainSync { node }, } => ControlRequest::KeychainSync { node },
Command::Auth {
command: AuthCommand::Sync { node },
} => ControlRequest::AuthSync { node },
Command::Auth { Command::Auth {
command: command:
AuthCommand::Explain { AuthCommand::Explain {
@ -1687,6 +1779,23 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("invalid_ops_rejected: {invalid_ops_rejected}"); println!("invalid_ops_rejected: {invalid_ops_rejected}");
println!("note: {note}"); 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 } => { ControlResponse::SecretStatus { secrets } => {
if secrets.is_empty() { if secrets.is_empty() {
println!("no resource secrets"); println!("no resource secrets");
@ -1832,9 +1941,96 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
println!("note: {note}"); println!("note: {note}");
} }
ControlResponse::NodeGrantUpdated { op, note } => { ControlResponse::NodeGrantUpdated {
op,
signatures,
note,
} => {
println!("recorded auth op: {}", op.id); println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource); println!("resource: {}", op.resource);
for signature in signatures {
println!(
"signed auth op: {} by {} ({})",
signature.op_id, signature.signer, signature.namespace
);
}
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}"); println!("note: {note}");
} }
ControlResponse::SshCertRequested { request } => { ControlResponse::SshCertRequested { request } => {

View file

@ -1,9 +1,9 @@
use geth_auth::{AuthExplanation, AuthOp}; use geth_auth::{AuthExplanation, AuthOp, AuthOpSignature};
use geth_cas::{FileConflict, FileRoot, FileRootScan}; 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, NodeRecord}; use geth_keychain::{KeychainOp, KeychainOpSignature, NodeEnrollmentRequest, 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;
@ -137,6 +137,33 @@ pub enum ControlRequest {
resource: String, resource: String,
grant_id: String, grant_id: String,
}, },
NodeEnrollRequest {
node_name: String,
capabilities: Vec<String>,
reason: Option<String>,
out: Option<PathBuf>,
},
NodeEnrollSubmit {
owner_node: String,
request_id: Option<String>,
path: Option<PathBuf>,
},
NodeEnrollImport {
path: PathBuf,
},
NodeEnrollList {
status: Option<String>,
},
NodeEnrollApprove {
request_id: String,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
node_name: Option<String>,
capabilities: Vec<String>,
},
NodeEnrollSync {
owner_node: String,
},
KeychainInit { KeychainInit {
admin_key_path: Option<PathBuf>, admin_key_path: Option<PathBuf>,
signing_key_path: Option<PathBuf>, signing_key_path: Option<PathBuf>,
@ -145,6 +172,9 @@ pub enum ControlRequest {
KeychainSync { KeychainSync {
node: String, node: String,
}, },
AuthSync {
node: String,
},
SecretStatus, SecretStatus,
SecretCreate { SecretCreate {
resource: String, resource: String,
@ -530,6 +560,15 @@ pub enum ControlResponse {
invalid_ops_rejected: usize, invalid_ops_rejected: usize,
note: String, 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 { SecretStatus {
secrets: Vec<ResourceMasterSecret>, secrets: Vec<ResourceMasterSecret>,
}, },
@ -574,6 +613,43 @@ pub enum ControlResponse {
}, },
NodeGrantUpdated { NodeGrantUpdated {
op: AuthOp, op: AuthOp,
signatures: Vec<AuthOpSignature>,
note: String,
},
NodeEnrollmentRequested {
request: NodeEnrollmentRequest,
out: Option<PathBuf>,
note: String,
},
NodeEnrollmentSubmitted {
request_id: String,
owner_node_id: String,
accepted: bool,
note: String,
},
NodeEnrollmentImported {
request: NodeEnrollmentRequest,
note: String,
},
NodeEnrollmentList {
requests: Vec<NodeEnrollmentRequest>,
note: String,
},
NodeEnrollmentApproved {
request: NodeEnrollmentRequest,
keychain_ops: Vec<KeychainOp>,
keychain_signatures: Vec<KeychainOpSignature>,
auth_ops: Vec<AuthOp>,
auth_signatures: Vec<AuthOpSignature>,
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, note: String,
}, },
SshCertRequested { SshCertRequested {
@ -860,6 +936,15 @@ pub enum PeerControlRequest {
peer_card: PeerCard, peer_card: PeerCard,
nonce: String, nonce: String,
}, },
AuthSync {
peer_card: PeerCard,
nonce: String,
},
NodeEnrollmentSubmit {
peer_card: PeerCard,
request: NodeEnrollmentRequest,
nonce: String,
},
CasFetch { CasFetch {
peer_card: PeerCard, peer_card: PeerCard,
hash: BlobHash, hash: BlobHash,
@ -988,6 +1073,26 @@ pub enum PeerControlResponse {
nonce: String, nonce: String,
note: String, note: String,
}, },
AuthSynced {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
ops: Vec<AuthOp>,
signatures: Vec<AuthOpSignature>,
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 { CasFetched {
node_id: String, node_id: String,
agent_id: String, agent_id: String,
@ -1318,6 +1423,25 @@ mod tests {
request 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 { let request = ControlRequest::KeychainSync {
node: "work-laptop".to_owned(), node: "work-laptop".to_owned(),
}; };
@ -1371,6 +1495,34 @@ mod tests {
response 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 { 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

@ -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 serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local";
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>; pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
@ -38,6 +41,111 @@ pub struct KeychainOpSignature {
pub created_at: UnixMillis, 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<NodeEnrollmentCapability>,
pub endpoint_id: Option<String>,
pub reason: Option<String>,
pub status: NodeEnrollmentStatus,
pub created_at: UnixMillis,
pub provenance: Option<NodeEnrollmentProvenance>,
}
#[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<Self, Self::Err> {
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<NodeEnrollmentCapability>,
pub endpoint_id: Option<String>,
pub reason: Option<String>,
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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")] #[serde(tag = "kind", rename_all = "kebab-case")]
pub enum KeychainOpKind { pub enum KeychainOpKind {

View file

@ -1,7 +1,7 @@
pub mod service; pub mod service;
use base64::Engine; use base64::Engine;
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind}; use geth_auth::{AUTH_SIGNATURE_NAMESPACE, AuthExplanation, AuthOp, AuthOpKind, AuthOpSignature};
use geth_cas::{ use geth_cas::{
BlobInfoSummary, CasTreeEntryKind, CasTreeObject, FileConflict, FileConflictKind, BlobInfoSummary, CasTreeEntryKind, CasTreeObject, FileConflict, FileConflictKind,
FileConflictResolution, FileConflictStatus, FileRoot, FileRootScan, LocalCas, hash_path, FileConflictResolution, FileConflictStatus, FileRoot, FileRootScan, LocalCas, hash_path,
@ -20,7 +20,11 @@ use geth_discovery::{
}; };
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; 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_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage};
use geth_pubsub::PubsubMessage; use geth_pubsub::PubsubMessage;
@ -37,10 +41,10 @@ use geth_ssh_identity::{
}; };
use geth_ssh_proxy::SshProxyConnection; use geth_ssh_proxy::SshProxyConnection;
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, Store, StoredAuthOp, StoredAuthSignature, StoredDbResource, StoredDocumentResource,
StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry, StoredKvStore, StoredFileConflict, StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry,
StoredModuleState, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest, StoredKvStore, StoredModuleState, StoredNodeEnrollmentRequest, StoredPeerCard, StoredResource,
StoredSshCertificate, StoredSshRevocation, StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
@ -85,6 +89,12 @@ pub enum NodeError {
SigningKeyRequired(String), SigningKeyRequired(String),
#[error("invalid init capability grant, expected <resource>=<capability>: {0}")] #[error("invalid init capability grant, expected <resource>=<capability>: {0}")]
InvalidInitGrant(String), InvalidInitGrant(String),
#[error("invalid enrollment capability, expected <resource>=<capability>: {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}")] #[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}")]
@ -641,6 +651,15 @@ pub async fn handle_request_async(
ControlRequest::KeychainSync { node: peer_node } => { ControlRequest::KeychainSync { node: peer_node } => {
keychain_sync_from_peer(node, &peer_node).await 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 { ControlRequest::CasFetch {
node: peer_node, node: peer_node,
hash, hash,
@ -1020,6 +1039,8 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
)), )),
PeerControlResponse::SyncStatus { .. } PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. } | PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasFetched { .. } | PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. } | PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. } | PeerControlResponse::SshCertSynced { .. }
@ -1139,6 +1160,8 @@ async fn peer_auth_check(
)), )),
PeerControlResponse::SyncStatus { .. } PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. } | PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasFetched { .. } | PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. } | PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. } | PeerControlResponse::SshCertSynced { .. }
@ -1291,6 +1314,8 @@ async fn cas_fetch_from_peer(
| PeerControlResponse::AuthChecked { .. } | PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. } | PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. } | PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasRootSynced { .. } | PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. } | PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } | PeerControlResponse::SshRevocationSynced { .. }
@ -2651,6 +2676,191 @@ async fn keychain_sync_from_peer(
} }
} }
async fn auth_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
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::<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)
|| !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::<Vec<_>>();
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<String>,
path: Option<PathBuf>,
) -> Result<ControlResponse, NodeError> {
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<ControlResponse, NodeError> {
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( async fn db_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
@ -3102,6 +3312,14 @@ async fn request_peer_control(
nonce: response_nonce, nonce: response_nonce,
.. ..
} }
| PeerControlResponse::AuthSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::NodeEnrollmentSubmitted {
nonce: response_nonce,
..
}
| PeerControlResponse::SshRevocationSynced { | PeerControlResponse::SshRevocationSynced {
nonce: response_nonce, nonce: response_nonce,
.. ..
@ -3280,6 +3498,12 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
None 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( if should_live_sync_stream(
&store, &store,
&peer.peer_id, &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(), 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 { PeerControlRequest::AuthCheck {
peer_card, peer_card,
resource, resource,
@ -5388,6 +5667,7 @@ pub fn handle_request(
let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?; let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?;
Ok(ControlResponse::NodeGrantUpdated { Ok(ControlResponse::NodeGrantUpdated {
op, op,
signatures: Vec::new(),
note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(), 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)?; store_auth_op(&store, &op)?;
Ok(ControlResponse::NodeGrantUpdated { Ok(ControlResponse::NodeGrantUpdated {
op, op,
signatures: Vec::new(),
note: "recorded capability grant revocation".to_owned(), 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 { ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus {
secrets: store secrets: store
.list_resource_secrets()? .list_resource_secrets()?
@ -6305,7 +6642,10 @@ pub fn handle_request(
ControlRequest::SshProxyConnect { .. } ControlRequest::SshProxyConnect { .. }
| ControlRequest::SshProxyStream { .. } | ControlRequest::SshProxyStream { .. }
| ControlRequest::SshAdminShell { .. } | ControlRequest::SshAdminShell { .. }
| ControlRequest::KeychainSync { .. } => Err(NodeError::IrohEndpointUnavailable), | ControlRequest::KeychainSync { .. }
| ControlRequest::AuthSync { .. }
| ControlRequest::NodeEnrollSubmit { .. }
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ModuleStub { module, command } => { ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command }) Ok(ControlResponse::NotImplemented { module, command })
} }
@ -6968,6 +7308,78 @@ fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
Ok(()) Ok(())
} }
fn store_and_sign_auth_ops(
store: &Store,
node: &LocalNode,
ops: &[AuthOp],
signing_key_path: &Path,
admin_key_path: Option<&Path>,
) -> Result<Vec<AuthOpSignature>, 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<AuthOpSignature, NodeError> {
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<Vec<AuthOp>, NodeError> { fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthOp>, NodeError> {
store store
.list_auth_ops_for_resource(resource)? .list_auth_ops_for_resource(resource)?
@ -6976,6 +7388,94 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthO
.collect() .collect()
} }
fn load_auth_ops(store: &Store) -> Result<Vec<AuthOp>, 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<Vec<AuthOpSignature>, 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<bool, NodeError> {
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> { fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
store.insert_keychain_op(&StoredKeychainOp { store.insert_keychain_op(&StoredKeychainOp {
op_id: op.id.to_string(), op_id: op.id.to_string(),
@ -7269,6 +7769,297 @@ fn stable_slug(value: &str) -> String {
} }
} }
fn parse_enrollment_capabilities(
capabilities: Vec<String>,
) -> Result<Vec<NodeEnrollmentCapability>, 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<String>,
reason: Option<String>,
) -> Result<NodeEnrollmentRequest, NodeError> {
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<bool, NodeError> {
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<NodeEnrollmentRequest, NodeError> {
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<Vec<NodeEnrollmentRequest>, NodeError> {
store
.list_node_enrollment_requests()?
.into_iter()
.map(node_enrollment_request_from_stored)
.collect()
}
fn node_enrollment_request_from_stored(
stored: StoredNodeEnrollmentRequest,
) -> Result<NodeEnrollmentRequest, NodeError> {
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<NodeEnrollmentRequest> {
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<NodeEnrollmentRequest, NodeError> {
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<String>,
extra_capabilities: Vec<String>,
) -> Result<ControlResponse, NodeError> {
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::<Vec<_>>();
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<UserId> {
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 { fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!( format!(
"grant:{}", "grant:{}",

View file

@ -79,12 +79,28 @@ impl Store {
created_at_ms INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
PRIMARY KEY (op_id, signer, namespace) 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 ( CREATE TABLE IF NOT EXISTS auth_ops (
op_id TEXT PRIMARY KEY, op_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL, resource_id TEXT NOT NULL,
op_json TEXT NOT NULL, op_json TEXT NOT NULL,
created_at_ms INTEGER 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 ( CREATE TABLE IF NOT EXISTS grants (
grant_id TEXT PRIMARY KEY, grant_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL, resource_id TEXT NOT NULL,
@ -935,6 +951,43 @@ impl Store {
.map_err(StoreError::from) .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<Vec<StoredAuthSignature>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> { pub fn insert_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms) r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms)
@ -1000,6 +1053,68 @@ impl Store {
.map_err(StoreError::from) .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<Option<StoredNodeEnrollmentRequest>, 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<Vec<StoredNodeEnrollmentRequest>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request( pub fn insert_ssh_cert_request(
&self, &self,
request: &StoredSshCertRequest, request: &StoredSshCertRequest,
@ -1381,6 +1496,16 @@ pub struct StoredAuthOp {
pub created_at_ms: i64, 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<u8>,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredKeychainOp { pub struct StoredKeychainOp {
pub op_id: String, pub op_id: String,
@ -1398,6 +1523,15 @@ pub struct StoredKeychainSignature {
pub created_at_ms: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest { pub struct StoredSshCertRequest {
pub request_id: String, 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] #[test]
fn auth_ops_roundtrip_by_resource() { fn auth_ops_roundtrip_by_resource() {
let store = Store::open_memory().expect("open"); 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); 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] #[test]
fn keychain_ops_roundtrip() { fn keychain_ops_roundtrip() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");

View file

@ -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] #[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

@ -26,10 +26,24 @@ list`. Renames and revocations are additional keychain operations. Resource
permissions remain resource-scoped auth operations and can be managed with permissions remain resource-scoped auth operations and can be managed with
`geth node grant` and `geth node revoke-grant`. `geth node grant` and `geth node revoke-grant`.
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 <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 <owner-node>` to pull both signed
logs.
`geth keychain sync <node>` pulls keychain operations and signatures over Iroh `geth keychain sync <node>` pulls keychain operations and signatures over Iroh
from an imported peer. The receiver imports only operations with valid OpenSSH from an imported peer. The receiver imports only operations with valid OpenSSH
signatures from currently trusted admin keys over the canonical keychain 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 <node>` applies the same trusted-admin signature rule
to resource auth operations. Discovery and peer cards still grant no trust by
themselves.
## Consequences ## 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. add or revoke endpoint bindings without replacing the node identity.
The current sync model is a pull-based signed operation log. It is not yet a The current sync model is a pull-based signed operation log. It is not yet a
Keyhive-style convergent authority, does not implement advanced group Keyhive-style convergent authority and does not implement advanced group
cryptography, and does not yet sign/verify replicated auth operations. Those are cryptography. Manual grant/revoke commands still need the same signing
future roadmap items. requirement as enrollment approval before this becomes a complete authorization
workflow.

View file

@ -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, over the canonical payload. This is currently a pull-based signed operation log,
not a CRDT or Keyhive-style convergent authority. 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 <owner-node>` 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, 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
current permission view for resources, grants, groups, and bearer access. The current permission view for resources, grants, groups, and bearer access. The
library can explain direct and group grants. The daemon persists local auth library can explain direct and group grants. The daemon persists local auth
grant/revoke operations and `geth auth explain` evaluates that local operation grant/revoke operations and `geth auth explain` evaluates that local operation
log. Signature validation, replication, and module enforcement are still future log. Enrollment approval and auth sync attach and verify OpenSSH admin
work. 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, Capability evaluation supports exact matches plus explicit scoped forms. For KV,
`kv.write_prefix:<prefix>` grants writes requested as `kv.write_key:<key>` only `kv.write_prefix:<prefix>` grants writes requested as `kv.write_key:<key>` only

View file

@ -188,6 +188,14 @@ resource-scoped capability decisions.
- `[x]` `geth node rename/revoke` require an admin signing key. - `[x]` `geth node rename/revoke` require an admin signing key.
- `[x]` `geth keychain sync <node>` verifies signatures from currently - `[x]` `geth keychain sync <node>` verifies signatures from currently
trusted admin keys before accepting keychain ops. 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 <owner-node>` pulls approved signed keychain
and auth state onto the requesting node.
- `[x]` Keychain operation reducer. - `[x]` Keychain operation reducer.
Acceptance criteria: Acceptance criteria:
@ -204,8 +212,11 @@ resource-scoped capability decisions.
revocation. revocation.
- `[x]` Node names can be used for management commands where the keychain view - `[x]` Node names can be used for management commands where the keychain view
has a unique active node name. has a unique active node name.
- `[ ]` Future completion signs auth ops and verifies signed auth ops before - `[x]` Enrollment approval signs capability grants as auth ops.
accepting replicated permission changes. - `[x]` `geth auth sync <node>` imports only auth ops signed by currently
trusted admin keys.
- `[ ]` Future completion requires signed auth ops for every manual
grant/revoke command, not only enrollment approval and replicated imports.
- `[x]` Resource auth operation reducer. - `[x]` Resource auth operation reducer.
Acceptance criteria: Acceptance criteria: