Add guided node enrollment

This commit is contained in:
Eric Wendland 2026-07-18 16:48:35 +02:00
commit ce3e029b3d
8 changed files with 490 additions and 18 deletions

View file

@ -161,6 +161,7 @@ The bootstrap implementation provides:
- `geth backup restore <backup-dir> --target-home <dir>`
- `geth node id`
- `geth node list`
- `geth node enroll join <owner-peer-card> --admin-key <owner-admin.pub> --node-name <name>`
- `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>`

View file

@ -174,21 +174,26 @@ const GUIDE_ENROLLMENT: &str = r#"Add another node/device:
On the new node:
geth init
geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub
geth peer import /tmp/owner.peer.json
geth node enroll request --node-name workstation --out /tmp/workstation-enrollment.json
geth node enroll submit owner --path /tmp/workstation-enrollment.json
geth daemon install
geth node enroll join /tmp/owner.peer.json \
--admin-key /tmp/owner-admin.pub \
--node-name workstation
On the owner/YubiKey machine:
geth node enroll list
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
Back on the new node:
geth sync now owner
geth node list
geth node enroll sync <owner-node-id>
geth keychain status
Enrollment approval records signed keychain/auth operations. Discovery and peer
cards alone never grant trust or capabilities.
cards alone never grant trust or capabilities. The join command combines peer
import, explicit admin public-key trust bootstrap, signed request creation, and
Iroh submission; it does not approve the request. Verify the admin public key
through a separate trusted channel before using it. For offline transfer or
recovery, the lower-level `request`, `submit`, and `import` commands remain
available.
"#;
const GUIDE_KEYS: &str = r#"Key terminology:
@ -819,6 +824,23 @@ pub enum NodeCommand {
#[derive(Debug, Subcommand)]
pub enum NodeEnrollCommand {
/// Import an owner peer card, create a request, and submit it in one step
#[command(
after_help = "Example:\n geth node enroll join owner.peer.json --admin-key owner-admin.pub --node-name workstation --capability resource:kv:preferences=kv.read\n\nThe explicitly supplied admin public key bootstraps trust for later approval sync. Peer-card import supplies candidate routing metadata only. Owner approval remains a separate, admin-signed action."
)]
Join {
peer_card_path: PathBuf,
#[arg(long)]
admin_key: PathBuf,
#[arg(long)]
node_name: String,
#[arg(long = "capability")]
capabilities: Vec<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
},
/// Create a signed enrollment request on the new node
#[command(
after_help = "Examples:\n geth node enroll request --node-name workstation --out workstation.enroll.json\n geth node enroll request --node-name ci-runner --capability resource:kv:builds=kv.read"
@ -1639,6 +1661,18 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
("geth node enroll request", "capabilities") => {
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
}
("geth node enroll join", "peer_card_path") => {
Some("Signed owner peer-card JSON file to import as an untrusted candidate")
}
("geth node enroll join", "admin_key") => {
Some("Owner OpenSSH admin public key used as the explicit trust anchor")
}
("geth node enroll join", "capabilities") => {
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
}
("geth node enroll join" | "geth node enroll request", "out") => {
Some("Optional path for a portable enrollment-request JSON copy")
}
("geth kv create" | "geth kv set" | "geth kv get" | "geth kv sync", "name") => {
Some("Name of the KV store")
}
@ -2244,6 +2278,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Node {
command: NodeCommand::Enroll { command },
} => match command {
NodeEnrollCommand::Join {
peer_card_path,
admin_key,
node_name,
capabilities,
reason,
out,
} => ControlRequest::NodeEnrollJoin {
peer_card_path,
admin_key_path: admin_key,
node_name,
capabilities,
reason,
out,
},
NodeEnrollCommand::Request {
node_name,
capabilities,
@ -4397,6 +4446,28 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
println!("note: {note}");
}
ControlResponse::NodeEnrollmentJoined {
peer,
request,
out,
owner_node_id,
admin_key_id,
accepted,
note,
} => {
println!("imported owner candidate: {}", peer.card.node_id);
println!("trust: candidate-only");
println!("admin trust anchor: {admin_key_id}");
println!("node enrollment request: {}", request.id);
println!("requested_name: {}", request.requested_node_name);
if let Some(out) = out {
println!("written: {}", out.display());
}
println!("submitted_to: {owner_node_id}");
println!("accepted: {accepted}");
println!("next: the owner reviews and approves this request with an admin key");
println!("note: {note}");
}
ControlResponse::NodeEnrollmentSubmitted {
request_id,
owner_node_id,
@ -5349,6 +5420,32 @@ mod tests {
assert_eq!(config.sync.live_sync_interval_ms, 500);
}
#[test]
fn guided_enrollment_join_parses_the_common_new_node_workflow() {
let parsed = Cli::try_parse_from([
"geth",
"node",
"enroll",
"join",
"owner.peer.json",
"--admin-key",
"owner-admin.pub",
"--node-name",
"workstation",
"--capability",
"resource:kv:preferences=kv.read",
])
.expect("parse guided enrollment");
assert!(matches!(
parsed.command,
Command::Node {
command: NodeCommand::Enroll {
command: NodeEnrollCommand::Join { .. }
}
}
));
}
#[tokio::test]
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
let parsed = Cli::try_parse_from([

View file

@ -203,6 +203,14 @@ pub enum ControlRequest {
reason: Option<String>,
out: Option<PathBuf>,
},
NodeEnrollJoin {
peer_card_path: PathBuf,
admin_key_path: PathBuf,
node_name: String,
capabilities: Vec<String>,
reason: Option<String>,
out: Option<PathBuf>,
},
NodeEnrollSubmit {
owner_node: String,
request_id: Option<String>,
@ -886,6 +894,15 @@ pub enum ControlResponse {
out: Option<PathBuf>,
note: String,
},
NodeEnrollmentJoined {
peer: DiscoveredPeer,
request: NodeEnrollmentRequest,
out: Option<PathBuf>,
owner_node_id: String,
admin_key_id: String,
accepted: bool,
note: String,
},
NodeEnrollmentSubmitted {
request_id: String,
owner_node_id: String,
@ -1817,8 +1834,8 @@ mod tests {
use super::*;
use serde_json::{Map, Value, json};
const CONTROL_REQUEST_VARIANTS: usize = 122;
const CONTROL_RESPONSE_VARIANTS: usize = 114;
const CONTROL_REQUEST_VARIANTS: usize = 123;
const CONTROL_RESPONSE_VARIANTS: usize = 115;
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
@ -2518,6 +2535,19 @@ mod tests {
request
);
let request = ControlRequest::NodeEnrollJoin {
peer_card_path: PathBuf::from("owner.peer.json"),
admin_key_path: PathBuf::from("owner-admin.pub"),
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(),
};

View file

@ -3516,6 +3516,142 @@ async fn node_enrollment_submit_to_peer(
}
}
async fn node_enrollment_join_owner(
node: &LocalNode,
peer_card_path: PathBuf,
admin_key_path: PathBuf,
node_name: String,
capabilities: Vec<String>,
reason: Option<String>,
out: Option<PathBuf>,
) -> Result<ControlResponse, NodeError> {
let (discovered, request, admin_key_id) = prepare_node_enrollment_join(
node,
&peer_card_path,
&admin_key_path,
node_name,
capabilities,
reason,
out.as_deref(),
)?;
let owner_node = discovered.card.node_id.to_string();
let submission = node_enrollment_submit_to_peer(
node,
&owner_node,
Some(request.id.to_string()),
None,
)
.await
.map_err(|error| {
NodeError::IrohPeer(format!(
"imported owner candidate and created enrollment request {}, but submission failed: {error}\nnext: retry with `geth node enroll submit {owner_node} --request-id {}`",
request.id, request.id
))
})?;
let ControlResponse::NodeEnrollmentSubmitted {
owner_node_id,
accepted,
note,
..
} = submission
else {
return Err(NodeError::IrohPeer(
"owner returned an unexpected enrollment response".to_owned(),
));
};
Ok(ControlResponse::NodeEnrollmentJoined {
peer: discovered,
request,
out,
owner_node_id,
admin_key_id,
accepted,
note: format!(
"{}; {note}; owner approval with an admin signing key is still required",
discovery_is_untrusted_note()
),
})
}
fn prepare_node_enrollment_join(
node: &LocalNode,
peer_card_path: &Path,
admin_key_path: &Path,
node_name: String,
capabilities: Vec<String>,
reason: Option<String>,
out: Option<&Path>,
) -> Result<(DiscoveredPeer, NodeEnrollmentRequest, String), NodeError> {
let admin_public_key = std::fs::read_to_string(admin_key_path)?;
if admin_public_key.trim().is_empty() {
return Err(NodeError::IrohPeer(
"owner admin public key file is empty".to_owned(),
));
}
validate_openssh_public_key_text(&admin_public_key)?;
let admin_key_id = ssh_public_key_fingerprint(&admin_public_key);
let card_json = std::fs::read_to_string(peer_card_path)?;
let card: PeerCard = serde_json::from_str(&card_json)?;
card.validate_candidate()?;
let discovered = DiscoveredPeer::candidate(
card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::Imported,
)?;
let store = Store::open(&node.paths.metadata_db())?;
let admin_key = KeyId::new(admin_key_id.clone());
let keychain = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
if !keychain.admin_keys.contains(&admin_key) {
handle_request(
node,
ControlRequest::KeychainInit {
admin_key_path: Some(admin_key_path.to_path_buf()),
signing_key_path: None,
},
)?;
}
store.upsert_peer_card(&StoredPeerCard {
peer_id: card.node_id.to_string(),
card_json: serde_json::to_string(&card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
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 {
write_node_enrollment_request_file(path, &request)?;
}
Ok((discovered, request, admin_key_id))
}
fn validate_openssh_public_key_text(public_key: &str) -> Result<(), NodeError> {
let mut fields = public_key.split_whitespace();
let key_type = fields.next().unwrap_or_default();
let encoded = fields.next().unwrap_or_default();
let recognized_type = key_type.starts_with("ssh-")
|| key_type.starts_with("ecdsa-sha2-")
|| key_type.starts_with("sk-");
if !recognized_type || encoded.is_empty() {
return Err(NodeError::IrohPeer(
"owner admin key must be an OpenSSH public key, for example `ssh-ed25519 AAAA...`"
.to_owned(),
));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded))
.map_err(|_| {
NodeError::IrohPeer("owner admin key contains invalid base64 key data".to_owned())
})?;
if decoded.is_empty() {
return Err(NodeError::IrohPeer(
"owner admin key contains empty key data".to_owned(),
));
}
Ok(())
}
async fn node_enrollment_sync_from_owner(
node: &LocalNode,
owner_node: &str,
@ -7571,6 +7707,7 @@ pub fn handle_request(
| ControlRequest::SshAdminShell { .. }
| ControlRequest::KeychainSync { .. }
| ControlRequest::AuthSync { .. }
| ControlRequest::NodeEnrollJoin { .. }
| ControlRequest::NodeEnrollSubmit { .. }
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ModuleStub { module, command } => {
@ -13823,6 +13960,176 @@ mod tests {
right_endpoint.shutdown().await;
}
#[tokio::test]
async fn guided_enrollment_imports_creates_and_submits_in_one_request() {
if skip_iroh_integration_tests() {
eprintln!("skipping Iroh integration test because GETH_TEST_SKIP_IROH is set");
return;
}
let requester_home = tempfile::tempdir().expect("requester home");
let owner_home = tempfile::tempdir().expect("owner home");
let requester_paths = GethPaths::from_home(requester_home.path());
let owner_paths = GethPaths::from_home(owner_home.path());
let mut requester = init_node(&requester_paths).expect("init requester");
let mut owner = init_node(&owner_paths).expect("init owner");
write_offline_iroh_config(&requester_paths);
write_offline_iroh_config(&owner_paths);
let Some(requester_endpoint) = start_daemon_iroh_endpoint(&mut requester)
.await
.expect("requester iroh")
else {
eprintln!("skipping guided enrollment; requester endpoint unavailable");
return;
};
let Some(owner_endpoint) = start_daemon_iroh_endpoint(&mut owner)
.await
.expect("owner iroh")
else {
eprintln!("skipping guided enrollment; owner endpoint unavailable");
requester_endpoint.shutdown().await;
return;
};
spawn_iroh_control_accept_loop(owner.clone(), owner_endpoint.clone());
let exported = handle_request_async(&owner, ControlRequest::PeerCardExport { out: None })
.await
.expect("export owner peer card");
let owner_card = match exported {
ControlResponse::PeerCardExported { card, .. } => card,
other => panic!("unexpected peer export response: {other:?}"),
};
let card_path = requester_home.path().join("owner.peer.json");
std::fs::write(
&card_path,
serde_json::to_string_pretty(&owner_card).expect("card json"),
)
.expect("write owner peer card");
let admin_key_path = requester_home.path().join("owner-admin.pub");
std::fs::write(
&admin_key_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n",
)
.expect("write owner admin public key");
let joined = handle_request_async(
&requester,
ControlRequest::NodeEnrollJoin {
peer_card_path: card_path,
admin_key_path,
node_name: "workstation".to_owned(),
capabilities: vec!["resource:kv:preferences=kv.read".to_owned()],
reason: Some("new workstation".to_owned()),
out: None,
},
)
.await
.expect("guided enrollment");
let request_id = match joined {
ControlResponse::NodeEnrollmentJoined {
request,
owner_node_id,
admin_key_id,
accepted,
peer,
..
} => {
assert!(accepted);
assert_eq!(owner_node_id, owner.node_id.to_string());
assert_eq!(peer.card.node_id.to_string(), owner.node_id);
assert!(admin_key_id.starts_with("ssh:blake3:"));
request.id.to_string()
}
other => panic!("unexpected guided enrollment response: {other:?}"),
};
let received = handle_request(&owner, ControlRequest::NodeEnrollList { status: None })
.expect("list owner requests");
match received {
ControlResponse::NodeEnrollmentList { requests, .. } => {
assert!(
requests
.iter()
.any(|request| request.id.to_string() == request_id)
);
}
other => panic!("unexpected enrollment list response: {other:?}"),
}
requester_endpoint.shutdown().await;
owner_endpoint.shutdown().await;
}
#[test]
fn guided_enrollment_preparation_records_explicit_trust_peer_and_request() {
assert!(validate_openssh_public_key_text("not-a-public-key").is_err());
let home = tempfile::tempdir().expect("requester home");
let paths = GethPaths::from_home(home.path());
let requester = init_node(&paths).expect("init requester");
let owner_key = AgentKey::generate();
let owner_card = PeerCard::signed(
"node:owner".into(),
&owner_key,
vec![EndpointCandidate {
endpoint_id: "endpoint:owner".to_owned(),
relay_url: None,
direct_addresses: Vec::new(),
source: DiscoverySource::Manual,
}],
UnixMillis(1),
)
.expect("owner peer card");
let card_path = home.path().join("owner.peer.json");
std::fs::write(
&card_path,
serde_json::to_string_pretty(&owner_card).expect("card json"),
)
.expect("write card");
let admin_key_path = home.path().join("owner-admin.pub");
std::fs::write(
&admin_key_path,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n",
)
.expect("write admin key");
let request_path = home.path().join("request.json");
let (peer, request, admin_key_id) = prepare_node_enrollment_join(
&requester,
&card_path,
&admin_key_path,
"workstation".to_owned(),
vec!["resource:kv:preferences=kv.read".to_owned()],
Some("new workstation".to_owned()),
Some(&request_path),
)
.expect("prepare guided enrollment");
assert_eq!(peer.card.node_id.to_string(), "node:owner");
assert!(admin_key_id.starts_with("ssh:blake3:"));
assert_eq!(request.requested_node_name, "workstation");
assert!(request.provenance.is_some());
assert!(request_path.is_file());
let store = Store::open(&paths.metadata_db()).expect("open store");
assert!(
store
.get_peer_card("node:owner")
.expect("get peer")
.is_some()
);
assert!(
store
.get_node_enrollment_request(request.id.as_str())
.expect("get request")
.is_some()
);
let status =
handle_request(&requester, ControlRequest::KeychainStatus).expect("keychain status");
match status {
ControlResponse::KeychainStatus(status) => assert_eq!(status.admin_keys, 1),
other => panic!("unexpected keychain status: {other:?}"),
}
}
fn test_tree(entries: Vec<geth_cas::CasTreeEntry>) -> CasTreeObject {
CasTreeObject {
version: geth_cas::CAS_TREE_OBJECT_VERSION,

View file

@ -81,6 +81,25 @@ async fn route_peer_family(
request_id,
path,
} => node_enrollment_submit_to_peer(node, &owner_node, request_id, path).await,
ControlRequest::NodeEnrollJoin {
peer_card_path,
admin_key_path,
node_name,
capabilities,
reason,
out,
} => {
node_enrollment_join_owner(
node,
peer_card_path,
admin_key_path,
node_name,
capabilities,
reason,
out,
)
.await
}
ControlRequest::NodeEnrollSync { owner_node } => {
node_enrollment_sync_from_owner(node, &owner_node).await
}
@ -266,6 +285,9 @@ pub(crate) fn control_request_trace_fields(request: &ControlRequest) -> ControlT
trace.peer_node = node.clone();
trace.stream = Some("all".to_owned());
}
ControlRequest::NodeEnrollJoin { .. } => {
trace.stream = Some("node-enrollment".to_owned());
}
ControlRequest::NodeGrant {
node,
resource,
@ -450,6 +472,7 @@ fn control_request_command(request: &ControlRequest) -> &'static str {
ControlRequest::ResourceCreate { .. } => "resource.create",
ControlRequest::KeychainSync { .. } => "keychain.sync",
ControlRequest::AuthSync { .. } => "auth.sync",
ControlRequest::NodeEnrollJoin { .. } => "node.enroll.join",
ControlRequest::SyncStatus => "sync.status",
ControlRequest::SyncNow { .. } => "sync.now",
ControlRequest::AuthExplain { .. } => "auth.explain",

View file

@ -473,7 +473,11 @@ sigchain design. This is currently a pull-based signed operation log, 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
state. `geth node enroll join` explicitly imports an owner admin public key as
the new node's trust anchor, imports the signed peer card only as untrusted
candidate routing metadata, creates the request, and submits it over Iroh. It
does not grant trust to the peer card or approve the requesting node. The lower
level `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.

View file

@ -597,6 +597,9 @@ resource-scoped capability decisions.
cursors instead of blindly re-requesting the full log on every tick.
- `[x]` `geth node enroll request` creates an agent-key-signed enrollment
request with requested node name and capabilities.
- `[x]` `geth node enroll join` combines explicit admin-key trust bootstrap,
candidate-only peer-card import, request creation, and Iroh submission while
leaving owner approval as a separate admin-signed step.
- `[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

View file

@ -129,14 +129,15 @@ discovery or a peer card as proof of trust.
geth peer export --out owner.peer.json
```
2. On the new node, initialize, import the card, and submit a request:
2. Transfer the owner's OpenSSH admin public key over a trusted channel. On the
new node, initialize and run the guided join:
```sh
geth init
geth daemon install
geth peer import owner.peer.json
geth node enroll request --node-name workstation --out workstation.enroll.json
geth node enroll submit owner-laptop --path workstation.enroll.json
geth node enroll join owner.peer.json \
--admin-key owner-admin.pub \
--node-name workstation
```
3. On the owner node, review and approve with the admin key:
@ -149,13 +150,19 @@ discovery or a peer card as proof of trust.
4. On the new node, pull and inspect the approved state:
```sh
geth sync now owner-laptop
geth wait sync owner-laptop
geth node enroll sync <owner-node-id>
geth wait sync <owner-node-id>
geth node list
```
Peer-card import only supplies signed endpoint metadata. The owner-signed
keychain and authorization operations are what create trust and capabilities.
The explicit admin public key bootstraps the trust anchor needed to verify the
later approval sync; verify that key through a separate trusted channel. The
peer card only supplies signed candidate endpoint metadata. `join` combines
peer import, request creation, and submission, but cannot approve itself. The
owner-signed keychain and authorization operations are what create the device
binding and capabilities. The lower-level `peer import` and `node enroll
request|submit|import` commands remain available for offline handoff and
recovery.
## Move A Blob Between Nodes