Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
61 changes: 49 additions & 12 deletions packages/rs/cairn-p2p/src/transport/swarm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<peer_id>` component of a multiaddr.
Expand Down Expand Up @@ -774,6 +786,13 @@ async fn run_event_loop(
let mut pending_kad_gets: std::collections::HashMap<libp2p::kad::QueryId, KadGetReply> =
std::collections::HashMap::new();

// Pending Kademlia START_PROVIDING queries: query_id -> reply sender.
type KadStartProvidingReply = oneshot::Sender<Result<()>>;
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<Result<Vec<PeerId>>>;
let mut pending_kad_get_providers: std::collections::HashMap<
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 16 additions & 5 deletions packages/ts/cairn-p2p/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@
/** Outbox of encoded envelopes (transport would drain this). */
readonly outbox: Uint8Array[] = [];
/** Reference to the libp2p node for sending (null if no transport). */
private _libp2pNode: any = null;

Check warning on line 159 in packages/ts/cairn-p2p/src/node.ts

View workflow job for this annotation

GitHub Actions / test-ts

Unexpected any. Specify a different type
/** The remote peer's libp2p PeerId (null if no transport). */
private _remotePeerId: any = null;

Check warning on line 161 in packages/ts/cairn-p2p/src/node.ts

View workflow job for this annotation

GitHub Actions / test-ts

Unexpected any. Specify a different type
/** Guard to prevent concurrent outbox drains. */
private _draining = false;
/** 16-byte session identifier for resumption. */
Expand Down Expand Up @@ -885,15 +885,26 @@
};
}

async pairEnterPin(pin: string): Promise<string> {
async pairEnterPin(pin: string, remotePeerId?: string): Promise<string> {
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<LinkPairingData> {
Expand Down
16 changes: 16 additions & 0 deletions packages/ts/cairn-p2p/src/transport/libp2p-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp
// Build the services object. In browsers, add identify (required for
// circuit relay and WebRTC) and optionally bootstrap for DHT discovery.
const services: Record<string, unknown> = {};
const peerDiscovery: unknown[] = [];
if (!isNodeEnvironment()) {
const { identify } = await import('@libp2p/identify');
services.identify = identify();
Expand All @@ -150,6 +151,20 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp
const { kadDHT } = await import('@libp2p/kad-dht');
services.dht = kadDHT({ clientMode: true });
} catch { /* @libp2p/kad-dht not available */ }

// Bootstrap peers — WSS addresses of IPFS DHT bootstrap nodes.
// Required for the browser's Kademlia routing table to have peers to query.
try {
const { bootstrap } = await import('@libp2p/bootstrap');
peerDiscovery.push(bootstrap({
list: [
'/dns/sv15.bootstrap.libp2p.io/tcp/443/wss/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN',
'/dns/ny5.bootstrap.libp2p.io/tcp/443/wss/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa',
'/dns/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb',
'/dns/sg1.bootstrap.libp2p.io/tcp/443/wss/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt',
],
}));
} catch { /* @libp2p/bootstrap not available */ }
}

const node = await createLibp2p({
Expand All @@ -160,6 +175,7 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp
connectionEncrypters: [noise()],
// @ts-expect-error libp2p ServiceFactoryMap types are too strict for dynamic service maps
services,
...(peerDiscovery.length > 0 ? { peerDiscovery: peerDiscovery as any[] } : {}),
connectionManager: {
// @ts-expect-error minConnections exists at runtime but is missing from ConnectionManagerInit typings
minConnections: 0,
Expand Down
11 changes: 8 additions & 3 deletions packages/ts/cairn-p2p/tests/unit/config-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading