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

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

View file

@ -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<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)]
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<ControlRequest> {
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<ControlRequest> {
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 } => {

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_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<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 {
admin_key_path: Option<PathBuf>,
signing_key_path: Option<PathBuf>,
@ -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<ResourceMasterSecret>,
},
@ -574,6 +613,43 @@ pub enum ControlResponse {
},
NodeGrantUpdated {
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,
},
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<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 {
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(),

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 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<KeychainOp, KeyId>;
@ -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<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)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum KeychainOpKind {

View file

@ -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 <resource>=<capability>: {0}")]
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}")]
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<ControlResponse,
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
@ -1139,6 +1160,8 @@ async fn peer_auth_check(
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
@ -1291,6 +1314,8 @@ async fn cas_fetch_from_peer(
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| 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(
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<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> {
store
.list_auth_ops_for_resource(resource)?
@ -6976,6 +7388,94 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthO
.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> {
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<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 {
format!(
"grant:{}",

View file

@ -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<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> {
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<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(
&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<u8>,
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");

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]
fn db_add_and_status_register_local_db_metadata() {
let home = tempfile::tempdir().expect("tempdir");