Sync SSH metadata over Iroh
This commit is contained in:
parent
e4b788fec2
commit
9887b47a40
7 changed files with 686 additions and 20 deletions
|
|
@ -107,7 +107,8 @@ Roadmap items should be actionable and checkable:
|
||||||
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
|
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
|
||||||
types, manual signed peer-card export/import/list commands, `geth peer ping`
|
types, manual signed peer-card export/import/list commands, `geth peer ping`
|
||||||
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
|
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
|
||||||
authorized `geth cas fetch` over the Iroh control ALPN, untrusted
|
authorized `geth cas fetch`, `geth ssh cert sync`, and
|
||||||
|
`geth ssh revocation sync` over the Iroh control ALPN, untrusted
|
||||||
discovery-backend trait, custom relay-map config, and Iroh local-network
|
discovery-backend trait, custom relay-map config, and Iroh local-network
|
||||||
discovery toggle exist.
|
discovery toggle exist.
|
||||||
- Canonical signed-operation envelopes exist for keychain/auth signature
|
- Canonical signed-operation envelopes exist for keychain/auth signature
|
||||||
|
|
@ -157,7 +158,10 @@ Roadmap items should be actionable and checkable:
|
||||||
specification imports are supported; binary KRL import is unsupported because
|
specification imports are supported; binary KRL import is unsupported because
|
||||||
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
|
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
|
||||||
public-key and certificate binary KRL revocations when `ssh-keygen` is
|
public-key and certificate binary KRL revocations when `ssh-keygen` is
|
||||||
available.
|
available. Authorized peers can pull SSH certificate-flow metadata with
|
||||||
|
`ssh_cert.sync` on `resource:ssh:certs` and revocation metadata with
|
||||||
|
`ssh_revocation.sync` on `resource:ssh:revocations`; this is pull-only
|
||||||
|
metadata sync, not yet a CRDT/resource-log replication model.
|
||||||
- cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth
|
- cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth
|
||||||
enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items
|
enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items
|
||||||
unless implemented later.
|
unless implemented later.
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -61,8 +61,9 @@ explicit `ssh-keygen -s ...` command suitable for a CA key or YubiKey-backed CA,
|
||||||
and the resulting `-cert.pub` can be imported for distribution. Certificate and
|
and the resulting `-cert.pub` can be imported for distribution. Certificate and
|
||||||
key revocation entries are tracked locally and can be exported as JSONL or as an
|
key revocation entries are tracked locally and can be exported as JSONL or as an
|
||||||
OpenSSH KRL specification file or a binary OpenSSH KRL generated through
|
OpenSSH KRL specification file or a binary OpenSSH KRL generated through
|
||||||
`ssh-keygen -k`. Future Iroh replication will distribute these records between
|
`ssh-keygen -k`. `geth ssh cert sync <node-id>` and
|
||||||
authorized nodes.
|
`geth ssh revocation sync <node-id>` pull certificate-flow and revocation
|
||||||
|
metadata from an authorized peer over Iroh.
|
||||||
|
|
||||||
## MVP Features
|
## MVP Features
|
||||||
|
|
||||||
|
|
@ -112,10 +113,12 @@ The bootstrap implementation provides:
|
||||||
- `geth ssh cert approve <request-id> --ca-key <path>`
|
- `geth ssh cert approve <request-id> --ca-key <path>`
|
||||||
- `geth ssh cert import <request-id> --cert <path>`
|
- `geth ssh cert import <request-id> --cert <path>`
|
||||||
- `geth ssh cert list`
|
- `geth ssh cert list`
|
||||||
|
- `geth ssh cert sync <node-id>`
|
||||||
- `geth ssh revocation add <kind> <target>`
|
- `geth ssh revocation add <kind> <target>`
|
||||||
- `geth ssh revocation list`
|
- `geth ssh revocation list`
|
||||||
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]`
|
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl]`
|
||||||
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec]`
|
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec]`
|
||||||
|
- `geth ssh revocation sync <node-id>`
|
||||||
- local pipe registry commands: `geth pipe listen/connect`
|
- local pipe registry commands: `geth pipe listen/connect`
|
||||||
|
|
||||||
`geth peer export/import/list` is for untrusted peer-card exchange. Peer cards
|
`geth peer export/import/list` is for untrusted peer-card exchange. Peer cards
|
||||||
|
|
@ -131,6 +134,11 @@ has `cas.fetch` on `resource:cas:local`, and the caller verifies that the bytes
|
||||||
hash to the requested BLAKE3 CAS hash before storing them locally. This is the
|
hash to the requested BLAKE3 CAS hash before storing them locally. This is the
|
||||||
bootstrap transfer path; future work will move provider/fetch behavior to
|
bootstrap transfer path; future work will move provider/fetch behavior to
|
||||||
`iroh-blobs`.
|
`iroh-blobs`.
|
||||||
|
`geth ssh cert sync <node-id>` requires `ssh_cert.sync` on `resource:ssh:certs`
|
||||||
|
at the peer. `geth ssh revocation sync <node-id>` requires
|
||||||
|
`ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge
|
||||||
|
authorized peer metadata into the local store for offline listing and later
|
||||||
|
approval/signing workflows.
|
||||||
Importing or pinging a peer card never grants capabilities by itself.
|
Importing or pinging a peer card never grants capabilities by itself.
|
||||||
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
|
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
|
||||||
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is
|
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,9 @@ pub enum SshCertCommand {
|
||||||
cert: PathBuf,
|
cert: PathBuf,
|
||||||
},
|
},
|
||||||
List,
|
List,
|
||||||
|
Sync {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|
@ -417,6 +420,9 @@ pub enum SshRevocationCommand {
|
||||||
#[arg(long, default_value = "jsonl")]
|
#[arg(long, default_value = "jsonl")]
|
||||||
format: String,
|
format: String,
|
||||||
},
|
},
|
||||||
|
Sync {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
|
|
@ -681,6 +687,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
cert_path: cert,
|
cert_path: cert,
|
||||||
},
|
},
|
||||||
SshCertCommand::List => ControlRequest::SshCertList,
|
SshCertCommand::List => ControlRequest::SshCertList,
|
||||||
|
SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node },
|
||||||
},
|
},
|
||||||
SshCommand::Revocation { command } => match command {
|
SshCommand::Revocation { command } => match command {
|
||||||
SshRevocationCommand::Add {
|
SshRevocationCommand::Add {
|
||||||
|
|
@ -705,6 +712,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
SshRevocationCommand::Import { path, format } => {
|
SshRevocationCommand::Import { path, format } => {
|
||||||
ControlRequest::SshRevocationImport { path, format }
|
ControlRequest::SshRevocationImport { path, format }
|
||||||
}
|
}
|
||||||
|
SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
|
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
|
||||||
|
|
@ -1140,6 +1148,29 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ControlResponse::SshCertSynced {
|
||||||
|
peer_node_id,
|
||||||
|
peer_agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
requests_imported,
|
||||||
|
certificates_imported,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
} => {
|
||||||
|
if allowed {
|
||||||
|
println!(
|
||||||
|
"synced ssh cert metadata from {peer_node_id}: {requests_imported} requests, {certificates_imported} certificates"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("ssh cert metadata sync denied by {peer_node_id}");
|
||||||
|
}
|
||||||
|
println!("agent: {peer_agent_id}");
|
||||||
|
println!("endpoint: {endpoint_id}");
|
||||||
|
println!("allowed: {allowed}");
|
||||||
|
println!("reason: {reason}");
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
ControlResponse::SshRevocationAdded { revocation } => {
|
ControlResponse::SshRevocationAdded { revocation } => {
|
||||||
println!("added ssh revocation: {}", revocation.id);
|
println!("added ssh revocation: {}", revocation.id);
|
||||||
println!("kind: {}", revocation.kind);
|
println!("kind: {}", revocation.kind);
|
||||||
|
|
@ -1189,6 +1220,26 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
}
|
}
|
||||||
println!("note: {note}");
|
println!("note: {note}");
|
||||||
}
|
}
|
||||||
|
ControlResponse::SshRevocationSynced {
|
||||||
|
peer_node_id,
|
||||||
|
peer_agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
revocations_imported,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
} => {
|
||||||
|
if allowed {
|
||||||
|
println!("synced {revocations_imported} ssh revocations from {peer_node_id}");
|
||||||
|
} else {
|
||||||
|
println!("ssh revocation sync denied by {peer_node_id}");
|
||||||
|
}
|
||||||
|
println!("agent: {peer_agent_id}");
|
||||||
|
println!("endpoint: {endpoint_id}");
|
||||||
|
println!("allowed: {allowed}");
|
||||||
|
println!("reason: {reason}");
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
ControlResponse::DbAdded { db } => {
|
ControlResponse::DbAdded { db } => {
|
||||||
println!("registered db: {}", db.name);
|
println!("registered db: {}", db.name);
|
||||||
println!("id: {}", db.id);
|
println!("id: {}", db.id);
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,9 @@ pub enum ControlRequest {
|
||||||
cert_path: PathBuf,
|
cert_path: PathBuf,
|
||||||
},
|
},
|
||||||
SshCertList,
|
SshCertList,
|
||||||
|
SshCertSync {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
SshRevocationAdd {
|
SshRevocationAdd {
|
||||||
kind: String,
|
kind: String,
|
||||||
target: String,
|
target: String,
|
||||||
|
|
@ -165,6 +168,9 @@ pub enum ControlRequest {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
format: String,
|
format: String,
|
||||||
},
|
},
|
||||||
|
SshRevocationSync {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
DbAdd {
|
DbAdd {
|
||||||
name: String,
|
name: String,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
@ -360,6 +366,16 @@ pub enum ControlResponse {
|
||||||
requests: Vec<SshCertRequest>,
|
requests: Vec<SshCertRequest>,
|
||||||
certificates: Vec<SshCertificateRecord>,
|
certificates: Vec<SshCertificateRecord>,
|
||||||
},
|
},
|
||||||
|
SshCertSynced {
|
||||||
|
peer_node_id: String,
|
||||||
|
peer_agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
requests_imported: usize,
|
||||||
|
certificates_imported: usize,
|
||||||
|
allowed: bool,
|
||||||
|
reason: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
SshRevocationAdded {
|
SshRevocationAdded {
|
||||||
revocation: SshRevocationEntry,
|
revocation: SshRevocationEntry,
|
||||||
},
|
},
|
||||||
|
|
@ -378,6 +394,15 @@ pub enum ControlResponse {
|
||||||
count: usize,
|
count: usize,
|
||||||
note: String,
|
note: String,
|
||||||
},
|
},
|
||||||
|
SshRevocationSynced {
|
||||||
|
peer_node_id: String,
|
||||||
|
peer_agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
revocations_imported: usize,
|
||||||
|
allowed: bool,
|
||||||
|
reason: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
DbAdded {
|
DbAdded {
|
||||||
db: DbResource,
|
db: DbResource,
|
||||||
},
|
},
|
||||||
|
|
@ -486,6 +511,14 @@ pub enum PeerControlRequest {
|
||||||
hash: BlobHash,
|
hash: BlobHash,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
},
|
},
|
||||||
|
SshCertSync {
|
||||||
|
peer_card: PeerCard,
|
||||||
|
nonce: String,
|
||||||
|
},
|
||||||
|
SshRevocationSync {
|
||||||
|
peer_card: PeerCard,
|
||||||
|
nonce: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -527,6 +560,31 @@ pub enum PeerControlResponse {
|
||||||
nonce: String,
|
nonce: String,
|
||||||
note: String,
|
note: String,
|
||||||
},
|
},
|
||||||
|
SshCertSynced {
|
||||||
|
node_id: String,
|
||||||
|
agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
requests: Vec<SshCertRequest>,
|
||||||
|
certificates: Vec<SshCertificateRecord>,
|
||||||
|
allowed: bool,
|
||||||
|
reason: String,
|
||||||
|
evaluated_ops: usize,
|
||||||
|
nonce: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
SshRevocationSynced {
|
||||||
|
node_id: String,
|
||||||
|
agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
revocations: Vec<SshRevocationEntry>,
|
||||||
|
allowed: bool,
|
||||||
|
reason: String,
|
||||||
|
evaluated_ops: usize,
|
||||||
|
nonce: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
@ -629,6 +687,22 @@ mod tests {
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::SshCertSync {
|
||||||
|
node: "node:ca".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::SshRevocationSync {
|
||||||
|
node: "node:ca".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
let response = ControlResponse::SshRevocationExported {
|
let response = ControlResponse::SshRevocationExported {
|
||||||
out: PathBuf::from("revocations.krl-spec"),
|
out: PathBuf::from("revocations.krl-spec"),
|
||||||
format: "openssh-krl-spec".to_owned(),
|
format: "openssh-krl-spec".to_owned(),
|
||||||
|
|
@ -640,6 +714,35 @@ mod tests {
|
||||||
response
|
response
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let response = ControlResponse::SshCertSynced {
|
||||||
|
peer_node_id: "node:ca".to_owned(),
|
||||||
|
peer_agent_id: "agent:ca".to_owned(),
|
||||||
|
endpoint_id: "endpoint:ca".to_owned(),
|
||||||
|
requests_imported: 1,
|
||||||
|
certificates_imported: 0,
|
||||||
|
allowed: true,
|
||||||
|
reason: "direct grant".to_owned(),
|
||||||
|
note: "cert sync".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = ControlResponse::SshRevocationSynced {
|
||||||
|
peer_node_id: "node:ca".to_owned(),
|
||||||
|
peer_agent_id: "agent:ca".to_owned(),
|
||||||
|
endpoint_id: "endpoint:ca".to_owned(),
|
||||||
|
revocations_imported: 2,
|
||||||
|
allowed: true,
|
||||||
|
reason: "direct grant".to_owned(),
|
||||||
|
note: "revocation sync".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
let request = ControlRequest::DocumentSet {
|
let request = ControlRequest::DocumentSet {
|
||||||
name: "notes".to_owned(),
|
name: "notes".to_owned(),
|
||||||
state_json: r#"{"title":"notes"}"#.to_owned(),
|
state_json: r#"{"title":"notes"}"#.to_owned(),
|
||||||
|
|
@ -762,5 +865,42 @@ mod tests {
|
||||||
.expect("decode"),
|
.expect("decode"),
|
||||||
response
|
response
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let response = PeerControlResponse::SshCertSynced {
|
||||||
|
node_id: "node:peer".to_owned(),
|
||||||
|
agent_id: "agent:peer".to_owned(),
|
||||||
|
endpoint_id: "endpoint:peer".to_owned(),
|
||||||
|
remote_endpoint_id: "endpoint:caller".to_owned(),
|
||||||
|
requests: Vec::new(),
|
||||||
|
certificates: Vec::new(),
|
||||||
|
allowed: false,
|
||||||
|
reason: "no grant".to_owned(),
|
||||||
|
evaluated_ops: 0,
|
||||||
|
nonce: "nonce".to_owned(),
|
||||||
|
note: "cert sync".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_peer_response(&encode_peer_response(&response).expect("encode"))
|
||||||
|
.expect("decode"),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = PeerControlResponse::SshRevocationSynced {
|
||||||
|
node_id: "node:peer".to_owned(),
|
||||||
|
agent_id: "agent:peer".to_owned(),
|
||||||
|
endpoint_id: "endpoint:peer".to_owned(),
|
||||||
|
remote_endpoint_id: "endpoint:caller".to_owned(),
|
||||||
|
revocations: Vec::new(),
|
||||||
|
allowed: false,
|
||||||
|
reason: "no grant".to_owned(),
|
||||||
|
evaluated_ops: 0,
|
||||||
|
nonce: "nonce".to_owned(),
|
||||||
|
note: "revocation sync".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_peer_response(&encode_peer_response(&response).expect("encode"))
|
||||||
|
.expect("decode"),
|
||||||
|
response
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,12 @@ pub async fn handle_request_async(
|
||||||
node: peer_node,
|
node: peer_node,
|
||||||
hash,
|
hash,
|
||||||
} => cas_fetch_from_peer(node, &peer_node, hash).await,
|
} => cas_fetch_from_peer(node, &peer_node, hash).await,
|
||||||
|
ControlRequest::SshCertSync { node: peer_node } => {
|
||||||
|
ssh_cert_sync_from_peer(node, &peer_node).await
|
||||||
|
}
|
||||||
|
ControlRequest::SshRevocationSync { node: peer_node } => {
|
||||||
|
ssh_revocation_sync_from_peer(node, &peer_node).await
|
||||||
|
}
|
||||||
other => handle_request(node, other),
|
other => handle_request(node, other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -517,8 +523,10 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
|
||||||
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
|
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned auth-check response to ping request".to_owned(),
|
"peer returned auth-check response to ping request".to_owned(),
|
||||||
)),
|
)),
|
||||||
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
|
PeerControlResponse::CasFetched { .. }
|
||||||
"peer returned CAS fetch response to ping request".to_owned(),
|
| PeerControlResponse::SshCertSynced { .. }
|
||||||
|
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned wrong response type to ping request".to_owned(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -622,8 +630,10 @@ async fn peer_auth_check(
|
||||||
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
|
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned pong to auth-check request".to_owned(),
|
"peer returned pong to auth-check request".to_owned(),
|
||||||
)),
|
)),
|
||||||
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
|
PeerControlResponse::CasFetched { .. }
|
||||||
"peer returned CAS fetch response to auth-check request".to_owned(),
|
| PeerControlResponse::SshCertSynced { .. }
|
||||||
|
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned wrong response type to auth-check request".to_owned(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -755,9 +765,199 @@ async fn cas_fetch_from_peer(
|
||||||
"peer CAS fetch response did not match request".to_owned(),
|
"peer CAS fetch response did not match request".to_owned(),
|
||||||
)),
|
)),
|
||||||
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||||
PeerControlResponse::Pong { .. } | PeerControlResponse::AuthChecked { .. } => Err(
|
PeerControlResponse::Pong { .. }
|
||||||
NodeError::IrohPeer("peer returned wrong response type to CAS fetch".to_owned()),
|
| PeerControlResponse::AuthChecked { .. }
|
||||||
),
|
| PeerControlResponse::SshCertSynced { .. }
|
||||||
|
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned wrong response type to CAS fetch".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ssh_cert_sync_from_peer(
|
||||||
|
node: &LocalNode,
|
||||||
|
peer_node: &str,
|
||||||
|
) -> Result<ControlResponse, NodeError> {
|
||||||
|
let response = request_peer_control(node, peer_node, "ssh-cert-sync", |peer_card, nonce| {
|
||||||
|
PeerControlRequest::SshCertSync { peer_card, nonce }
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
match response {
|
||||||
|
PeerControlResponse::SshCertSynced {
|
||||||
|
node_id,
|
||||||
|
agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
requests,
|
||||||
|
certificates,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !allowed {
|
||||||
|
return Ok(ControlResponse::SshCertSynced {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
requests_imported: 0,
|
||||||
|
certificates_imported: 0,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let requests_imported = requests.len();
|
||||||
|
let certificates_imported = certificates.len();
|
||||||
|
for request in &requests {
|
||||||
|
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(request))?;
|
||||||
|
}
|
||||||
|
for certificate in &certificates {
|
||||||
|
store.insert_ssh_certificate(&stored_from_ssh_certificate(certificate))?;
|
||||||
|
}
|
||||||
|
Ok(ControlResponse::SshCertSynced {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
requests_imported,
|
||||||
|
certificates_imported,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||||
|
_ => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned wrong response type to SSH cert sync".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ssh_revocation_sync_from_peer(
|
||||||
|
node: &LocalNode,
|
||||||
|
peer_node: &str,
|
||||||
|
) -> Result<ControlResponse, NodeError> {
|
||||||
|
let response = request_peer_control(
|
||||||
|
node,
|
||||||
|
peer_node,
|
||||||
|
"ssh-revocation-sync",
|
||||||
|
|peer_card, nonce| PeerControlRequest::SshRevocationSync { peer_card, nonce },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match response {
|
||||||
|
PeerControlResponse::SshRevocationSynced {
|
||||||
|
node_id,
|
||||||
|
agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
revocations,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !allowed {
|
||||||
|
return Ok(ControlResponse::SshRevocationSynced {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
revocations_imported: 0,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let revocations_imported = revocations.len();
|
||||||
|
for revocation in &revocations {
|
||||||
|
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
|
||||||
|
}
|
||||||
|
Ok(ControlResponse::SshRevocationSynced {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
revocations_imported,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||||
|
_ => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned wrong response type to SSH revocation sync".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_peer_control(
|
||||||
|
node: &LocalNode,
|
||||||
|
peer_node: &str,
|
||||||
|
operation: &str,
|
||||||
|
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
|
||||||
|
) -> Result<PeerControlResponse, NodeError> {
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let stored = store
|
||||||
|
.get_peer_card(peer_node)?
|
||||||
|
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
|
||||||
|
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
||||||
|
peer_card.validate_candidate()?;
|
||||||
|
let candidate = peer_card
|
||||||
|
.endpoints
|
||||||
|
.first()
|
||||||
|
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
|
||||||
|
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
|
||||||
|
let node_addr = iroh_node_addr_from_candidate(candidate)?;
|
||||||
|
let endpoint = node
|
||||||
|
.iroh_endpoint
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
||||||
|
.clone()
|
||||||
|
.ok_or(NodeError::IrohEndpointUnavailable)?;
|
||||||
|
let self_card = local_peer_card(node, DiscoverySource::PeerExchange, true).await?;
|
||||||
|
let nonce = geth_crypto::blake3_hex(
|
||||||
|
format!(
|
||||||
|
"{}\0{}\0{}\0{}",
|
||||||
|
node.node_id,
|
||||||
|
peer_node,
|
||||||
|
operation,
|
||||||
|
geth_store::now_ms()
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
let request = build_request(self_card, nonce.clone());
|
||||||
|
let conn = endpoint
|
||||||
|
.endpoint()
|
||||||
|
.connect(node_addr, geth_iroh::ALPN_CONTROL)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let (mut send, mut recv) = conn
|
||||||
|
.open_bi()
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
send.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
send.finish()
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let bytes = recv
|
||||||
|
.read_to_end(16 * 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let text =
|
||||||
|
std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let response = geth_control::decode_peer_response(text)?;
|
||||||
|
match &response {
|
||||||
|
PeerControlResponse::SshCertSynced {
|
||||||
|
nonce: response_nonce,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| PeerControlResponse::SshRevocationSynced {
|
||||||
|
nonce: response_nonce,
|
||||||
|
..
|
||||||
|
} if response_nonce == &nonce => Ok(response),
|
||||||
|
PeerControlResponse::Error { .. } => Ok(response),
|
||||||
|
_ => Err(NodeError::IrohPeer(format!(
|
||||||
|
"peer {operation} response did not match request"
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -932,6 +1132,102 @@ async fn handle_iroh_control_connection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PeerControlRequest::SshCertSync { peer_card, nonce } => {
|
||||||
|
peer_card.validate_candidate()?;
|
||||||
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
||||||
|
let discovered = DiscoveredPeer::candidate(
|
||||||
|
peer_card.clone(),
|
||||||
|
UnixMillis(geth_store::now_ms()),
|
||||||
|
DiscoverySource::PeerExchange,
|
||||||
|
)?;
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
store.upsert_peer_card(&StoredPeerCard {
|
||||||
|
peer_id: peer_card.node_id.to_string(),
|
||||||
|
card_json: serde_json::to_string(&peer_card)?,
|
||||||
|
updated_at_ms: discovered.discovered_at.0,
|
||||||
|
})?;
|
||||||
|
let resource = "resource:ssh:certs".to_owned();
|
||||||
|
let capability = "ssh_cert.sync".to_owned();
|
||||||
|
let explanation = geth_auth::explain_auth_ops(
|
||||||
|
&load_auth_ops_for_resource(&store, &resource)?,
|
||||||
|
PrincipalId::new(peer_card.node_id.to_string()),
|
||||||
|
ResourceId::new(resource),
|
||||||
|
Capability::new(capability),
|
||||||
|
);
|
||||||
|
let (requests, certificates) = if explanation.allowed {
|
||||||
|
(
|
||||||
|
store
|
||||||
|
.list_ssh_cert_requests()?
|
||||||
|
.into_iter()
|
||||||
|
.map(ssh_cert_request_from_stored)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
store
|
||||||
|
.list_ssh_certificates()?
|
||||||
|
.into_iter()
|
||||||
|
.map(ssh_certificate_from_stored)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
|
};
|
||||||
|
PeerControlResponse::SshCertSynced {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
agent_id: node.agent_id.clone(),
|
||||||
|
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
|
||||||
|
remote_endpoint_id,
|
||||||
|
requests,
|
||||||
|
certificates,
|
||||||
|
allowed: explanation.allowed,
|
||||||
|
reason: explanation.reason,
|
||||||
|
evaluated_ops: explanation.evaluated_ops,
|
||||||
|
nonce,
|
||||||
|
note: "SSH certificate metadata sync authenticated endpoint/card binding and required ssh_cert.sync on resource:ssh:certs".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PeerControlRequest::SshRevocationSync { peer_card, nonce } => {
|
||||||
|
peer_card.validate_candidate()?;
|
||||||
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
||||||
|
let discovered = DiscoveredPeer::candidate(
|
||||||
|
peer_card.clone(),
|
||||||
|
UnixMillis(geth_store::now_ms()),
|
||||||
|
DiscoverySource::PeerExchange,
|
||||||
|
)?;
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
store.upsert_peer_card(&StoredPeerCard {
|
||||||
|
peer_id: peer_card.node_id.to_string(),
|
||||||
|
card_json: serde_json::to_string(&peer_card)?,
|
||||||
|
updated_at_ms: discovered.discovered_at.0,
|
||||||
|
})?;
|
||||||
|
let resource = "resource:ssh:revocations".to_owned();
|
||||||
|
let capability = "ssh_revocation.sync".to_owned();
|
||||||
|
let explanation = geth_auth::explain_auth_ops(
|
||||||
|
&load_auth_ops_for_resource(&store, &resource)?,
|
||||||
|
PrincipalId::new(peer_card.node_id.to_string()),
|
||||||
|
ResourceId::new(resource),
|
||||||
|
Capability::new(capability),
|
||||||
|
);
|
||||||
|
let revocations = if explanation.allowed {
|
||||||
|
store
|
||||||
|
.list_ssh_revocations()?
|
||||||
|
.into_iter()
|
||||||
|
.map(ssh_revocation_from_stored)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
PeerControlResponse::SshRevocationSynced {
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
agent_id: node.agent_id.clone(),
|
||||||
|
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
|
||||||
|
remote_endpoint_id,
|
||||||
|
revocations,
|
||||||
|
allowed: explanation.allowed,
|
||||||
|
reason: explanation.reason,
|
||||||
|
evaluated_ops: explanation.evaluated_ops,
|
||||||
|
nonce,
|
||||||
|
note: "SSH revocation sync authenticated endpoint/card binding and required ssh_revocation.sync on resource:ssh:revocations".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
||||||
.await
|
.await
|
||||||
|
|
@ -1068,6 +1364,8 @@ pub fn handle_request(
|
||||||
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
|
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
|
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
|
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
|
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
|
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
|
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
|
||||||
resources: store
|
resources: store
|
||||||
.list_resources()?
|
.list_resources()?
|
||||||
|
|
@ -2546,6 +2844,41 @@ mod tests {
|
||||||
let right_blob = LocalCas::new(right.paths.cas_dir())
|
let right_blob = LocalCas::new(right.paths.cas_dir())
|
||||||
.add_bytes(b"remote cas bytes")
|
.add_bytes(b"remote cas bytes")
|
||||||
.expect("right cas add");
|
.expect("right cas add");
|
||||||
|
let right_pubkey = right_home.path().join("request.pub");
|
||||||
|
std::fs::write(
|
||||||
|
&right_pubkey,
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== node\n",
|
||||||
|
)
|
||||||
|
.expect("write right public key");
|
||||||
|
let requested = handle_request(
|
||||||
|
&right,
|
||||||
|
ControlRequest::SshCertRequest {
|
||||||
|
public_key_path: right_pubkey,
|
||||||
|
cert_kind: "user".to_owned(),
|
||||||
|
principals: vec!["eric".to_owned()],
|
||||||
|
requested_validity: Some("+52w".to_owned()),
|
||||||
|
renewal_of: None,
|
||||||
|
reason: Some("test sync".to_owned()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("right ssh cert request");
|
||||||
|
let right_request_id = match requested {
|
||||||
|
ControlResponse::SshCertRequested { request } => request.id,
|
||||||
|
other => panic!("unexpected SSH cert request response: {other:?}"),
|
||||||
|
};
|
||||||
|
let added_revocation = handle_request(
|
||||||
|
&right,
|
||||||
|
ControlRequest::SshRevocationAdd {
|
||||||
|
kind: "key-id".to_owned(),
|
||||||
|
target: "old-node-key".to_owned(),
|
||||||
|
reason: Some("test sync".to_owned()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("right ssh revocation");
|
||||||
|
let right_revocation_id = match added_revocation {
|
||||||
|
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
|
||||||
|
other => panic!("unexpected SSH revocation response: {other:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
let ping = handle_request_async(
|
let ping = handle_request_async(
|
||||||
&left,
|
&left,
|
||||||
|
|
@ -2692,6 +3025,119 @@ mod tests {
|
||||||
other => panic!("unexpected allowed CAS fetch response: {other:?}"),
|
other => panic!("unexpected allowed CAS fetch response: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let denied_cert_sync = handle_request_async(
|
||||||
|
&left,
|
||||||
|
ControlRequest::SshCertSync {
|
||||||
|
node: right_card.node_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("denied ssh cert sync");
|
||||||
|
match denied_cert_sync {
|
||||||
|
ControlResponse::SshCertSynced {
|
||||||
|
allowed,
|
||||||
|
requests_imported,
|
||||||
|
certificates_imported,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(!allowed);
|
||||||
|
assert_eq!(requests_imported, 0);
|
||||||
|
assert_eq!(certificates_imported, 0);
|
||||||
|
assert!(reason.contains("no active direct or group grant"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected denied SSH cert sync response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_request(
|
||||||
|
&right,
|
||||||
|
ControlRequest::AuthGrant {
|
||||||
|
subject: left.node_id.clone(),
|
||||||
|
resource: "resource:ssh:certs".to_owned(),
|
||||||
|
capability: "ssh_cert.sync".to_owned(),
|
||||||
|
grant_id: Some("grant:left-ssh-cert-sync".to_owned()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("grant left ssh cert sync");
|
||||||
|
handle_request(
|
||||||
|
&right,
|
||||||
|
ControlRequest::AuthGrant {
|
||||||
|
subject: left.node_id.clone(),
|
||||||
|
resource: "resource:ssh:revocations".to_owned(),
|
||||||
|
capability: "ssh_revocation.sync".to_owned(),
|
||||||
|
grant_id: Some("grant:left-ssh-revocation-sync".to_owned()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("grant left ssh revocation sync");
|
||||||
|
|
||||||
|
let cert_sync = handle_request_async(
|
||||||
|
&left,
|
||||||
|
ControlRequest::SshCertSync {
|
||||||
|
node: right_card.node_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("allowed ssh cert sync");
|
||||||
|
match cert_sync {
|
||||||
|
ControlResponse::SshCertSynced {
|
||||||
|
allowed,
|
||||||
|
requests_imported,
|
||||||
|
certificates_imported,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(allowed);
|
||||||
|
assert_eq!(requests_imported, 1);
|
||||||
|
assert_eq!(certificates_imported, 0);
|
||||||
|
assert!(reason.contains("direct grant"));
|
||||||
|
assert!(note.contains("ssh_cert.sync"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected allowed SSH cert sync response: {other:?}"),
|
||||||
|
}
|
||||||
|
let left_requests = Store::open(&left_paths.metadata_db())
|
||||||
|
.expect("open left after cert sync")
|
||||||
|
.list_ssh_cert_requests()
|
||||||
|
.expect("list synced requests");
|
||||||
|
assert!(
|
||||||
|
left_requests
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.request_id == right_request_id.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
let revocation_sync = handle_request_async(
|
||||||
|
&left,
|
||||||
|
ControlRequest::SshRevocationSync {
|
||||||
|
node: right_card.node_id.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("allowed ssh revocation sync");
|
||||||
|
match revocation_sync {
|
||||||
|
ControlResponse::SshRevocationSynced {
|
||||||
|
allowed,
|
||||||
|
revocations_imported,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(allowed);
|
||||||
|
assert_eq!(revocations_imported, 1);
|
||||||
|
assert!(reason.contains("direct grant"));
|
||||||
|
assert!(note.contains("ssh_revocation.sync"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected allowed SSH revocation sync response: {other:?}"),
|
||||||
|
}
|
||||||
|
let left_revocations = Store::open(&left_paths.metadata_db())
|
||||||
|
.expect("open left after revocation sync")
|
||||||
|
.list_ssh_revocations()
|
||||||
|
.expect("list synced revocations");
|
||||||
|
assert!(
|
||||||
|
left_revocations
|
||||||
|
.iter()
|
||||||
|
.any(|revocation| revocation.revocation_id == right_revocation_id.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
left_endpoint.shutdown().await;
|
left_endpoint.shutdown().await;
|
||||||
right_endpoint.shutdown().await;
|
right_endpoint.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,11 @@ SSH certificate flows use the same split. Nodes can request new OpenSSH
|
||||||
certificates or renewals through geth metadata. A machine with the CA key or
|
certificates or renewals through geth metadata. A machine with the CA key or
|
||||||
YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command,
|
YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command,
|
||||||
then import the resulting certificate for distribution. Certificate and key
|
then import the resulting certificate for distribution. Certificate and key
|
||||||
revocations are stored as signed-list-ready records and will be replicated over
|
revocations are stored as signed-list-ready records. The bootstrap can pull
|
||||||
Iroh in later phases.
|
certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the
|
||||||
|
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
|
||||||
|
`geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync`
|
||||||
|
on `resource:ssh:revocations`.
|
||||||
|
|
||||||
## Resource Model
|
## Resource Model
|
||||||
|
|
||||||
|
|
@ -164,7 +167,8 @@ OpenSSH KRL; serial and key-ID KRL entries require a CA public key via
|
||||||
exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are
|
exports and OpenSSH KRL specification source files. Binary OpenSSH KRL files are
|
||||||
not enumerable through OpenSSH tooling, so geth treats binary import as
|
not enumerable through OpenSSH tooling, so geth treats binary import as
|
||||||
unsupported and asks for JSONL or the spec source. Revocation lists are not yet
|
unsupported and asks for JSONL or the spec source. Revocation lists are not yet
|
||||||
replicated over Iroh.
|
full CRDT-replicated resources, but the daemon can already pull cert-flow and
|
||||||
|
revocation metadata from authorized peers over the protected Iroh control ALPN.
|
||||||
|
|
||||||
## Keychain, Auth, And Secrets
|
## Keychain, Auth, And Secrets
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,8 +210,12 @@ resource-scoped capability decisions.
|
||||||
revocations.
|
revocations.
|
||||||
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
|
- `[x]` Revocations can be exported as JSONL and OpenSSH KRL specification
|
||||||
text or as a binary OpenSSH KRL through `ssh-keygen`.
|
text or as a binary OpenSSH KRL through `ssh-keygen`.
|
||||||
- `[ ]` Future completion requires auth checks for request, approve, import,
|
- `[x]` Authorized peers can pull SSH cert-flow metadata with
|
||||||
publish, and read capabilities.
|
`geth ssh cert sync <node-id>`.
|
||||||
|
- `[x]` Authorized peers can pull SSH revocation metadata with
|
||||||
|
`geth ssh revocation sync <node-id>`.
|
||||||
|
- `[ ]` Future completion requires auth checks for local request, approve,
|
||||||
|
import, publish, and read capabilities.
|
||||||
|
|
||||||
## Phase 3: CAS, KV, And Pubsub
|
## Phase 3: CAS, KV, And Pubsub
|
||||||
|
|
||||||
|
|
@ -302,11 +306,20 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
||||||
admin shell.
|
admin shell.
|
||||||
- Knowing an EndpointID alone cannot reach sshd.
|
- Knowing an EndpointID alone cannot reach sshd.
|
||||||
|
|
||||||
- `[ ]` SSH certificate and revocation distribution.
|
- `[~]` SSH certificate and revocation distribution.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- Issued cert records and revocation records replicate over Iroh.
|
- `[x]` Cert request and imported certificate records can be pulled from an
|
||||||
- Consumers can list current certs/revocations from local state while offline.
|
imported peer over Iroh.
|
||||||
- Conflicting or unsigned records are rejected or quarantined.
|
- `[x]` Revocation records can be pulled from an imported peer over Iroh.
|
||||||
|
- `[x]` Remote sync validates the caller's signed peer card against the
|
||||||
|
observed Iroh EndpointID before considering authorization.
|
||||||
|
- `[x]` Cert metadata sync requires `ssh_cert.sync` on `resource:ssh:certs`.
|
||||||
|
- `[x]` Revocation metadata sync requires `ssh_revocation.sync` on
|
||||||
|
`resource:ssh:revocations`.
|
||||||
|
- `[x]` Consumers can list current certs/revocations from local state while
|
||||||
|
offline after sync.
|
||||||
|
- `[ ]` Replace pull-only metadata sync with a resource log or CRDT model.
|
||||||
|
- `[ ]` Conflicting or unsigned records are rejected or quarantined.
|
||||||
|
|
||||||
- `[x]` OpenSSH KRL import/export.
|
- `[x]` OpenSSH KRL import/export.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue