p2party/p2party-js

WebRTC mesh networks with offensive cryptography

★ 20Forks 1TypeScriptGitHub ↗Compare

Project website ↗

cryptographyfile-sharinggroup-chatmesh-networkspeer-to-peerprivacywebrtc

README

p2party cat logo

p2party

Protocol-v4 end-to-end encryption and reliable file transfer over a WebRTC room mesh.

Apache-2.0 · License · Getting started · Contributing · Security policy

Status: protocol v4 is an intentional wire break — v3 peers and persisted v3 crypto rows are not resumed. The current code has not completed an independent third-party security audit.

What is shipped

  • Every peer in a room connects to every other present peer through WebRTC; the signaling service is not the message hub.
  • Every peer edge performs authenticated interactive 3DH plus an authenticated, room-fixed ML-KEM-512, ML-KEM-768 (default), or ML-KEM-1024 bootstrap. PIN rooms additionally authenticate with CPace.
  • Three chained key-confirmation messages complete the application-layer cryptographic handshake. An RTCDataChannel becoming open establishes the transport; it is not a substitute for that confirmation.
  • Per-peer Double Ratchet state protects messages after the handshake.
  • Message data travels in fixed 65,490-byte protocol-v4 frames. Cryptographic overhead is absorbed inside that fixed cell budget; randomized padding and decoy slots can hide a message's exact payload length within its transfer.
  • Each outbound message has its own transfer identity and data channel, a cancellable handle, authenticated receipts, selective retransmission, and reconnect and sender-reload recovery from a bounded durable outbox.
  • Text and files up to the enforced 10 GiB application limit are supported. Browser builds use IndexedDB and, where available, OPFS for disk-backed large file receipt.
  • Room capabilities have a compact 43-character base64url form, a versioned fragment invite, and an optional checksum-protected 24-word representation drawn from the BIP-39 English wordlist. It borrows the words, not the format: the checksum is SHA-256 over p2party's own domain separator, so a BIP-39 phrase is not a valid invite. See docs/references.md.
  • An identity created from a recovery phrase can be restored on another device: 12 to 48 words drawn from the same wordlist, checksummed with SHA-512 and stretched into the Ed25519 seed with argon2id. The derivation runs one way, so a randomly generated identity cannot be exported — it can only be replaced by a recoverable one. See Back up and restore your identity.
  • p2party/session exposes the cryptography without Redux, IndexedDB, WebRTC, signaling, window, or localStorage.

Immediate delivery over the existing signaling rendezvous is the shipped default. Scheduled timing cover is also wired: a room policy may pin a cadence, lane count, and frames per cell, and every edge in the room then emits fixed-size cells on that schedule whether or not there is data to send. Sparse post-quantum healing (the OFFER/ADVANCE/ACK epoch exchange) is likewise live on the mesh path, with persistence before dispatch and application traffic blocked while an epoch is in flight.

The public connect() path still rejects opaque and blind meeting points — any rendezvousMode other than legacy-signaling — because that transport is not wired. The private BitTorrent extension remains a research direction, not a shipped property.

The current signaling operator can observe room membership, peer identities, network metadata, and timing. Fixed message cells and in-transfer decoys do not by themselves provide continuous traffic-analysis resistance.

Install

npm install p2party

That is the whole setup. The package ships its own WebAssembly cryptography and its own database worker; there is no build step, no postinstall, and no native dependency to compile.

Releases are published with npm provenance, so you can check that the tarball was built by the tagged GitHub Actions run rather than uploaded by hand:

npm audit signatures

To build the artifact yourself instead, see Building from source. That path needs an exact toolchain (Node 24, Emscripten 6.0.9, pinned submodules), because the release build reproduces the pinned WASM and refuses to emit an artifact it cannot attest.

Send a message between two browsers

A complete working page is in examples/browser-mesh/ — serve it, open it twice, paste the invite from the first tab into the second tab's URL fragment:

bunx vite examples/browser-mesh

The part that matters is short:

import p2party from "p2party";

// One 256-bit capability. Share the invite; anyone holding it can join.
const invite = p2party.generateRoomInvite();
const room = await p2party.joinRoom(invite);

// Fires once per fully-arrived message, already decoded.
p2party.onMessage(room.id, ({ message }) => {
  console.log("received", message);
});

// A room id does not mean anyone can receive yet.
await p2party.waitForPeers(room.id);
await p2party.sendMessage("hello", "chat", room.id).done;

joinRoom resolves once the signaling service has assigned the room its id. It rejects on a timeout rather than waiting forever, and takes an AbortSignal if the user navigates away:

const controller = new AbortController();
// controller.abort() on unmount, route change, or a Cancel button.

const room = await p2party.joinRoom(invite, undefined, undefined, {
  timeoutMs: 10_000,
  signal: controller.signal,
});

Reading an inbound message needs its Merkle root, which arrives on the room's messages state:

const rooms = p2party.roomSelector(p2party.store.getState());
const latest = rooms.find((r) => r.id === room.id)?.messages.at(-1);
if (latest) {
  const opened = await p2party.readMessage(latest.merkleRootHex);
  console.log(opened.message);
}

Choose your integration

p2party — the browser room mesh. It owns signaling, full-mesh WebRTC, Redux state, IndexedDB/OPFS, the handshake, the ratchet, and transfer with resume. You own the room capability and policy, the UI, and the optional PIN.

p2party/session — the cryptography alone, for Node, Bun, native shells or a custom network. It owns the handshake, the ratchet, uniform encrypted envelopes and snapshots. You own the transport, including message-delimited framing, peer-key trust and storage — see docs/session-api.md.

p2party/session + p2party/libcrypto.wasm — the same, with the exact release-built cryptographic module loaded from bytes you supply, for offline or integrity-pinned deployments.

Deeper guides:

Browser mesh

Every peer present in the same room connects to every other peer. The signaling service coordinates discovery and WebRTC setup; it is not the message hub. A room with n participants therefore has up to n(n - 1) / 2 peer edges.

joinRoom() covers the common case. The two steps underneath it are separate when you need them — connect() starts the join and returns immediately, waitForRoom() resolves once the id arrives:

await p2party.connect(invite);
// ...render a joining state, wire up other listeners...
const room = await p2party.waitForRoom(invite, { timeoutMs: 10_000 });

joinRoom() resolving does not mean every peer edge has finished its handshake. The library gates message cryptography on that separately, so render peer and message state from the exported store rather than treating the room as ready for everything at once.

An open RTCDataChannel means its DTLS/SCTP transport is ready. It is not the protocol-v4 acknowledgement: p2party next runs its authenticated HELLO plus three chained confirmation flights over the main channel. Message receipts are a third, delivery-level acknowledgement.

When a peer sleeps

A phone that backgrounds with its radio off for less than WebRTC's ~30 s ICE consent timeout used to wake into a permanent split. Its peer had already torn that edge down and built a fresh one, but the woken side still read connected, reused the dead transport, and let its zombie main channel block a replacement — so no handshake ever ran on the peer's new transport. One side showed the peer reachable and every send failed undelivered; the other showed no handshake at all and its sends hung forever. Only a manual reconnect cleared it.

Seven behaviours now hold the edge together, and each is what the library does rather than a guarantee about your network:

  • A peer edge is bound to the DTLS certificate it authenticated against. When the remote certificate changes, an already-authenticated transport is replaced outright. One that never authenticated is re-authenticated in place instead, at most once, so a peer that keeps presenting fresh certificates still converges on a replacement rather than looping.
  • Descriptions from a retired transport are discarded, so an answer belonging to a dead connection can no longer poison the one that replaced it.
  • Every failure teardown re-dials the room under its own debounce budget. Deliberate teardowns — disconnectFromPeer, leaving, address-book removal — do not. A superseded handshake attempt dies quietly instead of tearing down the transport that superseded it.
  • A send to a peer that never authenticates fails inside a bounded wait with a stated reason, rather than hanging on the ratchet gate forever.
  • A half-open signaling socket is detected, by a heartbeat watchdog (25 s with no server ping) and on wake events, and marked disconnected so a reconnect can run.
  • RTCPeerConnection operations settle when the connection closes underneath them, so a closing connection cannot strand the per-peer lock and wedge the socket's ingress queue.
  • An interrupted transfer resumes across an edge re-authentication. The transfer's cipher and its cancel detection follow the cryptographic generation rather than the connection object, so a channel closed by a re-authentication reads as a resume, not as a peer cancelling on you.

Verified with two separate headless-Chromium processes against a Bun relay mirroring the signaling server, one process SIGSTOPped whole-tree with its UDP host-candidate buffers flooded — a phone asleep with the radio off. Across seven scenarios (baseline, socket drop, a 20 s freeze in each identity role with the socket dropped and with it kept, and a 45 s freeze) both sides reconverge and exchange byte-exact messages 0.5–6.5 s after wake, and four in-flight 8 MiB transfer configurations finish byte-exact with the sender reporting delivered.

Prepared sends now survive sender reloads in the same browser storage and identity. Pending state and retry/cancel APIs are described in durable transfer recovery. A failed handle.done reports the outcome of that attempt; it does not mean its pending data was removed. The outbox retains prepared sends for up to 24 hours and cannot recover an upload whose preparation never finished or whose browser storage was erased. Current measured recovery results and remaining compatibility limits are in the browser report.

Wire format

Fixed 65,490-byte cells, a 65-byte receipt frame, and outer frame tags that make an application cell, a decoy and a post-quantum healing record indistinguishable by size. Byte layouts, the handshake ladder and the healing exchange are in docs/wire-format.md.

What a room invite looks like

One 256-bit capability, three presentations of the same bytes. Whoever holds it can join the room, so it is the secret — treat it like one:

compact   Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s          43 chars
fragment  v1.Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s      46 chars
words     craft hill business jelly crystal bunker furnace fresh trend
          crisp wedding immune flush horse people wolf renew good caught
          next fancy giggle palace huge                        24 words

In a URL the fragment goes after #, which keeps it out of the request line, out of Referer, and out of ordinary server logs:

https://p2party.com/#v1.Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s

All three decode to identical bytes, so peers can mix forms — one pastes a link, another reads the words aloud over a phone call:

const capability = p2party.generateRoomCapability();

const compact = p2party.encodeRoomCapabilityBase64Url(capability); // 43 chars
const fragment = p2party.encodeRoomInviteFragment(capability); // v1.<compact>
const words = await p2party.encodeRoomCapabilityWords(capability); // 24 words

const fromWords = await p2party.decodeRoomCapabilityWords(words);
console.assert(p2party.encodeRoomCapabilityBase64Url(fromWords) === compact);

await p2party.connect(fragment);

generateRoomInvite() is the one-liner for the fragment form. The word list is BIP-39 English and is checksum-protected — it encodes the same 256 bits, it is not a lower-entropy password. The shipped legacy-signaling route still sends the normalized capability to the signaling service, so a fragment is not server-blind rendezvous.

PIN rooms

A capability alone authenticates whoever received the link. If the link leaks — a forwarded chat, a screenshot, a shared clipboard — the holder joins. A PIN adds a second factor over a separate channel: peers must also prove they know the same short secret, using CPace, a balanced PAKE, so the PIN itself never crosses the wire and a wrong PIN fails the handshake instead of leaking a guess oracle.

PIN mode is in addition to identity authentication, never instead of it:

import p2party, { type RoomPolicyV1 } from "p2party";

const policy = {
  ...p2party.DEFAULT_ROOM_POLICY_V1,
  authMode: "pin",
  pqMode: "hybrid-mlkem1024", // 512 | 768 (default) | 1024, fixed up front
} satisfies RoomPolicyV1;

// Both peers need these exact bytes, carried out of band — spoken aloud,
// not sent through the same channel as the invite.
const pin = new TextEncoder().encode("correct horse battery staple");

try {
  await p2party.connect(invite, undefined, undefined, { policy, pin });
} finally {
  pin.fill(0); // connect() copied it into the in-memory room vault.
}

Room policy is immutable once the room is created locally, and every peer must present the same policy and the same PIN bytes. The ML-KEM suite is fixed before the handshake runs — there is no in-band negotiation, no downgrade, and no classical fallback, so a mismatched peer fails closed rather than quietly agreeing on something weaker.

PIN bytes are deliberately absent from the public policy, from Redux, from persisted room records and from logs. Wipe your copy when the room is up.

Scheduled cover traffic

Encryption hides what you say. It does not hide that you said something — an observer still sees a burst of frames the moment you press send. Scheduled cover replaces that pattern with a constant one: every edge in the room emits fixed-size cells on a fixed cadence whether or not there is anything to send, and real chunks are substituted into slots that were going to be sent anyway.

const policy = {
  ...p2party.DEFAULT_ROOM_POLICY_V1,
  coverMode: "scheduled",
  coverCadenceMs: 10_000, // one cycle every 10s
  coverLanes: 2, // parallel schedules per edge
  coverFramesPerCell: 1, // 65,490-byte frames per slot
  coverDurationEpochs: 360,
} satisfies RoomPolicyV1;

p2party.validateRoomPolicyV1(policy); // throws before you build a room on it

The bounds are exported — MIN_COVER_CADENCE_MS, MAX_COVER_CADENCE_MS, MAX_COVER_LANES, MAX_COVER_FRAMES_PER_CELL, MIN_COVER_SLOT_MS — so a policy UI validates against the library instead of re-declaring limits that drift.

Cover is a room-wide property: it hides timing only for as long as every edge keeps emitting on schedule, and it costs bandwidth continuously. It has been measured on loopback, not across a real network path, and fixed cells over a fixed cadence are not by themselves traffic-analysis resistance — see The Last Hop Attack for how loop cover over fixed cascades fails, and the security boundary for what is actually claimed. Immediate delivery remains the default.

Send, cancel, and read

sendMessage() returns a MessageTransferHandle, not a promise: transferId identifies this logical send, cancel() works even during hashing and channel setup, and done settles after every started peer send and cleanup.

done rejects when no peer took delivery — an empty room, or a cancel. Both are ordinary outcomes. The rejection is a MessageDeliveryError whose result.outcomes carries the same per-peer detail a resolved value would, so handle it rather than treating it as a crash. The quickstart above shows the shape; docs/getting-started.md covers reading inbound messages and the metadata-only read that avoids materializing large files.

Back up and restore your identity

An identity is an Ed25519 key pair in browser storage. Lose the profile and you lose the identity — unless it was created from a recovery phrase, because the phrase is the only thing that can reproduce it.

Three calls, introduced together: getIdentityBackupStatus() reports whether the identity in use can be backed up at all, createRecoverableIdentity() replaces it with one a phrase derives and hands back that phrase once, and restoreIdentityFromMnemonic() adopts the identity a phrase encodes — on this device or any other.

// Can the identity in use be backed up at all?
const status = await p2party.getIdentityBackupStatus();
// status.derivation === "mnemonic" -> a phrase restores it
// status.derivation === "random"   -> nothing can; it can only be replaced
// status.derivation === null       -> unknown: no identity is stored yet, or
//                                     one whose provenance was never recorded.
//                                     Treat it as having no backup.

if (status.derivation !== "mnemonic") {
  // Replaces the identity in use and hands back the only copy of the phrase.
  const { mnemonic, publicKey } = await p2party.createRecoverableIdentity({
    strength: 256, // 24 words; 128 gives 12. Default 256.
    // password: folded into the derivation, and just as unrecoverable.
  });

  // Show it once, in the UI. Never log it: console output is storage, and
  // devtools, extensions and webview log shippers all keep it.
  showRecoveryPhraseOnce(mnemonic);
  console.log("recoverable identity", publicKey);
}

On another device, before connecting, adopt the identity a phrase encodes:

try {
  const { publicKey } = await p2party.restoreIdentityFromMnemonic(
    typedPhrase,
    password, // omit unless one was used at creation
  );
  console.log("restored", publicKey);
} catch (error) {
  if (error instanceof p2party.InvalidRecoveryPhraseError) {
    // A typo. Nothing was disconnected and nothing was replaced, so keep the
    // form open and let the user fix it.
  }
}

// Both calls leave signaling disconnected. Come back online yourself.
await p2party.connect(roomUrl);

What the phrase is. 12 to 48 words from the shared wordlist, checksummed with SHA-512 and stretched by argon2id — salted with the optional password — into the Ed25519 seed. It borrows the BIP-39 English words and nothing else, so no BIP-39 wallet can restore one of these identities — docs/references.md has the exact differences. The derivation runs one way: an identity can be created from a phrase, never exported to one.

Existing identities cannot be exported. Every identity the SDK generated before this — and any it still generates on first connect — comes from a random seed, so no phrase describes it. A newly generated one is recorded as "random"; one that predates the record, or whose record no longer binds to the stored key, reads null. Both mean the same thing to an app, and reporting it is the point: say the identity has no backup instead of offering one that cannot exist. The only route to a recoverable identity is createRecoverableIdentity(), which replaces the current one: contacts see a new identity key for this device and their clients flag the change, exactly as they would after a purge.

The phrase is shown once and stored nowhere. Neither the SDK nor its storage keeps a copy, by design — a phrase at rest is the identity at rest — so an app that drops it before the user has written it down has lost the backup. The password is not stored or checked either: restoring with a different one silently derives a different identity.

What restoring does to a running mesh. It rotates the account key, so it takes the same exclusive path a purge does: signaling is disconnected, every room's WebRTC transport is brought to a terminal state, and the cross-signed X25519 identity is dropped so the next connection regenerates and re-signs it. Rooms, messages, the address book and cached room PINs are all kept — reconnect and you are in the same rooms under the restored key. An invalid phrase rejects with InvalidRecoveryPhraseError before any of that starts.

While a phrase is being typed, validateMnemonicWords(words) is synchronous and names the first word that is not in the wordlist; validateMnemonic(phrase) adds the SHA-512 checksum and returns a promise.

Cryptography without WebRTC

p2party/session is the same protocol-v4 cryptography with no Redux, no IndexedDB, no WebRTC, no signaling, no window and no localStorage — for Node, Bun, a native shell, a CLI, or any transport you already have.

Nothing to configure. The WASM loads from the installed package, so this runs offline:

import { generateSessionIdentity } from "p2party/session";

const identity = await generateSessionIdentity();

Two peers, both sides

The session needs one thing from you: a transport. send hands off a message, recv resolves with the next one. Whole messages, in order, no partial reads — a WebSocket, a TCP socket with length prefixes, or a queue all qualify. An in-memory pipe is enough to run both peers in one process:

// One one-way pipe. Two of these make a full-duplex transport.
const makeLink = () => {
  const queued: Uint8Array[] = [];
  const waiters: Array<(bytes: Uint8Array) => void> = [];
  return {
    send(bytes: Uint8Array) {
      const owned = Uint8Array.from(bytes); // copy: the caller reuses buffers
      const waiter = waiters.shift();
      if (waiter) waiter(owned);
      else queued.push(owned);
    },
    recv(): Promise<Uint8Array> {
      const bytes = queued.shift();
      return bytes
        ? Promise.resolve(bytes)
        : new Promise((resolve) => waiters.push(resolve));
    },
  };
};

Alice and Bob each generate a long-term identity, exchange Ed25519 public keys out of band, agree on a channel binding, and hand the session two byte pipes. Nothing below is elided — this is the whole setup:

import { createSession, generateSessionIdentity } from "p2party/session";

// 1. Long-term identities. Persist these; they are who each peer *is*.
const aliceIdentity = await generateSessionIdentity();
const bobIdentity = await generateSessionIdentity();

// 2. Trust. Each side must already know the other's Ed25519 public key —
//    pinned from a previous session, read off a QR code, or explicitly
//    TOFU-accepted. The session never decides this for you.
const alicePublicKey = aliceIdentity.ed25519PublicKey;
const bobPublicKey = bobIdentity.ed25519PublicKey;

// 3. Channel binding, identical on both sides but with the fingerprints
//    swapped. Bound into the handshake transcript so a relay cannot sit in
//    the middle and swap sides.
const channelId = crypto.getRandomValues(new Uint8Array(16));
const aliceFingerprint = crypto.getRandomValues(new Uint8Array(32));
const bobFingerprint = crypto.getRandomValues(new Uint8Array(32));

// 4. Two one-way pipes. Replace these with your socket, WebSocket, pipe or
//    queue — anything that delivers whole messages, in order.
const aliceToBob = makeLink();
const bobToAlice = makeLink();

// 5. Handshake. Both sides run concurrently: the flights are interactive, so
//    awaiting one before starting the other deadlocks.
const [alice, bob] = await Promise.all([
  createSession({
    role: "initiator",
    identity: aliceIdentity,
    peerIdentityEd25519PublicKey: bobPublicKey,
    channel: {
      channelId,
      localFingerprint: aliceFingerprint,
      remoteFingerprint: bobFingerprint,
    },
    transport: { send: aliceToBob.send, recv: bobToAlice.recv },
    mode: "nopin",
  }),
  createSession({
    role: "responder",
    identity: bobIdentity,
    peerIdentityEd25519PublicKey: alicePublicKey,
    channel: {
      channelId,
      localFingerprint: bobFingerprint,
      remoteFingerprint: aliceFingerprint,
    },
    transport: { send: bobToAlice.send, recv: aliceToBob.recv },
    mode: "nopin",
  }),
]);

For a PIN-authenticated session, both sides pass mode: "pin" with identical pin bytes instead — the same CPace step the browser mesh uses.

What goes over the wire

const encoder = new TextEncoder();
const decoder = new TextDecoder();

const sealed = await alice.encrypt(encoder.encode("hello bob"));

// sealed.protocolVersion === 4
// sealed.root   -> 64-byte SHA-512 Merkle root, authenticated as AEAD
//                  additional data (decrypt() rejects any other length)
// sealed.frames -> [Uint8Array(65490)]  one uniform cell; a 9-byte message and
//                  a 60 KiB message produce byte-identical frame sizes.
//                  Each frame is:
//                    type(1) | DH pubkey(32) | N(8) | PN(8) | PQ epoch(8) |
//                    nonce(12) | ciphertext(65405) | Poly1305 tag(16)
//                  Only the 69-byte header is readable; it is authenticated,
//                  not secret. Everything else is indistinguishable from
//                  random to anyone without the message key.

// Hand sealed.frames to your transport verbatim. It must delimit records
// itself — the session returns opaque bytes, not a framed stream.
const opened = await bob.decrypt(sealed);
console.log(decoder.decode(opened)); // "hello bob"

// Either side may speak first, and simultaneous first sends are fine: the
// handshake primes both ratchet directions.
const reply = await bob.encrypt(encoder.encode("hi alice"));
console.log(decoder.decode(await alice.decrypt(reply))); // "hi alice"

Each logical message consumes one ratchet step. Replays and tampered frames are rejected; out-of-order arrival is tolerated within a bounded skipped-key window.

Suspend and resume

import { restoreSession } from "p2party/session";

const snapshot = await alice.serialize(); // plaintext secret — encrypt at rest
await alice.destroy();

const restored = await restoreSession(snapshot);

// Same ratchet, same counters. Bob notices nothing.
const later = await bob.encrypt(encoder.encode("still there?"));
console.log(decoder.decode(await restored.decrypt(later))); // "still there?"

snapshot.fill(0);

Run the complete two-party script — including the sparse post-quantum healing exchange — from a checkout:

bun run examples/standalone-e2ee.ts

examples/standalone-e2ee.ts is also shipped inside the package, and includes the makeLink() helper used above.

Four things stay yours, because no library can decide them for you:

You own Because
Peer-key trust peerIdentityEd25519PublicKey must be pinned or explicitly TOFU-accepted; the session never guesses
Message framing encrypt() returns opaque frames — your transport must delimit and length-check records itself
Snapshot storage serialize() is plaintext secret material: encrypt at rest, and protect against rollback
The channel binding A channel id and two endpoint fingerprints, bound into the transcript so a relay cannot swap sides

Outside WebRTC there are no DTLS fingerprints to bind, so derive the channel binding from whatever your transport authenticates — a TLS exporter, a session id, or random bytes both sides agree on out of band. The full contract, the envelope codec and the sparse-PQ healing hooks are in docs/session-api.md.

Running the operations one at a time

joinRoom() and createSession() are the batteries-included paths. Every step they take is also a public call, so you can drive the protocol yourself.

Identity, signing, and recovery phrases. Keys are Ed25519, and a phrase derives one deterministically:

const mnemonic = await p2party.generateMnemonic(256); // 24 words
const keyPair = await p2party.keyPairFromMnemonic(mnemonic); // deterministic
const fresh = await p2party.newKeyPair(); // or just random

const bytes = new TextEncoder().encode("anything you want attributable");
const signature = await p2party.sign(bytes, keyPair.secretKey);
const ok = await p2party.verify(bytes, signature, keyPair.publicKey);

This phrase is not BIP-39, and a BIP-39 wallet cannot restore it. It draws its words from the BIP-39 English wordlist and nothing else: the checksum is the leading bits of SHA-512 rather than SHA-256, entropy runs from 128 to 512 bits (12 to 48 words, where BIP-39 stops at 24), and the seed comes from argon2id over the normalized phrase — with an optional password used as the salt — rather than PBKDF2-HMAC-SHA512. The three canonical BIP-39 English test vectors all fail validateMnemonic(). Treat it as a p2party-specific format.

These are the primitives. The identity the mesh actually uses is created, replaced and restored through createRecoverableIdentity() and restoreIdentityFromMnemonic(), which take care of the storage, the cross-signed X25519 identity and the transports authenticated by the outgoing key.

Room policy as data. A policy is a value you can encode, hash, compare and validate before anything touches the network — useful for showing two peers that they really are about to join the same room:

const policy = { ...p2party.DEFAULT_ROOM_POLICY_V1 } satisfies RoomPolicyV1;

const encoded = p2party.encodeRoomPolicyV1(policy); // canonical bytes
const digest = await p2party.hashRoomPolicyV1(policy); // stable identifier
p2party.validateRoomPolicyV1(policy); // throws with the offending field

// Peers fail closed on a policy mismatch, so compare before you connect and
// you can say *which* setting differs instead of surfacing a failed handshake.
// `encoded` here stands in for the canonical bytes the other peer sent you.
const theirs = p2party.decodeRoomPolicyV1(encoded);
const agreed = p2party.roomPoliciesEqualV1(policy, theirs);

The ratchet, step by step. A P2PartySession exposes each operation individually rather than only a send/receive loop:

Call What it does
encrypt / decrypt One ratchet step per logical message
serialize / restoreSession Snapshot and resume the exact ratchet state
prepareHealing Start a post-quantum epoch when one is due
acceptControlFrame Process an inbound OFFER / ADVANCE / ACK, return the reply
pendingControl Re-emit the exact frame for a dropped flight
pqEpoch, healingInProgress, canEncrypt Inspect live state
destroy Wipe key material

Driving the ratchet by hand. This is a p2party/session concern only. The browser root installs a healing orchestrator on every peer edge as its channel opens and drives exchanges on a timer, so a p2party room needs none of the code below. p2party/session owns no transport and therefore no scheduler, which is why it hands you the steps instead.

Each encrypt() advances the ratchet one step either way. A complete healing exchange, both sides:

// The ratchet advances per message, and you can watch it do so.
console.log(alice.pqEpoch); // 0n before any healing exchange

// Healing is due after 64 messages or 24 hours, and only on your turn.
// prepareHealing() returns { frame: null } when it is neither.
const offer = await alice.prepareHealing();

if (offer.frame) {
  // THE RULE: persist before the frame leaves. A crash after sending but
  // before persisting loses the ephemeral KEM secret, and the two sides then
  // disagree about the epoch. That is a dead session, not a slow one.
  await alice.serialize();
  const advance = await bob.acceptControlFrame(offer.frame); // OFFER  -> ADVANCE

  await bob.serialize();
  const ack = await alice.acceptControlFrame(advance.frame!); // ADVANCE -> ACK

  await alice.serialize();
  await bob.acceptControlFrame(ack.frame!); // ACK -> done

  console.log(alice.pqEpoch, bob.pqEpoch); // 1n 1n
}

// If a flight is dropped, re-send the exact same bytes. Do not call
// prepareHealing() again — fresh randomness forks the exchange.
const retry = await alice.pendingControl();
if (retry) transport.send(retry);

healingInProgress is true while an exchange is open, and application traffic is blocked until it closes. That is deliberate: a message encrypted under an ambiguous epoch is worse than a message delayed by one round trip.

Every serialize() above sits before its send, and that ordering is the whole contract. requiresPersistBeforeSend on the returned SessionControlOutput tells you when a durable write is genuinely required, so you can skip the disk hit on an exact duplicate response.

examples/standalone-e2ee.ts runs this end to end, including the 64 messages that make an exchange due.

The lower-level primitives — X25519, HKDF-SHA512, ML-KEM, CPace, the Merkle tree, the raw ratchet — are deliberately not exported. They are easy to combine into something that looks right and is not, and the whole point of the package is that the combination has been done once, carefully. If you need those, use libsodium and mlkem-native directly, which is what this package compiles.

No build step: a script tag and the CDN

0.14.10 is a local release candidate. This work does not publish npm packages or CDN assets. The snippet names the candidate's intended release path; use the generated local bundle until that version is published.

A release publishes its browser bundle, its database worker and its cryptographic module as immutable, versioned CDN objects. The version is in the path, so a URL names exactly one build and is safe to cache forever. Drop the script in and window.p2party is there — no npm, no bundler, no build:

<!doctype html>
<meta charset="utf-8" />
<title>p2party in one file</title>

<script
  src="https://cdn.p2party.com/@0.14.10/p2party.min.js"
  integrity="sha384-z5PjsRDKjtPcQ9JhEwkPyr/7zthzQgjjh6pSTdjTmhZrwVhhhFS/KhRkGtmJwqJX"
  crossorigin="anonymous"
></script>

<script type="module">
  // The bundle embeds its worker and fetches its own WASM from the same
  // versioned path, under a build-pinned SHA-384 SRI.
  const invite = p2party.generateRoomInvite();
  location.hash = invite; // share this URL; anyone holding it can join

  const room = await p2party.joinRoom(location.hash.slice(1) || invite);

  p2party.onMessage(room.id, ({ message }) => {
    document.body.append(
      Object.assign(document.createElement("p"), {
        textContent: message,
      }),
    );
  });

  await p2party.waitForPeers(room.id);
  await p2party.sendMessage("hello from a script tag", "chat", room.id).done;
</script>

Open that file in two tabs, paste the first tab's URL into the second, and they connect directly to each other.

The three published objects:

https://cdn.p2party.com/@<version>/p2party.min.js  UMD bundle -> window.p2party
https://cdn.p2party.com/@<version>/db.worker.js    IndexedDB/OPFS worker
https://cdn.p2party.com/@<version>/libcrypto.wasm  the cryptographic module

The integrity value above is this release's bundle, and the release build fails if the README and the built artifact ever disagree — so it is safe to copy verbatim. The worker, if you host it yourself, is integrity=sha384-iZfBJVoMgpK1+s2shZQucC0OUSBaX4eHMTjHj4NqzGApUodsP9T16+EHgyDKTVQP.

The WASM is integrity-checked whether or not you pin the script: that hash is compiled into the bundle and cannot be turned off.

Local, self-hosted, or release-pinned WASM

The browser root always fetches the exact versioned CDN WASM with a build-pinned SHA-384 SRI value by default. A self-hosted browser app can point it at the same release bytes before calling connect():

import p2party from "p2party";

p2party.setWasmSourceUrl(
  new URL("/vendor/p2party/libcrypto.wasm", window.location.href),
);

The SRI check remains active, so a URL serving different bytes fails closed.

Get the WASM and check it

The npm tarball is the copy that always exists. It carries both files, and the package exports them, so no network fetch is needed to obtain either:

npm pack p2party && tar -xzf p2party-*.tgz
ls package/lib/libcrypto.wasm package/lib/libcrypto.provenance.json
# already installed? the subpaths resolve straight out of node_modules:
node -p "require.resolve('p2party/libcrypto.wasm')"
node -p "require.resolve('p2party/libcrypto.provenance.json')"

A published release also mirrors its cryptographic module to the CDN under an immutable, versioned path, so a URL names exactly one build and is safe to cache forever:

curl --compressed -O "https://cdn.p2party.com/@$(npm view p2party version)/libcrypto.wasm"

Two caveats, both current as of this release. The CDN carries only three objects — p2party.min.js, db.worker.js and libcrypto.wasm (see the assets array in scripts/uploadToCDN.mjs) — so libcrypto.provenance.json is not on the CDN for any version; take it from the tarball. And nothing is uploaded for this release yet, which is why the command above asks npm for a version that is actually published instead of naming this one.

New CDN uploads gzip all three objects; WASM keeps Content-Type: application/wasm and uses Content-Encoding: gzip. Browsers decode it before SRI verification, and curl --compressed saves those same decoded bytes for the checks below. Existing raw WASM objects remain untouched when their decoded content matches; enabling compression never overwrites a published version. Upload preparation compresses the validated artifacts already in lib/; it does not replace them from a local development crypto build.

Check what you downloaded before you serve it. The SHA-256 and the SRI value are both recorded in the provenance file:

shasum -a 256 libcrypto.wasm
openssl dgst -sha384 -binary libcrypto.wasm | openssl base64 -A

Compare them against libcrypto.provenance.json, which records the same sha256 and sri for the bytes the same release ships. CDN and npm cannot diverge for a release the pipeline actually ran: .github/workflows/release.yml unpacks the validated tarball, uploads the CDN object from it, and runs npm run verify:cdn before npm publish.

Serve the file yourself and point the browser root at it with setWasmSourceUrl() above, or hand the bytes straight to p2party/session. Self-hosting is the path that works today for every version, published or not.

On Node and Bun, p2party/session needs none of this: it reads the WASM from the installed package and checks it against the same pinned SHA-384, so an offline or air-gapped install works with no configuration and no network call. Supply wasmBinary only to override that — bytes you host, embed, or verify yourself:

import { readFile } from "node:fs/promises";
import { generateSessionIdentity } from "p2party/session";

const wasmBinary = await readFile("/opt/p2party/libcrypto.wasm");
const identity = await generateSessionIdentity({ wasmBinary });

The package also exports p2party/libcrypto.provenance.json, recording the libsodium and mlkem-native commits, the Emscripten release, and the artifact's digests. JavaScript and WASM are one release unit; never pair this release's JavaScript with an older module.

The release gate runs packaged identity generation through both Node ESM and CommonJS, once with explicit bytes and once with no arguments at all — the second pass with fetch stubbed to throw, so a silent CDN fallback fails the release rather than surfacing later as a broken offline install.

Development

The reproducible release toolchain is Node 24.11.1, npm 11.6.2, Bun 1.3.14, Emscripten 6.0.9, and the repository's pinned libsodium source object. npm and package-lock.json are the dependency authority; Bun is the test runner.

git clone --recurse-submodules https://github.com/p2party/p2party-js.git
cd p2party-js
git -C libsodium fetch --depth=1 origin 2ce4d906a68eae82b27b4867f3d4172ec508cb27
npm ci
npm run predist
npm run check

npm run release:pack is the only supported package build. It rebuilds and validates the cryptographic artifacts in a fresh staging tree, checks the vendored source digests and provenance, enforces the tarball allowlist, and produces p2party-<version>.tgz. Direct source-tree publication is refused.

Tagged releases publish immutable CDN objects first, fetch the public WASM back and compare its exact bytes, SHA-256, and SRI to the validated build, and only then publish the npm tarball with provenance.

Built on

Cryptography, compiled into the shipped libcrypto.wasm:

Component Provides
libsodium X25519, Ed25519, ChaCha20-Poly1305, BLAKE2b, HKDF-SHA512, Argon2
mlkem-native ML-KEM-512/768/1024
Emscripten Compiles both to the pinned WebAssembly module
Redux Toolkit The browser root's state store
BIP-39 wordlist The 24-word capability and recovery-phrase encoding

Standards the wire format implements:

Standard Where it appears
FIPS 203 ML-KEM bootstrap and healing epochs
RFC 8439 ChaCha20-Poly1305 for every chunk frame
RFC 5869 HKDF root and chain-key derivation
RFC 7748 X25519 for 3DH and the ratchet DH turns
RFC 8032 Ed25519 identities and cross-signatures
draft-irtf-cfrg-cpace-21 The PIN-room balanced PAKE
RFC 8831 / 8832 WebRTC data channels
RFC 8122 SDP DTLS fingerprints bound into the transcript
RFC 9794 PQ/T hybrid terminology

The design follows the Double Ratchet and X3DH specifications, and sparse post-quantum healing is directly inspired by Signal's SPQR — a different construction, not a reimplementation, and not independently analysed. Full citations, the papers behind the design, and comparable projects are in docs/references.md; what is deliberately still open is in the roadmap.

Security and licensing

Report vulnerabilities privately according to SECURITY.md. Contributions are covered by CONTRIBUTING.md and the Code of Conduct.

p2party is licensed under Apache-2.0. Vendored and bundled components retain their own terms; see THIRD_PARTY_NOTICES.md.

Issues