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.
- package/LICENSE.md +202 -661
- package/README.md +736 -404
- package/THIRD_PARTY_NOTICES.md +458 -0
- package/docs/assets/p2party-cat.svg +1 -0
- package/docs/getting-started.md +203 -0
- package/docs/protocol-v4-security.md +158 -0
- package/docs/references.md +157 -0
- package/docs/session-api.md +457 -0
- package/docs/wire-format.md +103 -0
- package/examples/standalone-e2ee.ts +194 -0
- package/lib/api/signalingServerApi.d.ts +6 -7
- package/lib/api/webrtc/baseQuery.d.ts +1 -2
- package/lib/api/webrtc/disconnectFromAllRoomsQuery.d.ts +1 -2
- package/lib/api/webrtc/disconnectFromChannelLabelQuery.d.ts +1 -2
- package/lib/api/webrtc/disconnectFromPeerChannelLabelQuery.d.ts +1 -2
- package/lib/api/webrtc/disconnectFromPeerQuery.d.ts +3 -3
- package/lib/api/webrtc/disconnectFromRoomQuery.d.ts +1 -2
- package/lib/api/webrtc/disconnectQuery.d.ts +1 -2
- package/lib/api/webrtc/iceGeneration.d.ts +17 -0
- package/lib/api/webrtc/iceRepair.d.ts +10 -0
- package/lib/api/webrtc/index.d.ts +13 -13
- package/lib/api/webrtc/interfaces.d.ts +67 -11
- package/lib/api/webrtc/negotiationLock.d.ts +12 -0
- package/lib/api/webrtc/openChannelQuery.d.ts +1 -2
- package/lib/api/webrtc/pendingIceCandidates.d.ts +14 -0
- package/lib/api/webrtc/roomPeer.d.ts +11 -0
- package/lib/api/webrtc/sendMessageQuery.d.ts +7 -4
- package/lib/api/webrtc/setCandidateQuery.d.ts +1 -2
- package/lib/api/webrtc/setDescriptionQuery.d.ts +1 -2
- package/lib/cryptography/coverCell.d.ts +65 -0
- package/lib/cryptography/cpace.d.ts +41 -0
- package/lib/cryptography/ed25519.d.ts +0 -1
- package/lib/cryptography/hashStream.d.ts +15 -0
- package/lib/cryptography/hkdf.d.ts +3 -0
- package/lib/cryptography/identityCrossSig.d.ts +7 -0
- package/lib/cryptography/interfaces.d.ts +28 -13
- package/lib/cryptography/memory.d.ts +2 -4
- package/lib/cryptography/merkle.d.ts +0 -1
- package/lib/cryptography/mlkem.d.ts +117 -0
- package/lib/cryptography/mnemonic.d.ts +0 -1
- package/lib/cryptography/pqHealing.d.ts +268 -0
- package/lib/cryptography/pqHealingFrame.d.ts +39 -0
- package/lib/cryptography/pqMessageKey.d.ts +30 -0
- package/lib/cryptography/random.d.ts +22 -0
- package/lib/cryptography/ratchet.d.ts +146 -0
- package/lib/cryptography/testModule.d.ts +7 -0
- package/lib/cryptography/utils.d.ts +0 -1
- package/lib/cryptography/wasmLoader.d.ts +6 -1
- package/lib/cryptography/x25519.d.ts +15 -0
- package/lib/cryptography/x3dh.d.ts +9 -0
- package/lib/db/api.d.ts +54 -22
- package/lib/db/db.worker.d.ts +0 -1
- package/lib/db/ratchetWrap.d.ts +36 -0
- package/lib/db/src/getDB.d.ts +17 -4
- package/lib/db/types.d.ts +274 -32
- package/lib/db.worker.js +1 -2
- package/lib/handlers/chunkFrame.d.ts +13 -0
- package/lib/handlers/connectionSignal.d.ts +26 -0
- package/lib/handlers/coverEdge.d.ts +42 -0
- package/lib/handlers/coverRuntime.d.ts +91 -0
- package/lib/handlers/coverScheduler.d.ts +184 -0
- package/lib/handlers/coverTransfer.d.ts +46 -0
- package/lib/handlers/frameType.d.ts +10 -0
- package/lib/handlers/handleChallenge.d.ts +0 -1
- package/lib/handlers/handleConnectToPeer.d.ts +0 -1
- package/lib/handlers/handleHandshake.d.ts +74 -0
- package/lib/handlers/handleMessageQueueing.d.ts +53 -4
- package/lib/handlers/handleOpenChannel.d.ts +4 -2
- package/lib/handlers/handleQueuedIceCandidates.d.ts +0 -1
- package/lib/handlers/handleReadReceipt.d.ts +17 -4
- package/lib/handlers/handleReceiveMessage.d.ts +21 -3
- package/lib/handlers/handleSendMessage.d.ts +110 -2
- package/lib/handlers/handleWebSocketMessage.d.ts +1 -3
- package/lib/handlers/handshakeCore.d.ts +95 -0
- package/lib/handlers/messageChunkCrypto.d.ts +70 -0
- package/lib/handlers/peerRosterDelta.d.ts +12 -0
- package/lib/handlers/pqHealingOrchestrator.d.ts +46 -0
- package/lib/handlers/pqHealingRuntime.d.ts +82 -0
- package/lib/handlers/ratchetGate.d.ts +18 -0
- package/lib/handlers/ratchetPersist.d.ts +53 -0
- package/lib/handlers/receiptFrame.d.ts +18 -0
- package/lib/handlers/receiveMessageKeyLifetime.d.ts +24 -0
- package/lib/handlers/reconcile.d.ts +16 -0
- package/lib/handlers/transferAbort.d.ts +42 -0
- package/lib/index.d.ts +203 -46
- package/lib/index.js +1 -2
- package/lib/index.min.js +1 -2
- package/lib/index.mjs +1 -2
- package/lib/libcrypto.provenance.json +45 -0
- package/lib/libcrypto.wasm +0 -0
- package/lib/middleware/keyPairListenerMiddleware.d.ts +0 -1
- package/lib/middleware/roomListenerMiddleware.d.ts +0 -1
- package/lib/reducers/commonSlice.d.ts +0 -1
- package/lib/reducers/keyPairSlice.d.ts +0 -1
- package/lib/reducers/roomSlice.d.ts +65 -3
- package/lib/reducers/signalingServerSlice.d.ts +25 -4
- package/lib/roomInvite.d.ts +18 -0
- package/lib/roomPinAttempts.d.ts +12 -0
- package/lib/roomPinVault.d.ts +26 -0
- package/lib/roomPolicy.d.ts +60 -0
- package/lib/session.d.ts +110 -0
- package/lib/session.js +1 -0
- package/lib/session.mjs +1 -0
- package/lib/store.d.ts +40 -41
- package/lib/utils/channelLabel.d.ts +0 -1
- package/lib/utils/chunkBounds.d.ts +9 -0
- package/lib/utils/constants.d.ts +50 -6
- package/lib/utils/debug.d.ts +25 -0
- package/lib/utils/drainAndClose.d.ts +5 -0
- package/lib/utils/identityRole.d.ts +19 -0
- package/lib/utils/interfaces.d.ts +31 -43
- package/lib/utils/leafHash.d.ts +13 -0
- package/lib/utils/messageTypes.d.ts +0 -1
- package/lib/utils/metadata.d.ts +6 -1
- package/lib/utils/mutex.d.ts +24 -0
- package/lib/utils/protocolVersion.d.ts +5 -0
- package/lib/utils/receiptToken.d.ts +7 -0
- package/lib/utils/sendQueueKey.d.ts +2 -0
- package/lib/utils/signalingAuth.d.ts +8 -0
- package/lib/utils/signalingBounds.d.ts +9 -0
- package/lib/utils/splitToChunks.d.ts +7 -4
- package/lib/utils/uint8array.d.ts +1 -2
- package/lib/utils/waitForOpen.d.ts +3 -0
- package/lib/utils/zeroFree.d.ts +10 -0
- package/package.json +109 -49
- package/lib/api/signalingServerApi.d.ts.map +0 -1
- package/lib/api/webrtc/baseQuery.d.ts.map +0 -1
- package/lib/api/webrtc/disconnectFromAllRoomsQuery.d.ts.map +0 -1
- package/lib/api/webrtc/disconnectFromChannelLabelQuery.d.ts.map +0 -1
- package/lib/api/webrtc/disconnectFromPeerChannelLabelQuery.d.ts.map +0 -1
- package/lib/api/webrtc/disconnectFromPeerQuery.d.ts.map +0 -1
- package/lib/api/webrtc/disconnectFromRoomQuery.d.ts.map +0 -1
- package/lib/api/webrtc/disconnectQuery.d.ts.map +0 -1
- package/lib/api/webrtc/index.d.ts.map +0 -1
- package/lib/api/webrtc/interfaces.d.ts.map +0 -1
- package/lib/api/webrtc/openChannelQuery.d.ts.map +0 -1
- package/lib/api/webrtc/sendMessageQuery.d.ts.map +0 -1
- package/lib/api/webrtc/setCandidateQuery.d.ts.map +0 -1
- package/lib/api/webrtc/setDescriptionQuery.d.ts.map +0 -1
- package/lib/cryptography/chacha20poly1305.d.ts +0 -116
- package/lib/cryptography/chacha20poly1305.d.ts.map +0 -1
- package/lib/cryptography/ed25519.d.ts.map +0 -1
- package/lib/cryptography/interfaces.d.ts.map +0 -1
- package/lib/cryptography/memory.d.ts.map +0 -1
- package/lib/cryptography/merkle.d.ts.map +0 -1
- package/lib/cryptography/mnemonic.d.ts.map +0 -1
- package/lib/cryptography/utils.d.ts.map +0 -1
- package/lib/cryptography/wasmLoader.d.ts.map +0 -1
- package/lib/db/api.d.ts.map +0 -1
- package/lib/db/db.worker.d.ts.map +0 -1
- package/lib/db/src/getDB.d.ts.map +0 -1
- package/lib/db/types.d.ts.map +0 -1
- package/lib/db.worker.js.map +0 -1
- package/lib/handlers/handleChallenge.d.ts.map +0 -1
- package/lib/handlers/handleConnectToPeer.d.ts.map +0 -1
- package/lib/handlers/handleMessageQueueing.d.ts.map +0 -1
- package/lib/handlers/handleOpenChannel.d.ts.map +0 -1
- package/lib/handlers/handleQueuedIceCandidates.d.ts.map +0 -1
- package/lib/handlers/handleReadReceipt.d.ts.map +0 -1
- package/lib/handlers/handleReceiveMessage.d.ts.map +0 -1
- package/lib/handlers/handleSendMessage.d.ts.map +0 -1
- package/lib/handlers/handleWebSocketMessage.d.ts.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/index.min.js.map +0 -1
- package/lib/index.mjs.map +0 -1
- package/lib/middleware/keyPairListenerMiddleware.d.ts.map +0 -1
- package/lib/middleware/roomListenerMiddleware.d.ts.map +0 -1
- package/lib/reducers/commonSlice.d.ts.map +0 -1
- package/lib/reducers/keyPairSlice.d.ts.map +0 -1
- package/lib/reducers/roomSlice.d.ts.map +0 -1
- package/lib/reducers/signalingServerSlice.d.ts.map +0 -1
- package/lib/store.d.ts.map +0 -1
- package/lib/utils/allocators.d.ts +0 -22
- package/lib/utils/allocators.d.ts.map +0 -1
- package/lib/utils/channelLabel.d.ts.map +0 -1
- package/lib/utils/constants.d.ts.map +0 -1
- package/lib/utils/interfaces.d.ts.map +0 -1
- package/lib/utils/messageTypes.d.ts.map +0 -1
- package/lib/utils/metadata.d.ts.map +0 -1
- package/lib/utils/splitToChunks.d.ts.map +0 -1
- package/lib/utils/uint8array.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1,485 +1,817 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
[](https://snyk.io/test/github/p2party/p2party-js?targetFile=package.json)
|
|
6
|
-
<br>
|
|
7
|
-

|
|
8
|
-

|
|
9
|
-
[![code-style-prettier][code-style-prettier-image]][code-style-prettier-url]
|
|
10
|
-
<br>
|
|
11
|
-

|
|
12
|
-
[](https://www.jsdelivr.com/package/npm/p2party)
|
|
65
|
+
## Install
|
|
13
66
|
|
|
14
|
-
|
|
15
|
-
|
|
67
|
+
```sh
|
|
68
|
+
npm install p2party
|
|
69
|
+
```
|
|
16
70
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
88
|
+
## Send a message between two browsers
|
|
27
89
|
|
|
28
|
-
|
|
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
|
-
|
|
94
|
+
```sh
|
|
95
|
+
bunx vite examples/browser-mesh
|
|
96
|
+
```
|
|
31
97
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
// Fires once per fully-arrived message, already decoded.
|
|
108
|
+
p2party.onMessage(room.id, ({ message }) => {
|
|
109
|
+
console.log("received", message);
|
|
110
|
+
});
|
|
44
111
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
+
```ts
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
// controller.abort() on unmount, route change, or a Cancel button.
|
|
50
124
|
|
|
51
|
-
|
|
125
|
+
const room = await p2party.joinRoom(invite, undefined, undefined, {
|
|
126
|
+
timeoutMs: 10_000,
|
|
127
|
+
signal: controller.signal,
|
|
128
|
+
});
|
|
129
|
+
```
|
|
52
130
|
|
|
53
|
-
|
|
131
|
+
Reading an inbound message needs its Merkle root, which arrives on the room's
|
|
132
|
+
`messages` state:
|
|
54
133
|
|
|
55
|
-
```
|
|
56
|
-
|
|
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
|
-
|
|
143
|
+
## Choose your integration
|
|
60
144
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
74
|
-
|
|
218
|
+
```text
|
|
219
|
+
https://p2party.com/#v1.Mg10fDvjVDzXkuboBnfjuNWc26i35rYsjJHKpS7D58s
|
|
75
220
|
```
|
|
76
221
|
|
|
77
|
-
|
|
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
|
-
|
|
225
|
+
```ts
|
|
226
|
+
const capability = p2party.generateRoomCapability();
|
|
80
227
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
232
|
+
const fromWords = await p2party.decodeRoomCapabilityWords(words);
|
|
233
|
+
console.assert(p2party.encodeRoomCapabilityBase64Url(fromWords) === compact);
|
|
86
234
|
|
|
87
|
-
|
|
235
|
+
await p2party.connect(fragment);
|
|
236
|
+
```
|
|
88
237
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
const navigate = useNavigate();
|
|
473
|
+
### Suspend and resume
|
|
162
474
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
*/
|
|
475
|
+
```ts
|
|
476
|
+
import { restoreSession } from "p2party/session";
|
|
166
477
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
navigate("/rooms/" + random, { replace });
|
|
170
|
-
};
|
|
478
|
+
const snapshot = await alice.serialize(); // plaintext secret — encrypt at rest
|
|
479
|
+
await alice.destroy();
|
|
171
480
|
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
750
|
+
## Development
|
|
480
751
|
|
|
481
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
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).
|