Add manual signed peer card exchange
This commit is contained in:
parent
20c88af800
commit
1ab24631cd
14 changed files with 360 additions and 25 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue