p2party 0.8.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE.md +202 -661
  2. package/README.md +736 -404
  3. package/THIRD_PARTY_NOTICES.md +458 -0
  4. package/docs/assets/p2party-cat.svg +1 -0
  5. package/docs/getting-started.md +203 -0
  6. package/docs/protocol-v4-security.md +158 -0
  7. package/docs/references.md +157 -0
  8. package/docs/session-api.md +457 -0
  9. package/docs/wire-format.md +103 -0
  10. package/examples/standalone-e2ee.ts +194 -0
  11. package/lib/api/signalingServerApi.d.ts +6 -7
  12. package/lib/api/webrtc/baseQuery.d.ts +1 -2
  13. package/lib/api/webrtc/disconnectFromAllRoomsQuery.d.ts +1 -2
  14. package/lib/api/webrtc/disconnectFromChannelLabelQuery.d.ts +1 -2
  15. package/lib/api/webrtc/disconnectFromPeerChannelLabelQuery.d.ts +1 -2
  16. package/lib/api/webrtc/disconnectFromPeerQuery.d.ts +3 -3
  17. package/lib/api/webrtc/disconnectFromRoomQuery.d.ts +1 -2
  18. package/lib/api/webrtc/disconnectQuery.d.ts +1 -2
  19. package/lib/api/webrtc/iceGeneration.d.ts +17 -0
  20. package/lib/api/webrtc/iceRepair.d.ts +10 -0
  21. package/lib/api/webrtc/index.d.ts +13 -13
  22. package/lib/api/webrtc/interfaces.d.ts +67 -11
  23. package/lib/api/webrtc/negotiationLock.d.ts +12 -0
  24. package/lib/api/webrtc/openChannelQuery.d.ts +1 -2
  25. package/lib/api/webrtc/pendingIceCandidates.d.ts +14 -0
  26. package/lib/api/webrtc/roomPeer.d.ts +11 -0
  27. package/lib/api/webrtc/sendMessageQuery.d.ts +7 -4
  28. package/lib/api/webrtc/setCandidateQuery.d.ts +1 -2
  29. package/lib/api/webrtc/setDescriptionQuery.d.ts +1 -2
  30. package/lib/cryptography/coverCell.d.ts +65 -0
  31. package/lib/cryptography/cpace.d.ts +41 -0
  32. package/lib/cryptography/ed25519.d.ts +0 -1
  33. package/lib/cryptography/hashStream.d.ts +15 -0
  34. package/lib/cryptography/hkdf.d.ts +3 -0
  35. package/lib/cryptography/identityCrossSig.d.ts +7 -0
  36. package/lib/cryptography/interfaces.d.ts +28 -13
  37. package/lib/cryptography/memory.d.ts +2 -4
  38. package/lib/cryptography/merkle.d.ts +0 -1
  39. package/lib/cryptography/mlkem.d.ts +117 -0
  40. package/lib/cryptography/mnemonic.d.ts +0 -1
  41. package/lib/cryptography/pqHealing.d.ts +268 -0
  42. package/lib/cryptography/pqHealingFrame.d.ts +39 -0
  43. package/lib/cryptography/pqMessageKey.d.ts +30 -0
  44. package/lib/cryptography/random.d.ts +22 -0
  45. package/lib/cryptography/ratchet.d.ts +146 -0
  46. package/lib/cryptography/testModule.d.ts +7 -0
  47. package/lib/cryptography/utils.d.ts +0 -1
  48. package/lib/cryptography/wasmLoader.d.ts +6 -1
  49. package/lib/cryptography/x25519.d.ts +15 -0
  50. package/lib/cryptography/x3dh.d.ts +9 -0
  51. package/lib/db/api.d.ts +54 -22
  52. package/lib/db/db.worker.d.ts +0 -1
  53. package/lib/db/ratchetWrap.d.ts +36 -0
  54. package/lib/db/src/getDB.d.ts +17 -4
  55. package/lib/db/types.d.ts +274 -32
  56. package/lib/db.worker.js +1 -2
  57. package/lib/handlers/chunkFrame.d.ts +13 -0
  58. package/lib/handlers/connectionSignal.d.ts +26 -0
  59. package/lib/handlers/coverEdge.d.ts +42 -0
  60. package/lib/handlers/coverRuntime.d.ts +91 -0
  61. package/lib/handlers/coverScheduler.d.ts +184 -0
  62. package/lib/handlers/coverTransfer.d.ts +46 -0
  63. package/lib/handlers/frameType.d.ts +10 -0
  64. package/lib/handlers/handleChallenge.d.ts +0 -1
  65. package/lib/handlers/handleConnectToPeer.d.ts +0 -1
  66. package/lib/handlers/handleHandshake.d.ts +74 -0
  67. package/lib/handlers/handleMessageQueueing.d.ts +53 -4
  68. package/lib/handlers/handleOpenChannel.d.ts +4 -2
  69. package/lib/handlers/handleQueuedIceCandidates.d.ts +0 -1
  70. package/lib/handlers/handleReadReceipt.d.ts +17 -4
  71. package/lib/handlers/handleReceiveMessage.d.ts +21 -3
  72. package/lib/handlers/handleSendMessage.d.ts +110 -2
  73. package/lib/handlers/handleWebSocketMessage.d.ts +1 -3
  74. package/lib/handlers/handshakeCore.d.ts +95 -0
  75. package/lib/handlers/messageChunkCrypto.d.ts +70 -0
  76. package/lib/handlers/peerRosterDelta.d.ts +12 -0
  77. package/lib/handlers/pqHealingOrchestrator.d.ts +46 -0
  78. package/lib/handlers/pqHealingRuntime.d.ts +82 -0
  79. package/lib/handlers/ratchetGate.d.ts +18 -0
  80. package/lib/handlers/ratchetPersist.d.ts +53 -0
  81. package/lib/handlers/receiptFrame.d.ts +18 -0
  82. package/lib/handlers/receiveMessageKeyLifetime.d.ts +24 -0
  83. package/lib/handlers/reconcile.d.ts +16 -0
  84. package/lib/handlers/transferAbort.d.ts +42 -0
  85. package/lib/index.d.ts +203 -46
  86. package/lib/index.js +1 -2
  87. package/lib/index.min.js +1 -2
  88. package/lib/index.mjs +1 -2
  89. package/lib/libcrypto.provenance.json +45 -0
  90. package/lib/libcrypto.wasm +0 -0
  91. package/lib/middleware/keyPairListenerMiddleware.d.ts +0 -1
  92. package/lib/middleware/roomListenerMiddleware.d.ts +0 -1
  93. package/lib/reducers/commonSlice.d.ts +0 -1
  94. package/lib/reducers/keyPairSlice.d.ts +0 -1
  95. package/lib/reducers/roomSlice.d.ts +65 -3
  96. package/lib/reducers/signalingServerSlice.d.ts +25 -4
  97. package/lib/roomInvite.d.ts +18 -0
  98. package/lib/roomPinAttempts.d.ts +12 -0
  99. package/lib/roomPinVault.d.ts +26 -0
  100. package/lib/roomPolicy.d.ts +60 -0
  101. package/lib/session.d.ts +110 -0
  102. package/lib/session.js +1 -0
  103. package/lib/session.mjs +1 -0
  104. package/lib/store.d.ts +40 -41
  105. package/lib/utils/channelLabel.d.ts +0 -1
  106. package/lib/utils/chunkBounds.d.ts +9 -0
  107. package/lib/utils/constants.d.ts +50 -6
  108. package/lib/utils/debug.d.ts +25 -0
  109. package/lib/utils/drainAndClose.d.ts +5 -0
  110. package/lib/utils/identityRole.d.ts +19 -0
  111. package/lib/utils/interfaces.d.ts +31 -43
  112. package/lib/utils/leafHash.d.ts +13 -0
  113. package/lib/utils/messageTypes.d.ts +0 -1
  114. package/lib/utils/metadata.d.ts +6 -1
  115. package/lib/utils/mutex.d.ts +24 -0
  116. package/lib/utils/protocolVersion.d.ts +5 -0
  117. package/lib/utils/receiptToken.d.ts +7 -0
  118. package/lib/utils/sendQueueKey.d.ts +2 -0
  119. package/lib/utils/signalingAuth.d.ts +8 -0
  120. package/lib/utils/signalingBounds.d.ts +9 -0
  121. package/lib/utils/splitToChunks.d.ts +7 -4
  122. package/lib/utils/uint8array.d.ts +1 -2
  123. package/lib/utils/waitForOpen.d.ts +3 -0
  124. package/lib/utils/zeroFree.d.ts +10 -0
  125. package/package.json +109 -49
  126. package/lib/api/signalingServerApi.d.ts.map +0 -1
  127. package/lib/api/webrtc/baseQuery.d.ts.map +0 -1
  128. package/lib/api/webrtc/disconnectFromAllRoomsQuery.d.ts.map +0 -1
  129. package/lib/api/webrtc/disconnectFromChannelLabelQuery.d.ts.map +0 -1
  130. package/lib/api/webrtc/disconnectFromPeerChannelLabelQuery.d.ts.map +0 -1
  131. package/lib/api/webrtc/disconnectFromPeerQuery.d.ts.map +0 -1
  132. package/lib/api/webrtc/disconnectFromRoomQuery.d.ts.map +0 -1
  133. package/lib/api/webrtc/disconnectQuery.d.ts.map +0 -1
  134. package/lib/api/webrtc/index.d.ts.map +0 -1
  135. package/lib/api/webrtc/interfaces.d.ts.map +0 -1
  136. package/lib/api/webrtc/openChannelQuery.d.ts.map +0 -1
  137. package/lib/api/webrtc/sendMessageQuery.d.ts.map +0 -1
  138. package/lib/api/webrtc/setCandidateQuery.d.ts.map +0 -1
  139. package/lib/api/webrtc/setDescriptionQuery.d.ts.map +0 -1
  140. package/lib/cryptography/chacha20poly1305.d.ts +0 -116
  141. package/lib/cryptography/chacha20poly1305.d.ts.map +0 -1
  142. package/lib/cryptography/ed25519.d.ts.map +0 -1
  143. package/lib/cryptography/interfaces.d.ts.map +0 -1
  144. package/lib/cryptography/memory.d.ts.map +0 -1
  145. package/lib/cryptography/merkle.d.ts.map +0 -1
  146. package/lib/cryptography/mnemonic.d.ts.map +0 -1
  147. package/lib/cryptography/utils.d.ts.map +0 -1
  148. package/lib/cryptography/wasmLoader.d.ts.map +0 -1
  149. package/lib/db/api.d.ts.map +0 -1
  150. package/lib/db/db.worker.d.ts.map +0 -1
  151. package/lib/db/src/getDB.d.ts.map +0 -1
  152. package/lib/db/types.d.ts.map +0 -1
  153. package/lib/db.worker.js.map +0 -1
  154. package/lib/handlers/handleChallenge.d.ts.map +0 -1
  155. package/lib/handlers/handleConnectToPeer.d.ts.map +0 -1
  156. package/lib/handlers/handleMessageQueueing.d.ts.map +0 -1
  157. package/lib/handlers/handleOpenChannel.d.ts.map +0 -1
  158. package/lib/handlers/handleQueuedIceCandidates.d.ts.map +0 -1
  159. package/lib/handlers/handleReadReceipt.d.ts.map +0 -1
  160. package/lib/handlers/handleReceiveMessage.d.ts.map +0 -1
  161. package/lib/handlers/handleSendMessage.d.ts.map +0 -1
  162. package/lib/handlers/handleWebSocketMessage.d.ts.map +0 -1
  163. package/lib/index.d.ts.map +0 -1
  164. package/lib/index.js.map +0 -1
  165. package/lib/index.min.js.map +0 -1
  166. package/lib/index.mjs.map +0 -1
  167. package/lib/middleware/keyPairListenerMiddleware.d.ts.map +0 -1
  168. package/lib/middleware/roomListenerMiddleware.d.ts.map +0 -1
  169. package/lib/reducers/commonSlice.d.ts.map +0 -1
  170. package/lib/reducers/keyPairSlice.d.ts.map +0 -1
  171. package/lib/reducers/roomSlice.d.ts.map +0 -1
  172. package/lib/reducers/signalingServerSlice.d.ts.map +0 -1
  173. package/lib/store.d.ts.map +0 -1
  174. package/lib/utils/allocators.d.ts +0 -22
  175. package/lib/utils/allocators.d.ts.map +0 -1
  176. package/lib/utils/channelLabel.d.ts.map +0 -1
  177. package/lib/utils/constants.d.ts.map +0 -1
  178. package/lib/utils/interfaces.d.ts.map +0 -1
  179. package/lib/utils/messageTypes.d.ts.map +0 -1
  180. package/lib/utils/metadata.d.ts.map +0 -1
  181. package/lib/utils/splitToChunks.d.ts.map +0 -1
  182. package/lib/utils/uint8array.d.ts.map +0 -1
package/README.md CHANGED
@@ -1,485 +1,817 @@
1
- # p2party-js
1
+ <p align="center">
2
+ <a href="https://p2party.com">
3
+ <img src="docs/assets/p2party-cat.svg" width="180" alt="p2party cat logo">
4
+ </a>
5
+ </p>
6
+
7
+ # p2party
8
+
9
+ Protocol-v4 end-to-end encryption and reliable file transfer over a WebRTC
10
+ room mesh.
11
+
12
+ Apache-2.0 · [LICENSE.md](LICENSE.md)
13
+
14
+ > Status: protocol v4 is an intentional wire break — v3 peers and persisted v3
15
+ > crypto rows are not resumed. The current code has not completed an
16
+ > independent third-party security audit.
17
+
18
+ ## What is shipped
19
+
20
+ - Every peer in a room connects to every other present peer through WebRTC;
21
+ the signaling service is not the message hub.
22
+ - Every peer edge performs authenticated interactive 3DH plus an
23
+ authenticated, room-fixed [ML-KEM](https://csrc.nist.gov/pubs/fips/203/final)-512,
24
+ ML-KEM-768 (default), or ML-KEM-1024 bootstrap. PIN rooms additionally
25
+ authenticate with [CPace](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-cpace-21).
26
+ - Three chained key-confirmation messages complete the application-layer
27
+ cryptographic handshake. An `RTCDataChannel` becoming `open` establishes the
28
+ transport; it is not a substitute for that confirmation.
29
+ - Per-peer [Double Ratchet](https://signal.org/docs/specifications/doubleratchet/)
30
+ state protects messages after the handshake.
31
+ - Message data travels in fixed 65,490-byte protocol-v4 frames. Cryptographic
32
+ overhead is absorbed inside that fixed cell budget; randomized padding and
33
+ decoy slots can hide a message's exact payload length within its transfer.
34
+ - Each outbound message has its own transfer identity and data channel, a
35
+ cancellable handle, authenticated receipts, selective retransmission, and
36
+ reconnect resume.
37
+ - Text and files up to the enforced 10 GiB application limit are supported.
38
+ Browser builds use IndexedDB and, where available, OPFS for disk-backed large
39
+ file receipt.
40
+ - Room capabilities have a compact 43-character base64url form, a versioned
41
+ fragment invite, and an optional checksum-protected 24-word
42
+ [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039/bip-0039-wordlists.md)
43
+ representation.
44
+ - `p2party/session` exposes the cryptography without
45
+ [Redux](https://redux-toolkit.js.org/), IndexedDB, WebRTC, signaling,
46
+ `window`, or `localStorage`.
47
+
48
+ Immediate delivery over the existing signaling rendezvous is the shipped
49
+ default. Scheduled timing cover is also wired as of 0.14.2: a room policy may
50
+ pin a cadence, lane count, and frames per cell, and every edge in the room then
51
+ emits fixed-size cells on that schedule whether or not there is data to send.
52
+ Sparse post-quantum healing (the OFFER/ADVANCE/ACK epoch exchange) is likewise
53
+ live on the mesh path, with persistence before dispatch and application traffic
54
+ blocked while an epoch is in flight.
55
+
56
+ The public `connect()` path still rejects opaque and blind meeting points —
57
+ any `rendezvousMode` other than `legacy-signaling` — because that transport is
58
+ not wired. The private BitTorrent extension remains a research direction, not a
59
+ shipped property.
60
+
61
+ The current signaling operator can observe room membership, peer identities,
62
+ network metadata, and timing. Fixed message cells and in-transfer decoys do not
63
+ by themselves provide continuous traffic-analysis resistance.
2
64
 
3
- <!-- [![codecov][codecov-image]][codecov-url] -->
4
-
5
- [![Known Vulnerabilities](https://snyk.io/test/github/p2party/p2party-js/badge.svg?targetFile=package.json)](https://snyk.io/test/github/p2party/p2party-js?targetFile=package.json)
6
- <br>
7
- ![NPM Version](https://img.shields.io/npm/v/p2party)
8
- ![NPM License](https://img.shields.io/npm/l/p2party)
9
- [![code-style-prettier][code-style-prettier-image]][code-style-prettier-url]
10
- <br>
11
- ![NPM Downloads](https://img.shields.io/npm/dw/p2party)
12
- [![](https://data.jsdelivr.com/v1/package/npm/p2party/badge)](https://www.jsdelivr.com/package/npm/p2party)
65
+ ## Install
13
66
 
14
- <!-- [codecov-image]: https://codecov.io/gh/deliberative/crypto/branch/master/graph/badge.svg -->
15
- <!-- [codecov-url]: https://codecov.io/gh/deliberative/crypto -->
67
+ ```sh
68
+ npm install p2party
69
+ ```
16
70
 
17
- [code-style-prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square
18
- [code-style-prettier-url]: https://github.com/prettier/prettier
71
+ That is the whole setup. The package ships its own WebAssembly cryptography and
72
+ its own database worker; there is no build step, no postinstall, and no native
73
+ dependency to compile.
19
74
 
20
- > Peer-to-peer WebRTC mesh networking with "offensive" cryptographic.
75
+ Releases are published with npm provenance, so you can check that the tarball
76
+ was built by the tagged GitHub Actions run rather than uploaded by hand:
21
77
 
22
- **p2party** connects peers visiting the same URL into a WebRTC mesh network and enables secure message exchange of any size over ephemeral data channels. Unlike traditional privacy-enabling libraries, `p2party` obfuscates traffic using noisy and randomized padding of real information, isomorphic packet transmission (64kb), making message intent opaque. Of course it also adds a layer of ChaChaPoly1305 end-to-end encryption with ephemeral Ed25519 sender keys.
78
+ ```sh
79
+ npm audit signatures
80
+ ```
23
81
 
24
- ---
82
+ To build the artifact yourself instead, see
83
+ [Building from source](CONTRIBUTING.md#building-from-source). That path needs an
84
+ exact toolchain (Node 24, Emscripten 6.0.3, pinned submodules), because the
85
+ release build reproduces the pinned WASM and refuses to emit an artifact it
86
+ cannot attest.
25
87
 
26
- ## Disclaimer
88
+ ## Send a message between two browsers
27
89
 
28
- The API is not completely stable and the code has not undergone external security audits. Use at your own risk.
90
+ A complete working page is in
91
+ [`examples/browser-mesh/`](examples/browser-mesh/) — serve it, open it twice,
92
+ paste the invite from the first tab into the second tab's URL fragment:
29
93
 
30
- ## Features
94
+ ```sh
95
+ bunx vite examples/browser-mesh
96
+ ```
31
97
 
32
- - 📡 Auto-connect peers based on shared URLs
33
- - 🔀 WebRTC mesh topology (no central servers except for signaling and STUN/TURN)
34
- - 🔐 "Offensive" cryptography: every message can be split in multiple 64KB chunks so a stalker stores a lot of useless info
35
- - 🧩 Supports `File` and `string` messages via chunked encoding
36
- - 🧠 Built-in address book (whitelist), blacklist, and room memory, all stored in the browser's IndexedDB
37
- - 🛠 Easy API and integration with React via custom hooks
98
+ The part that matters is short:
38
99
 
39
- ---
100
+ ```ts
101
+ import p2party from "p2party";
40
102
 
41
- ## Dependencies
103
+ // One 256-bit capability. Share the invite; anyone holding it can join.
104
+ const invite = p2party.generateRoomInvite();
105
+ const room = await p2party.joinRoom(invite);
42
106
 
43
- This library relies heavily on [libsodium](https://github.com/jedisct1/libsodium) for cryptographic operations, which is a battle-tested project, compiled to WebAssembly for speed.
107
+ // Fires once per fully-arrived message, already decoded.
108
+ p2party.onMessage(room.id, ({ message }) => {
109
+ console.log("received", message);
110
+ });
44
111
 
45
- The library offers mnemonic generation, validation and Ed25519 key pair from mnemonic functionality that was inspired by [bip39](https://github.com/bitcoinjs/bip39) but instead of Blake2b we use Argon2, provided by libsodium, and instead of SHA256 we use SHA512 (native browser functionality).
112
+ // A room id does not mean anyone can receive yet.
113
+ await p2party.waitForPeers(room.id);
114
+ await p2party.sendMessage("hello", "chat", room.id).done;
115
+ ```
46
116
 
47
- A project that was previously developed and gave a lot of inspiration for this library was [libcrypto](https://github.com/deliberative/crypto).
117
+ `joinRoom` resolves once the signaling service has assigned the room its id. It
118
+ rejects on a timeout rather than waiting forever, and takes an `AbortSignal` if
119
+ the user navigates away:
48
120
 
49
- On the js side, the library depends on [Redux](https://github.com/redux) for state management.
121
+ ```ts
122
+ const controller = new AbortController();
123
+ // controller.abort() on unmount, route change, or a Cancel button.
50
124
 
51
- ## Install
125
+ const room = await p2party.joinRoom(invite, undefined, undefined, {
126
+ timeoutMs: 10_000,
127
+ signal: controller.signal,
128
+ });
129
+ ```
52
130
 
53
- To start, you install by typing in your project
131
+ Reading an inbound message needs its Merkle root, which arrives on the room's
132
+ `messages` state:
54
133
 
55
- ```bash
56
- npm install p2party
134
+ ```ts
135
+ const rooms = p2party.roomSelector(p2party.store.getState());
136
+ const latest = rooms.find((r) => r.id === room.id)?.messages.at(-1);
137
+ if (latest) {
138
+ const opened = await p2party.readMessage(latest.merkleRootHex);
139
+ console.log(opened.message);
140
+ }
57
141
  ```
58
142
 
59
- and include as ES module
143
+ ## Choose your integration
60
144
 
61
- ```typescript
62
- import p2party from "p2party";
145
+ **`p2party`** — the browser room mesh. It owns signaling, full-mesh WebRTC,
146
+ Redux state, IndexedDB/OPFS, the handshake, the ratchet, and transfer with
147
+ resume. You own the room capability and policy, the UI, and the optional PIN.
148
+
149
+ **`p2party/session`** — the cryptography alone, for Node, Bun, native shells or
150
+ a custom network. It owns the handshake, the ratchet, uniform encrypted
151
+ envelopes and snapshots. **You own the transport**, including message-delimited
152
+ framing, peer-key trust and storage — see
153
+ [docs/session-api.md](docs/session-api.md).
154
+
155
+ **`p2party/session` + `p2party/libcrypto.wasm`** — the same, with the exact
156
+ release-built cryptographic module loaded from bytes you supply, for offline or
157
+ integrity-pinned deployments.
158
+
159
+ Deeper guides:
160
+
161
+ - [Getting started](docs/getting-started.md) — the browser tutorial
162
+ - [Wire format](docs/wire-format.md) — frame layouts and the handshake ladder
163
+ - [Store-free session API](docs/session-api.md) — the `p2party/session` contract
164
+ - [Protocol-v4 security boundary](docs/protocol-v4-security.md) — what is and
165
+ is not a guarantee
166
+ - [References](docs/references.md) — standards, papers and related projects
167
+ - [Roadmap](ROADMAP.md) — what is next, and the open problems it depends on
168
+
169
+ ## Browser mesh
170
+
171
+ Every peer present in the same room connects to every other peer. The signaling
172
+ service coordinates discovery and WebRTC setup; it is not the message hub. A
173
+ room with `n` participants therefore has up to `n(n - 1) / 2` peer edges.
174
+
175
+ `joinRoom()` covers the common case. The two steps underneath it are separate
176
+ when you need them — `connect()` starts the join and returns immediately,
177
+ `waitForRoom()` resolves once the id arrives:
178
+
179
+ ```ts
180
+ await p2party.connect(invite);
181
+ // ...render a joining state, wire up other listeners...
182
+ const room = await p2party.waitForRoom(invite, { timeoutMs: 10_000 });
63
183
  ```
64
184
 
65
- or as CommonJS module
185
+ `joinRoom()` resolving does not mean every peer edge has finished its
186
+ handshake. The library gates message cryptography on that separately, so render
187
+ peer and message state from the exported store rather than treating the room as
188
+ ready for everything at once.
189
+
190
+ An open RTCDataChannel means its DTLS/SCTP transport is ready. It is not the
191
+ protocol-v4 acknowledgement: p2party next runs its authenticated HELLO plus
192
+ three chained confirmation flights over the main channel. Message receipts are
193
+ a third, delivery-level acknowledgement.
194
+
195
+ ## Wire format
196
+
197
+ Fixed 65,490-byte cells, a 65-byte receipt frame, and outer frame tags that make
198
+ an application cell, a decoy and a post-quantum healing record indistinguishable
199
+ by size. Byte layouts, the handshake ladder and the healing exchange are in
200
+ [docs/wire-format.md](docs/wire-format.md).
201
+
202
+ ## What a room invite looks like
66
203
 
67
- ```javascript
68
- const p2party = require("p2party");
204
+ One 256-bit capability, three presentations of the same bytes. Whoever holds it
205
+ can join the room, so it is the secret — treat it like one:
206
+
207
+ ```text
208
+ compact Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s 43 chars
209
+ fragment v1.Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s 46 chars
210
+ words craft hill business jelly crystal bunker furnace fresh trend
211
+ crisp wedding immune flush horse people wolf renew good caught
212
+ next fancy giggle palace huge 24 words
69
213
  ```
70
214
 
71
- or as UMD in the browser
215
+ In a URL the fragment goes after `#`, which keeps it out of the request line,
216
+ out of `Referer`, and out of ordinary server logs:
72
217
 
73
- ```html
74
- <script src="https://cdn.jsdelivr.net/npm/p2party@latest/lib/index.min.js"></script>
218
+ ```text
219
+ https://p2party.com/#v1.Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s
75
220
  ```
76
221
 
77
- ## Usage
222
+ All three decode to identical bytes, so peers can mix forms — one pastes a
223
+ link, another reads the words aloud over a phone call:
78
224
 
79
- The official website [p2party.com](https://p2party.com), which is an SPA written in React, consumes the library with a hook in the following way:
225
+ ```ts
226
+ const capability = p2party.generateRoomCapability();
80
227
 
81
- ```typescript
82
- import p2party from "p2party";
228
+ const compact = p2party.encodeRoomCapabilityBase64Url(capability); // 43 chars
229
+ const fragment = p2party.encodeRoomInviteFragment(capability); // v1.<compact>
230
+ const words = await p2party.encodeRoomCapabilityWords(capability); // 24 words
83
231
 
84
- import { useState } from "react";
85
- import { useSelector } from "react-redux";
232
+ const fromWords = await p2party.decodeRoomCapabilityWords(words);
233
+ console.assert(p2party.encodeRoomCapabilityBase64Url(fromWords) === compact);
86
234
 
87
- import type { Message } from "p2party";
235
+ await p2party.connect(fragment);
236
+ ```
88
237
 
89
- export interface MessageWithData extends Message {
90
- data: string | File;
238
+ `generateRoomInvite()` is the one-liner for the fragment form. The word list is
239
+ [BIP-39 English](https://github.com/bitcoin/bips/blob/master/bip-0039/bip-0039-wordlists.md)
240
+ and is checksum-protected — it encodes the same 256 bits, it is not a
241
+ lower-entropy password. The shipped `legacy-signaling` route still sends the
242
+ normalized capability to the signaling service, so a fragment is not
243
+ server-blind rendezvous.
244
+
245
+ ## PIN rooms
246
+
247
+ A capability alone authenticates whoever _received the link_. If the link leaks
248
+ — a forwarded chat, a screenshot, a shared clipboard — the holder joins. A PIN
249
+ adds a second factor over a separate channel: peers must also prove they know
250
+ the same short secret, using [CPace](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-cpace-21),
251
+ a balanced PAKE, so the PIN itself never crosses the wire and a wrong PIN fails
252
+ the handshake instead of leaking a guess oracle.
253
+
254
+ PIN mode is **in addition to** identity authentication, never instead of it:
255
+
256
+ ```ts
257
+ import p2party, { type RoomPolicyV1 } from "p2party";
258
+
259
+ const policy = {
260
+ ...p2party.DEFAULT_ROOM_POLICY_V1,
261
+ authMode: "pin",
262
+ pqMode: "hybrid-mlkem1024", // 512 | 768 (default) | 1024, fixed up front
263
+ } satisfies RoomPolicyV1;
264
+
265
+ // Both peers need these exact bytes, carried out of band — spoken aloud,
266
+ // not sent through the same channel as the invite.
267
+ const pin = new TextEncoder().encode("correct horse battery staple");
268
+
269
+ try {
270
+ await p2party.connect(invite, undefined, undefined, { policy, pin });
271
+ } finally {
272
+ pin.fill(0); // connect() copied it into the in-memory room vault.
91
273
  }
274
+ ```
92
275
 
93
- export const useRoom = () => {
94
- const [roomIndex, setRoomIndex] = useState(-1);
95
- const keyPair = useSelector(p2party.keyPairSelector);
96
- const rooms = useSelector(p2party.roomSelector);
97
- const signalingServerConnection = useSelector(
98
- p2party.signalingServerSelector,
99
- );
100
-
101
- const openChannel = async (name: string) => {
102
- if (roomIndex === -1) throw new Error("No room was selected");
103
-
104
- await p2party.openChannel(
105
- rooms[roomIndex].id,
106
- name,
107
- rooms[roomIndex].peers,
108
- );
109
- };
276
+ Room policy is immutable once the room is created locally, and every peer must
277
+ present the same policy and the same PIN bytes. The ML-KEM suite is fixed
278
+ before the handshake runs — there is no in-band negotiation, no downgrade, and
279
+ no classical fallback, so a mismatched peer fails closed rather than quietly
280
+ agreeing on something weaker.
281
+
282
+ PIN bytes are deliberately absent from the public policy, from Redux, from
283
+ persisted room records and from logs. Wipe your copy when the room is up.
284
+
285
+ ## Scheduled cover traffic
286
+
287
+ Encryption hides what you say. It does not hide _that_ you said something —
288
+ an observer still sees a burst of frames the moment you press send. Scheduled
289
+ cover replaces that pattern with a constant one: every edge in the room emits
290
+ fixed-size cells on a fixed cadence whether or not there is anything to send,
291
+ and real chunks are substituted into slots that were going to be sent anyway.
292
+
293
+ ```ts
294
+ const policy = {
295
+ ...p2party.DEFAULT_ROOM_POLICY_V1,
296
+ coverMode: "scheduled",
297
+ coverCadenceMs: 10_000, // one cycle every 10s
298
+ coverLanes: 2, // parallel schedules per edge
299
+ coverFramesPerCell: 1, // 65,490-byte frames per slot
300
+ coverDurationEpochs: 360,
301
+ } satisfies RoomPolicyV1;
302
+
303
+ p2party.validateRoomPolicyV1(policy); // throws before you build a room on it
304
+ ```
110
305
 
111
- const sendMessage = async (message: string | File, channel: string) => {
112
- if (roomIndex === -1) throw new Error("No room was selected");
306
+ The bounds are exported `MIN_COVER_CADENCE_MS`, `MAX_COVER_CADENCE_MS`,
307
+ `MAX_COVER_LANES`, `MAX_COVER_FRAMES_PER_CELL`, `MIN_COVER_SLOT_MS` so a
308
+ policy UI validates against the library instead of re-declaring limits that
309
+ drift.
113
310
 
114
- await p2party.sendMessage(
115
- message,
116
- channel,
117
- rooms[roomIndex].id,
118
- percentageFilledChunk / 100,
119
- chunks,
120
- );
121
- };
311
+ Cover is a **room-wide** property: it hides timing only for as long as every
312
+ edge keeps emitting on schedule, and it costs bandwidth continuously. It has
313
+ been measured on loopback, not across a real network path, and fixed cells over
314
+ a fixed cadence are not by themselves traffic-analysis resistance — see
315
+ [The Last Hop Attack](https://doi.org/10.56553/popets-2025-0067) for how loop
316
+ cover over fixed cascades fails, and the
317
+ [security boundary](docs/protocol-v4-security.md) for what is actually claimed.
318
+ Immediate delivery remains the default.
319
+
320
+ ## Send, cancel, and read
321
+
322
+ `sendMessage()` returns a `MessageTransferHandle`, not a promise: `transferId`
323
+ identifies this logical send, `cancel()` works even during hashing and channel
324
+ setup, and `done` settles after every started peer send and cleanup.
122
325
 
326
+ `done` **rejects** when no peer took delivery — an empty room, or a cancel.
327
+ Both are ordinary outcomes. The rejection is a `MessageDeliveryError` whose
328
+ `result.outcomes` carries the same per-peer detail a resolved value would, so
329
+ handle it rather than treating it as a crash. The quickstart above shows the
330
+ shape; [docs/getting-started.md](docs/getting-started.md#send-cancel-and-read)
331
+ covers reading inbound messages and the metadata-only read that avoids
332
+ materializing large files.
333
+
334
+ ## Cryptography without WebRTC
335
+
336
+ `p2party/session` is the same protocol-v4 cryptography with no Redux, no
337
+ IndexedDB, no WebRTC, no signaling, no `window` and no `localStorage` — for
338
+ Node, Bun, a native shell, a CLI, or any transport you already have.
339
+
340
+ Nothing to configure. The WASM loads from the installed package, so this runs
341
+ offline:
342
+
343
+ ```ts
344
+ import { generateSessionIdentity } from "p2party/session";
345
+
346
+ const identity = await generateSessionIdentity();
347
+ ```
348
+
349
+ ### Two peers, both sides
350
+
351
+ The session needs one thing from you: a transport. `send` hands off a message,
352
+ `recv` resolves with the next one. Whole messages, in order, no partial reads —
353
+ a WebSocket, a TCP socket with length prefixes, or a queue all qualify. An
354
+ in-memory pipe is enough to run both peers in one process:
355
+
356
+ ```ts
357
+ // One one-way pipe. Two of these make a full-duplex transport.
358
+ const makeLink = () => {
359
+ const queued: Uint8Array[] = [];
360
+ const waiters: Array<(bytes: Uint8Array) => void> = [];
123
361
  return {
124
- keyPair,
125
- peerId: keyPair.peerId,
126
- signalingServerURL: signalingServerConnection.serverUrl,
127
- signalingServerConnectionState: signalingServerConnection,
128
- peers: roomIndex > -1 ? rooms[roomIndex].peers : [],
129
- channels: roomIndex > -1 ? rooms[roomIndex].channels : [],
130
- messages: roomIndex > -1 ? rooms[roomIndex].messages : [],
131
- connect: p2party.connect,
132
- connectToSignalingServer: p2party.connectToSignalingServer,
133
- disconnect: p2party.disconnectFromRoom,
134
- disconnectFromSignalingServer: p2party.disconnectFromSignalingServer,
135
- disconnectFromRoom: p2party.disconnectFromRoom,
136
- disconnectFromAllRooms: p2party.disconnectFromAllRooms,
137
- disconnectFromPeer: p2party.disconnectFromPeer,
138
- openChannel,
139
- selectChannel: setSelectedChannel,
140
- sendMessage,
141
- readMessage: p2party.readMessage,
142
- cancelMessage: p2party.cancelMessage,
143
- deleteMessage: p2party.deleteMessage,
144
- purge: p2party.purge,
145
- purgeRoom: p2party.purgeRoom,
146
- purgeIdentity: p2party.purgeIdentity,
362
+ send(bytes: Uint8Array) {
363
+ const owned = Uint8Array.from(bytes); // copy: the caller reuses buffers
364
+ const waiter = waiters.shift();
365
+ if (waiter) waiter(owned);
366
+ else queued.push(owned);
367
+ },
368
+ recv(): Promise<Uint8Array> {
369
+ const bytes = queued.shift();
370
+ return bytes
371
+ ? Promise.resolve(bytes)
372
+ : new Promise((resolve) => waiters.push(resolve));
373
+ },
147
374
  };
148
375
  };
149
376
  ```
150
377
 
151
- In the [p2party.com](https://p2party.com) SPA, where we use [React-Router](https://github.com/remix-run/react-router) for navigation, we use the following function to navigate to a new room that is randomly generated. We implement it inside the hook and export it with it.
378
+ Alice and Bob each generate a long-term identity, exchange Ed25519 public keys
379
+ out of band, agree on a channel binding, and hand the session two byte pipes.
380
+ Nothing below is elided — this is the whole setup:
381
+
382
+ ```ts
383
+ import { createSession, generateSessionIdentity } from "p2party/session";
384
+
385
+ // 1. Long-term identities. Persist these; they are who each peer *is*.
386
+ const aliceIdentity = await generateSessionIdentity();
387
+ const bobIdentity = await generateSessionIdentity();
388
+
389
+ // 2. Trust. Each side must already know the other's Ed25519 public key —
390
+ // pinned from a previous session, read off a QR code, or explicitly
391
+ // TOFU-accepted. The session never decides this for you.
392
+ const alicePublicKey = aliceIdentity.ed25519PublicKey;
393
+ const bobPublicKey = bobIdentity.ed25519PublicKey;
394
+
395
+ // 3. Channel binding, identical on both sides but with the fingerprints
396
+ // swapped. Bound into the handshake transcript so a relay cannot sit in
397
+ // the middle and swap sides.
398
+ const channelId = crypto.getRandomValues(new Uint8Array(16));
399
+ const aliceFingerprint = crypto.getRandomValues(new Uint8Array(32));
400
+ const bobFingerprint = crypto.getRandomValues(new Uint8Array(32));
401
+
402
+ // 4. Two one-way pipes. Replace these with your socket, WebSocket, pipe or
403
+ // queue — anything that delivers whole messages, in order.
404
+ const aliceToBob = makeLink();
405
+ const bobToAlice = makeLink();
406
+
407
+ // 5. Handshake. Both sides run concurrently: the flights are interactive, so
408
+ // awaiting one before starting the other deadlocks.
409
+ const [alice, bob] = await Promise.all([
410
+ createSession({
411
+ role: "initiator",
412
+ identity: aliceIdentity,
413
+ peerIdentityEd25519PublicKey: bobPublicKey,
414
+ channel: {
415
+ channelId,
416
+ localFingerprint: aliceFingerprint,
417
+ remoteFingerprint: bobFingerprint,
418
+ },
419
+ transport: { send: aliceToBob.send, recv: bobToAlice.recv },
420
+ mode: "nopin",
421
+ }),
422
+ createSession({
423
+ role: "responder",
424
+ identity: bobIdentity,
425
+ peerIdentityEd25519PublicKey: alicePublicKey,
426
+ channel: {
427
+ channelId,
428
+ localFingerprint: bobFingerprint,
429
+ remoteFingerprint: aliceFingerprint,
430
+ },
431
+ transport: { send: bobToAlice.send, recv: aliceToBob.recv },
432
+ mode: "nopin",
433
+ }),
434
+ ]);
435
+ ```
152
436
 
153
- ```typescript
154
- /**
155
- * Previous imports
156
- */
437
+ For a PIN-authenticated session, both sides pass `mode: "pin"` with identical
438
+ `pin` bytes instead — the same CPace step the browser mesh uses.
439
+
440
+ ### What goes over the wire
441
+
442
+ ```ts
443
+ const encoder = new TextEncoder();
444
+ const decoder = new TextDecoder();
445
+
446
+ const sealed = await alice.encrypt(encoder.encode("hello bob"));
447
+
448
+ // sealed.protocolVersion === 4
449
+ // sealed.root -> 32-byte Merkle root, authenticated as AEAD additional data
450
+ // sealed.frames -> [Uint8Array(65490)] one uniform cell; a 9-byte message and
451
+ // a 60 KiB message produce byte-identical frame sizes.
452
+ // Each frame is:
453
+ // type(1) | DH pubkey(32) | N(8) | PN(8) | PQ epoch(8) |
454
+ // nonce(12) | ciphertext(65405) | Poly1305 tag(16)
455
+ // Only the 69-byte header is readable; it is authenticated,
456
+ // not secret. Everything else is indistinguishable from
457
+ // random to anyone without the message key.
458
+
459
+ // Hand sealed.frames to your transport verbatim. It must delimit records
460
+ // itself — the session returns opaque bytes, not a framed stream.
461
+ const opened = await bob.decrypt(sealed);
462
+ console.log(decoder.decode(opened)); // "hello bob"
463
+
464
+ // Either side may speak first, and simultaneous first sends are fine: the
465
+ // handshake primes both ratchet directions.
466
+ const reply = await bob.encrypt(encoder.encode("hi alice"));
467
+ console.log(decoder.decode(await alice.decrypt(reply))); // "hi alice"
468
+ ```
157
469
 
158
- import { useNavigate } from "react-router";
470
+ Each logical message consumes one ratchet step. Replays and tampered frames are
471
+ rejected; out-of-order arrival is tolerated within a bounded skipped-key window.
159
472
 
160
- export const useRoom = () => {
161
- const navigate = useNavigate();
473
+ ### Suspend and resume
162
474
 
163
- /**
164
- * Previous functions
165
- */
475
+ ```ts
476
+ import { restoreSession } from "p2party/session";
166
477
 
167
- const goToRandomRoom = async (replace = false) => {
168
- const random = await p2party.generateRandomRoomUrl();
169
- navigate("/rooms/" + random, { replace });
170
- };
478
+ const snapshot = await alice.serialize(); // plaintext secret — encrypt at rest
479
+ await alice.destroy();
171
480
 
172
- return {
173
- goToRandomRoom,
174
- };
175
- };
481
+ const restored = await restoreSession(snapshot);
482
+
483
+ // Same ratchet, same counters. Bob notices nothing.
484
+ const later = await bob.encrypt(encoder.encode("still there?"));
485
+ console.log(decoder.decode(await restored.decrypt(later))); // "still there?"
486
+
487
+ snapshot.fill(0);
488
+ ```
489
+
490
+ Run the complete two-party script — including the sparse post-quantum healing
491
+ exchange — from a checkout:
492
+
493
+ ```sh
494
+ bun run examples/standalone-e2ee.ts
176
495
  ```
177
496
 
178
- The most important exported functions by p2party, with their types, are:
179
-
180
- ```typescript
181
- /**
182
- * Connects peer to a room.
183
- * A room URL is 64 chars long. We use the sha256 of the sha512 of random data.
184
- */
185
- const connect = async (
186
- roomUrl: string,
187
- signalingServerUrl = "wss://signaling.p2party.com/ws",
188
- rtcConfig: RTCConfiguration = {
189
- iceServers: [
190
- {
191
- urls: ["stun:stun.p2party.com:3478"],
192
- },
193
- ],
194
- iceTransportPolicy: "all",
195
- },
196
- ) => Promise<void>;
197
-
198
- const connectToSignalingServer = async (
199
- roomUrl: string,
200
- signalingServerUrl = "wss://signaling.p2party.com/ws",
201
- ) => Promise<void>;
202
-
203
- const sendMessage = async (
204
- data: string | File,
205
- toChannel: string,
206
- roomId: string,
207
- percentageFilledChunk = 0.9,
208
- minChunks = 3,
209
- chunkSize = CHUNK_LEN,
210
- metadataSchemaVersion = 1,
211
- ) => Promise<void>;
212
-
213
- const readMessage = async (merkleRootHex?: string, hashHex?: string) =>
214
- Promise<{
215
- message: string | Blob;
216
- percentage: number;
217
- size: number;
218
- filename: string;
219
- mimeType: MimeType;
220
- extension: FileExtension;
221
- category: string;
222
- }>;
223
-
224
- const cancelMessage = async (
225
- channelLabel: string,
226
- merkleRoot?: string | Uint8Array,
227
- hash?: string | Uint8Array,
228
- ) => Promise<void>;
497
+ [`examples/standalone-e2ee.ts`](examples/standalone-e2ee.ts) is also shipped
498
+ inside the package, and includes the `makeLink()` helper used above.
499
+
500
+ Four things stay yours, because no library can decide them for you:
501
+
502
+ | You own | Because |
503
+ | --------------------- | ---------------------------------------------------------------------------------------------------- |
504
+ | Peer-key trust | `peerIdentityEd25519PublicKey` must be pinned or explicitly TOFU-accepted; the session never guesses |
505
+ | Message framing | `encrypt()` returns opaque frames — your transport must delimit and length-check records itself |
506
+ | Snapshot storage | `serialize()` is plaintext secret material: encrypt at rest, and protect against rollback |
507
+ | The `channel` binding | A channel id and two endpoint fingerprints, bound into the transcript so a relay cannot swap sides |
508
+
509
+ Outside WebRTC there are no DTLS fingerprints to bind, so derive the channel
510
+ binding from whatever your transport authenticates — a TLS exporter, a session
511
+ id, or random bytes both sides agree on out of band. The full contract, the
512
+ envelope codec and the sparse-PQ healing hooks are in
513
+ [docs/session-api.md](docs/session-api.md).
514
+
515
+ ## Running the operations one at a time
516
+
517
+ `joinRoom()` and `createSession()` are the batteries-included paths. Every step
518
+ they take is also a public call, so you can drive the protocol yourself.
519
+
520
+ **Identity, signing, and recovery phrases.** Keys are Ed25519; the recovery
521
+ phrase is BIP-39, so a wallet-style backup flow works without a second library:
522
+
523
+ ```ts
524
+ const mnemonic = await p2party.generateMnemonic(256); // 24 words
525
+ const keyPair = await p2party.keyPairFromMnemonic(mnemonic); // deterministic
526
+ const fresh = await p2party.newKeyPair(); // or just random
527
+
528
+ const bytes = new TextEncoder().encode("anything you want attributable");
529
+ const signature = await p2party.sign(bytes, keyPair.secretKey);
530
+ const ok = await p2party.verify(bytes, signature, keyPair.publicKey);
229
531
  ```
230
532
 
231
- For a complete reference of the API you can check the library output file [index.ts](src/index.ts).
533
+ **Room policy as data.** A policy is a value you can encode, hash, compare and
534
+ validate before anything touches the network — useful for showing two peers
535
+ that they really are about to join the same room:
536
+
537
+ ```ts
538
+ const policy = { ...p2party.DEFAULT_ROOM_POLICY_V1 } satisfies RoomPolicyV1;
539
+
540
+ const encoded = p2party.encodeRoomPolicyV1(policy); // canonical bytes
541
+ const digest = await p2party.hashRoomPolicyV1(policy); // stable identifier
542
+ p2party.validateRoomPolicyV1(policy); // throws with the offending field
232
543
 
233
- To load all the past room data you call
544
+ // Peers fail closed on a policy mismatch, so compare before you connect and
545
+ // you can say *which* setting differs instead of surfacing a failed handshake.
546
+ // `encoded` here stands in for the canonical bytes the other peer sent you.
547
+ const theirs = p2party.decodeRoomPolicyV1(encoded);
548
+ const agreed = p2party.roomPoliciesEqualV1(policy, theirs);
549
+ ```
550
+
551
+ **The ratchet, step by step.** A `P2PartySession` exposes each operation
552
+ individually rather than only a send/receive loop:
553
+
554
+ | Call | What it does |
555
+ | -------------------------------------------- | ---------------------------------------------------------- |
556
+ | `encrypt` / `decrypt` | One ratchet step per logical message |
557
+ | `serialize` / `restoreSession` | Snapshot and resume the exact ratchet state |
558
+ | `prepareHealing` | Start a post-quantum epoch when one is due |
559
+ | `acceptControlFrame` | Process an inbound OFFER / ADVANCE / ACK, return the reply |
560
+ | `pendingControl` | Re-emit the exact frame for a dropped flight |
561
+ | `pqEpoch`, `healingInProgress`, `canEncrypt` | Inspect live state |
562
+ | `destroy` | Wipe key material |
563
+
564
+ **Driving the ratchet by hand.** This is a `p2party/session` concern only. The
565
+ browser root installs a healing orchestrator on every peer edge as its channel
566
+ opens and drives exchanges on a timer, so a `p2party` room needs none of the
567
+ code below. `p2party/session` owns no transport and therefore no scheduler,
568
+ which is why it hands you the steps instead.
569
+
570
+ Each `encrypt()` advances the ratchet one step either way. A complete healing
571
+ exchange, both sides:
572
+
573
+ ```ts
574
+ // The ratchet advances per message, and you can watch it do so.
575
+ console.log(alice.pqEpoch); // 0n before any healing exchange
576
+
577
+ // Healing is due after 64 messages or 24 hours, and only on your turn.
578
+ // prepareHealing() returns { frame: null } when it is neither.
579
+ const offer = await alice.prepareHealing();
580
+
581
+ if (offer.frame) {
582
+ // THE RULE: persist before the frame leaves. A crash after sending but
583
+ // before persisting loses the ephemeral KEM secret, and the two sides then
584
+ // disagree about the epoch. That is a dead session, not a slow one.
585
+ await alice.serialize();
586
+ const advance = await bob.acceptControlFrame(offer.frame); // OFFER -> ADVANCE
587
+
588
+ await bob.serialize();
589
+ const ack = await alice.acceptControlFrame(advance.frame!); // ADVANCE -> ACK
590
+
591
+ await alice.serialize();
592
+ await bob.acceptControlFrame(ack.frame!); // ACK -> done
593
+
594
+ console.log(alice.pqEpoch, bob.pqEpoch); // 1n 1n
595
+ }
234
596
 
235
- ```typescript
236
- const rooms = await p2party.getAllExistingRooms();
597
+ // If a flight is dropped, re-send the exact same bytes. Do not call
598
+ // prepareHealing() again fresh randomness forks the exchange.
599
+ const retry = await alice.pendingControl();
600
+ if (retry) transport.send(retry);
237
601
  ```
238
602
 
239
- To load the contents of a private message you can use the following React item with the react hook:
240
-
241
- ```tsx
242
- // Suppose Text React element exists
243
- import { Text } from "./Text";
244
-
245
- // {{ message }} comes from const { messages } = useRoom();
246
- const MessageItem: FC<MessageItemProps> = ({ message }) => {
247
- const [state, setState] = useState<{
248
- msg: string;
249
- msgSize: number;
250
- msgFilename: string;
251
- msgCategory: string;
252
- msgPercentage: number;
253
- msgLoadingText: string;
254
- msgExtension: FileExtension;
255
- }>({
256
- msg: "",
257
- msgSize: 0,
258
- msgFilename: "",
259
- msgCategory: p2party.MessageCategory.Text,
260
- msgLoadingText: "",
261
- msgPercentage: 0,
262
- msgExtension: "",
603
+ `healingInProgress` is true while an exchange is open, and application traffic
604
+ is blocked until it closes. That is deliberate: a message encrypted under an
605
+ ambiguous epoch is worse than a message delayed by one round trip.
606
+
607
+ Every `serialize()` above sits **before** its send, and that ordering is the
608
+ whole contract. `requiresPersistBeforeSend` on the returned
609
+ `SessionControlOutput` tells you when a durable write is genuinely required, so
610
+ you can skip the disk hit on an exact duplicate response.
611
+
612
+ [`examples/standalone-e2ee.ts`](examples/standalone-e2ee.ts) runs this end to
613
+ end, including the 64 messages that make an exchange due.
614
+
615
+ The lower-level primitives — X25519, HKDF-SHA512, ML-KEM, CPace, the Merkle
616
+ tree, the raw ratchet — are deliberately _not_ exported. They are easy to
617
+ combine into something that looks right and is not, and the whole point of the
618
+ package is that the combination has been done once, carefully. If you need
619
+ those, use [libsodium](https://github.com/jedisct1/libsodium) and
620
+ [mlkem-native](https://github.com/pq-code-package/mlkem-native) directly, which
621
+ is what this package compiles.
622
+
623
+ ## No build step: a script tag and the CDN
624
+
625
+ Every release publishes its browser bundle, its database worker and its
626
+ cryptographic module as immutable, versioned CDN objects. The version is in the
627
+ path, so a URL names exactly one build and is safe to cache forever. Drop the
628
+ script in and `window.p2party` is there — no npm, no bundler, no build:
629
+
630
+ ```html
631
+ <!doctype html>
632
+ <meta charset="utf-8" />
633
+ <title>p2party in one file</title>
634
+
635
+ <script
636
+ src="https://cdn.p2party.com/@0.14.2/p2party.min.js"
637
+ integrity="sha384-KJLIhsZkQzkYg1lyk57oAacQBR3DeWHX92Ksv9QTO9tUJVw1pxUNHI9e2nIcacgZ"
638
+ crossorigin="anonymous"
639
+ ></script>
640
+
641
+ <script type="module">
642
+ // The bundle embeds its worker and fetches its own WASM from the same
643
+ // versioned path, under a build-pinned SHA-384 SRI.
644
+ const invite = p2party.generateRoomInvite();
645
+ location.hash = invite; // share this URL; anyone holding it can join
646
+
647
+ const room = await p2party.joinRoom(location.hash.slice(1) || invite);
648
+
649
+ p2party.onMessage(room.id, ({ message }) => {
650
+ document.body.append(
651
+ Object.assign(document.createElement("p"), {
652
+ textContent: message,
653
+ }),
654
+ );
263
655
  });
264
656
 
265
- useEffect(() => {
266
- const controller = new AbortController();
267
-
268
- const setMessage = async () => {
269
- const m = await readMessage(message.merkleRootHex, message.sha512Hex);
270
-
271
- /**
272
- * In this situation the user is the sender and before they
273
- * send the message they need to split it into chunks
274
- * in order to calculate the Merkle root and proof before send.
275
- */
276
- if (
277
- message.fromPeerId === peerId &&
278
- message.totalChunks > 0 &&
279
- message.chunksCreated < message.totalChunks
280
- ) {
281
- setState((prevState) => ({
282
- ...prevState,
283
- msg:
284
- typeof m.message === "string"
285
- ? m.message
286
- : m.message
287
- ? URL.createObjectURL(m.message)
288
- : "",
289
- msgLoadingText:
290
- "Split " +
291
- message.chunksCreated +
292
- " chunks of " +
293
- message.totalChunks,
294
- msgFilename: m.filename,
295
- msgCategory: m.category,
296
- msgExtension: m.extension,
297
- msgPercentage: Math.floor(
298
- (message.chunksCreated / message.totalChunks) * 100,
299
- ),
300
- }));
301
- } else {
302
- /**
303
- * Here the user is the receiver and they can read the message since they have
304
- * all the necessary chunks
305
- */
306
- if (m.percentage === 100) {
307
- setState((prevState) => ({
308
- ...prevState,
309
- msg:
310
- typeof m.message === "string"
311
- ? m.message
312
- : m.message
313
- ? URL.createObjectURL(m.message)
314
- : "",
315
- msgSize: m.size,
316
- msgLoadingText: "",
317
- msgFilename: m.filename,
318
- msgCategory: m.category,
319
- msgExtension: m.extension,
320
- msgPercentage: m.percentage, // 100,
321
- }));
322
- } else {
323
- /**
324
- * Here the receiver does not have all the chunks necessary to read the message
325
- **/
326
- setState((prevState) => ({
327
- ...prevState,
328
- msgSize: m.size,
329
- msgLoadingText:
330
- "Received " +
331
- formatBytes(message.savedSize) +
332
- " of " +
333
- formatBytes(message.totalSize),
334
- msgFilename: m.filename,
335
- msgCategory: m.category,
336
- msgExtension: m.extension,
337
- msgPercentage: m.percentage,
338
- }));
339
- }
340
- }
341
- };
342
-
343
- setMessage();
344
-
345
- return () => {
346
- controller.abort();
347
-
348
- if (msg.length > 0 && msgCategory !== p2party.MessageCategory.Text)
349
- URL.revokeObjectURL(msg);
350
- };
351
- }, [
352
- message.merkleRootHex,
353
- message.sha512Hex,
354
- message.savedSize,
355
- message.chunksCreated,
356
- ]);
357
-
358
- const {
359
- msg,
360
- msgSize,
361
- msgCategory,
362
- msgPercentage,
363
- msgExtension,
364
- msgLoadingText,
365
- msgFilename,
366
- } = state;
367
-
368
- return (
369
- <div>
370
- {msgCategory === p2party.MessageCategory.Text && url.length === 0 && (
371
- <Text>{msg as string}</Text>
372
- )}
373
-
374
- {msgCategory === p2party.MessageCategory.Text && url.length > 0 && (
375
- <Text>{msg as string}</Text>
376
- )}
377
-
378
- {msgCategory !== p2party.MessageCategory.Text && (
379
- <Text>{msgFilename}</Text>
380
- )}
381
- </div>
382
- );
383
- };
657
+ await p2party.waitForPeers(room.id);
658
+ await p2party.sendMessage("hello from a script tag", "chat", room.id).done;
659
+ </script>
384
660
  ```
385
661
 
386
- For privacy features like whitelist, blacklist and room purging we have the following APIs:
387
-
388
- ```typescript
389
- /**
390
- * This deletes the user's private key but keeps all the messages.
391
- * A side effect is that the user is disconnected from all their rooms.
392
- */
393
- const purgeIdentity = () => void;
394
-
395
- /**
396
- * This deletes all the data of a room and disconnects the user from it.
397
- */
398
- const purgeRoom = (roomUrl: string) => void;
399
-
400
- /**
401
- * This deletes both private keys and messages and gives a clean state.
402
- */
403
- const purge = async () => void;
404
-
405
- /**
406
- * This deletes a specific message (merkle root) or all instances of
407
- * a specific message (hash).
408
- */
409
- const deleteMessage = async (
410
- merkleRoot?: string | Uint8Array,
411
- hash?: string | Uint8Array,
412
- ) => void;
413
-
414
- /**
415
- * This does not do anything by itself unless the next function is called.
416
- */
417
- const addPeerToAddressBook = async (
418
- username: string,
419
- peerId: string,
420
- peerPublicKey: string,
421
- ) => void;
422
-
423
- /**
424
- * Once this function is called with onlyAllow: true,
425
- * the user can only connect to peers in their whitelist in a specific room.
426
- * Everyone else cannot even see if the user is connected in the same URL.
427
- * Can be reverted by calling the function with onlyAllow: false.
428
- * Default state for new rooms is onlyAllow: false.
429
- */
430
- const onlyAllowConnectionsFromAddressBook = async (
431
- roomUrl: string,
432
- onlyAllow: boolean,
433
- ) => void;
434
- const deletePeerFromAddressBook = async (
435
- username?: string,
436
- peerId?: string,
437
- peerPublicKey?: string,
438
- ) => void;
439
-
440
- /**
441
- * Once the user is here they cannot connect with us
442
- * and they cannot even see if we are connected in the room at the same time as them.
443
- * They can theoretically receive the same messages as us from our common peers who have
444
- * not blacklisted them.
445
- */
446
- const blacklistPeer = async (peerId: string, peerPublicKey: string) => void;
447
- const removePeerFromBlacklist = async (peerId?: string, peerPublicKey?: string) => void;
662
+ Open that file in two tabs, paste the first tab's URL into the second, and they
663
+ connect directly to each other.
448
664
 
665
+ The three published objects:
666
+
667
+ ```text
668
+ https://cdn.p2party.com/@0.14.2/p2party.min.js UMD bundle -> window.p2party
669
+ https://cdn.p2party.com/@0.14.2/db.worker.js IndexedDB/OPFS worker
670
+ https://cdn.p2party.com/@0.14.2/libcrypto.wasm the cryptographic module
449
671
  ```
450
672
 
451
- Because a message is split into chunks with noisy padding for which we need to calculate Merkle proofs, it may take some time for the process to finish before starting transmitting the information over a channel.
673
+ The `integrity` value above is this release's bundle, and the release build
674
+ fails if the README and the built artifact ever disagree — so it is safe to
675
+ copy verbatim. The worker, if you host it yourself, is
676
+ `sha384-qWbMGmyz8mCS/hgbn0jEf4Fo73iXiy6w+H/NfO6ymxKwzldUeHSxolNZtSZfV1nf`.
452
677
 
453
- ## Development
678
+ The WASM is integrity-checked whether or not you pin the script: that hash is
679
+ compiled into the bundle and cannot be turned off.
680
+
681
+ ## Local, self-hosted, or release-pinned WASM
454
682
 
455
- If you want to build the library yourselves, you need to have [Emscripten](https://github.com/emscripten-core/emscripten)
456
- installed on your machine in order to compile the C code into WebAssembly.
457
- We have the `-s SINGLE_FILE=1` option for the `emcc` compiler, which converts the `wasm` file to a `base64` string
458
- that will be compiled by the glue js code into a WebAssembly module. This was done for the purpose of interoperability
459
- and modularity.
683
+ The browser root always fetches the exact versioned CDN WASM with a build-pinned
684
+ SHA-384 SRI value by default. A self-hosted browser app can point it at the
685
+ same release bytes before calling `connect()`:
460
686
 
461
- Clone the repo, download the libsodium submodule and install packages:
687
+ ```ts
688
+ import p2party from "p2party";
462
689
 
690
+ p2party.setWasmSourceUrl(
691
+ new URL("/vendor/p2party-0.14.2/libcrypto.wasm", window.location.href),
692
+ );
463
693
  ```
464
- git clone https://github.com/p2party/p2party-js.git
465
- cd p2party-js
466
- git submodule init
467
- git submodule update
468
- npm i
694
+
695
+ The SRI check remains active, so a URL serving different bytes fails closed.
696
+
697
+ ### Download the WASM from the CDN
698
+
699
+ Every release publishes its cryptographic module as an immutable, versioned
700
+ object. The path carries the version, so a URL always names exactly one build
701
+ and is safe to cache forever:
702
+
703
+ ```sh
704
+ curl -O https://cdn.p2party.com/@0.14.2/libcrypto.wasm
705
+ curl -O https://cdn.p2party.com/@0.14.2/libcrypto.provenance.json
469
706
  ```
470
707
 
471
- Once you have all the dependencies installed, you can run
708
+ Check what you downloaded before you serve it. The SHA-256 and the SRI value
709
+ are both recorded in the provenance file that sits next to it:
472
710
 
711
+ ```sh
712
+ shasum -a 256 libcrypto.wasm
713
+ openssl dgst -sha384 -binary libcrypto.wasm | openssl base64 -A
473
714
  ```
474
- npm run dist
715
+
716
+ For 0.14.2 those are
717
+ `7eea31157e69ac61f3a512b624d5c210296302fd289fc2b65c63ecc31a056267` and
718
+ `sha384-pBMyUqQ3KBztxgeJMgDFZeohfj9QlAFNwt4/gRlqT0vlZ2kbkKxv+q5DwbZOBuUP`.
719
+ They are the same bytes npm ships — the release workflow uploads the CDN object
720
+ from the very tarball it publishes, then re-downloads and compares before
721
+ `npm publish` runs, so the two can never diverge.
722
+
723
+ Serve the file yourself and point the browser root at it with
724
+ `setWasmSourceUrl()` above, or hand the bytes straight to `p2party/session`.
725
+
726
+ On Node and Bun, `p2party/session` needs none of this: it reads the WASM from
727
+ the installed package and checks it against the same pinned SHA-384, so an
728
+ offline or air-gapped install works with no configuration and no network call.
729
+ Supply `wasmBinary` only to override that — bytes you host, embed, or verify
730
+ yourself:
731
+
732
+ ```ts
733
+ import { readFile } from "node:fs/promises";
734
+ import { generateSessionIdentity } from "p2party/session";
735
+
736
+ const wasmBinary = await readFile("/opt/p2party/libcrypto.wasm");
737
+ const identity = await generateSessionIdentity({ wasmBinary });
475
738
  ```
476
739
 
477
- and [Rollup](https://github.com/rollup/rollup) will generate the UMD, ESM and CJS bundles.
740
+ The package also exports `p2party/libcrypto.provenance.json`, recording the
741
+ libsodium and mlkem-native commits, the Emscripten release, and the artifact's
742
+ digests. JavaScript and WASM are one release unit; never pair this release's
743
+ JavaScript with an older module.
744
+
745
+ The release gate runs packaged identity generation through both Node ESM and
746
+ CommonJS, once with explicit bytes and once with no arguments at all — the
747
+ second pass with `fetch` stubbed to throw, so a silent CDN fallback fails the
748
+ release rather than surfacing later as a broken offline install.
478
749
 
479
- ## License
750
+ ## Development
480
751
 
481
- The source code is licensed under the terms of the Affero General Public License version 3.0 (see [LICENSE](LICENSE)).
752
+ The reproducible release toolchain is Node 24.11.1, npm 11.6.2, Bun 1.3.14,
753
+ Emscripten 6.0.3, and the repository's pinned libsodium source object. npm and
754
+ `package-lock.json` are the dependency authority; Bun is the test runner.
482
755
 
483
- ## Copyright
756
+ ```sh
757
+ git clone --recurse-submodules https://github.com/p2party/p2party-js.git
758
+ cd p2party-js
759
+ git -C libsodium fetch --depth=1 origin 2ce4d906a68eae82b27b4867f3d4172ec508cb27
760
+ npm ci
761
+ npm run predist
762
+ npm run check
763
+ ```
484
764
 
485
- Copyright (C) 2025 Deliberative Technologies P.C.
765
+ `npm run release:pack` is the only supported package build. It rebuilds and
766
+ validates the cryptographic artifacts in a fresh staging tree, checks the
767
+ vendored source digests and provenance, enforces the tarball allowlist, and
768
+ produces `p2party-<version>.tgz`. Direct source-tree publication is refused.
769
+
770
+ Tagged releases publish immutable CDN objects first, fetch the public WASM back
771
+ and compare its exact bytes, SHA-256, and SRI to the validated build, and only
772
+ then publish the npm tarball with provenance.
773
+
774
+ ## Built on
775
+
776
+ Cryptography, compiled into the shipped `libcrypto.wasm`:
777
+
778
+ | Component | Provides |
779
+ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
780
+ | [libsodium](https://github.com/jedisct1/libsodium) | X25519, Ed25519, ChaCha20-Poly1305, BLAKE2b, HKDF-SHA512, Argon2 |
781
+ | [mlkem-native](https://github.com/pq-code-package/mlkem-native) | ML-KEM-512/768/1024 |
782
+ | [Emscripten](https://emscripten.org/) | Compiles both to the pinned WebAssembly module |
783
+ | [Redux Toolkit](https://redux-toolkit.js.org/) | The browser root's state store |
784
+ | [BIP-39 wordlist](https://github.com/bitcoin/bips/blob/master/bip-0039/bip-0039-wordlists.md) | The 24-word capability and recovery-phrase encoding |
785
+
786
+ Standards the wire format implements:
787
+
788
+ | Standard | Where it appears |
789
+ | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
790
+ | [FIPS 203](https://csrc.nist.gov/pubs/fips/203/final) | ML-KEM bootstrap and healing epochs |
791
+ | [RFC 8439](https://www.rfc-editor.org/rfc/rfc8439.html) | ChaCha20-Poly1305 for every chunk frame |
792
+ | [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869.html) | HKDF root and chain-key derivation |
793
+ | [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748.html) | X25519 for 3DH and the ratchet DH turns |
794
+ | [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032.html) | Ed25519 identities and cross-signatures |
795
+ | [draft-irtf-cfrg-cpace-21](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-cpace-21) | The PIN-room balanced PAKE |
796
+ | [RFC 8831](https://datatracker.ietf.org/doc/html/rfc8831) / [8832](https://datatracker.ietf.org/doc/html/rfc8832) | WebRTC data channels |
797
+ | [RFC 8122](https://datatracker.ietf.org/doc/html/rfc8122) | SDP DTLS fingerprints bound into the transcript |
798
+ | [RFC 9794](https://www.rfc-editor.org/rfc/rfc9794.html) | PQ/T hybrid terminology |
799
+
800
+ The design follows the [Double Ratchet](https://signal.org/docs/specifications/doubleratchet/)
801
+ and [X3DH](https://signal.org/docs/specifications/x3dh/) specifications, and
802
+ sparse post-quantum healing is directly inspired by Signal's
803
+ [SPQR](https://signal.org/blog/spqr/) — a different construction, not a
804
+ reimplementation, and not independently analysed. Full citations, the papers
805
+ behind the design, and comparable projects are in
806
+ [docs/references.md](docs/references.md); what is deliberately still open is in
807
+ the [roadmap](ROADMAP.md).
808
+
809
+ ## Security and licensing
810
+
811
+ Report vulnerabilities privately according to [SECURITY.md](SECURITY.md).
812
+ Contributions are covered by [CONTRIBUTING.md](CONTRIBUTING.md) and the
813
+ [Code of Conduct](CODE_OF_CONDUCT.md).
814
+
815
+ p2party is licensed under [Apache-2.0](LICENSE.md). Vendored and bundled
816
+ components retain their own terms; see
817
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).