Add smooth node enrollment flow
This commit is contained in:
parent
b941037652
commit
27a79768e4
12 changed files with 1662 additions and 22 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:{}",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue