Add peer card discovery scaffold

This commit is contained in:
Eric Wendland 2026-05-16 14:24:21 +02:00
commit 81149605bc
11 changed files with 271 additions and 15 deletions

View file

@ -104,8 +104,9 @@ Roadmap items should be actionable and checkable:
- Local daemon, local control socket, local identity, local store, local CAS, - Local daemon, local control socket, local identity, local store, local CAS,
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 exist. pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
- Custom relay maps, mDNS discovery, peer auth over Iroh, cr-sqlite, iroh-docs, types, and untrusted discovery-backend trait exist.
iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL - Custom relay maps, mDNS discovery transport, peer auth over Iroh, cr-sqlite,
generation, and Keyhive/BeeKEM-style authorization are future roadmap items iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
unless implemented later. OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
roadmap items unless implemented later.

2
Cargo.lock generated
View file

@ -1032,6 +1032,7 @@ dependencies = [
"geth-config", "geth-config",
"geth-control", "geth-control",
"geth-node", "geth-node",
"geth-store",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing-subscriber", "tracing-subscriber",
@ -1134,6 +1135,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"geth-types", "geth-types",
"serde", "serde",
"thiserror 2.0.18",
] ]
[[package]] [[package]]

View file

@ -71,6 +71,18 @@ impl AuthExplanation {
evaluated_ops: 0, evaluated_ops: 0,
} }
} }
#[must_use]
pub fn discovered_candidate(subject: String, resource: String, capability: String) -> Self {
Self {
subject,
resource,
capability,
allowed: false,
reason: "subject is a discovered peer candidate only; discovery does not grant trust or authorization".to_owned(),
evaluated_ops: 0,
}
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -7,4 +7,5 @@ license.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }

View file

@ -1,18 +1,149 @@
use geth_types::NodeId; use geth_types::{AgentId, NodeId, UnixMillis};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCard { pub struct PeerCard {
pub node: NodeId, pub node_id: NodeId,
pub endpoints: Vec<String>, pub agent_id: AgentId,
pub signed_by: String, pub endpoints: Vec<EndpointCandidate>,
pub issued_at: UnixMillis,
pub signature: SignatureMetadata,
}
impl PeerCard {
pub fn validate_candidate(&self) -> Result<(), DiscoveryError> {
if self.endpoints.is_empty() {
return Err(DiscoveryError::MissingEndpoint);
}
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() {
return Err(DiscoveryError::UnsignedPeerCard);
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointCandidate {
pub endpoint_id: String,
pub relay_url: Option<String>,
pub source: DiscoverySource,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignatureMetadata {
pub namespace: String,
pub signer: String,
pub signature: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DiscoverySource {
Manual,
Mdns,
PeerExchange,
Imported,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredPeer {
pub card: PeerCard,
pub discovered_at: UnixMillis,
pub source: DiscoverySource,
pub trust_state: CandidateTrustState,
}
impl DiscoveredPeer {
pub fn candidate(
card: PeerCard,
discovered_at: UnixMillis,
source: DiscoverySource,
) -> Result<Self, DiscoveryError> {
card.validate_candidate()?;
Ok(Self {
card,
discovered_at,
source,
trust_state: CandidateTrustState::CandidateOnly,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CandidateTrustState {
CandidateOnly,
} }
pub trait DiscoveryBackend { pub trait DiscoveryBackend {
fn candidates(&self) -> Vec<PeerCard>; fn candidates(&self) -> Result<Vec<DiscoveredPeer>, DiscoveryError>;
}
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("peer card has no endpoint candidates")]
MissingEndpoint,
#[error("peer card is missing signature metadata")]
UnsignedPeerCard,
#[error("invalid peer card signature namespace: {0}")]
InvalidSignatureNamespace(String),
} }
#[must_use] #[must_use]
pub fn discovery_is_untrusted_note() -> &'static str { pub fn discovery_is_untrusted_note() -> &'static str {
"discovery returns candidate peers only and never grants trust or authorization" "discovery returns candidate peers only and never grants trust or authorization"
} }
#[cfg(test)]
mod tests {
use super::*;
fn signed_card() -> PeerCard {
PeerCard {
node_id: "node:laptop".into(),
agent_id: "agent:abc".into(),
endpoints: 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(),
},
}
}
#[test]
fn signed_peer_card_is_valid_candidate() {
signed_card()
.validate_candidate()
.expect("valid signed candidate");
}
#[test]
fn discovered_peer_is_candidate_only() {
let peer = DiscoveredPeer::candidate(signed_card(), UnixMillis(2), DiscoverySource::Mdns)
.expect("candidate");
assert_eq!(peer.trust_state, CandidateTrustState::CandidateOnly);
}
#[test]
fn unsigned_peer_card_is_not_valid_candidate() {
let mut card = signed_card();
card.signature.signature.clear();
assert!(matches!(
card.validate_candidate(),
Err(DiscoveryError::UnsignedPeerCard)
));
}
}

View file

@ -247,9 +247,17 @@ pub fn handle_request(
subject, subject,
resource, resource,
capability, capability,
} => Ok(ControlResponse::AuthExplain(AuthExplanation::stub( } => {
subject, resource, capability, if store.get_peer_card(&subject)?.is_some() {
))), Ok(ControlResponse::AuthExplain(
AuthExplanation::discovered_candidate(subject, resource, capability),
))
} else {
Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
subject, resource, capability,
)))
}
}
ControlRequest::SshCertRequest { ControlRequest::SshCertRequest {
public_key_path, public_key_path,
cert_kind, cert_kind,

View file

@ -250,6 +250,45 @@ impl Store {
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn upsert_peer_card(&self, peer_card: &StoredPeerCard) -> Result<(), StoreError> {
self.conn.execute(
"INSERT OR REPLACE INTO peer_cards(peer_id, card_json, updated_at_ms) VALUES (?1, ?2, ?3)",
params![peer_card.peer_id, peer_card.card_json, peer_card.updated_at_ms],
)?;
Ok(())
}
pub fn get_peer_card(&self, peer_id: &str) -> Result<Option<StoredPeerCard>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT peer_id, card_json, updated_at_ms FROM peer_cards WHERE peer_id = ?1",
)?;
let mut rows = stmt.query(params![peer_id])?;
if let Some(row) = rows.next()? {
Ok(Some(StoredPeerCard {
peer_id: row.get(0)?,
card_json: row.get(1)?,
updated_at_ms: row.get(2)?,
}))
} else {
Ok(None)
}
}
pub fn list_peer_cards(&self) -> Result<Vec<StoredPeerCard>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT peer_id, card_json, updated_at_ms FROM peer_cards ORDER BY peer_id")?;
let rows = stmt.query_map([], |row| {
Ok(StoredPeerCard {
peer_id: row.get(0)?,
card_json: row.get(1)?,
updated_at_ms: row.get(2)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request( pub fn insert_ssh_cert_request(
&self, &self,
request: &StoredSshCertRequest, request: &StoredSshCertRequest,
@ -430,6 +469,13 @@ pub struct CasObject {
pub path: String, pub path: String,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredPeerCard {
pub peer_id: String,
pub card_json: String,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest { pub struct StoredSshCertRequest {
pub request_id: String, pub request_id: String,
@ -527,4 +573,20 @@ mod tests {
vec![revocation] vec![revocation]
); );
} }
#[test]
fn peer_card_roundtrip() {
let store = Store::open_memory().expect("open");
let peer_card = StoredPeerCard {
peer_id: "node:laptop".to_owned(),
card_json: r#"{"node_id":"node:laptop"}"#.to_owned(),
updated_at_ms: 10,
};
store.upsert_peer_card(&peer_card).expect("insert");
assert_eq!(
store.get_peer_card("node:laptop").expect("get"),
Some(peer_card.clone())
);
assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]);
}
} }

View file

@ -20,4 +20,5 @@ 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-node = { path = "../geth-node" } geth-node = { path = "../geth-node" }
geth-store = { path = "../geth-store" }
tempfile.workspace = true tempfile.workspace = true

View file

@ -116,6 +116,38 @@ fn initialized_node_can_roundtrip_cas_blob() {
); );
} }
#[test]
fn auth_explain_distinguishes_discovered_peer_candidates() {
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let store = geth_store::Store::open(&paths.metadata_db()).expect("open store");
store
.upsert_peer_card(&geth_store::StoredPeerCard {
peer_id: "node:discovered".to_owned(),
card_json: r#"{"node_id":"node:discovered"}"#.to_owned(),
updated_at_ms: 1,
})
.expect("insert peer card");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:discovered".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("auth explain");
match response {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed);
assert!(explanation.reason.contains("discovered peer candidate"));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test] #[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() { fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -38,6 +38,12 @@ connectivity and mDNS/LAN discovery for local networks. These are connectivity
and candidate-discovery mechanisms only. They do not grant trust, mutate 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.
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.
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
recorded as a transport binding for the stable geth node identity. If local UDP recorded as a transport binding for the stable geth node identity. If local UDP

View file

@ -101,14 +101,14 @@ geth-to-geth connections without granting trust from discovery alone.
- Unknown ALPNs are rejected explicitly. - Unknown ALPNs are rejected explicitly.
- Tests cover registration collisions and unknown protocol handling. - Tests cover registration collisions and unknown protocol handling.
- `[ ]` Peer cards. - `[x]` Peer cards.
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 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.
- `[ ]` Untrusted discovery backend trait. - `[x]` Untrusted discovery backend trait.
Acceptance criteria: Acceptance criteria:
- Discovery returns candidate peer cards only. - Discovery returns candidate peer cards only.
- No discovery result grants capabilities or trust. - No discovery result grants capabilities or trust.