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 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>`
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue