From 1e186650b8e1768392b7fa8e76b73a8d60a08658 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:32:28 +0200 Subject: [PATCH 1/5] =?UTF-8?q?fix(rs):=20fix=20DHT=20bootstrap=20?= =?UTF-8?q?=E2=80=94=20add=20RSA=20support,=20use=20resolved=20/dns/=20add?= =?UTF-8?q?resses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default IPFS bootstrap nodes used /dnsaddr/ multiaddrs which require TXT record resolution not performed by libp2p's transport layer. Also, all IPFS bootstrap PeerIDs are RSA-based (Qm...) but only the ed25519 feature was enabled. Changes: - Enable `rsa` feature in libp2p and libp2p-identity - Replace /dnsaddr/ addresses with resolved /dns/ addresses (QUIC+TCP) - Make KadStartProviding wait for actual DHT propagation before replying (previously returned Ok immediately on local accept, misleading callers) --- Cargo.toml | 4 +- packages/rs/cairn-p2p/src/transport/swarm.rs | 61 ++++++++++++++++---- 2 files changed, 51 insertions(+), 14 deletions(-) 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( From c5129a6cac994fda05acb6a8baa88ee580dff831 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:33:24 +0200 Subject: [PATCH 2/5] fix(ts): add bootstrap peers to browser Kademlia DHT Browser DHT was created with clientMode but zero bootstrap peers, making lookupPinOnDht always fail. Add WSS addresses of the IPFS bootstrap nodes via @libp2p/bootstrap (already in deps). --- .../ts/cairn-p2p/src/transport/libp2p-node.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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, From 91528fe068d76937193b6f4214728ae09610699a Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:34:45 +0200 Subject: [PATCH 3/5] fix(ts): replace pairEnterPin stub with DHT lookup + caller-provided peer ID pairEnterPin previously returned random bytes as the remote peer ID. Now accepts an optional remotePeerId parameter (from connection profile) and falls back to DHT lookup via lookupPinOnDht. Throws PAIRING error if neither source provides a peer ID. --- packages/ts/cairn-p2p/src/node.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 { From 2bb3e738281ce5aed055ae0cc2735fce65acc7f4 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:01:30 +0200 Subject: [PATCH 4/5] chore(rs): update Cargo.lock for rsa feature dependencies --- Cargo.lock | 8 ++++++++ 1 file changed, 8 insertions(+) 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", From f8eca09e4236e5f3f1facfa1010f5506fb09bf92 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:08:02 +0200 Subject: [PATCH 5/5] test(ts): update pairEnterPin test for new remotePeerId parameter Test now passes remotePeerId explicitly (simulating profile-based flow) and adds a separate test verifying the error when DHT is unavailable. --- packages/ts/cairn-p2p/tests/unit/config-api.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 () => {