Add manual signed peer card exchange

This commit is contained in:
Eric Wendland 2026-05-18 04:03:52 +02:00
commit 1ab24631cd
14 changed files with 360 additions and 25 deletions

View file

@ -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
View file

@ -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",

View file

@ -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

View file

@ -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");

View file

@ -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" }

View file

@ -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
);
} }
} }

View file

@ -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" }

View file

@ -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))
));
}
} }

View file

@ -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" }

View file

@ -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,

View file

@ -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" }

View file

@ -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");

View file

@ -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

View file

@ -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.