Test owner approved enrollment sync flow
This commit is contained in:
parent
74cee49a1a
commit
94ad64d0db
3 changed files with 288 additions and 6 deletions
16
README.md
16
README.md
|
|
@ -385,7 +385,12 @@ geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk
|
||||||
The enrollment flow for a new node is:
|
The enrollment flow for a new node is:
|
||||||
|
|
||||||
```sh
|
```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 \
|
geth node enroll request \
|
||||||
--node-name workstation \
|
--node-name workstation \
|
||||||
--capability resource:ssh-proxy:local=ssh_proxy.connect \
|
--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
|
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
|
||||||
|
|
||||||
# Back on the new node, pull signed identity and authorization state:
|
# 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
|
Enrollment requests are signed by the requesting agent key. Approval records
|
||||||
signed keychain operations for the new device/node/agent binding and signed auth
|
signed keychain operations for the new device/node/agent binding and signed auth
|
||||||
operations for requested resource capabilities. `geth node enroll sync` pulls
|
operations for requested resource capabilities. The requesting node must already
|
||||||
both signed logs from the owner node.
|
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
|
`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
|
peer over Iroh and rejects operations that do not have a valid OpenSSH signature
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,24 @@ fn spawn_daemon(home: &std::path::Path) -> Child {
|
||||||
.expect("spawn daemon")
|
.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) {
|
fn wait_for_socket(path: &std::path::Path) {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
while started.elapsed() < Duration::from_secs(5) {
|
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"));
|
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]
|
#[test]
|
||||||
fn peer_card_export_import_and_list_are_candidate_only() {
|
fn peer_card_export_import_and_list_are_candidate_only() {
|
||||||
let source_home = tempfile::tempdir().expect("source tempdir");
|
let source_home = tempfile::tempdir().expect("source tempdir");
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ end-to-end test target for the intended personal mesh use cases.
|
||||||
|
|
||||||
- `[~]` Two-node operator-flow test coverage.
|
- `[~]` Two-node operator-flow test coverage.
|
||||||
Acceptance criteria:
|
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
|
enrollment request submission, owner approval, `geth sync now`, and
|
||||||
`geth sync status`.
|
`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.
|
enrolled node without using the old one-off `node enroll sync` shortcut.
|
||||||
- `[ ]` Tests assert rejected/unsigned/conflicting replicated records do not
|
- `[ ]` Tests assert rejected/unsigned/conflicting replicated records do not
|
||||||
mutate local trust or resource state.
|
mutate local trust or resource state.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue