Skip to content

Commit d166ece

Browse files
authored
fix: keep a stranger on the relay, where the protocols already refuse them (#71)
`/ablage/sync/1.0.0` is gated by the admission dialog, and for a while that was believed to cover everything. It does not: bitswap is a second protocol on the same node and serves any block it holds to anyone who names the address. That is #43, and it was measured rather than argued. What could not be settled at the time was why. `@helia/bitswap` registers its handler with `runOnLimitedConnection: false`, so a relayed connection should already have been refused - and the first reading said the leak happened over one anyway, concluding it was older than DCUtR. That reading was wrong, and the `holePunch: false` switch from #66 is what settles it. With no DCUtR and no `/webrtc` address the circuit is the only path two peers can have, and an unadmitted read over it times out. So the earlier `limited: true` observation was a second, unlimited connection standing beside the one being reported. The hole is the upgrade, not the circuit. A stranger is refused for as long as they are stuck on the relay and served the moment DCUtR gets them off it, because a direct connection is unlimited and every protocol on the node becomes reachable at once. So `peer.js` closes a direct connection to a peer that was neither scanned nor admitted. Narrow on purpose: it inherits every gate the relayed path already has, instead of adding a second list of protocols to keep in step. Two things had to be narrowed before it was right, and both were the same mistake - a guard aimed at strangers hitting our own people first. It closed the relay connection. The relay is unlimited, and a peer this node has no relationship with by construction. Discovery went with it and neither peer heard the other at all. Only `/webrtc` is a link to another browser; everything else is infrastructure. It closed scanned peers. `acceptOffer` and `acceptAnswer` recorded the scan *after* the call that establishes the connection, so `connection:open` fired first and a connection that existed because somebody scanned a code looked exactly like a stranger's. Ten QR specs failed there. The scan is recorded before the session call now, still only after `decodePayload` verifies the signature - an unverifiable code was never consent and still is not. Three specs, and the mutation check shows they test three things: disable the guard and only the third fails. a stranger over a direct connection reads - the protocol hole, unchanged a stranger with only a circuit reads nothing a stranger with the hole punch available never gets the direct connection 171 unit tests, 443 browser tests green. Withdrawal is split out as #70: somebody admitted once keeps every address they saw, and taking that back needs the blocks to be useless without a key.
1 parent ae660a5 commit d166ece

7 files changed

Lines changed: 281 additions & 27 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ablage",
3-
"version": "0.1.23",
3+
"version": "0.1.24",
44
"private": true,
55
"type": "module",
66
"description": "A folder that stays the same on two devices. No account, nothing in the middle.",

src/app/main.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,16 @@ const letGo = new Set()
478478
/** Who may write into this folder. The QR scan is the only automatic yes. */
479479
const admitted = admission({ key: scoped('ablage.admitted', shareId) })
480480

481+
/**
482+
* Who was let in during this session, remembered or not.
483+
*
484+
* Read by the guard in `peer.js`, which closes a *direct* connection to anyone
485+
* this device has no relationship with - see #43. Kept beside the stored set
486+
* rather than inside it: this one is deliberately forgotten on reload, because
487+
* a one-time yes was never meant to outlive the session.
488+
*/
489+
const letIn = new Set()
490+
481491
/** This folder's id and name, for the message a switch sends. */
482492
let folder = { id: null, name: null }
483493

@@ -903,6 +913,14 @@ async function render () {
903913
* replaces that peer's provider rather than accumulating one per attempt.
904914
*/
905915
function attach (stream, peerId) {
916+
// **Admitted for this session, whether or not the box was ticked.**
917+
//
918+
// `admitted.remembered()` only knows the peers somebody chose to keep. A
919+
// one-time yes is just as much a yes, and the guard in `peer.js` has to hear
920+
// about it - otherwise the direct connection to somebody who was let in a
921+
// moment ago gets closed as a stranger's.
922+
letIn.add(String(peerId))
923+
906924
const send = message => stream.send(encode(JSON.stringify(message)))
907925

908926
// Its own binding, read by its own loop below. Reading the shared one was
@@ -1031,6 +1049,10 @@ async function start () {
10311049
// during the introduction's check and heard nobody for ever, which is what
10321050
// "connected, and no peers anywhere" turned out to mean.
10331051
relayBootstrapAddrs: startupRelays(localStore, RELAY_ADDRESSES_KEY),
1052+
1053+
// Both halves of "does this peer have a relationship with us": the ones
1054+
// somebody chose to keep, and the ones let in since this page loaded.
1055+
admitted: id => letIn.has(String(id)) || admitted.remembered(String(id)),
10341056
onSyncStream: (stream, peerId) => {
10351057
// `peer.arrivedByScan`, not the address. A peer that hole-punched out of
10361058
// the relay has a `/webrtc/p2p/<id>` address with no circuit in it -

src/harness.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ window.__ablage = {
174174
* step between "they see each other" and "they sync", and it is where the
175175
* report of two devices that find each other and do nothing points.
176176
*/
177-
meetAndDial: async ({ holePunch = true } = {}) => {
177+
meetAndDial: async ({ holePunch = true, admitAll = false } = {}) => {
178178
const { createPeer } = await import('./peer.js')
179179

180180
const heard = new Set()
@@ -192,7 +192,13 @@ window.__ablage = {
192192
// With this off there is no DCUtR and no `/webrtc` address, so the
193193
// circuit is the only path that will ever exist - which is what two
194194
// phones on mobile data have.
195-
holePunch
195+
holePunch,
196+
197+
// A spec measuring *transport* - does DCUtR get these two off the relay -
198+
// has to say so, because a node now closes a direct connection to anyone
199+
// it has no relationship with. Saying it out loud beats a spec that
200+
// silently measures the guard instead of the hole punch.
201+
admitted: () => admitAll
196202
})
197203

198204
peer.node.addEventListener('peer:discovery', event => heard.add(event.detail.id.toString()))
@@ -263,12 +269,22 @@ window.__ablage = {
263269
* comes back with the bytes, an unadmitted peer can read a file whose address
264270
* it knows.
265271
*/
266-
bitswapAcrossTheRelay: async () => {
272+
bitswapAcrossTheRelay: async ({ holePunch = true, admitAll = false } = {}) => {
267273
const { createPeer } = await import('./peer.js')
268274
const { createContent } = await import('./content.js')
269275

270276
const start = async () => {
271-
const peer = await createPeer({ relayOptIn: true, relayBootstrapAddrs: await relayAddresses() })
277+
// With the hole punch off there is no DCUtR and no `/webrtc` address, so
278+
// the circuit is the only path the two can ever have. That is what makes
279+
// "bitswap refuses limited connections" a claim this harness can test
280+
// rather than hope for: with a direct path available it is never put to
281+
// the question.
282+
const peer = await createPeer({
283+
relayOptIn: true,
284+
relayBootstrapAddrs: await relayAddresses(),
285+
holePunch,
286+
admitted: () => admitAll
287+
})
272288
const heard = new Set()
273289

274290
peer.node.addEventListener('peer:discovery', event => heard.add(event.detail.id.toString()))

src/peer.js

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { identify, identifyPush } from '@libp2p/identify'
99
import { ping } from '@libp2p/ping'
1010
import { webRTC } from '@libp2p/webrtc'
1111
import { webSockets } from '@libp2p/websockets'
12-
import { decodePayload, QRSession, QR_TYPE_OFFER, webRTCQR } from '@le-space/libp2p-webrtc-qr'
12+
import { decodePayload, QRSession, QR_TYPE_ANSWER, QR_TYPE_OFFER, webRTCQR } from '@le-space/libp2p-webrtc-qr'
1313
import { createLibp2p } from 'libp2p'
1414

1515
import { denyDial, relayBootstrapList } from './relay-policy.js'
@@ -146,7 +146,20 @@ export async function createPeer ({
146146
* So this exists to take the direct path away: no DCUtR to arrange one, and
147147
* no `/webrtc` address to arrange it to.
148148
*/
149-
holePunch = true
149+
holePunch = true,
150+
151+
/**
152+
* Does this peer have a relationship with this device?
153+
*
154+
* Consulted before a *direct* connection to them is allowed to stand. The
155+
* default answers no to everybody, which is the conservative half: a node
156+
* whose caller wires nothing up keeps strangers on the relay, where the
157+
* protocols that matter already refuse them.
158+
*
159+
* Scanning a code is the other way in and is not asked about here - the scan
160+
* was the consent, and `scanned` records the act.
161+
*/
162+
admitted = () => false
150163
} = {}) {
151164
let session = null
152165

@@ -316,6 +329,90 @@ export async function createPeer ({
316329
}
317330
})
318331

332+
/**
333+
* Record a scanned peer *before* the connection to them exists.
334+
*
335+
* `session.acceptOffer` and `session.acceptAnswer` are what establish the
336+
* WebRTC connection, so the guard below sees `connection:open` while they are
337+
* still running. Recording the scan afterwards left a window in which a
338+
* connection that exists *because* somebody scanned a code looked exactly
339+
* like a stranger's and was closed - ten QR specs failed there, which is how
340+
* the ordering was found.
341+
*
342+
* Still only after verification. `decodePayload` checks the signature, and an
343+
* offer that does not decode is not consent to anything - that property is
344+
* older than the guard and is kept.
345+
*
346+
* @param {string} payload
347+
* @param {unknown} type
348+
*/
349+
async function noteScan (payload, type) {
350+
try {
351+
const { peerId } = await decodePayload(payload, type)
352+
353+
scanned.add(String(peerId))
354+
} catch {
355+
// Unverifiable, so not consent. The peer is asked about rather than
356+
// admitted, which is the direction to fail in.
357+
}
358+
}
359+
360+
/**
361+
* **Keep a stranger on the relay.**
362+
*
363+
* `/ablage/sync/1.0.0` is gated by the admission dialog, and for a while that
364+
* was believed to cover everything. It does not: bitswap is a second protocol
365+
* on the same node, and it serves any block it holds to anyone who names the
366+
* address. Measured, and it is #43.
367+
*
368+
* What was *not* known until it could be measured in isolation is that
369+
* bitswap already refuses a relayed connection - `runOnLimitedConnection:
370+
* false` is its default and it works. Over a circuit that is the only path
371+
* two peers have, an unadmitted read times out. The leak is not the circuit;
372+
* it is the moment DCUtR gets them off it, because a direct connection is
373+
* unlimited and every protocol on the node becomes reachable at once.
374+
*
375+
* So this closes the direct connection rather than gating each protocol on
376+
* it. Narrow, and it inherits every gate the relayed path already has instead
377+
* of adding a new list to keep in step.
378+
*
379+
* **What it does not fix.** Somebody admitted once keeps what they saw and
380+
* can fetch those blocks again afterwards; taking that back needs the bytes
381+
* to be useless without a key, which is a different change.
382+
*/
383+
node.addEventListener('connection:open', event => {
384+
const connection = event.detail
385+
386+
// Relayed, and therefore already refused by the protocols that matter.
387+
if (connection?.limits != null) return
388+
389+
/**
390+
* **Only a link to another browser, never one to infrastructure.**
391+
*
392+
* The first version of this closed anything unlimited, and the relay is
393+
* exactly that: a plain WebSocket this node dialled, to a peer it has no
394+
* relationship with by construction. Closing it took discovery down with
395+
* it and the two peers never heard each other at all.
396+
*
397+
* Two browsers can only ever reach each other directly over WebRTC - a
398+
* hole punch reads `/p2p-circuit/webrtc/p2p/…`, a scanned session reads
399+
* `/webrtc/p2p/…`. Anything without `/webrtc` in it is a server, and this
400+
* is not about servers.
401+
*/
402+
if (!String(connection.remoteAddr ?? '').includes('/webrtc')) return
403+
404+
const id = String(connection.remotePeer)
405+
406+
// The scan was the consent, and the QR path is direct by construction.
407+
if (scanned.has(id)) return
408+
if (admitted(id)) return
409+
410+
connection.close().catch(() => {
411+
// A connection that will not close is one libp2p is already tearing
412+
// down. Nothing better to do here than let it.
413+
})
414+
})
415+
319416
session = new QRSession(node, rtcConfiguration != null ? { rtcConfiguration } : {})
320417

321418
// Positional, like the demo. Destructuring `{ stream }` here reports itself as
@@ -503,25 +600,20 @@ export async function createPeer ({
503600
* consent to anything.
504601
*/
505602
acceptOffer: async offer => {
506-
const answer = await session.acceptOffer(offer)
603+
await noteScan(offer, QR_TYPE_OFFER)
507604

508-
try {
509-
const { peerId } = await decodePayload(offer, QR_TYPE_OFFER)
510-
511-
scanned.add(String(peerId))
512-
} catch {
513-
// It decoded a moment ago inside `acceptOffer`, so this is close to
514-
// unreachable. If it ever happens the peer is asked about rather than
515-
// admitted, which is the direction to fail in.
516-
}
517-
518-
return answer
605+
return session.acceptOffer(offer)
519606
},
520607

521608
/** Back on the offering side: read the reply and connect. */
522609
acceptAnswer: async answer => {
610+
await noteScan(answer, QR_TYPE_ANSWER)
611+
523612
const { peerId } = await session.acceptAnswer(answer)
524613

614+
// Again, and this one is authoritative: the session knows who it
615+
// actually connected to, while the line above only knows who signed the
616+
// code. They agree in every case that works.
525617
scanned.add(peerId.toString())
526618
return peerId.toString()
527619
},

0 commit comments

Comments
 (0)