Add owner-rooted node management
This commit is contained in:
parent
59ccf6c748
commit
b941037652
9 changed files with 1001 additions and 52 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -122,13 +122,16 @@ Roadmap items should be actionable and checkable:
|
||||||
and uses them for `auth explain`. `kv set --subject <principal>` enforces
|
and uses them for `auth explain`. `kv set --subject <principal>` enforces
|
||||||
local KV write grants for non-local test callers. Signature validation and
|
local KV write grants for non-local test callers. Signature validation and
|
||||||
broader daemon-side module enforcement are still roadmap work.
|
broader daemon-side module enforcement are still roadmap work.
|
||||||
- The daemon persists local keychain init/admin-key ops and reduces them for
|
- The daemon persists local keychain ops and reduces them for `keychain status`.
|
||||||
`keychain status`. `keychain init --signing-key <path>` signs recorded
|
`geth init --admin-key <pub> --signing-key <key> --node-name <name>` records
|
||||||
keychain ops with `ssh-keygen -Y sign` under the
|
signed owner/user/device/node/agent binding operations. `keychain
|
||||||
`geth.keychain.v1@geth.local` namespace and stores signatures locally.
|
init --signing-key <path>` signs recorded keychain ops with `ssh-keygen -Y
|
||||||
`keychain status` verifies stored signatures with OpenSSH when public key
|
sign` under the `geth.keychain.v1@geth.local` namespace and stores signatures
|
||||||
material is available. Verification before accepting replicated keychain ops
|
locally. `keychain status` verifies stored signatures with OpenSSH when public
|
||||||
is still roadmap work.
|
key material is available. `geth keychain sync <node>` imports only keychain
|
||||||
|
ops with valid OpenSSH signatures from currently trusted admin keys. `geth
|
||||||
|
node list/rename/revoke/grant` are the current node-management surface over
|
||||||
|
that reduced keychain/auth view; rename and revoke require `--signing-key`.
|
||||||
- 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
|
||||||
|
|
|
||||||
57
README.md
57
README.md
|
|
@ -27,13 +27,17 @@ registry, module router, local metadata store, and synchronized data structures.
|
||||||
Most non-daemon commands talk to the daemon through a local Unix socket at
|
Most non-daemon commands talk to the daemon through a local Unix socket at
|
||||||
`$GETH_HOME/run/geth.sock`.
|
`$GETH_HOME/run/geth.sock`.
|
||||||
|
|
||||||
`geth keychain init --admin-key <public-key> --signing-key <private-key>` records
|
`geth init --admin-key <public-key> --signing-key <private-key> --node-name
|
||||||
the keychain initialization/admin-key operations and signs their canonical
|
<name>` records an owner/admin keychain, the local user/device/node binding, and
|
||||||
payloads through `ssh-keygen -Y sign` using the
|
signs canonical keychain payloads through `ssh-keygen -Y sign` using the
|
||||||
`geth.keychain.v1@geth.local` namespace. This is the bootstrap path for
|
`geth.keychain.v1@geth.local` namespace. This is the bootstrap path for
|
||||||
admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored
|
admin/YubiKey-rooted trust. `geth keychain sync <node>` pulls the signed
|
||||||
keychain signatures plus how many currently verify with OpenSSH; rejecting
|
keychain operation log from an imported peer and imports only operations with
|
||||||
unsigned or invalid replicated keychain ops is still future work.
|
valid OpenSSH signatures from currently trusted admin keys. `geth node list`
|
||||||
|
shows the active reduced node view, and `geth node rename/revoke` require
|
||||||
|
`--signing-key` so device-management changes can replicate as verified admin
|
||||||
|
statements. `geth node grant/revoke-grant` records the current resource-scoped
|
||||||
|
capability prototype.
|
||||||
SSH certificate-flow and revocation records carry agent-key signed provenance
|
SSH certificate-flow and revocation records carry agent-key signed provenance
|
||||||
over canonical payloads, and sync import rejects new unsigned or invalidly
|
over canonical payloads, and sync import rejects new unsigned or invalidly
|
||||||
signed records.
|
signed records.
|
||||||
|
|
@ -82,10 +86,16 @@ metadata from an authorized peer over Iroh.
|
||||||
The bootstrap implementation provides:
|
The bootstrap implementation provides:
|
||||||
|
|
||||||
- `geth init`
|
- `geth init`
|
||||||
|
- `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>`
|
||||||
- `geth daemon run`
|
- `geth daemon run`
|
||||||
- `geth daemon service install|uninstall|start|stop|status|print`
|
- `geth daemon service install|uninstall|start|stop|status|print`
|
||||||
- `geth status`
|
- `geth status`
|
||||||
- `geth node id`
|
- `geth node id`
|
||||||
|
- `geth node list`
|
||||||
|
- `geth node rename <node-or-name> <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 revoke-grant <resource> <grant-id>`
|
||||||
- `geth peer export [--out <path>]`
|
- `geth peer export [--out <path>]`
|
||||||
- `geth peer import <path>`
|
- `geth peer import <path>`
|
||||||
- `geth peer list`
|
- `geth peer list`
|
||||||
|
|
@ -95,6 +105,7 @@ The bootstrap implementation provides:
|
||||||
- `geth resource create <kind> <name>`
|
- `geth resource create <kind> <name>`
|
||||||
- `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 secret status`
|
- `geth secret status`
|
||||||
- `geth secret create <resource>`
|
- `geth secret create <resource>`
|
||||||
- `geth secret rotate <resource>`
|
- `geth secret rotate <resource>`
|
||||||
|
|
@ -327,6 +338,40 @@ cargo run -p geth -- cas add /tmp/hello-geth.txt
|
||||||
cargo run -p geth -- cas list
|
cargo run -p geth -- cas list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Owner And Node Management
|
||||||
|
|
||||||
|
The intended owner setup is SSH-admin-rooted:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth init \
|
||||||
|
--admin-key ~/.ssh/id_ed25519_sk.pub \
|
||||||
|
--signing-key ~/.ssh/id_ed25519_sk \
|
||||||
|
--node-name laptop \
|
||||||
|
--capability resource:ssh-proxy:local=ssh_proxy.admin_shell
|
||||||
|
```
|
||||||
|
|
||||||
|
When any owner setup option is used, both `--admin-key` and `--signing-key` are
|
||||||
|
required. This prevents accidentally creating an unsigned owner/device/node
|
||||||
|
statement that cannot be accepted by another node during keychain sync.
|
||||||
|
|
||||||
|
This records signed keychain operations for `KeychainInit`, `AdminKeyAdd`,
|
||||||
|
`UserAdd`, `DeviceAdd`, `NodeAdd`, and `AgentBind`. The current node identity is
|
||||||
|
stable above endpoint rotation: future endpoint bindings should attach to the
|
||||||
|
node, not replace it. Node management is done through the reduced keychain view:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth node list
|
||||||
|
geth node rename laptop work-laptop --signing-key ~/.ssh/id_ed25519_sk
|
||||||
|
geth node grant work-laptop resource:ssh-proxy:local ssh_proxy.connect
|
||||||
|
geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
from a currently trusted admin key over the canonical keychain payload. This is
|
||||||
|
the current replicated device-management substrate. It is still a pull-based
|
||||||
|
operation log, not yet a CRDT or Keyhive-style convergent authority.
|
||||||
|
|
||||||
## Authorization Direction
|
## Authorization Direction
|
||||||
|
|
||||||
The MVP defines the split between:
|
The MVP defines the split between:
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,18 @@ pub struct Cli {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Init,
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
admin_key: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
signing_key: Option<PathBuf>,
|
||||||
|
#[arg(long, default_value = "owner")]
|
||||||
|
owner: String,
|
||||||
|
#[arg(long, default_value = "local")]
|
||||||
|
node_name: String,
|
||||||
|
#[arg(long = "capability")]
|
||||||
|
capabilities: Vec<String>,
|
||||||
|
},
|
||||||
Daemon {
|
Daemon {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: DaemonCommand,
|
command: DaemonCommand,
|
||||||
|
|
@ -127,6 +138,29 @@ pub enum ServiceCommand {
|
||||||
pub enum NodeCommand {
|
pub enum NodeCommand {
|
||||||
Id,
|
Id,
|
||||||
Status,
|
Status,
|
||||||
|
List,
|
||||||
|
Rename {
|
||||||
|
node: String,
|
||||||
|
name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
signing_key: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
Revoke {
|
||||||
|
node: String,
|
||||||
|
#[arg(long)]
|
||||||
|
signing_key: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
Grant {
|
||||||
|
node: String,
|
||||||
|
resource: String,
|
||||||
|
capability: String,
|
||||||
|
#[arg(long)]
|
||||||
|
grant_id: Option<String>,
|
||||||
|
},
|
||||||
|
RevokeGrant {
|
||||||
|
resource: String,
|
||||||
|
grant_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|
@ -164,6 +198,9 @@ pub enum KeychainCommand {
|
||||||
signing_key: Option<PathBuf>,
|
signing_key: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
Status,
|
Status,
|
||||||
|
Sync {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|
@ -616,8 +653,24 @@ pub async fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let paths = GethPaths::resolve().context("resolve geth paths")?;
|
let paths = GethPaths::resolve().context("resolve geth paths")?;
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Init => {
|
Command::Init {
|
||||||
let node = geth_node::init_node(&paths).context("initialize geth node")?;
|
admin_key,
|
||||||
|
signing_key,
|
||||||
|
owner,
|
||||||
|
node_name,
|
||||||
|
capabilities,
|
||||||
|
} => {
|
||||||
|
let node = geth_node::init_owned_node(
|
||||||
|
&paths,
|
||||||
|
geth_node::InitOwnerOptions {
|
||||||
|
admin_key_path: admin_key,
|
||||||
|
signing_key_path: signing_key,
|
||||||
|
owner_name: owner,
|
||||||
|
node_name,
|
||||||
|
capabilities,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("initialize geth node")?;
|
||||||
println!("initialized geth home: {}", node.paths.home().display());
|
println!("initialized geth home: {}", node.paths.home().display());
|
||||||
println!("agent: {}", node.agent_id);
|
println!("agent: {}", node.agent_id);
|
||||||
println!("node: {}", node.node_id);
|
println!("node: {}", node.node_id);
|
||||||
|
|
@ -701,6 +754,44 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
Command::Node {
|
Command::Node {
|
||||||
command: NodeCommand::Status,
|
command: NodeCommand::Status,
|
||||||
} => ControlRequest::Status,
|
} => ControlRequest::Status,
|
||||||
|
Command::Node {
|
||||||
|
command: NodeCommand::List,
|
||||||
|
} => ControlRequest::NodeList,
|
||||||
|
Command::Node {
|
||||||
|
command:
|
||||||
|
NodeCommand::Rename {
|
||||||
|
node,
|
||||||
|
name,
|
||||||
|
signing_key,
|
||||||
|
},
|
||||||
|
} => ControlRequest::NodeRename {
|
||||||
|
node,
|
||||||
|
name,
|
||||||
|
signing_key_path: signing_key,
|
||||||
|
},
|
||||||
|
Command::Node {
|
||||||
|
command: NodeCommand::Revoke { node, signing_key },
|
||||||
|
} => ControlRequest::NodeRevoke {
|
||||||
|
node,
|
||||||
|
signing_key_path: signing_key,
|
||||||
|
},
|
||||||
|
Command::Node {
|
||||||
|
command:
|
||||||
|
NodeCommand::Grant {
|
||||||
|
node,
|
||||||
|
resource,
|
||||||
|
capability,
|
||||||
|
grant_id,
|
||||||
|
},
|
||||||
|
} => ControlRequest::NodeGrant {
|
||||||
|
node,
|
||||||
|
resource,
|
||||||
|
capability,
|
||||||
|
grant_id,
|
||||||
|
},
|
||||||
|
Command::Node {
|
||||||
|
command: NodeCommand::RevokeGrant { resource, grant_id },
|
||||||
|
} => ControlRequest::NodeRevokeGrant { resource, grant_id },
|
||||||
Command::Peer { command } => match command {
|
Command::Peer { command } => match command {
|
||||||
PeerCommand::Export { out } => ControlRequest::PeerCardExport { out },
|
PeerCommand::Export { out } => ControlRequest::PeerCardExport { out },
|
||||||
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
|
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
|
||||||
|
|
@ -735,6 +826,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
Command::Keychain {
|
Command::Keychain {
|
||||||
command: KeychainCommand::Status,
|
command: KeychainCommand::Status,
|
||||||
} => ControlRequest::KeychainStatus,
|
} => ControlRequest::KeychainStatus,
|
||||||
|
Command::Keychain {
|
||||||
|
command: KeychainCommand::Sync { node },
|
||||||
|
} => ControlRequest::KeychainSync { node },
|
||||||
Command::Auth {
|
Command::Auth {
|
||||||
command:
|
command:
|
||||||
AuthCommand::Explain {
|
AuthCommand::Explain {
|
||||||
|
|
@ -1163,7 +1257,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
|
Command::Init { .. } | Command::Daemon { .. } => bail!("command is handled directly"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1576,6 +1670,23 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ControlResponse::KeychainSynced {
|
||||||
|
peer_node_id,
|
||||||
|
peer_agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
ops_imported,
|
||||||
|
signatures_imported,
|
||||||
|
invalid_ops_rejected,
|
||||||
|
note,
|
||||||
|
} => {
|
||||||
|
println!("synced keychain 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");
|
||||||
|
|
@ -1689,6 +1800,43 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
println!("recorded auth op: {}", op.id);
|
println!("recorded auth op: {}", op.id);
|
||||||
println!("resource: {}", op.resource);
|
println!("resource: {}", op.resource);
|
||||||
}
|
}
|
||||||
|
ControlResponse::NodeList { nodes, note } => {
|
||||||
|
if nodes.is_empty() {
|
||||||
|
println!("no enrolled nodes");
|
||||||
|
} else {
|
||||||
|
for node in nodes {
|
||||||
|
println!(
|
||||||
|
"{}\t{}\tdevice {}\t{} endpoints",
|
||||||
|
node.name,
|
||||||
|
node.id,
|
||||||
|
node.device,
|
||||||
|
node.endpoints.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
|
ControlResponse::NodeKeychainUpdated {
|
||||||
|
ops,
|
||||||
|
signatures,
|
||||||
|
note,
|
||||||
|
} => {
|
||||||
|
for op in ops {
|
||||||
|
println!("recorded keychain op: {}", op.id);
|
||||||
|
}
|
||||||
|
for signature in signatures {
|
||||||
|
println!(
|
||||||
|
"signed keychain op: {} by {} ({})",
|
||||||
|
signature.op_id, signature.signer, signature.namespace
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
|
ControlResponse::NodeGrantUpdated { op, note } => {
|
||||||
|
println!("recorded auth op: {}", op.id);
|
||||||
|
println!("resource: {}", op.resource);
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
ControlResponse::SshCertRequested { request } => {
|
ControlResponse::SshCertRequested { request } => {
|
||||||
println!("ssh cert request: {}", request.id);
|
println!("ssh cert request: {}", request.id);
|
||||||
println!("status: {}", request.status);
|
println!("status: {}", request.status);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ 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};
|
use geth_keychain::{KeychainOp, KeychainOpSignature, 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;
|
||||||
|
|
@ -117,11 +117,34 @@ pub enum ControlRequest {
|
||||||
resolution: String,
|
resolution: String,
|
||||||
note: Option<String>,
|
note: Option<String>,
|
||||||
},
|
},
|
||||||
|
NodeList,
|
||||||
|
NodeRename {
|
||||||
|
node: String,
|
||||||
|
name: String,
|
||||||
|
signing_key_path: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
NodeRevoke {
|
||||||
|
node: String,
|
||||||
|
signing_key_path: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
NodeGrant {
|
||||||
|
node: String,
|
||||||
|
resource: String,
|
||||||
|
capability: String,
|
||||||
|
grant_id: Option<String>,
|
||||||
|
},
|
||||||
|
NodeRevokeGrant {
|
||||||
|
resource: String,
|
||||||
|
grant_id: String,
|
||||||
|
},
|
||||||
KeychainInit {
|
KeychainInit {
|
||||||
admin_key_path: Option<PathBuf>,
|
admin_key_path: Option<PathBuf>,
|
||||||
signing_key_path: Option<PathBuf>,
|
signing_key_path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
KeychainStatus,
|
KeychainStatus,
|
||||||
|
KeychainSync {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
SecretStatus,
|
SecretStatus,
|
||||||
SecretCreate {
|
SecretCreate {
|
||||||
resource: String,
|
resource: String,
|
||||||
|
|
@ -498,6 +521,15 @@ pub enum ControlResponse {
|
||||||
ops: Vec<KeychainOp>,
|
ops: Vec<KeychainOp>,
|
||||||
signatures: Vec<KeychainOpSignature>,
|
signatures: Vec<KeychainOpSignature>,
|
||||||
},
|
},
|
||||||
|
KeychainSynced {
|
||||||
|
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>,
|
||||||
},
|
},
|
||||||
|
|
@ -531,6 +563,19 @@ pub enum ControlResponse {
|
||||||
AuthOpRecorded {
|
AuthOpRecorded {
|
||||||
op: AuthOp,
|
op: AuthOp,
|
||||||
},
|
},
|
||||||
|
NodeList {
|
||||||
|
nodes: Vec<NodeRecord>,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
NodeKeychainUpdated {
|
||||||
|
ops: Vec<KeychainOp>,
|
||||||
|
signatures: Vec<KeychainOpSignature>,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
NodeGrantUpdated {
|
||||||
|
op: AuthOp,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
SshCertRequested {
|
SshCertRequested {
|
||||||
request: SshCertRequest,
|
request: SshCertRequest,
|
||||||
},
|
},
|
||||||
|
|
@ -811,6 +856,10 @@ pub enum PeerControlRequest {
|
||||||
peer_card: PeerCard,
|
peer_card: PeerCard,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
},
|
},
|
||||||
|
KeychainSync {
|
||||||
|
peer_card: PeerCard,
|
||||||
|
nonce: String,
|
||||||
|
},
|
||||||
CasFetch {
|
CasFetch {
|
||||||
peer_card: PeerCard,
|
peer_card: PeerCard,
|
||||||
hash: BlobHash,
|
hash: BlobHash,
|
||||||
|
|
@ -929,6 +978,16 @@ pub enum PeerControlResponse {
|
||||||
nonce: String,
|
nonce: String,
|
||||||
note: String,
|
note: String,
|
||||||
},
|
},
|
||||||
|
KeychainSynced {
|
||||||
|
node_id: String,
|
||||||
|
agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
ops: Vec<KeychainOp>,
|
||||||
|
signatures: Vec<KeychainOpSignature>,
|
||||||
|
nonce: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
CasFetched {
|
CasFetched {
|
||||||
node_id: String,
|
node_id: String,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
|
@ -1249,6 +1308,24 @@ mod tests {
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::NodeRename {
|
||||||
|
node: "laptop".to_owned(),
|
||||||
|
name: "work-laptop".to_owned(),
|
||||||
|
signing_key_path: Some(PathBuf::from("admin")),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::KeychainSync {
|
||||||
|
node: "work-laptop".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
let request = ControlRequest::CasHas {
|
let request = ControlRequest::CasHas {
|
||||||
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||||
};
|
};
|
||||||
|
|
@ -1266,6 +1343,34 @@ mod tests {
|
||||||
response
|
response
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let response = ControlResponse::NodeList {
|
||||||
|
nodes: vec![NodeRecord {
|
||||||
|
id: geth_types::NodeId::new("node:local"),
|
||||||
|
device: geth_types::DeviceId::new("device:local"),
|
||||||
|
name: "work-laptop".to_owned(),
|
||||||
|
endpoints: Vec::new(),
|
||||||
|
}],
|
||||||
|
note: "reduced keychain view".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = ControlResponse::KeychainSynced {
|
||||||
|
peer_node_id: "node:peer".to_owned(),
|
||||||
|
peer_agent_id: "agent:peer".to_owned(),
|
||||||
|
endpoint_id: "endpoint:peer".to_owned(),
|
||||||
|
ops_imported: 2,
|
||||||
|
signatures_imported: 2,
|
||||||
|
invalid_ops_rejected: 1,
|
||||||
|
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(),
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ use geth_store::{
|
||||||
StoredSshCertificate, StoredSshRevocation,
|
StoredSshCertificate, StoredSshRevocation,
|
||||||
};
|
};
|
||||||
use geth_types::{
|
use geth_types::{
|
||||||
AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
|
AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
|
||||||
ResourceName, SshCertId, SshCertRequestId, UnixMillis,
|
ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
@ -75,6 +75,16 @@ pub enum NodeError {
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error("invalid resource kind: {0}")]
|
#[error("invalid resource kind: {0}")]
|
||||||
InvalidResourceKind(String),
|
InvalidResourceKind(String),
|
||||||
|
#[error("owner init requires --admin-key so the owner trust anchor is recorded")]
|
||||||
|
OwnerInitRequiresAdminKey,
|
||||||
|
#[error("owner init requires --signing-key so the owner statement is signed")]
|
||||||
|
OwnerInitRequiresSigningKey,
|
||||||
|
#[error(
|
||||||
|
"{0} requires --signing-key so the keychain operation can replicate as a verified admin statement"
|
||||||
|
)]
|
||||||
|
SigningKeyRequired(String),
|
||||||
|
#[error("invalid init capability grant, expected <resource>=<capability>: {0}")]
|
||||||
|
InvalidInitGrant(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}")]
|
||||||
|
|
@ -180,6 +190,37 @@ struct PipeUnixConnectWire {
|
||||||
bearer_proof: Option<BearerProof>,
|
bearer_proof: Option<BearerProof>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct InitOwnerOptions {
|
||||||
|
pub admin_key_path: Option<PathBuf>,
|
||||||
|
pub signing_key_path: Option<PathBuf>,
|
||||||
|
pub owner_name: String,
|
||||||
|
pub node_name: String,
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InitOwnerOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
admin_key_path: None,
|
||||||
|
signing_key_path: None,
|
||||||
|
owner_name: "owner".to_owned(),
|
||||||
|
node_name: "local".to_owned(),
|
||||||
|
capabilities: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InitOwnerOptions {
|
||||||
|
fn requests_owner_keychain(&self) -> bool {
|
||||||
|
self.admin_key_path.is_some()
|
||||||
|
|| self.signing_key_path.is_some()
|
||||||
|
|| self.node_name != "local"
|
||||||
|
|| self.owner_name != "owner"
|
||||||
|
|| !self.capabilities.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
|
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
|
||||||
paths.ensure_base_dirs()?;
|
paths.ensure_base_dirs()?;
|
||||||
if !paths.config_file().exists() {
|
if !paths.config_file().exists() {
|
||||||
|
|
@ -210,10 +251,121 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn init_owned_node(
|
||||||
|
paths: &GethPaths,
|
||||||
|
options: InitOwnerOptions,
|
||||||
|
) -> Result<LocalNode, NodeError> {
|
||||||
|
let initialize_owner = options.requests_owner_keychain();
|
||||||
|
if initialize_owner && options.admin_key_path.is_none() {
|
||||||
|
return Err(NodeError::OwnerInitRequiresAdminKey);
|
||||||
|
}
|
||||||
|
if initialize_owner && options.signing_key_path.is_none() {
|
||||||
|
return Err(NodeError::OwnerInitRequiresSigningKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let node = init_node(paths)?;
|
||||||
|
if initialize_owner {
|
||||||
|
initialize_owner_keychain(&node, options)?;
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
|
pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
|
||||||
init_node(paths)
|
init_node(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initialize_owner_keychain(
|
||||||
|
node: &LocalNode,
|
||||||
|
options: InitOwnerOptions,
|
||||||
|
) -> Result<Vec<KeychainOp>, NodeError> {
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let owner = UserId::new(format!("user:{}", stable_slug(&options.owner_name)));
|
||||||
|
let device = DeviceId::new(format!("device:{}", node.node_id));
|
||||||
|
let node_id = NodeId::new(node.node_id.clone());
|
||||||
|
let agent = node.agent_id.clone().into();
|
||||||
|
let now = UnixMillis(geth_store::now_ms());
|
||||||
|
let mut ops = vec![
|
||||||
|
KeychainOp {
|
||||||
|
id: generated_keychain_op_id("keychain-init", "owner", now),
|
||||||
|
created_at: now,
|
||||||
|
kind: KeychainOpKind::KeychainInit,
|
||||||
|
},
|
||||||
|
KeychainOp {
|
||||||
|
id: generated_keychain_op_id("user-add", owner.as_str(), now),
|
||||||
|
created_at: now,
|
||||||
|
kind: KeychainOpKind::UserAdd {
|
||||||
|
user: owner.clone(),
|
||||||
|
name: options.owner_name.clone(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
KeychainOp {
|
||||||
|
id: generated_keychain_op_id("device-add", device.as_str(), now),
|
||||||
|
created_at: now,
|
||||||
|
kind: KeychainOpKind::DeviceAdd {
|
||||||
|
device: device.clone(),
|
||||||
|
user: owner.clone(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
KeychainOp {
|
||||||
|
id: generated_keychain_op_id("node-add", node_id.as_str(), now),
|
||||||
|
created_at: now,
|
||||||
|
kind: KeychainOpKind::NodeAdd {
|
||||||
|
node: node_id.clone(),
|
||||||
|
device: device.clone(),
|
||||||
|
name: options.node_name.clone(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
KeychainOp {
|
||||||
|
id: generated_keychain_op_id("agent-bind", &node.agent_id, now),
|
||||||
|
created_at: now,
|
||||||
|
kind: KeychainOpKind::AgentBind {
|
||||||
|
agent,
|
||||||
|
node: node_id.clone(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if let Some(admin_key_path) = options.admin_key_path.as_ref() {
|
||||||
|
let public_key = std::fs::read_to_string(admin_key_path)?;
|
||||||
|
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
|
||||||
|
ops.push(KeychainOp {
|
||||||
|
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
|
||||||
|
created_at: now,
|
||||||
|
kind: KeychainOpKind::AdminKeyAdd { key: admin_key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let signatures = store_and_sign_keychain_ops(
|
||||||
|
&store,
|
||||||
|
node,
|
||||||
|
&ops,
|
||||||
|
options.signing_key_path.as_deref(),
|
||||||
|
options.admin_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
tracing::debug!(
|
||||||
|
ops = ops.len(),
|
||||||
|
signatures = signatures.len(),
|
||||||
|
"initialized owner keychain"
|
||||||
|
);
|
||||||
|
|
||||||
|
for grant in options.capabilities {
|
||||||
|
let (resource, capability) = grant
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| NodeError::InvalidInitGrant(grant.clone()))?;
|
||||||
|
record_node_grant(
|
||||||
|
&store,
|
||||||
|
node_id.as_str(),
|
||||||
|
resource,
|
||||||
|
capability,
|
||||||
|
Some(format!(
|
||||||
|
"grant:init:{}:{}",
|
||||||
|
stable_slug(node_id.as_str()),
|
||||||
|
stable_slug(capability)
|
||||||
|
)),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(ops)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
||||||
let mut node = init_node(&paths)?;
|
let mut node = init_node(&paths)?;
|
||||||
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
|
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
|
||||||
|
|
@ -486,6 +638,9 @@ pub async fn handle_request_async(
|
||||||
resource,
|
resource,
|
||||||
capability,
|
capability,
|
||||||
} => peer_auth_check(node, &peer_node, resource, capability).await,
|
} => peer_auth_check(node, &peer_node, resource, capability).await,
|
||||||
|
ControlRequest::KeychainSync { node: peer_node } => {
|
||||||
|
keychain_sync_from_peer(node, &peer_node).await
|
||||||
|
}
|
||||||
ControlRequest::CasFetch {
|
ControlRequest::CasFetch {
|
||||||
node: peer_node,
|
node: peer_node,
|
||||||
hash,
|
hash,
|
||||||
|
|
@ -864,6 +1019,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
|
||||||
"peer returned auth-check response to ping request".to_owned(),
|
"peer returned auth-check response to ping request".to_owned(),
|
||||||
)),
|
)),
|
||||||
PeerControlResponse::SyncStatus { .. }
|
PeerControlResponse::SyncStatus { .. }
|
||||||
|
| PeerControlResponse::KeychainSynced { .. }
|
||||||
| PeerControlResponse::CasFetched { .. }
|
| PeerControlResponse::CasFetched { .. }
|
||||||
| PeerControlResponse::CasRootSynced { .. }
|
| PeerControlResponse::CasRootSynced { .. }
|
||||||
| PeerControlResponse::SshCertSynced { .. }
|
| PeerControlResponse::SshCertSynced { .. }
|
||||||
|
|
@ -982,6 +1138,7 @@ async fn peer_auth_check(
|
||||||
"peer returned pong to auth-check request".to_owned(),
|
"peer returned pong to auth-check request".to_owned(),
|
||||||
)),
|
)),
|
||||||
PeerControlResponse::SyncStatus { .. }
|
PeerControlResponse::SyncStatus { .. }
|
||||||
|
| PeerControlResponse::KeychainSynced { .. }
|
||||||
| PeerControlResponse::CasFetched { .. }
|
| PeerControlResponse::CasFetched { .. }
|
||||||
| PeerControlResponse::CasRootSynced { .. }
|
| PeerControlResponse::CasRootSynced { .. }
|
||||||
| PeerControlResponse::SshCertSynced { .. }
|
| PeerControlResponse::SshCertSynced { .. }
|
||||||
|
|
@ -1133,6 +1290,7 @@ async fn cas_fetch_from_peer(
|
||||||
PeerControlResponse::Pong { .. }
|
PeerControlResponse::Pong { .. }
|
||||||
| PeerControlResponse::AuthChecked { .. }
|
| PeerControlResponse::AuthChecked { .. }
|
||||||
| PeerControlResponse::SyncStatus { .. }
|
| PeerControlResponse::SyncStatus { .. }
|
||||||
|
| PeerControlResponse::KeychainSynced { .. }
|
||||||
| PeerControlResponse::CasRootSynced { .. }
|
| PeerControlResponse::CasRootSynced { .. }
|
||||||
| PeerControlResponse::SshCertSynced { .. }
|
| PeerControlResponse::SshCertSynced { .. }
|
||||||
| PeerControlResponse::SshRevocationSynced { .. }
|
| PeerControlResponse::SshRevocationSynced { .. }
|
||||||
|
|
@ -2398,6 +2556,101 @@ async fn sync_status_from_peer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn keychain_sync_from_peer(
|
||||||
|
node: &LocalNode,
|
||||||
|
peer_node: &str,
|
||||||
|
) -> Result<ControlResponse, NodeError> {
|
||||||
|
let response = request_peer_control(node, peer_node, "keychain-sync", |peer_card, nonce| {
|
||||||
|
PeerControlRequest::KeychainSync { peer_card, nonce }
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
match response {
|
||||||
|
PeerControlResponse::KeychainSynced {
|
||||||
|
node_id,
|
||||||
|
agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
ops,
|
||||||
|
signatures,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let mut local_ops = load_keychain_ops(&store)?;
|
||||||
|
let mut trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).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)
|
||||||
|
|| !keychain_signature_uses_claimed_key(signature)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let stored = stored_keychain_signature_from_signature(signature);
|
||||||
|
verify_keychain_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_keychain_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_keychain_op(&store, &op)?;
|
||||||
|
if existing.is_none() {
|
||||||
|
local_ops.push(op.clone());
|
||||||
|
trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
|
||||||
|
ops_imported += 1;
|
||||||
|
}
|
||||||
|
for signature in valid_signatures {
|
||||||
|
store.insert_keychain_signature(&StoredKeychainSignature {
|
||||||
|
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::KeychainSynced {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
ops_imported,
|
||||||
|
signatures_imported,
|
||||||
|
invalid_ops_rejected,
|
||||||
|
note: format!(
|
||||||
|
"{note}; imported only keychain ops signed by currently trusted admin SSH keys"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||||
|
_ => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned wrong response type to keychain sync".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn db_sync_from_peer(
|
async fn db_sync_from_peer(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
peer_node: &str,
|
peer_node: &str,
|
||||||
|
|
@ -2789,9 +3042,10 @@ async fn request_peer_control(
|
||||||
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
|
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
|
||||||
) -> Result<PeerControlResponse, NodeError> {
|
) -> Result<PeerControlResponse, NodeError> {
|
||||||
let store = Store::open(&node.paths.metadata_db())?;
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let peer_node = resolve_peer_node_for_control(&store, peer_node);
|
||||||
let stored = store
|
let stored = store
|
||||||
.get_peer_card(peer_node)?
|
.get_peer_card(&peer_node)?
|
||||||
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
|
.ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?;
|
||||||
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
||||||
peer_card.validate_candidate()?;
|
peer_card.validate_candidate()?;
|
||||||
let candidate = peer_card
|
let candidate = peer_card
|
||||||
|
|
@ -2844,6 +3098,10 @@ async fn request_peer_control(
|
||||||
nonce: response_nonce,
|
nonce: response_nonce,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
|
| PeerControlResponse::KeychainSynced {
|
||||||
|
nonce: response_nonce,
|
||||||
|
..
|
||||||
|
}
|
||||||
| PeerControlResponse::SshRevocationSynced {
|
| PeerControlResponse::SshRevocationSynced {
|
||||||
nonce: response_nonce,
|
nonce: response_nonce,
|
||||||
..
|
..
|
||||||
|
|
@ -3174,6 +3432,31 @@ async fn handle_iroh_control_connection(
|
||||||
note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(),
|
note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PeerControlRequest::KeychainSync { 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::KeychainSynced {
|
||||||
|
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_keychain_ops(&store)?,
|
||||||
|
signatures: load_keychain_signatures(&store)?,
|
||||||
|
nonce,
|
||||||
|
note: "keychain sync returns signed operation-log data; receiver must verify OpenSSH signatures before import".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
PeerControlRequest::AuthCheck {
|
PeerControlRequest::AuthCheck {
|
||||||
peer_card,
|
peer_card,
|
||||||
resource,
|
resource,
|
||||||
|
|
@ -5011,24 +5294,13 @@ pub fn handle_request(
|
||||||
ops.push(op);
|
ops.push(op);
|
||||||
}
|
}
|
||||||
|
|
||||||
let signatures = if let Some(signing_key_path) = signing_key_path {
|
let signatures = store_and_sign_keychain_ops(
|
||||||
let (signer, signer_public_key) =
|
&store,
|
||||||
keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
|
node,
|
||||||
let mut signatures = Vec::new();
|
&ops,
|
||||||
for op in &ops {
|
signing_key_path.as_deref(),
|
||||||
signatures.push(sign_keychain_op_with_ssh(
|
admin_key_path.as_deref(),
|
||||||
&store,
|
)?;
|
||||||
node,
|
|
||||||
op,
|
|
||||||
&signing_key_path,
|
|
||||||
&signer,
|
|
||||||
&signer_public_key,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
signatures
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ControlResponse::KeychainInitialized { ops, signatures })
|
Ok(ControlResponse::KeychainInitialized { ops, signatures })
|
||||||
}
|
}
|
||||||
|
|
@ -5046,6 +5318,93 @@ pub fn handle_request(
|
||||||
nodes: view.nodes.len(),
|
nodes: view.nodes.len(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
ControlRequest::NodeList => {
|
||||||
|
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
|
||||||
|
Ok(ControlResponse::NodeList {
|
||||||
|
nodes: view.nodes.into_values().collect(),
|
||||||
|
note: "nodes are reduced from the signed keychain operation log; revoked devices and nodes are omitted".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::NodeRename {
|
||||||
|
node: target,
|
||||||
|
name,
|
||||||
|
signing_key_path,
|
||||||
|
} => {
|
||||||
|
let signing_key_path = signing_key_path
|
||||||
|
.ok_or_else(|| NodeError::SigningKeyRequired("node rename".to_owned()))?;
|
||||||
|
let target = resolve_keychain_node(&store, &target)?;
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
let op = KeychainOp {
|
||||||
|
id: generated_keychain_op_id("node-rename", target.as_str(), created_at),
|
||||||
|
created_at,
|
||||||
|
kind: KeychainOpKind::NodeRename { node: target, name },
|
||||||
|
};
|
||||||
|
let signatures = store_and_sign_keychain_ops(
|
||||||
|
&store,
|
||||||
|
node,
|
||||||
|
std::slice::from_ref(&op),
|
||||||
|
Some(signing_key_path.as_path()),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::NodeKeychainUpdated {
|
||||||
|
ops: vec![op],
|
||||||
|
signatures,
|
||||||
|
note: "recorded signed node rename as a keychain operation".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::NodeRevoke {
|
||||||
|
node: target,
|
||||||
|
signing_key_path,
|
||||||
|
} => {
|
||||||
|
let signing_key_path = signing_key_path
|
||||||
|
.ok_or_else(|| NodeError::SigningKeyRequired("node revoke".to_owned()))?;
|
||||||
|
let target = resolve_keychain_node(&store, &target)?;
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
let op = KeychainOp {
|
||||||
|
id: generated_keychain_op_id("node-revoke", target.as_str(), created_at),
|
||||||
|
created_at,
|
||||||
|
kind: KeychainOpKind::NodeRevoke { node: target },
|
||||||
|
};
|
||||||
|
let signatures = store_and_sign_keychain_ops(
|
||||||
|
&store,
|
||||||
|
node,
|
||||||
|
std::slice::from_ref(&op),
|
||||||
|
Some(signing_key_path.as_path()),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::NodeKeychainUpdated {
|
||||||
|
ops: vec![op],
|
||||||
|
signatures,
|
||||||
|
note: "recorded node revocation as a keychain operation; replicated peers will omit the node after verified keychain sync".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::NodeGrant {
|
||||||
|
node: target,
|
||||||
|
resource,
|
||||||
|
capability,
|
||||||
|
grant_id,
|
||||||
|
} => {
|
||||||
|
let target = resolve_keychain_node(&store, &target)?;
|
||||||
|
let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?;
|
||||||
|
Ok(ControlResponse::NodeGrantUpdated {
|
||||||
|
op,
|
||||||
|
note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::NodeRevokeGrant { resource, grant_id } => {
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
let op = AuthOp {
|
||||||
|
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
|
||||||
|
resource: ResourceId::new(resource),
|
||||||
|
created_at,
|
||||||
|
kind: AuthOpKind::GrantRevoke { grant_id },
|
||||||
|
};
|
||||||
|
store_auth_op(&store, &op)?;
|
||||||
|
Ok(ControlResponse::NodeGrantUpdated {
|
||||||
|
op,
|
||||||
|
note: "recorded capability grant revocation".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus {
|
ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus {
|
||||||
secrets: store
|
secrets: store
|
||||||
.list_resource_secrets()?
|
.list_resource_secrets()?
|
||||||
|
|
@ -5945,7 +6304,8 @@ pub fn handle_request(
|
||||||
}
|
}
|
||||||
ControlRequest::SshProxyConnect { .. }
|
ControlRequest::SshProxyConnect { .. }
|
||||||
| ControlRequest::SshProxyStream { .. }
|
| ControlRequest::SshProxyStream { .. }
|
||||||
| ControlRequest::SshAdminShell { .. } => Err(NodeError::IrohEndpointUnavailable),
|
| ControlRequest::SshAdminShell { .. }
|
||||||
|
| ControlRequest::KeychainSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::ModuleStub { module, command } => {
|
ControlRequest::ModuleStub { module, command } => {
|
||||||
Ok(ControlResponse::NotImplemented { module, command })
|
Ok(ControlResponse::NotImplemented { module, command })
|
||||||
}
|
}
|
||||||
|
|
@ -6625,6 +6985,34 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn store_and_sign_keychain_ops(
|
||||||
|
store: &Store,
|
||||||
|
node: &LocalNode,
|
||||||
|
ops: &[KeychainOp],
|
||||||
|
signing_key_path: Option<&Path>,
|
||||||
|
admin_key_path: Option<&Path>,
|
||||||
|
) -> Result<Vec<KeychainOpSignature>, NodeError> {
|
||||||
|
for op in ops {
|
||||||
|
store_keychain_op(store, op)?;
|
||||||
|
}
|
||||||
|
let Some(signing_key_path) = signing_key_path else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?;
|
||||||
|
ops.iter()
|
||||||
|
.map(|op| {
|
||||||
|
sign_keychain_op_with_ssh(
|
||||||
|
store,
|
||||||
|
node,
|
||||||
|
op,
|
||||||
|
signing_key_path,
|
||||||
|
&signer,
|
||||||
|
&signer_public_key,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn keychain_signer_from_paths(
|
fn keychain_signer_from_paths(
|
||||||
signing_key_path: &Path,
|
signing_key_path: &Path,
|
||||||
admin_key_path: Option<&Path>,
|
admin_key_path: Option<&Path>,
|
||||||
|
|
@ -6689,6 +7077,23 @@ fn sign_keychain_op_with_ssh(
|
||||||
Ok(signature)
|
Ok(signature)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stored_keychain_signature_from_signature(
|
||||||
|
signature: &KeychainOpSignature,
|
||||||
|
) -> StoredKeychainSignature {
|
||||||
|
StoredKeychainSignature {
|
||||||
|
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 keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
|
||||||
|
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
struct KeychainSignatureStatus {
|
struct KeychainSignatureStatus {
|
||||||
total: usize,
|
total: usize,
|
||||||
|
|
@ -6787,6 +7192,83 @@ fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {
|
||||||
|
Ok(store
|
||||||
|
.list_keychain_signatures()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|stored| KeychainOpSignature {
|
||||||
|
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 resolve_keychain_node(store: &Store, node_or_name: &str) -> Result<NodeId, NodeError> {
|
||||||
|
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?);
|
||||||
|
if let Some((node_id, _)) = view
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|(node_id, record)| node_id.as_str() == node_or_name || record.name == node_or_name)
|
||||||
|
{
|
||||||
|
Ok(node_id.clone())
|
||||||
|
} else {
|
||||||
|
Err(NodeError::ResourceNotFound(format!("node:{node_or_name}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_peer_node_for_control(store: &Store, node_or_name: &str) -> String {
|
||||||
|
resolve_keychain_node(store, node_or_name)
|
||||||
|
.map(|node| node.to_string())
|
||||||
|
.unwrap_or_else(|_| node_or_name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_node_grant(
|
||||||
|
store: &Store,
|
||||||
|
node_id: &str,
|
||||||
|
resource: &str,
|
||||||
|
capability: &str,
|
||||||
|
grant_id: Option<String>,
|
||||||
|
) -> Result<AuthOp, NodeError> {
|
||||||
|
let created_at = UnixMillis(geth_store::now_ms());
|
||||||
|
let grant_id = grant_id.unwrap_or_else(|| generated_grant_id(node_id, resource, capability));
|
||||||
|
let op = AuthOp {
|
||||||
|
id: generated_auth_op_id("grant-create", resource, &grant_id, created_at),
|
||||||
|
resource: ResourceId::new(resource.to_owned()),
|
||||||
|
created_at,
|
||||||
|
kind: AuthOpKind::GrantCreate {
|
||||||
|
grant_id,
|
||||||
|
principal: PrincipalId::new(node_id.to_owned()),
|
||||||
|
capabilities: vec![Capability::new(capability.to_owned())],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
store_auth_op(store, &op)?;
|
||||||
|
Ok(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_slug(value: &str) -> String {
|
||||||
|
let slug = value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||||
|
ch.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_owned();
|
||||||
|
if slug.is_empty() {
|
||||||
|
"unnamed".to_owned()
|
||||||
|
} else {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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:{}",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,19 @@ fn geth_init_in_temp_home() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owner_init_requires_admin_key_and_signing_key() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let output = run_geth(home.path(), &["init", "--node-name", "laptop"]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&output.stderr).contains("owner init requires --admin-key"),
|
||||||
|
"stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
assert!(!home.path().join("geth.sqlite").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn geth_status_against_running_daemon() {
|
fn geth_status_against_running_daemon() {
|
||||||
let home = tempfile::tempdir().expect("tempdir");
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
@ -929,6 +942,99 @@ fn keychain_init_can_record_openssh_signatures() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_owned_node_records_signed_owner_device_and_node() {
|
||||||
|
if Command::new("ssh-keygen").arg("-?").output().is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let paths = geth_config::GethPaths::from_home(home.path());
|
||||||
|
let admin_key_path = 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 node = geth_node::init_owned_node(
|
||||||
|
&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: "laptop".to_owned(),
|
||||||
|
capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.admin_shell".to_owned()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("init owned node");
|
||||||
|
|
||||||
|
match geth_node::handle_request(&node, geth_control::ControlRequest::KeychainStatus)
|
||||||
|
.expect("keychain status")
|
||||||
|
{
|
||||||
|
geth_control::ControlResponse::KeychainStatus(status) => {
|
||||||
|
assert!(status.initialized);
|
||||||
|
assert_eq!(status.admin_keys, 1);
|
||||||
|
assert_eq!(status.users, 1);
|
||||||
|
assert_eq!(status.devices, 1);
|
||||||
|
assert_eq!(status.nodes, 1);
|
||||||
|
assert_eq!(status.verified_signatures, status.signatures);
|
||||||
|
assert!(status.signatures >= 6);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match geth_node::handle_request(&node, geth_control::ControlRequest::NodeList)
|
||||||
|
.expect("node list")
|
||||||
|
{
|
||||||
|
geth_control::ControlResponse::NodeList { nodes, .. } => {
|
||||||
|
assert_eq!(nodes.len(), 1);
|
||||||
|
assert_eq!(nodes[0].name, "laptop");
|
||||||
|
assert_eq!(nodes[0].id.as_str(), node.node_id);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let renamed = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::NodeRename {
|
||||||
|
node: "laptop".to_owned(),
|
||||||
|
name: "work-laptop".to_owned(),
|
||||||
|
signing_key_path: Some(admin_key_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("rename node");
|
||||||
|
match renamed {
|
||||||
|
geth_control::ControlResponse::NodeKeychainUpdated { signatures, .. } => {
|
||||||
|
assert_eq!(signatures.len(), 1);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let explained = geth_node::handle_request(
|
||||||
|
&node,
|
||||||
|
geth_control::ControlRequest::AuthExplain {
|
||||||
|
subject: node.node_id.clone(),
|
||||||
|
resource: "resource:ssh-proxy:local".to_owned(),
|
||||||
|
capability: "ssh_proxy.admin_shell".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("explain init grant");
|
||||||
|
match explained {
|
||||||
|
geth_control::ControlResponse::AuthExplain(explanation) => {
|
||||||
|
assert!(explanation.allowed);
|
||||||
|
assert!(explanation.reason.contains("direct grant"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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");
|
||||||
|
|
|
||||||
42
docs/adr/0016-owner-node-enrollment.md
Normal file
42
docs/adr/0016-owner-node-enrollment.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# ADR 0016: Owner-Rooted Node Enrollment
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Geth needs a practical device-management flow: initialize a mesh with an
|
||||||
|
admin/owner identity, bind local daemon instances to named nodes/devices, list
|
||||||
|
devices by name, grant resource capabilities, and revoke devices. This must not
|
||||||
|
be a mutable device table because the project needs to evolve toward replicated
|
||||||
|
local-first authorization.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Owner and node management is represented as signed keychain operations. `geth
|
||||||
|
init --admin-key <pub> --signing-key <key> --node-name <name>` records
|
||||||
|
`KeychainInit`, `AdminKeyAdd`, `UserAdd`, `DeviceAdd`, `NodeAdd`, and
|
||||||
|
`AgentBind` operations. When owner setup options are used, both `--admin-key`
|
||||||
|
and `--signing-key` are required. Geth signs canonical keychain payloads with
|
||||||
|
OpenSSH using the `geth.keychain.v1@geth.local` namespace.
|
||||||
|
|
||||||
|
The active device list is the reduced keychain view, surfaced through `geth node
|
||||||
|
list`. Renames and revocations are additional keychain operations. Resource
|
||||||
|
permissions remain resource-scoped auth operations and can be managed with
|
||||||
|
`geth node grant` and `geth node revoke-grant`.
|
||||||
|
|
||||||
|
`geth keychain sync <node>` pulls keychain operations and signatures over Iroh
|
||||||
|
from an imported peer. The receiver imports only operations with valid OpenSSH
|
||||||
|
signatures from currently trusted admin keys over the canonical keychain
|
||||||
|
payload. Discovery and peer cards still grant no trust by themselves.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Node names are convenience labels over stable node IDs. Endpoint rotation should
|
||||||
|
add or revoke endpoint bindings without replacing the node identity.
|
||||||
|
|
||||||
|
The current sync model is a pull-based signed operation log. It is not yet a
|
||||||
|
Keyhive-style convergent authority, does not implement advanced group
|
||||||
|
cryptography, and does not yet sign/verify replicated auth operations. Those are
|
||||||
|
future roadmap items.
|
||||||
|
|
@ -275,15 +275,18 @@ The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents
|
||||||
and endpoint bindings. Endpoint rotation must not destroy higher-level node
|
and endpoint bindings. Endpoint rotation must not destroy higher-level node
|
||||||
identity. Keychain operations reduce into an active view containing current
|
identity. Keychain operations reduce into an active view containing current
|
||||||
admin keys, users, devices, node records, agent bindings, and endpoint-to-node
|
admin keys, users, devices, node records, agent bindings, and endpoint-to-node
|
||||||
bindings. Revoked identity subtrees are excluded from that active view. The
|
bindings. Revoked identity subtrees are excluded from that active view. `geth
|
||||||
daemon persists local keychain init/admin-key operations and `keychain status`
|
init --admin-key <pub> --signing-key <key> --node-name <name>` records an
|
||||||
reports the reduced local view. `keychain init --signing-key <path>` writes the
|
owner/admin key, user, device, node, and agent binding as keychain operations
|
||||||
canonical keychain signing payloads, runs `ssh-keygen -Y sign` with the explicit
|
and signs them with OpenSSH under `geth.keychain.v1@geth.local`. Both keys are
|
||||||
`geth.keychain.v1@geth.local` namespace, and stores the resulting OpenSSH
|
required when owner setup options are used, so the node does not create unsigned
|
||||||
signatures in local SQLite. `keychain status` reports the stored signature
|
owner statements by accident. `geth node list` shows the active reduced node
|
||||||
count and verifies stored signatures against their canonical payloads with
|
view. `geth node rename` and `geth node revoke` record signed keychain
|
||||||
OpenSSH when possible. Rejection of unsigned or invalid replicated keychain
|
operations and require `--signing-key`. `geth keychain sync <node>` pulls
|
||||||
operations is still future work.
|
keychain operations and signatures from an imported peer over Iroh and imports
|
||||||
|
only operations with a valid OpenSSH signature from a currently trusted admin key
|
||||||
|
over the canonical payload. This is currently a pull-based signed operation log,
|
||||||
|
not a CRDT or Keyhive-style convergent authority.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,12 @@ resource-scoped capability decisions.
|
||||||
- `[x]` Tests cover signed keychain init with a generated local OpenSSH key
|
- `[x]` Tests cover signed keychain init with a generated local OpenSSH key
|
||||||
when `ssh-keygen` is available.
|
when `ssh-keygen` is available.
|
||||||
- `[x]` Tests cover local OpenSSH verification of stored keychain signatures.
|
- `[x]` Tests cover local OpenSSH verification of stored keychain signatures.
|
||||||
- `[ ]` Future completion verifies signatures before accepting replicated
|
- `[x]` `geth init --admin-key --signing-key --node-name` records signed
|
||||||
keychain ops.
|
owner/user/device/node/agent binding operations.
|
||||||
|
- `[x]` `geth node list/rename/revoke` operate on the reduced keychain view.
|
||||||
|
- `[x]` `geth node rename/revoke` require an admin signing key.
|
||||||
|
- `[x]` `geth keychain sync <node>` verifies signatures from currently
|
||||||
|
trusted admin keys before accepting keychain ops.
|
||||||
|
|
||||||
- `[x]` Keychain operation reducer.
|
- `[x]` Keychain operation reducer.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
@ -192,6 +196,17 @@ resource-scoped capability decisions.
|
||||||
- Revoked keys/devices/nodes are excluded from active views.
|
- Revoked keys/devices/nodes are excluded from active views.
|
||||||
- Tests cover add, rename, revoke, and endpoint rotation.
|
- Tests cover add, rename, revoke, and endpoint rotation.
|
||||||
|
|
||||||
|
- `[~]` Node capability management.
|
||||||
|
Acceptance criteria:
|
||||||
|
- `[x]` `geth node grant <node> <resource> <capability>` records a
|
||||||
|
resource-scoped capability grant for a known node.
|
||||||
|
- `[x]` `geth node revoke-grant <resource> <grant-id>` records grant
|
||||||
|
revocation.
|
||||||
|
- `[x]` Node names can be used for management commands where the keychain view
|
||||||
|
has a unique active node name.
|
||||||
|
- `[ ]` Future completion signs auth ops and verifies signed auth ops before
|
||||||
|
accepting replicated permission changes.
|
||||||
|
|
||||||
- `[x]` Resource auth operation reducer.
|
- `[x]` Resource auth operation reducer.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- Resource create, authority set, grants, revocations, and groups reduce into
|
- Resource create, authority set, grants, revocations, and groups reduce into
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue