Test owner approved enrollment sync flow

This commit is contained in:
Eric Wendland 2026-05-22 01:28:45 +02:00
commit 94ad64d0db
3 changed files with 288 additions and 6 deletions

View file

@ -385,7 +385,12 @@ geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
The enrollment flow for a new node is:
```sh
# On the new node:
# On the new node, initialize local state and trust the owner's admin public key:
geth init
geth daemon run
geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub
# Then create a signed enrollment request:
geth node enroll request \
--node-name workstation \
--capability resource:ssh-proxy:local=ssh_proxy.connect \
@ -400,13 +405,16 @@ geth node enroll list --status pending
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
# Back on the new node, pull signed identity and authorization state:
geth node enroll sync owner-laptop
geth sync now owner-laptop
geth sync status
```
Enrollment requests are signed by the requesting agent key. Approval records
signed keychain operations for the new device/node/agent binding and signed auth
operations for requested resource capabilities. `geth node enroll sync` pulls
both signed logs from the owner node.
operations for requested resource capabilities. The requesting node must already
know the owner's admin public key so it can verify the signed operation logs
before importing them. `geth sync now` pulls both signed logs from the owner
node through the same path used by background live sync.
`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

View file

@ -36,6 +36,24 @@ fn spawn_daemon(home: &std::path::Path) -> Child {
.expect("spawn daemon")
}
fn ssh_keygen_available() -> bool {
Command::new("ssh-keygen").arg("-?").output().is_ok()
}
fn generate_ssh_key(path: &std::path::Path) {
let status = Command::new("ssh-keygen")
.arg("-q")
.arg("-t")
.arg("ed25519")
.arg("-N")
.arg("")
.arg("-f")
.arg(path)
.status()
.expect("generate ssh key");
assert!(status.success());
}
fn wait_for_socket(path: &std::path::Path) {
let started = Instant::now();
while started.elapsed() < Duration::from_secs(5) {
@ -197,6 +215,262 @@ fn peer_ping_uses_daemon_owned_iroh_endpoint() {
assert!(stdout.contains("candidate status does not grant resource capabilities"));
}
#[test]
fn sync_now_completes_owner_approved_node_enrollment_flow() {
if !ssh_keygen_available() {
return;
}
let owner_home = tempfile::tempdir().expect("owner tempdir");
let requester_home = tempfile::tempdir().expect("requester tempdir");
if !unix_sockets_available(owner_home.path()) || !unix_sockets_available(requester_home.path())
{
return;
}
let owner_key = owner_home.path().join("owner_ed25519");
generate_ssh_key(&owner_key);
let owner_pub = owner_key.with_extension("pub");
let owner_pub_arg = owner_pub.to_str().expect("owner pub path");
let owner_key_arg = owner_key.to_str().expect("owner key path");
let owner_init = run_geth(
owner_home.path(),
&[
"init",
"--admin-key",
owner_pub_arg,
"--signing-key",
owner_key_arg,
"--owner",
"Eric",
"--node-name",
"owner-laptop",
],
);
assert!(
owner_init.status.success(),
"owner init stderr: {}",
String::from_utf8_lossy(&owner_init.stderr)
);
let requester_init = run_geth(requester_home.path(), &["init"]);
assert!(
requester_init.status.success(),
"requester init stderr: {}",
String::from_utf8_lossy(&requester_init.stderr)
);
for home in [owner_home.path(), requester_home.path()] {
std::fs::write(
home.join("config.toml"),
"[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n",
)
.expect("write config");
}
let mut owner_daemon = spawn_daemon(owner_home.path());
let mut requester_daemon = spawn_daemon(requester_home.path());
wait_for_socket(&owner_home.path().join("run/geth.sock"));
wait_for_socket(&requester_home.path().join("run/geth.sock"));
let requester_trust = run_geth(
requester_home.path(),
&["keychain", "init", "--admin-key", owner_pub_arg],
);
assert!(
requester_trust.status.success(),
"requester trust stderr: {}",
String::from_utf8_lossy(&requester_trust.stderr)
);
let owner_card_path = owner_home.path().join("owner-peer-card.json");
let requester_card_path = requester_home.path().join("requester-peer-card.json");
let owner_export = run_geth(
owner_home.path(),
&[
"peer",
"export",
"--out",
owner_card_path.to_str().expect("owner card path"),
],
);
let requester_export = run_geth(
requester_home.path(),
&[
"peer",
"export",
"--out",
requester_card_path.to_str().expect("requester card path"),
],
);
assert!(
owner_export.status.success(),
"owner export stderr: {}",
String::from_utf8_lossy(&owner_export.stderr)
);
assert!(
requester_export.status.success(),
"requester export stderr: {}",
String::from_utf8_lossy(&requester_export.stderr)
);
let owner_card_json = std::fs::read_to_string(&owner_card_path).expect("read owner card");
let owner_card: geth_discovery::PeerCard =
serde_json::from_str(&owner_card_json).expect("decode owner card");
let requester_card_json =
std::fs::read_to_string(&requester_card_path).expect("read requester card");
let requester_card: geth_discovery::PeerCard =
serde_json::from_str(&requester_card_json).expect("decode requester card");
let import_owner = run_geth(
requester_home.path(),
&[
"peer",
"import",
owner_card_path.to_str().expect("owner card path"),
],
);
let import_requester = run_geth(
owner_home.path(),
&[
"peer",
"import",
requester_card_path.to_str().expect("requester card path"),
],
);
assert!(
import_owner.status.success(),
"requester import owner stderr: {}",
String::from_utf8_lossy(&import_owner.stderr)
);
assert!(
import_requester.status.success(),
"owner import requester stderr: {}",
String::from_utf8_lossy(&import_requester.stderr)
);
let enroll_request = run_geth(
requester_home.path(),
&[
"node",
"enroll",
"request",
"--node-name",
"workstation",
"--capability",
"resource:ssh-proxy:local=ssh_proxy.connect",
],
);
assert!(
enroll_request.status.success(),
"enroll request stderr: {}",
String::from_utf8_lossy(&enroll_request.stderr)
);
let enroll_submit = run_geth(
requester_home.path(),
&["node", "enroll", "submit", owner_card.node_id.as_str()],
);
assert!(
enroll_submit.status.success(),
"enroll submit stderr: {}",
String::from_utf8_lossy(&enroll_submit.stderr)
);
assert!(String::from_utf8_lossy(&enroll_submit.stdout).contains("accepted: true"));
let enroll_list = run_geth(owner_home.path(), &["node", "enroll", "list"]);
assert!(
enroll_list.status.success(),
"enroll list stderr: {}",
String::from_utf8_lossy(&enroll_list.stderr)
);
let enroll_list_stdout = String::from_utf8_lossy(&enroll_list.stdout);
let request_id = enroll_list_stdout
.lines()
.find_map(|line| {
let mut fields = line.split('\t');
let id = fields.next()?;
if id.starts_with("node-enrollment:") {
Some(id.to_owned())
} else {
None
}
})
.expect("request id in enrollment list");
let approve = run_geth(
owner_home.path(),
&[
"node",
"enroll",
"approve",
&request_id,
"--signing-key",
owner_key_arg,
"--capability",
"resource:cas:local=cas.fetch",
],
);
assert!(
approve.status.success(),
"approve stderr: {}",
String::from_utf8_lossy(&approve.stderr)
);
let sync = run_geth(
requester_home.path(),
&["sync", "now", owner_card.node_id.as_str()],
);
assert!(
sync.status.success(),
"sync now stderr: {}",
String::from_utf8_lossy(&sync.stderr)
);
let sync_stdout = String::from_utf8_lossy(&sync.stdout);
assert!(sync_stdout.contains("keychain\tok"));
assert!(sync_stdout.contains("auth\tok"));
let requester_nodes = run_geth(requester_home.path(), &["node", "list"]);
assert!(
requester_nodes.status.success(),
"requester node list stderr: {}",
String::from_utf8_lossy(&requester_nodes.stderr)
);
let requester_nodes_stdout = String::from_utf8_lossy(&requester_nodes.stdout);
assert!(requester_nodes_stdout.contains("owner-laptop"));
assert!(requester_nodes_stdout.contains("workstation"));
let explain = run_geth(
requester_home.path(),
&[
"auth",
"explain",
requester_card.node_id.as_str(),
"resource:ssh-proxy:local",
"ssh_proxy.connect",
],
);
assert!(
explain.status.success(),
"auth explain stderr: {}",
String::from_utf8_lossy(&explain.stderr)
);
assert!(String::from_utf8_lossy(&explain.stdout).contains("allowed: true"));
let sync_status = run_geth(requester_home.path(), &["sync", "status"]);
let _ = owner_daemon.kill();
let _ = requester_daemon.kill();
let _ = owner_daemon.wait();
let _ = requester_daemon.wait();
assert!(
sync_status.status.success(),
"sync status stderr: {}",
String::from_utf8_lossy(&sync_status.stderr)
);
let sync_status_stdout = String::from_utf8_lossy(&sync_status.stdout);
assert!(sync_status_stdout.contains("peer:"));
assert!(sync_status_stdout.contains("keychain"));
assert!(sync_status_stdout.contains("auth"));
}
#[test]
fn peer_card_export_import_and_list_are_candidate_only() {
let source_home = tempfile::tempdir().expect("source tempdir");

View file

@ -16,10 +16,10 @@ end-to-end test target for the intended personal mesh use cases.
- `[~]` Two-node operator-flow test coverage.
Acceptance criteria:
- `[ ]` A two-daemon test covers owner-rooted init, peer-card exchange,
- `[x]` A two-daemon test covers owner-rooted init, peer-card exchange,
enrollment request submission, owner approval, `geth sync now`, and
`geth sync status`.
- `[ ]` A two-daemon test proves approved keychain/auth state reaches the
- `[x]` A two-daemon test proves approved keychain/auth state reaches the
enrolled node without using the old one-off `node enroll sync` shortcut.
- `[ ]` Tests assert rejected/unsigned/conflicting replicated records do not
mutate local trust or resource state.