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