diff --git a/Cargo.lock b/Cargo.lock index ba41c9a..7756f3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,12 @@ dependencies = [ "syn", ] +[[package]] +name = "asn1_der" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156" + [[package]] name = "async-io" version = "2.6.0" @@ -1910,12 +1916,14 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" dependencies = [ + "asn1_der", "bs58", "ed25519-dalek", "hkdf", "multihash", "quick-protobuf", "rand 0.8.5", + "ring 0.17.14", "sha2", "thiserror 2.0.18", "tracing", diff --git a/Cargo.toml b/Cargo.toml index e1aa706..58b293a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v7", "serde"] } ed25519-dalek = { version = "2", features = ["rand_core"] } -libp2p = { version = "0.54", features = ["tokio", "quic", "tcp", "websocket", "tls", "yamux", "noise", "dns", "macros", "ed25519", "mdns", "kad", "request-response", "identify", "relay", "dcutr", "autonat", "upnp"] } -libp2p-identity = { version = "0.2", features = ["ed25519"] } +libp2p = { version = "0.54", features = ["tokio", "quic", "tcp", "websocket", "tls", "yamux", "noise", "dns", "macros", "ed25519", "rsa", "mdns", "kad", "request-response", "identify", "relay", "dcutr", "autonat", "upnp"] } +libp2p-identity = { version = "0.2", features = ["ed25519", "rsa"] } x25519-dalek = { version = "2", features = ["static_secrets"] } hkdf = "0.12" sha2 = "0.10" diff --git a/packages/rs/cairn-p2p/src/transport/swarm.rs b/packages/rs/cairn-p2p/src/transport/swarm.rs index 5153978..5c9609b 100644 --- a/packages/rs/cairn-p2p/src/transport/swarm.rs +++ b/packages/rs/cairn-p2p/src/transport/swarm.rs @@ -531,11 +531,23 @@ use behaviour::CairnBehaviourEvent; /// Public IPFS bootstrap nodes for joining the global Kademlia DHT. /// These allow cairn peers to find each other across the internet without /// any cairn-specific infrastructure. +/// +/// Uses resolved `/dns/` addresses instead of `/dnsaddr/` — the dnsaddr protocol +/// requires TXT record resolution that libp2p's transport layer does not perform. +/// Each node has QUIC (preferred) + TCP + WSS fallback addresses. pub const DEFAULT_BOOTSTRAP_NODES: &[&str] = &[ - "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", - "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", - "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", - "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", + // sv15 — San Jose + "/dns/sv15.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "/dns/sv15.bootstrap.libp2p.io/tcp/4001/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + // ny5 — New York + "/dns/ny5.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", + "/dns/ny5.bootstrap.libp2p.io/tcp/4001/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", + // am6 — Amsterdam + "/dns/am6.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", + "/dns/am6.bootstrap.libp2p.io/tcp/4001/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", + // sg1 — Singapore + "/dns/sg1.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", + "/dns/sg1.bootstrap.libp2p.io/tcp/4001/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", ]; /// Extract the PeerId from the last `/p2p/` component of a multiaddr. @@ -774,6 +786,13 @@ async fn run_event_loop( let mut pending_kad_gets: std::collections::HashMap = std::collections::HashMap::new(); + // Pending Kademlia START_PROVIDING queries: query_id -> reply sender. + type KadStartProvidingReply = oneshot::Sender>; + let mut pending_kad_start_providing: std::collections::HashMap< + libp2p::kad::QueryId, + KadStartProvidingReply, + > = std::collections::HashMap::new(); + // Pending Kademlia GET_PROVIDERS queries: query_id -> (reply sender, accumulated providers). type KadGetProvidersReply = oneshot::Sender>>; let mut pending_kad_get_providers: std::collections::HashMap< @@ -857,15 +876,22 @@ async fn run_event_loop( } Some(SwarmCommand::KadStartProviding { key, reply }) => { let record_key = libp2p::kad::RecordKey::new(&key); - let result = swarm + match swarm .behaviour_mut() .kademlia .start_providing(record_key) - .map(|_| ()) - .map_err(|e| { - CairnError::Transport(format!("kad start_providing failed: {e:?}")) - }); - let _ = reply.send(result); + { + Ok(query_id) => { + // Defer reply until the StartProviding event confirms + // the record was actually propagated to DHT peers. + pending_kad_start_providing.insert(query_id, reply); + } + Err(e) => { + let _ = reply.send(Err(CairnError::Transport( + format!("kad start_providing failed: {e:?}"), + ))); + } + } } Some(SwarmCommand::KadGetProviders { key, reply }) => { let record_key = libp2p::kad::RecordKey::new(&key); @@ -1030,17 +1056,28 @@ async fn run_event_loop( } LibSwarmEvent::Behaviour(CairnBehaviourEvent::Kademlia( libp2p::kad::Event::OutboundQueryProgressed { + id, result: libp2p::kad::QueryResult::StartProviding(result), .. }, )) => { - match result { + match &result { Ok(ok) => info!( key = ?ok.key, - "Kademlia: started providing" + "Kademlia: provider record propagated to DHT" ), Err(e) => warn!(?e, "Kademlia: start providing failed"), } + if let Some(reply) = pending_kad_start_providing.remove(&id) { + match result { + Ok(_) => { let _ = reply.send(Ok(())); } + Err(e) => { + let _ = reply.send(Err(CairnError::Transport( + format!("DHT provider record propagation failed: {e:?}"), + ))); + } + } + } None } LibSwarmEvent::Behaviour(CairnBehaviourEvent::Kademlia( diff --git a/packages/ts/cairn-p2p/src/node.ts b/packages/ts/cairn-p2p/src/node.ts index faec186..06d3fa8 100644 --- a/packages/ts/cairn-p2p/src/node.ts +++ b/packages/ts/cairn-p2p/src/node.ts @@ -885,15 +885,26 @@ export class Node { }; } - async pairEnterPin(pin: string): Promise { + async pairEnterPin(pin: string, remotePeerId?: string): Promise { const normalized = normalizePin(pin); validatePin(normalized); const password = new TextEncoder().encode(normalized); this._runPairingExchange(password); - // In a real implementation, the remote peer ID would come from the pairing exchange - const remotePeerId = bytesToHex(crypto.getRandomValues(new Uint8Array(32))); - this._completePairing(remotePeerId); - return remotePeerId; + + // Use the caller-provided remote peer ID (from a connection profile or + // DHT lookup), or attempt DHT discovery as a fallback. + let peerId = remotePeerId; + if (!peerId) { + peerId = (await this.lookupPinOnDht(normalized)) ?? undefined; + } + if (!peerId) { + throw new CairnError( + 'PAIRING', + 'Could not determine remote peer ID. Provide a connection profile or ensure DHT is reachable.', + ); + } + this._completePairing(peerId); + return peerId; } async pairGenerateLink(): Promise { diff --git a/packages/ts/cairn-p2p/src/transport/libp2p-node.ts b/packages/ts/cairn-p2p/src/transport/libp2p-node.ts index 4790a00..ea3d77b 100644 --- a/packages/ts/cairn-p2p/src/transport/libp2p-node.ts +++ b/packages/ts/cairn-p2p/src/transport/libp2p-node.ts @@ -141,6 +141,7 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise = {}; + const peerDiscovery: unknown[] = []; if (!isNodeEnvironment()) { const { identify } = await import('@libp2p/identify'); services.identify = identify(); @@ -150,6 +151,20 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise 0 ? { peerDiscovery: peerDiscovery as any[] } : {}), connectionManager: { // @ts-expect-error minConnections exists at runtime but is missing from ConnectionManagerInit typings minConnections: 0, diff --git a/packages/ts/cairn-p2p/tests/unit/config-api.test.ts b/packages/ts/cairn-p2p/tests/unit/config-api.test.ts index af115bc..e9172b1 100644 --- a/packages/ts/cairn-p2p/tests/unit/config-api.test.ts +++ b/packages/ts/cairn-p2p/tests/unit/config-api.test.ts @@ -102,10 +102,15 @@ describe('Node pairing', () => { expect(data.expiresIn).toBe(node.config.reconnectionPolicy.pairingPayloadExpiry); }); - it('pairEnterPin succeeds with valid pin', async () => { + it('pairEnterPin succeeds with valid pin and remotePeerId', async () => { const node = await Node.create(); - const peerId = await node.pairEnterPin('ABCD-EFGH'); - expect(peerId).toBeTruthy(); + const peerId = await node.pairEnterPin('ABCD-EFGH', 'test-peer-id-123'); + expect(peerId).toBe('test-peer-id-123'); + }); + + it('pairEnterPin throws without remotePeerId when DHT unavailable', async () => { + const node = await Node.create(); + await expect(node.pairEnterPin('ABCD-EFGH')).rejects.toThrow('Could not determine remote peer ID'); }); it('pairEnterPin rejects invalid characters', async () => {