Add manual signed peer card exchange
This commit is contained in:
parent
20c88af800
commit
1ab24631cd
14 changed files with 360 additions and 25 deletions
|
|
@ -105,8 +105,9 @@ Roadmap items should be actionable and checkable:
|
||||||
daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH
|
daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH
|
||||||
certificate metadata, revocation metadata, user service definitions, and a
|
certificate metadata, revocation metadata, user service definitions, and a
|
||||||
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
|
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
|
||||||
types, untrusted discovery-backend trait, custom relay-map config, and Iroh
|
types, manual signed peer-card export/import/list commands, untrusted
|
||||||
local-network discovery toggle exist.
|
discovery-backend trait, custom relay-map config, and Iroh local-network
|
||||||
|
discovery toggle exist.
|
||||||
- Canonical signed-operation envelopes exist for keychain/auth signature
|
- Canonical signed-operation envelopes exist for keychain/auth signature
|
||||||
payloads. The keychain reducer builds an active identity view for admin keys,
|
payloads. The keychain reducer builds an active identity view for admin keys,
|
||||||
users, devices, nodes, agents, and endpoint bindings.
|
users, devices, nodes, agents, and endpoint bindings.
|
||||||
|
|
|
||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -1045,6 +1045,8 @@ dependencies = [
|
||||||
"geth-cli",
|
"geth-cli",
|
||||||
"geth-config",
|
"geth-config",
|
||||||
"geth-control",
|
"geth-control",
|
||||||
|
"geth-discovery",
|
||||||
|
"geth-iroh",
|
||||||
"geth-node",
|
"geth-node",
|
||||||
"geth-store",
|
"geth-store",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
|
|
@ -1122,6 +1124,7 @@ dependencies = [
|
||||||
"geth-auth",
|
"geth-auth",
|
||||||
"geth-cas",
|
"geth-cas",
|
||||||
"geth-db",
|
"geth-db",
|
||||||
|
"geth-discovery",
|
||||||
"geth-document",
|
"geth-document",
|
||||||
"geth-keychain",
|
"geth-keychain",
|
||||||
"geth-kv",
|
"geth-kv",
|
||||||
|
|
@ -1167,7 +1170,9 @@ dependencies = [
|
||||||
name = "geth-discovery"
|
name = "geth-discovery"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"geth-crypto",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
@ -1225,6 +1230,7 @@ dependencies = [
|
||||||
"geth-control",
|
"geth-control",
|
||||||
"geth-crypto",
|
"geth-crypto",
|
||||||
"geth-db",
|
"geth-db",
|
||||||
|
"geth-discovery",
|
||||||
"geth-document",
|
"geth-document",
|
||||||
"geth-iroh",
|
"geth-iroh",
|
||||||
"geth-keychain",
|
"geth-keychain",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ The bootstrap implementation provides:
|
||||||
- `geth daemon service install|uninstall|start|stop|status|print`
|
- `geth daemon service install|uninstall|start|stop|status|print`
|
||||||
- `geth status`
|
- `geth status`
|
||||||
- `geth node id`
|
- `geth node id`
|
||||||
|
- `geth peer export [--out <path>]`
|
||||||
|
- `geth peer import <path>`
|
||||||
|
- `geth peer list`
|
||||||
- `geth resource list`
|
- `geth resource list`
|
||||||
- `geth resource create <kind> <name>`
|
- `geth resource create <kind> <name>`
|
||||||
- `geth keychain init [--admin-key <path>]`
|
- `geth keychain init [--admin-key <path>]`
|
||||||
|
|
@ -109,7 +112,11 @@ The bootstrap implementation provides:
|
||||||
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec]`
|
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec]`
|
||||||
- local pipe registry commands: `geth pipe listen/connect`
|
- local pipe registry commands: `geth pipe listen/connect`
|
||||||
|
|
||||||
Other command groups exist as explicit stubs: `ssh`.
|
`geth peer export/import/list` is for untrusted peer-card exchange while live
|
||||||
|
LAN discovery and authenticated Iroh dialing are still being built. Importing a
|
||||||
|
peer card never grants capabilities by itself.
|
||||||
|
|
||||||
|
Other command groups exist as explicit stubs: `ssh proxy`.
|
||||||
|
|
||||||
## Resource Modules
|
## Resource Modules
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ pub enum Command {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: NodeCommand,
|
command: NodeCommand,
|
||||||
},
|
},
|
||||||
|
Peer {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: PeerCommand,
|
||||||
|
},
|
||||||
Resource {
|
Resource {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: ResourceCommand,
|
command: ResourceCommand,
|
||||||
|
|
@ -123,6 +127,18 @@ pub enum NodeCommand {
|
||||||
Status,
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum PeerCommand {
|
||||||
|
Export {
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
Import {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum ResourceCommand {
|
pub enum ResourceCommand {
|
||||||
List,
|
List,
|
||||||
|
|
@ -431,6 +447,11 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
Command::Node {
|
Command::Node {
|
||||||
command: NodeCommand::Status,
|
command: NodeCommand::Status,
|
||||||
} => ControlRequest::Status,
|
} => ControlRequest::Status,
|
||||||
|
Command::Peer { command } => match command {
|
||||||
|
PeerCommand::Export { out } => ControlRequest::PeerCardExport { out },
|
||||||
|
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
|
||||||
|
PeerCommand::List => ControlRequest::PeerCardList,
|
||||||
|
},
|
||||||
Command::Resource {
|
Command::Resource {
|
||||||
command: ResourceCommand::List,
|
command: ResourceCommand::List,
|
||||||
} => ControlRequest::ResourceList,
|
} => ControlRequest::ResourceList,
|
||||||
|
|
@ -721,6 +742,38 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
.unwrap_or("not started in bootstrap")
|
.unwrap_or("not started in bootstrap")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
ControlResponse::PeerCardExported { card, out, note } => {
|
||||||
|
println!("peer card: {}", card.node_id);
|
||||||
|
println!("agent: {}", card.agent_id);
|
||||||
|
println!("endpoints: {}", card.endpoints.len());
|
||||||
|
if let Some(path) = out {
|
||||||
|
println!("wrote: {}", path.display());
|
||||||
|
} else {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&card)?);
|
||||||
|
}
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
|
ControlResponse::PeerCardImported { peer, note } => {
|
||||||
|
println!("imported peer: {}", peer.card.node_id);
|
||||||
|
println!("agent: {}", peer.card.agent_id);
|
||||||
|
println!("trust: candidate-only");
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
|
ControlResponse::PeerCardList { peers, note } => {
|
||||||
|
if peers.is_empty() {
|
||||||
|
println!("no peer candidates");
|
||||||
|
} else {
|
||||||
|
for peer in peers {
|
||||||
|
println!(
|
||||||
|
"{}\t{}\t{} endpoints\tcandidate-only",
|
||||||
|
peer.card.node_id,
|
||||||
|
peer.card.agent_id,
|
||||||
|
peer.card.endpoints.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
ControlResponse::ResourceList { resources } => {
|
ControlResponse::ResourceList { resources } => {
|
||||||
if resources.is_empty() {
|
if resources.is_empty() {
|
||||||
println!("no resources");
|
println!("no resources");
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ thiserror.workspace = true
|
||||||
geth-auth = { path = "../geth-auth" }
|
geth-auth = { path = "../geth-auth" }
|
||||||
geth-cas = { path = "../geth-cas" }
|
geth-cas = { path = "../geth-cas" }
|
||||||
geth-db = { path = "../geth-db" }
|
geth-db = { path = "../geth-db" }
|
||||||
|
geth-discovery = { path = "../geth-discovery" }
|
||||||
geth-document = { path = "../geth-document" }
|
geth-document = { path = "../geth-document" }
|
||||||
geth-keychain = { path = "../geth-keychain" }
|
geth-keychain = { path = "../geth-keychain" }
|
||||||
geth-kv = { path = "../geth-kv" }
|
geth-kv = { path = "../geth-kv" }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use geth_auth::{AuthExplanation, AuthOp};
|
use geth_auth::{AuthExplanation, AuthOp};
|
||||||
use geth_cas::{FileConflict, FileRoot, FileRootScan};
|
use geth_cas::{FileConflict, FileRoot, FileRootScan};
|
||||||
use geth_db::{CrSqliteChangeBatch, DbResource};
|
use geth_db::{CrSqliteChangeBatch, DbResource};
|
||||||
|
use geth_discovery::{DiscoveredPeer, PeerCard};
|
||||||
use geth_document::{DocumentResource, DocumentState};
|
use geth_document::{DocumentResource, DocumentState};
|
||||||
use geth_keychain::KeychainOp;
|
use geth_keychain::KeychainOp;
|
||||||
use geth_kv::{KvEntry, KvResource};
|
use geth_kv::{KvEntry, KvResource};
|
||||||
|
|
@ -20,6 +21,13 @@ use std::path::PathBuf;
|
||||||
pub enum ControlRequest {
|
pub enum ControlRequest {
|
||||||
Status,
|
Status,
|
||||||
NodeId,
|
NodeId,
|
||||||
|
PeerCardExport {
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
PeerCardImport {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
PeerCardList,
|
||||||
ResourceList,
|
ResourceList,
|
||||||
ResourceCreate {
|
ResourceCreate {
|
||||||
kind: String,
|
kind: String,
|
||||||
|
|
@ -201,6 +209,19 @@ pub enum ControlRequest {
|
||||||
pub enum ControlResponse {
|
pub enum ControlResponse {
|
||||||
Status(StatusResponse),
|
Status(StatusResponse),
|
||||||
NodeId(NodeIdResponse),
|
NodeId(NodeIdResponse),
|
||||||
|
PeerCardExported {
|
||||||
|
card: PeerCard,
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
PeerCardImported {
|
||||||
|
peer: DiscoveredPeer,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
PeerCardList {
|
||||||
|
peers: Vec<DiscoveredPeer>,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
ResourceList {
|
ResourceList {
|
||||||
resources: Vec<ResourceDescriptor>,
|
resources: Vec<ResourceDescriptor>,
|
||||||
},
|
},
|
||||||
|
|
@ -508,5 +529,11 @@ mod tests {
|
||||||
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::PeerCardExport { out: None };
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
|
request
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,6 @@ license.workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
geth-crypto = { path = "../geth-crypto" }
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use geth_crypto::AgentKey;
|
||||||
use geth_types::{AgentId, NodeId, UnixMillis};
|
use geth_types::{AgentId, NodeId, UnixMillis};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -13,20 +14,93 @@ pub struct PeerCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeerCard {
|
impl PeerCard {
|
||||||
|
pub fn signed(
|
||||||
|
node_id: NodeId,
|
||||||
|
agent_key: &AgentKey,
|
||||||
|
endpoints: Vec<EndpointCandidate>,
|
||||||
|
issued_at: UnixMillis,
|
||||||
|
) -> Result<Self, DiscoveryError> {
|
||||||
|
let agent_id = agent_key.agent_id();
|
||||||
|
let payload = PeerCardSigningPayload {
|
||||||
|
node_id: node_id.clone(),
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
endpoints: endpoints.clone(),
|
||||||
|
issued_at,
|
||||||
|
};
|
||||||
|
let signature = agent_key.sign_canonical(PEER_CARD_SIGNATURE_NAMESPACE, &payload)?;
|
||||||
|
Ok(Self {
|
||||||
|
node_id,
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
endpoints,
|
||||||
|
issued_at,
|
||||||
|
signature: SignatureMetadata {
|
||||||
|
namespace: PEER_CARD_SIGNATURE_NAMESPACE.to_owned(),
|
||||||
|
signer: agent_id.to_string(),
|
||||||
|
public_key: agent_key.public_key_hex(),
|
||||||
|
signature: hex::encode(signature),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn validate_candidate(&self) -> Result<(), DiscoveryError> {
|
pub fn validate_candidate(&self) -> Result<(), DiscoveryError> {
|
||||||
if self.endpoints.is_empty() {
|
if self.endpoints.is_empty() {
|
||||||
return Err(DiscoveryError::MissingEndpoint);
|
return Err(DiscoveryError::MissingEndpoint);
|
||||||
}
|
}
|
||||||
|
self.verify_signature()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_signature(&self) -> Result<(), DiscoveryError> {
|
||||||
if self.signature.namespace != PEER_CARD_SIGNATURE_NAMESPACE {
|
if self.signature.namespace != PEER_CARD_SIGNATURE_NAMESPACE {
|
||||||
return Err(DiscoveryError::InvalidSignatureNamespace(
|
return Err(DiscoveryError::InvalidSignatureNamespace(
|
||||||
self.signature.namespace.clone(),
|
self.signature.namespace.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.signature.signer.is_empty() || self.signature.signature.is_empty() {
|
if self.signature.signer.is_empty()
|
||||||
|
|| self.signature.public_key.is_empty()
|
||||||
|
|| self.signature.signature.is_empty()
|
||||||
|
{
|
||||||
return Err(DiscoveryError::UnsignedPeerCard);
|
return Err(DiscoveryError::UnsignedPeerCard);
|
||||||
}
|
}
|
||||||
|
if self.signature.signer != self.agent_id.as_str() {
|
||||||
|
return Err(DiscoveryError::SignerMismatch {
|
||||||
|
signer: self.signature.signer.clone(),
|
||||||
|
agent: self.agent_id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let public_key = hex::decode(&self.signature.public_key)?;
|
||||||
|
let fingerprint = geth_crypto::key_fingerprint(&public_key);
|
||||||
|
if fingerprint != self.signature.signer {
|
||||||
|
return Err(DiscoveryError::SignerPublicKeyMismatch {
|
||||||
|
signer: self.signature.signer.clone(),
|
||||||
|
fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let signature = hex::decode(&self.signature.signature)?;
|
||||||
|
geth_crypto::verify_canonical(
|
||||||
|
&public_key,
|
||||||
|
PEER_CARD_SIGNATURE_NAMESPACE,
|
||||||
|
&self.signing_payload(),
|
||||||
|
&signature,
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn signing_payload(&self) -> PeerCardSigningPayload {
|
||||||
|
PeerCardSigningPayload {
|
||||||
|
node_id: self.node_id.clone(),
|
||||||
|
agent_id: self.agent_id.clone(),
|
||||||
|
endpoints: self.endpoints.clone(),
|
||||||
|
issued_at: self.issued_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct PeerCardSigningPayload {
|
||||||
|
node_id: NodeId,
|
||||||
|
agent_id: AgentId,
|
||||||
|
endpoints: Vec<EndpointCandidate>,
|
||||||
|
issued_at: UnixMillis,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -40,6 +114,8 @@ pub struct EndpointCandidate {
|
||||||
pub struct SignatureMetadata {
|
pub struct SignatureMetadata {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub signer: String,
|
pub signer: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub public_key: String,
|
||||||
pub signature: String,
|
pub signature: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +170,14 @@ pub enum DiscoveryError {
|
||||||
UnsignedPeerCard,
|
UnsignedPeerCard,
|
||||||
#[error("invalid peer card signature namespace: {0}")]
|
#[error("invalid peer card signature namespace: {0}")]
|
||||||
InvalidSignatureNamespace(String),
|
InvalidSignatureNamespace(String),
|
||||||
|
#[error("peer card signer {signer} does not match agent {agent}")]
|
||||||
|
SignerMismatch { signer: String, agent: String },
|
||||||
|
#[error("peer card signer {signer} does not match public key fingerprint {fingerprint}")]
|
||||||
|
SignerPublicKeyMismatch { signer: String, fingerprint: String },
|
||||||
|
#[error("invalid peer card hex: {0}")]
|
||||||
|
Hex(#[from] hex::FromHexError),
|
||||||
|
#[error("peer card signature error: {0}")]
|
||||||
|
Crypto(#[from] geth_crypto::CryptoError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -106,21 +190,19 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn signed_card() -> PeerCard {
|
fn signed_card() -> PeerCard {
|
||||||
PeerCard {
|
let key = AgentKey::generate();
|
||||||
node_id: "node:laptop".into(),
|
let agent_id = key.agent_id();
|
||||||
agent_id: "agent:abc".into(),
|
PeerCard::signed(
|
||||||
endpoints: vec![EndpointCandidate {
|
format!("node:{agent_id}").into(),
|
||||||
|
&key,
|
||||||
|
vec![EndpointCandidate {
|
||||||
endpoint_id: "endpoint:iroh".to_owned(),
|
endpoint_id: "endpoint:iroh".to_owned(),
|
||||||
relay_url: None,
|
relay_url: None,
|
||||||
source: DiscoverySource::Manual,
|
source: DiscoverySource::Manual,
|
||||||
}],
|
}],
|
||||||
issued_at: UnixMillis(1),
|
UnixMillis(1),
|
||||||
signature: SignatureMetadata {
|
)
|
||||||
namespace: PEER_CARD_SIGNATURE_NAMESPACE.to_owned(),
|
.expect("signed card")
|
||||||
signer: "agent:abc".to_owned(),
|
|
||||||
signature: "sig:test".to_owned(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -146,4 +228,14 @@ mod tests {
|
||||||
Err(DiscoveryError::UnsignedPeerCard)
|
Err(DiscoveryError::UnsignedPeerCard)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_peer_card_signature_is_rejected() {
|
||||||
|
let mut card = signed_card();
|
||||||
|
card.endpoints[0].endpoint_id = "endpoint:tampered".to_owned();
|
||||||
|
assert!(matches!(
|
||||||
|
card.validate_candidate(),
|
||||||
|
Err(DiscoveryError::Crypto(geth_crypto::CryptoError::Verify))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ geth-config = { path = "../geth-config" }
|
||||||
geth-control = { path = "../geth-control" }
|
geth-control = { path = "../geth-control" }
|
||||||
geth-crypto = { path = "../geth-crypto" }
|
geth-crypto = { path = "../geth-crypto" }
|
||||||
geth-db = { path = "../geth-db" }
|
geth-db = { path = "../geth-db" }
|
||||||
|
geth-discovery = { path = "../geth-discovery" }
|
||||||
geth-document = { path = "../geth-document" }
|
geth-document = { path = "../geth-document" }
|
||||||
geth-iroh = { path = "../geth-iroh" }
|
geth-iroh = { path = "../geth-iroh" }
|
||||||
geth-keychain = { path = "../geth-keychain" }
|
geth-keychain = { path = "../geth-keychain" }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ use geth_control::{
|
||||||
};
|
};
|
||||||
use geth_crypto::AgentKey;
|
use geth_crypto::AgentKey;
|
||||||
use geth_db::DbResource;
|
use geth_db::DbResource;
|
||||||
|
use geth_discovery::{
|
||||||
|
DiscoveredPeer, DiscoverySource, EndpointCandidate, PeerCard, discovery_is_untrusted_note,
|
||||||
|
};
|
||||||
use geth_document::{DocumentResource, DocumentState};
|
use geth_document::{DocumentResource, DocumentState};
|
||||||
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
||||||
use geth_keychain::{KeychainOp, KeychainOpKind};
|
use geth_keychain::{KeychainOp, KeychainOpKind};
|
||||||
|
|
@ -27,7 +30,7 @@ use geth_ssh_identity::{
|
||||||
};
|
};
|
||||||
use geth_store::{
|
use geth_store::{
|
||||||
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
|
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
|
||||||
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource,
|
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredPeerCard, StoredResource,
|
||||||
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
|
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
|
||||||
};
|
};
|
||||||
use geth_types::{
|
use geth_types::{
|
||||||
|
|
@ -102,6 +105,10 @@ pub enum NodeError {
|
||||||
SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError),
|
SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError),
|
||||||
#[error("ssh identity error: {0}")]
|
#[error("ssh identity error: {0}")]
|
||||||
SshIdentity(#[from] geth_ssh_identity::SshIdentityError),
|
SshIdentity(#[from] geth_ssh_identity::SshIdentityError),
|
||||||
|
#[error("discovery error: {0}")]
|
||||||
|
Discovery(#[from] geth_discovery::DiscoveryError),
|
||||||
|
#[error("cannot export peer card before the daemon has an Iroh EndpointID")]
|
||||||
|
IrohEndpointUnavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -241,6 +248,62 @@ pub fn handle_request(
|
||||||
node_id: node.node_id.clone(),
|
node_id: node.node_id.clone(),
|
||||||
endpoint_id: node.iroh_status.endpoint_id.clone(),
|
endpoint_id: node.iroh_status.endpoint_id.clone(),
|
||||||
})),
|
})),
|
||||||
|
ControlRequest::PeerCardExport { out } => {
|
||||||
|
let endpoint_id = node
|
||||||
|
.iroh_status
|
||||||
|
.endpoint_id
|
||||||
|
.clone()
|
||||||
|
.ok_or(NodeError::IrohEndpointUnavailable)?;
|
||||||
|
let key = AgentKey::load(&node.paths.agent_key())?;
|
||||||
|
let card = PeerCard::signed(
|
||||||
|
NodeId::new(node.node_id.clone()),
|
||||||
|
&key,
|
||||||
|
vec![EndpointCandidate {
|
||||||
|
endpoint_id,
|
||||||
|
relay_url: None,
|
||||||
|
source: DiscoverySource::Manual,
|
||||||
|
}],
|
||||||
|
UnixMillis(geth_store::now_ms()),
|
||||||
|
)?;
|
||||||
|
if let Some(path) = &out {
|
||||||
|
std::fs::write(path, serde_json::to_string_pretty(&card)?)?;
|
||||||
|
}
|
||||||
|
Ok(ControlResponse::PeerCardExported {
|
||||||
|
card,
|
||||||
|
out,
|
||||||
|
note: discovery_is_untrusted_note().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::PeerCardImport { path } => {
|
||||||
|
let card_json = std::fs::read_to_string(&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,
|
||||||
|
)?;
|
||||||
|
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,
|
||||||
|
})?;
|
||||||
|
Ok(ControlResponse::PeerCardImported {
|
||||||
|
peer: discovered,
|
||||||
|
note: discovery_is_untrusted_note().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlRequest::PeerCardList => {
|
||||||
|
let peers = store
|
||||||
|
.list_peer_cards()?
|
||||||
|
.into_iter()
|
||||||
|
.map(discovered_peer_from_stored)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(ControlResponse::PeerCardList {
|
||||||
|
peers,
|
||||||
|
note: discovery_is_untrusted_note().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
|
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
|
||||||
resources: store
|
resources: store
|
||||||
.list_resources()?
|
.list_resources()?
|
||||||
|
|
@ -1214,6 +1277,15 @@ fn file_root_from_stored(stored: &StoredFileRoot) -> FileRoot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn discovered_peer_from_stored(stored: StoredPeerCard) -> Result<DiscoveredPeer, NodeError> {
|
||||||
|
let card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
||||||
|
Ok(DiscoveredPeer::candidate(
|
||||||
|
card,
|
||||||
|
UnixMillis(stored.updated_at_ms),
|
||||||
|
DiscoverySource::Imported,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
fn file_conflict_from_stored(stored: StoredFileConflict) -> Result<FileConflict, NodeError> {
|
fn file_conflict_from_stored(stored: StoredFileConflict) -> Result<FileConflict, NodeError> {
|
||||||
Ok(FileConflict {
|
Ok(FileConflict {
|
||||||
id: stored.conflict_id,
|
id: stored.conflict_id,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ geth-cli = { path = "../geth-cli" }
|
||||||
geth-cas = { path = "../geth-cas" }
|
geth-cas = { path = "../geth-cas" }
|
||||||
geth-config = { path = "../geth-config" }
|
geth-config = { path = "../geth-config" }
|
||||||
geth-control = { path = "../geth-control" }
|
geth-control = { path = "../geth-control" }
|
||||||
|
geth-discovery = { path = "../geth-discovery" }
|
||||||
|
geth-iroh = { path = "../geth-iroh" }
|
||||||
geth-node = { path = "../geth-node" }
|
geth-node = { path = "../geth-node" }
|
||||||
geth-store = { path = "../geth-store" }
|
geth-store = { path = "../geth-store" }
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,72 @@ fn geth_status_against_running_daemon() {
|
||||||
assert!(stdout.contains("iroh discovery: local-network disabled"));
|
assert!(stdout.contains("iroh discovery: local-network disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_card_export_import_and_list_are_candidate_only() {
|
||||||
|
let source_home = tempfile::tempdir().expect("source tempdir");
|
||||||
|
let source_paths = geth_config::GethPaths::from_home(source_home.path());
|
||||||
|
let mut source = geth_node::init_node(&source_paths).expect("init source");
|
||||||
|
source.iroh_status = geth_iroh::EndpointStatus {
|
||||||
|
enabled: true,
|
||||||
|
endpoint_id: Some("endpoint:test-source".to_owned()),
|
||||||
|
relay_mode: "disabled".to_owned(),
|
||||||
|
local_discovery: false,
|
||||||
|
note: "test endpoint".to_owned(),
|
||||||
|
};
|
||||||
|
let peer_card_path = source_home.path().join("peer-card.json");
|
||||||
|
|
||||||
|
let exported = geth_node::handle_request(
|
||||||
|
&source,
|
||||||
|
geth_control::ControlRequest::PeerCardExport {
|
||||||
|
out: Some(peer_card_path.clone()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("export peer card");
|
||||||
|
let exported_card = match exported {
|
||||||
|
geth_control::ControlResponse::PeerCardExported { card, out, note } => {
|
||||||
|
assert_eq!(out, Some(peer_card_path.clone()));
|
||||||
|
assert!(note.contains("never grants trust"));
|
||||||
|
card.validate_candidate().expect("valid exported card");
|
||||||
|
card
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
};
|
||||||
|
assert!(peer_card_path.exists());
|
||||||
|
|
||||||
|
let target_home = tempfile::tempdir().expect("target tempdir");
|
||||||
|
let target_paths = geth_config::GethPaths::from_home(target_home.path());
|
||||||
|
let target = geth_node::init_node(&target_paths).expect("init target");
|
||||||
|
let imported = geth_node::handle_request(
|
||||||
|
&target,
|
||||||
|
geth_control::ControlRequest::PeerCardImport {
|
||||||
|
path: peer_card_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("import peer card");
|
||||||
|
match imported {
|
||||||
|
geth_control::ControlResponse::PeerCardImported { peer, note } => {
|
||||||
|
assert_eq!(peer.card, exported_card);
|
||||||
|
assert_eq!(
|
||||||
|
peer.trust_state,
|
||||||
|
geth_discovery::CandidateTrustState::CandidateOnly
|
||||||
|
);
|
||||||
|
assert!(note.contains("never grants trust"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let listed = geth_node::handle_request(&target, geth_control::ControlRequest::PeerCardList)
|
||||||
|
.expect("list peers");
|
||||||
|
match listed {
|
||||||
|
geth_control::ControlResponse::PeerCardList { peers, note } => {
|
||||||
|
assert_eq!(peers.len(), 1);
|
||||||
|
assert_eq!(peers[0].card, exported_card);
|
||||||
|
assert!(note.contains("never grants trust"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initialized_node_can_roundtrip_cas_blob() {
|
fn initialized_node_can_roundtrip_cas_blob() {
|
||||||
let home = tempfile::tempdir().expect("tempdir");
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,15 @@ and candidate-discovery mechanisms only. They do not grant trust, mutate
|
||||||
authorization state, or make EndpointID knowledge sufficient for access.
|
authorization state, or make EndpointID knowledge sufficient for access.
|
||||||
The current daemon can enable Iroh's local-network discovery service through
|
The current daemon can enable Iroh's local-network discovery service through
|
||||||
`[iroh].local_discovery = true`, which is the default. This publishes and
|
`[iroh].local_discovery = true`, which is the default. This publishes and
|
||||||
discovers Iroh node addressing. Signed geth peer-card payloads over LAN
|
discovers Iroh node addressing. `geth peer export/import/list` supports manual
|
||||||
discovery remain separate future work.
|
exchange of signed peer cards as untrusted candidates. Automatic signed
|
||||||
|
peer-card advertisement over LAN discovery remains separate future work.
|
||||||
|
|
||||||
Peer cards are the discovery payload. A peer card carries node ID, agent ID,
|
Peer cards are the discovery payload. A peer card carries node ID, agent ID,
|
||||||
endpoint candidates, timestamp, and signature metadata. The current scaffold
|
endpoint candidates, timestamp, signing public key, and an Ed25519 signature
|
||||||
stores peer cards as untrusted metadata in `peer_cards`; signature verification
|
over a canonical payload. Imported peer cards are stored as untrusted metadata
|
||||||
and trust reduction are future work. `auth explain` reports when a subject is
|
in `peer_cards`; trust reduction is future work. `auth explain` reports when a
|
||||||
only a discovered peer candidate and denies access.
|
subject is only a discovered peer candidate and denies access.
|
||||||
|
|
||||||
The daemon starts this endpoint during `geth daemon run` and keeps it alive for
|
The daemon starts this endpoint during `geth daemon run` and keeps it alive for
|
||||||
the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is
|
the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,12 @@ geth-to-geth connections without granting trust from discovery alone.
|
||||||
|
|
||||||
- `[ ]` Signed peer-card LAN discovery payloads.
|
- `[ ]` Signed peer-card LAN discovery payloads.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- The daemon can advertise and discover signed geth peer cards over LAN
|
- `[x]` Manual `geth peer export/import/list` can exchange signed peer cards
|
||||||
|
and store them as untrusted candidates.
|
||||||
|
- `[ ]` The daemon can advertise and discover signed geth peer cards over LAN
|
||||||
discovery.
|
discovery.
|
||||||
- LAN-discovered peer cards are stored only as untrusted peer candidates.
|
- `[x]` Imported peer cards are stored only as untrusted peer candidates.
|
||||||
- Discovered EndpointIDs do not grant module access without keychain/auth
|
- `[x]` Discovered EndpointIDs do not grant module access without keychain/auth
|
||||||
validation.
|
validation.
|
||||||
|
|
||||||
- `[x]` Protocol/router scaffold.
|
- `[x]` Protocol/router scaffold.
|
||||||
|
|
@ -112,6 +114,8 @@ geth-to-geth connections without granting trust from discovery alone.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- A peer card contains node ID, agent ID, endpoint candidates, timestamp, and
|
- A peer card contains node ID, agent ID, endpoint candidates, timestamp, and
|
||||||
signature metadata.
|
signature metadata.
|
||||||
|
- Peer-card signatures cover deterministic canonical payloads and reject
|
||||||
|
tampered endpoint candidates.
|
||||||
- Peer cards are stored in `peer_cards`.
|
- Peer cards are stored in `peer_cards`.
|
||||||
- Invalid or unsigned peer cards do not update trust state.
|
- Invalid or unsigned peer cards do not update trust state.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue