Skip to content

feat: Invite over internet#1254

Draft
RangerMauve wants to merge 31 commits intomainfrom
feat/invite-over-internet
Draft

feat: Invite over internet#1254
RangerMauve wants to merge 31 commits intomainfrom
feat/invite-over-internet

Conversation

@RangerMauve
Copy link
Copy Markdown
Contributor

Closes #1207

@awana-lockfile-bot
Copy link
Copy Markdown

package-lock.json changes

Summary

Status Count
ADDED 26
UPDATED 4
Click to toggle table visibility
Name Status Previous Current
adaptive-timeout ADDED - 1.0.1
bare-addon-resolve ADDED - 1.10.0
bare-ansi-escapes ADDED - 2.2.3
bare-assert ADDED - 1.2.0
bare-events UPDATED 2.4.2 2.8.2
bare-inspect ADDED - 3.1.4
bare-module-resolve ADDED - 1.12.1
bare-semver ADDED - 1.0.2
bare-stream UPDATED 2.1.3 2.10.0
bare-type ADDED - 1.1.0
bits-to-bytes ADDED - 1.3.0
blind-relay ADDED - 1.4.0
compact-encoding-bitfield ADDED - 1.0.0
dht-rpc ADDED - 6.26.3
events-universal ADDED - 1.0.1
hypercore-id-encoding ADDED - 1.3.0
hyperdht ADDED - 6.29.4
hyperswarm ADDED - 4.17.0
kademlia-routing-table ADDED - 1.0.6
nat-sampler ADDED - 1.0.1
record-cache ADDED - 1.2.0
require-addon ADDED - 1.2.0
shuffled-priority-queue ADDED - 2.1.0
signal-promise ADDED - 1.0.3
streamx UPDATED 2.19.0 2.25.0
teex ADDED - 1.0.1
time-ordered-set ADDED - 2.0.1
udx-native ADDED - 1.19.2
unordered-set ADDED - 2.0.1
xache UPDATED 1.1.0 1.2.1

@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 24, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedstreamx@​2.19.0 ⏵ 2.25.0100 +110010090 +9100
Addedhyperswarm@​4.17.09110010093100

View full report

@RangerMauve RangerMauve changed the title feat: RemoteDiscovery module with Hyperswarm feat: Invite over internet Mar 25, 2026
Copy link
Copy Markdown
Member

@gmaclennan gmaclennan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments on the approach. I'm not sure the division of responsiblities between the member-api and the remote-discovery class is quite right, but it depends on how you resolve the validation / authentication of incoming connections, which is the most important challenge to address.

Comment thread src/discovery/remote-discovery.js
Comment thread src/mapeo-manager.js
Comment thread src/mapeo-manager.js
Comment thread src/member-api.js Outdated
Comment thread src/member-api.js Outdated
@RangerMauve
Copy link
Copy Markdown
Contributor Author

I've got the connection and invite flow going. We want to make it harder to track specific peers via the DHT, therefore we want to use a fresh DHT keypair each time. The difficult part is that the keypair is used for the deviceId during the RPC API and that's tied with the member record. We'd need to either tie all members with an additional keypair, or somehow override the deviceID at the RPC layer, or something along those lines.

Gregor proposed we have something like this to send the real identity as the first packet down the noise stream after handshaking.

// Stable identity — persisted across sessions
const stableKeyPair = DHT.keyPair(someSeed)

// Ephemeral DHT presence — rotated each session
const swarm = new Hyperswarm({ keyPair: DHT.keyPair() })

swarm.join(topic, { server: true, client: true })

swarm.on('connection', async (conn, peerInfo) => {
  try {
    const remote = await handshakeIdentity(conn, stableKeyPair)
    console.log('Authenticated:', b4a.toString(remote.publicKey, 'hex'))
  } catch (err) {
    conn.destroy()
  }
})

function handshakeIdentity (conn, keyPair) {
  return new Promise((resolve, reject) => {
    // Sign the Noise handshake hash with our stable key
    const sig = b4a.allocUnsafe(64)
    sodium.crypto_sign_detached(sig, conn.handshakeHash, keyPair.secretKey)

    // Send stable public key + proof in a single message
    conn.write(encodeHandshake({
      publicKey: keyPair.publicKey,
      signature: sig
    }))

    conn.once('data', (data) => {
      const msg = decodeHandshake(data)

      const valid = sodium.crypto_sign_verify_detached(
        msg.signature,
        conn.handshakeHash,  // same hash on both sides
        msg.publicKey
      )

      if (!valid) return reject(new Error('Invalid identity proof'))
      resolve({ publicKey: msg.publicKey })
    })

    setTimeout(() => reject(new Error('Auth timeout')), 10000)
  })
}

This could leave the option to join by remote public key instead of a fresh topic.

We will also need some sort of access control step to have the invite ID before we expose the protomux channel for RPC.

@RangerMauve
Copy link
Copy Markdown
Contributor Author

Been wrestling with some sort of race condition for several hours now. I think I traced it down to the protomux channel recieving data before it's fully opened.

Copy link
Copy Markdown
Member

@gmaclennan gmaclennan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think we need an authentication step before connecting to the RPC channel:

Validating the inviteID and allowing the user (invitor) to confirm before connecting, which means queueing the connection somewhere until the user confirms

Comment thread src/discovery/remote-discovery.js Outdated
}).finish()
)

const data = await firstData
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No guarantee this will come as a single chunk. Maybe needs to be length delimited or something? Or rely on it being a fixed length.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Length delimited is probably the safest option. uint32 to be safe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand I'm like 99% sure that the first packet will always be a single UDP packet passed through UDX

Comment thread src/discovery/remote-discovery.js Outdated
@RangerMauve
Copy link
Copy Markdown
Contributor Author

Been blocked getting the tests to work with the handshake logic. Seems the connection is breaking when we try to write the public key down the wire and isn't draining.

@RangerMauve
Copy link
Copy Markdown
Contributor Author

Might do a flag in local-peers to ignore incoming RPC messages until we get authenticated? I think this extra handshake step is making everything more fragile.

@RangerMauve
Copy link
Copy Markdown
Contributor Author

Been trying a bunch of ways to rework this and I'm getting a bit stuck. Gonna write out some thoughts on approaches here.

Initial approach:

  • Invitor creates invite id
  • Invitor starts swarm to listen on connections with same identity keypair (with deviceId ad publicKey) as local connections
  • Invitor sends URL with own deviceId and the inviteId to invitee out of band
  • Invitee takes deviceID and inviteId from URL
  • Invitee starts swarm with own identity keypair
  • Invitee calls "joinPeer" with invitor deviceId and waits for a connection to be established to them
  • Invitee calls RPC API to send inviteID to the invitor deviceID to redeem the invite
  • Meanwhile, the invitor was replicating all incoming swarm connections to localPeers and was waiting for an invite redeem call
  • Invitor runs regular invite flow to the deviceID if the inviteID is valid
  • Invitee auto-accepts invite if it is from the invitor deviceID
  • Invitee becomes part of the project

This worked fine

Ephemeral keys approach

  • Invitor initializes a fresh ephemeral keypair for the swarm
  • Invitor creates invite id
  • Invitor starts swarm to listen on connections with swarm keypair
  • Invitor sends URL with swarm public key and the inviteId to invitee out of band
  • Invitee takes deviceID and inviteId from URL
  • Invitee starts swarm with own ephemeral swarm keypair
  • Invitee calls "joinPeer" with invitor swarm id and waits for a connection to be established to the ephemeral keypair
  • On both sides we wait for the first "network packet" and fetch a proof for them owning a deviceId keypair (this is their identity key). This Id is used to identify them in local-peers instead of the remotePublic key of their ephemeral swarm keypair in the connection
  • While waiting for this proof, we generate our own proof and send it down the wire
  • Invitee calls RPC API to send inviteID to the invitor deviceID to redeem the invite
  • Invitor runs regular invite flow
  • Invitee auto-accepts invite if it is from the invitor deviceID
  • Invitee becomes part of the project

This didn't work because the connection was "breaking" after sending the first packet. I think it's got something to do with how we're reading a packet of the stream before sending it off elsewhere, but it's been really difficult to debug.

Edit: I got this working! I needed to pause the stream after getting the first packet, else we'd drop an event.

Queue up connections before RPC

In order to guard the RPC methods from spam, we should prompt the user to verify them before replicating anything. If we queue up the connections (at the manager level) we need a new place to track them that isn't local peers and would need to somehow tie them to the project they're trying to join via the member API. This extra book keeping and connection management is IMO not ideal. We'd likely need to add the invite ID into the handshake after establishing hyperswarm, which would also mean getting the remote discovery module to ask each project's member-api if this is a valid invite or not. Kinda leaky IMO

Alternate: Disable RPC until connection is verified

Instead we could mark peers inside localPeers as being untrusted which limits which types of RPC events they are allowed to send. We could keep the invite redeem event open and give member-api a new trustConnection(peerId) method which would unlock the RPC channels. On the invitee side we can mark the connection as trusted right off the bat since we validate the remote ID. I think I'll go with this approach first since it's got the least amount of code changes and back-and-forths.

@RangerMauve
Copy link
Copy Markdown
Contributor Author

TODO: Test all the edge cases

@RangerMauve
Copy link
Copy Markdown
Contributor Author

TODO: Convert all new Error to proper Error classes inside errors.js

Comment thread src/mapeo-manager.js Outdated
@gmaclennan
Copy link
Copy Markdown
Member

Good progress here. I think tracking peer state (isTrusted) makes sense vs. a waterfall approach where we try to authenticate first. Remember the requirement about user intervention to confirm a connection - do you have a plan for how to expose this in the API?
As I understand the code so far, the ephemeral keypair used for swarm connection is generated on app startup, and the invite incorporates this and uses it to connect back to the invitor (e.g. not using the topic approach that we discussed). This makes sense to me, however two caveats:

  1. On some devices, we may find that Android aggressively shuts down the app in the background, which means that the user experience will be that unless they keep the app open from the moment of sending the invite to the invitee responding, it may not work because the keypair will have rotated.
  2. On other devices, we could find that the app remains open, potentially for multiple days, which creates a risk of tracking (stable public key on the DHT over multiple days), and equally we would have no way of controlling that.
    One way of addressing this would be to deterministically generate the swarm keypair with the current day (local time), so we know it is stable within the same day, and will always change at the end of the day (e.g. midnight). I think that is easy to understand for users ("your invite is valid until midnight").

@RangerMauve
Copy link
Copy Markdown
Contributor Author

Deterministic swarm key creation is a great idea!

@RangerMauve
Copy link
Copy Markdown
Contributor Author

I'll wait on the key rotation changes pending answers to these questions:

  1. Is identity-${year}-${month}-${day} sufficient for the key name derivation?
  2. If we want to be able to recover the swarm ID after reloading the app does that mean that we should be persisting invite over internet data to a DB so that we can reload it upon project load?

Comment thread src/member-api.js
projectKeyToPublicId,
} from './utils.js'
import { Logger } from './logger.js'
import { keyBy } from './lib/key-by.js'
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Shall we delete this util if it's no longer being used?

@RangerMauve RangerMauve mentioned this pull request Apr 14, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invite over Internet

2 participants