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
certificate metadata, revocation metadata, user service definitions, and a
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
types, untrusted discovery-backend trait, custom relay-map config, and Iroh
local-network discovery toggle exist.
types, manual signed peer-card export/import/list commands, untrusted
discovery-backend trait, custom relay-map config, and Iroh local-network
discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature
payloads. The keychain reducer builds an active identity view for admin keys,
users, devices, nodes, agents, and endpoint bindings.

6
Cargo.lock generated
View file

@ -1045,6 +1045,8 @@ dependencies = [
"geth-cli",
"geth-config",
"geth-control",
"geth-discovery",
"geth-iroh",
"geth-node",
"geth-store",
"geth-types",
@ -1122,6 +1124,7 @@ dependencies = [
"geth-auth",
"geth-cas",
"geth-db",
"geth-discovery",
"geth-document",
"geth-keychain",
"geth-kv",
@ -1167,7 +1170,9 @@ dependencies = [
name = "geth-discovery"
version = "0.1.0"
dependencies = [
"geth-crypto",
"geth-types",
"hex",
"serde",
"thiserror 2.0.18",
]
@ -1225,6 +1230,7 @@ dependencies = [
"geth-control",
"geth-crypto",
"geth-db",
"geth-discovery",
"geth-document",
"geth-iroh",
"geth-keychain",

View file

@ -72,6 +72,9 @@ The bootstrap implementation provides:
- `geth daemon service install|uninstall|start|stop|status|print`
- `geth status`
- `geth node id`
- `geth peer export [--out <path>]`
- `geth peer import <path>`
- `geth peer list`
- `geth resource list`
- `geth resource create <kind> <name>`
- `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]`
- 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

View file

@ -28,6 +28,10 @@ pub enum Command {
#[command(subcommand)]
command: NodeCommand,
},
Peer {
#[command(subcommand)]
command: PeerCommand,
},
Resource {
#[command(subcommand)]
command: ResourceCommand,
@ -123,6 +127,18 @@ pub enum NodeCommand {
Status,
}
#[derive(Debug, Subcommand)]
pub enum PeerCommand {
Export {
#[arg(long)]
out: Option<PathBuf>,
},
Import {
path: PathBuf,
},
List,
}
#[derive(Debug, Subcommand)]
pub enum ResourceCommand {
List,
@ -431,6 +447,11 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Node {
command: NodeCommand::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: ResourceCommand::List,
} => ControlRequest::ResourceList,
@ -721,6 +742,38 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
.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 } => {
if resources.is_empty() {
println!("no resources");

View file

@ -12,6 +12,7 @@ thiserror.workspace = true
geth-auth = { path = "../geth-auth" }
geth-cas = { path = "../geth-cas" }
geth-db = { path = "../geth-db" }
geth-discovery = { path = "../geth-discovery" }
geth-document = { path = "../geth-document" }
geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" }

View file

@ -1,6 +1,7 @@
use geth_auth::{AuthExplanation, AuthOp};
use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp;
use geth_kv::{KvEntry, KvResource};
@ -20,6 +21,13 @@ use std::path::PathBuf;
pub enum ControlRequest {
Status,
NodeId,
PeerCardExport {
out: Option<PathBuf>,
},
PeerCardImport {
path: PathBuf,
},
PeerCardList,
ResourceList,
ResourceCreate {
kind: String,
@ -201,6 +209,19 @@ pub enum ControlRequest {
pub enum ControlResponse {
Status(StatusResponse),
NodeId(NodeIdResponse),
PeerCardExported {
card: PeerCard,
out: Option<PathBuf>,
note: String,
},
PeerCardImported {
peer: DiscoveredPeer,
note: String,
},
PeerCardList {
peers: Vec<DiscoveredPeer>,
note: String,
},
ResourceList {
resources: Vec<ResourceDescriptor>,
},
@ -508,5 +529,11 @@ mod tests {
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
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]
serde.workspace = true
thiserror.workspace = true
hex.workspace = true
geth-crypto = { path = "../geth-crypto" }
geth-types = { path = "../geth-types" }

View file

@ -1,3 +1,4 @@
use geth_crypto::AgentKey;
use geth_types::{AgentId, NodeId, UnixMillis};
use serde::{Deserialize, Serialize};
@ -13,20 +14,93 @@ pub struct 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> {
if self.endpoints.is_empty() {
return Err(DiscoveryError::MissingEndpoint);
}
self.verify_signature()
}
pub fn verify_signature(&self) -> Result<(), DiscoveryError> {
if self.signature.namespace != PEER_CARD_SIGNATURE_NAMESPACE {
return Err(DiscoveryError::InvalidSignatureNamespace(
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);
}
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(())
}
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)]
@ -40,6 +114,8 @@ pub struct EndpointCandidate {
pub struct SignatureMetadata {
pub namespace: String,
pub signer: String,
#[serde(default)]
pub public_key: String,
pub signature: String,
}
@ -94,6 +170,14 @@ pub enum DiscoveryError {
UnsignedPeerCard,
#[error("invalid peer card signature namespace: {0}")]
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]
@ -106,21 +190,19 @@ mod tests {
use super::*;
fn signed_card() -> PeerCard {
PeerCard {
node_id: "node:laptop".into(),
agent_id: "agent:abc".into(),
endpoints: vec![EndpointCandidate {
let key = AgentKey::generate();
let agent_id = key.agent_id();
PeerCard::signed(
format!("node:{agent_id}").into(),
&key,
vec![EndpointCandidate {
endpoint_id: "endpoint:iroh".to_owned(),
relay_url: None,
source: DiscoverySource::Manual,
}],
issued_at: UnixMillis(1),
signature: SignatureMetadata {
namespace: PEER_CARD_SIGNATURE_NAMESPACE.to_owned(),
signer: "agent:abc".to_owned(),
signature: "sig:test".to_owned(),
},
}
UnixMillis(1),
)
.expect("signed card")
}
#[test]
@ -146,4 +228,14 @@ mod tests {
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-crypto = { path = "../geth-crypto" }
geth-db = { path = "../geth-db" }
geth-discovery = { path = "../geth-discovery" }
geth-document = { path = "../geth-document" }
geth-iroh = { path = "../geth-iroh" }
geth-keychain = { path = "../geth-keychain" }

View file

@ -12,6 +12,9 @@ use geth_control::{
};
use geth_crypto::AgentKey;
use geth_db::DbResource;
use geth_discovery::{
DiscoveredPeer, DiscoverySource, EndpointCandidate, PeerCard, discovery_is_untrusted_note,
};
use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind};
@ -27,7 +30,7 @@ use geth_ssh_identity::{
};
use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredPeerCard, StoredResource,
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
@ -102,6 +105,10 @@ pub enum NodeError {
SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError),
#[error("ssh identity error: {0}")]
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)]
@ -241,6 +248,62 @@ pub fn handle_request(
node_id: node.node_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 {
resources: store
.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> {
Ok(FileConflict {
id: stored.conflict_id,

View file

@ -19,6 +19,8 @@ geth-cli = { path = "../geth-cli" }
geth-cas = { path = "../geth-cas" }
geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-discovery = { path = "../geth-discovery" }
geth-iroh = { path = "../geth-iroh" }
geth-node = { path = "../geth-node" }
geth-store = { path = "../geth-store" }
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"));
}
#[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]
fn initialized_node_can_roundtrip_cas_blob() {
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.
The current daemon can enable Iroh's local-network discovery service through
`[iroh].local_discovery = true`, which is the default. This publishes and
discovers Iroh node addressing. Signed geth peer-card payloads over LAN
discovery remain separate future work.
discovers Iroh node addressing. `geth peer export/import/list` supports manual
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,
endpoint candidates, timestamp, and signature metadata. The current scaffold
stores peer cards as untrusted metadata in `peer_cards`; signature verification
and trust reduction are future work. `auth explain` reports when a subject is
only a discovered peer candidate and denies access.
endpoint candidates, timestamp, signing public key, and an Ed25519 signature
over a canonical payload. Imported peer cards are stored as untrusted metadata
in `peer_cards`; trust reduction is future work. `auth explain` reports when a
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 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.
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.
- LAN-discovered peer cards are stored only as untrusted peer candidates.
- Discovered EndpointIDs do not grant module access without keychain/auth
- `[x]` Imported peer cards are stored only as untrusted peer candidates.
- `[x]` Discovered EndpointIDs do not grant module access without keychain/auth
validation.
- `[x]` Protocol/router scaffold.
@ -112,6 +114,8 @@ geth-to-geth connections without granting trust from discovery alone.
Acceptance criteria:
- A peer card contains node ID, agent ID, endpoint candidates, timestamp, and
signature metadata.
- Peer-card signatures cover deterministic canonical payloads and reject
tampered endpoint candidates.
- Peer cards are stored in `peer_cards`.
- Invalid or unsigned peer cards do not update trust state.