Add guided node enrollment
This commit is contained in:
parent
678865bfdc
commit
ce3e029b3d
8 changed files with 490 additions and 18 deletions
|
|
@ -161,6 +161,7 @@ The bootstrap implementation provides:
|
||||||
- `geth backup restore <backup-dir> --target-home <dir>`
|
- `geth backup restore <backup-dir> --target-home <dir>`
|
||||||
- `geth node id`
|
- `geth node id`
|
||||||
- `geth node list`
|
- `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 request --node-name <name> --capability <resource=capability> [--out <path>]`
|
||||||
- `geth node enroll submit <owner-node> [--request-id <id>|--path <path>]`
|
- `geth node enroll submit <owner-node> [--request-id <id>|--path <path>]`
|
||||||
- `geth node enroll import <path>`
|
- `geth node enroll import <path>`
|
||||||
|
|
|
||||||
|
|
@ -174,21 +174,26 @@ const GUIDE_ENROLLMENT: &str = r#"Add another node/device:
|
||||||
|
|
||||||
On the new node:
|
On the new node:
|
||||||
geth init
|
geth init
|
||||||
geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub
|
geth daemon install
|
||||||
geth peer import /tmp/owner.peer.json
|
geth node enroll join /tmp/owner.peer.json \
|
||||||
geth node enroll request --node-name workstation --out /tmp/workstation-enrollment.json
|
--admin-key /tmp/owner-admin.pub \
|
||||||
geth node enroll submit owner --path /tmp/workstation-enrollment.json
|
--node-name workstation
|
||||||
|
|
||||||
On the owner/YubiKey machine:
|
On the owner/YubiKey machine:
|
||||||
geth node enroll list
|
geth node enroll list
|
||||||
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
|
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
|
||||||
|
|
||||||
Back on the new node:
|
Back on the new node:
|
||||||
geth sync now owner
|
geth node enroll sync <owner-node-id>
|
||||||
geth node list
|
geth keychain status
|
||||||
|
|
||||||
Enrollment approval records signed keychain/auth operations. Discovery and peer
|
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:
|
const GUIDE_KEYS: &str = r#"Key terminology:
|
||||||
|
|
@ -819,6 +824,23 @@ pub enum NodeCommand {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum NodeEnrollCommand {
|
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
|
/// Create a signed enrollment request on the new node
|
||||||
#[command(
|
#[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"
|
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") => {
|
("geth node enroll request", "capabilities") => {
|
||||||
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
|
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") => {
|
("geth kv create" | "geth kv set" | "geth kv get" | "geth kv sync", "name") => {
|
||||||
Some("Name of the KV store")
|
Some("Name of the KV store")
|
||||||
}
|
}
|
||||||
|
|
@ -2244,6 +2278,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
Command::Node {
|
Command::Node {
|
||||||
command: NodeCommand::Enroll { command },
|
command: NodeCommand::Enroll { command },
|
||||||
} => match 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 {
|
NodeEnrollCommand::Request {
|
||||||
node_name,
|
node_name,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
|
@ -4397,6 +4446,28 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
}
|
}
|
||||||
println!("note: {note}");
|
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 {
|
ControlResponse::NodeEnrollmentSubmitted {
|
||||||
request_id,
|
request_id,
|
||||||
owner_node_id,
|
owner_node_id,
|
||||||
|
|
@ -5349,6 +5420,32 @@ mod tests {
|
||||||
assert_eq!(config.sync.live_sync_interval_ms, 500);
|
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]
|
#[tokio::test]
|
||||||
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
|
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
|
||||||
let parsed = Cli::try_parse_from([
|
let parsed = Cli::try_parse_from([
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,14 @@ pub enum ControlRequest {
|
||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
out: Option<PathBuf>,
|
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 {
|
NodeEnrollSubmit {
|
||||||
owner_node: String,
|
owner_node: String,
|
||||||
request_id: Option<String>,
|
request_id: Option<String>,
|
||||||
|
|
@ -886,6 +894,15 @@ pub enum ControlResponse {
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
note: String,
|
note: String,
|
||||||
},
|
},
|
||||||
|
NodeEnrollmentJoined {
|
||||||
|
peer: DiscoveredPeer,
|
||||||
|
request: NodeEnrollmentRequest,
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
owner_node_id: String,
|
||||||
|
admin_key_id: String,
|
||||||
|
accepted: bool,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
NodeEnrollmentSubmitted {
|
NodeEnrollmentSubmitted {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
owner_node_id: String,
|
owner_node_id: String,
|
||||||
|
|
@ -1817,8 +1834,8 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
const CONTROL_REQUEST_VARIANTS: usize = 122;
|
const CONTROL_REQUEST_VARIANTS: usize = 123;
|
||||||
const CONTROL_RESPONSE_VARIANTS: usize = 114;
|
const CONTROL_RESPONSE_VARIANTS: usize = 115;
|
||||||
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
|
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
|
||||||
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
|
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
|
||||||
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
|
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
|
||||||
|
|
@ -2518,6 +2535,19 @@ mod tests {
|
||||||
request
|
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 {
|
let request = ControlRequest::NodeEnrollSync {
|
||||||
owner_node: "owner-laptop".to_owned(),
|
owner_node: "owner-laptop".to_owned(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
async fn node_enrollment_sync_from_owner(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
owner_node: &str,
|
owner_node: &str,
|
||||||
|
|
@ -7571,6 +7707,7 @@ pub fn handle_request(
|
||||||
| ControlRequest::SshAdminShell { .. }
|
| ControlRequest::SshAdminShell { .. }
|
||||||
| ControlRequest::KeychainSync { .. }
|
| ControlRequest::KeychainSync { .. }
|
||||||
| ControlRequest::AuthSync { .. }
|
| ControlRequest::AuthSync { .. }
|
||||||
|
| ControlRequest::NodeEnrollJoin { .. }
|
||||||
| ControlRequest::NodeEnrollSubmit { .. }
|
| ControlRequest::NodeEnrollSubmit { .. }
|
||||||
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::ModuleStub { module, command } => {
|
ControlRequest::ModuleStub { module, command } => {
|
||||||
|
|
@ -13823,6 +13960,176 @@ mod tests {
|
||||||
right_endpoint.shutdown().await;
|
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 {
|
fn test_tree(entries: Vec<geth_cas::CasTreeEntry>) -> CasTreeObject {
|
||||||
CasTreeObject {
|
CasTreeObject {
|
||||||
version: geth_cas::CAS_TREE_OBJECT_VERSION,
|
version: geth_cas::CAS_TREE_OBJECT_VERSION,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,25 @@ async fn route_peer_family(
|
||||||
request_id,
|
request_id,
|
||||||
path,
|
path,
|
||||||
} => node_enrollment_submit_to_peer(node, &owner_node, request_id, path).await,
|
} => 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 } => {
|
ControlRequest::NodeEnrollSync { owner_node } => {
|
||||||
node_enrollment_sync_from_owner(node, &owner_node).await
|
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.peer_node = node.clone();
|
||||||
trace.stream = Some("all".to_owned());
|
trace.stream = Some("all".to_owned());
|
||||||
}
|
}
|
||||||
|
ControlRequest::NodeEnrollJoin { .. } => {
|
||||||
|
trace.stream = Some("node-enrollment".to_owned());
|
||||||
|
}
|
||||||
ControlRequest::NodeGrant {
|
ControlRequest::NodeGrant {
|
||||||
node,
|
node,
|
||||||
resource,
|
resource,
|
||||||
|
|
@ -450,6 +472,7 @@ fn control_request_command(request: &ControlRequest) -> &'static str {
|
||||||
ControlRequest::ResourceCreate { .. } => "resource.create",
|
ControlRequest::ResourceCreate { .. } => "resource.create",
|
||||||
ControlRequest::KeychainSync { .. } => "keychain.sync",
|
ControlRequest::KeychainSync { .. } => "keychain.sync",
|
||||||
ControlRequest::AuthSync { .. } => "auth.sync",
|
ControlRequest::AuthSync { .. } => "auth.sync",
|
||||||
|
ControlRequest::NodeEnrollJoin { .. } => "node.enroll.join",
|
||||||
ControlRequest::SyncStatus => "sync.status",
|
ControlRequest::SyncStatus => "sync.status",
|
||||||
ControlRequest::SyncNow { .. } => "sync.now",
|
ControlRequest::SyncNow { .. } => "sync.now",
|
||||||
ControlRequest::AuthExplain { .. } => "auth.explain",
|
ControlRequest::AuthExplain { .. } => "auth.explain",
|
||||||
|
|
|
||||||
|
|
@ -473,7 +473,11 @@ sigchain design. This is currently a pull-based signed operation log, not a
|
||||||
CRDT or Keyhive-style convergent authority.
|
CRDT or Keyhive-style convergent authority.
|
||||||
|
|
||||||
New devices can use the node enrollment flow instead of hand-editing keychain
|
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
|
containing the requesting node ID, agent ID, requested node name, optional Iroh
|
||||||
endpoint, and requested resource capabilities. The request can be submitted over
|
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.
|
Iroh to an imported owner peer or moved as a JSON file to the owner machine.
|
||||||
|
|
|
||||||
|
|
@ -597,6 +597,9 @@ resource-scoped capability decisions.
|
||||||
cursors instead of blindly re-requesting the full log on every tick.
|
cursors instead of blindly re-requesting the full log on every tick.
|
||||||
- `[x]` `geth node enroll request` creates an agent-key-signed enrollment
|
- `[x]` `geth node enroll request` creates an agent-key-signed enrollment
|
||||||
request with requested node name and capabilities.
|
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
|
- `[x]` `geth node enroll submit/import/list` moves pending enrollment
|
||||||
requests over Iroh or JSON file for owner review.
|
requests over Iroh or JSON file for owner review.
|
||||||
- `[x]` `geth node enroll approve --signing-key` records signed keychain ops
|
- `[x]` `geth node enroll approve --signing-key` records signed keychain ops
|
||||||
|
|
|
||||||
|
|
@ -129,14 +129,15 @@ discovery or a peer card as proof of trust.
|
||||||
geth peer export --out owner.peer.json
|
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
|
```sh
|
||||||
geth init
|
geth init
|
||||||
geth daemon install
|
geth daemon install
|
||||||
geth peer import owner.peer.json
|
geth node enroll join owner.peer.json \
|
||||||
geth node enroll request --node-name workstation --out workstation.enroll.json
|
--admin-key owner-admin.pub \
|
||||||
geth node enroll submit owner-laptop --path workstation.enroll.json
|
--node-name workstation
|
||||||
```
|
```
|
||||||
|
|
||||||
3. On the owner node, review and approve with the admin key:
|
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:
|
4. On the new node, pull and inspect the approved state:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
geth sync now owner-laptop
|
geth node enroll sync <owner-node-id>
|
||||||
geth wait sync owner-laptop
|
geth wait sync <owner-node-id>
|
||||||
geth node list
|
geth node list
|
||||||
```
|
```
|
||||||
|
|
||||||
Peer-card import only supplies signed endpoint metadata. The owner-signed
|
The explicit admin public key bootstraps the trust anchor needed to verify the
|
||||||
keychain and authorization operations are what create trust and capabilities.
|
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
|
## Move A Blob Between Nodes
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue