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
@@ -0,0 +1,457 @@
1
+ # Store-free session API
2
+
3
+ `p2party/session` exposes protocol-v4 without Redux, IndexedDB, OPFS, WebRTC,
4
+ signaling, `window`, or `localStorage`. It still requires WebCrypto,
5
+ WebAssembly, secure identity storage, and a transport supplied by the
6
+ application.
7
+
8
+ The public surface is:
9
+
10
+ ```ts
11
+ import {
12
+ createSession,
13
+ generateSessionIdentity,
14
+ restoreSession,
15
+ PROTOCOL_VERSION,
16
+ WIRE_CHUNK_FRAME_LEN,
17
+ type CreateSessionOptions,
18
+ type EncryptedSessionMessage,
19
+ type GenerateSessionIdentityOptions,
20
+ type GeneratedSessionIdentity,
21
+ type HandshakeTransport,
22
+ type LocalSessionIdentity,
23
+ type P2PartySession,
24
+ type RoomPqMode,
25
+ type SessionAuth,
26
+ type SessionChannelBinding,
27
+ type SessionControlOutput,
28
+ type SessionCryptoOptions,
29
+ } from "p2party/session";
30
+ ```
31
+
32
+ ## Complete Node/Bun example
33
+
34
+ This executable example uses two in-memory byte pipes. Replace those pipes with
35
+ your socket, stream multiplexer, native bridge, or other message transport.
36
+
37
+ ```ts
38
+ import { readFile } from "node:fs/promises";
39
+ import { createRequire } from "node:module";
40
+ import {
41
+ createSession,
42
+ generateSessionIdentity,
43
+ restoreSession,
44
+ type HandshakeTransport,
45
+ } from "p2party/session";
46
+
47
+ const require = createRequire(import.meta.url);
48
+ const wasmBinary = Uint8Array.from(
49
+ await readFile(require.resolve("p2party/libcrypto.wasm")),
50
+ );
51
+ const cryptoOptions = { wasmBinary };
52
+
53
+ const makePipe = (): HandshakeTransport => {
54
+ const queued: Uint8Array[] = [];
55
+ const waiters: Array<(bytes: Uint8Array) => void> = [];
56
+ return {
57
+ send(bytes): void {
58
+ const owned = Uint8Array.from(bytes);
59
+ const waiter = waiters.shift();
60
+ if (waiter) waiter(owned);
61
+ else queued.push(owned);
62
+ },
63
+ recv(): Promise<Uint8Array> {
64
+ const bytes = queued.shift();
65
+ return bytes
66
+ ? Promise.resolve(bytes)
67
+ : new Promise((resolve) => waiters.push(resolve));
68
+ },
69
+ };
70
+ };
71
+
72
+ const [aliceIdentity, bobIdentity] = await Promise.all([
73
+ generateSessionIdentity(cryptoOptions),
74
+ generateSessionIdentity(cryptoOptions),
75
+ ]);
76
+
77
+ const aliceToBob = makePipe();
78
+ const bobToAlice = makePipe();
79
+ const channelId = globalThis.crypto.getRandomValues(new Uint8Array(16));
80
+ const aliceFingerprint = globalThis.crypto.getRandomValues(new Uint8Array(32));
81
+ const bobFingerprint = globalThis.crypto.getRandomValues(new Uint8Array(32));
82
+
83
+ const [alice, bob] = await Promise.all([
84
+ createSession({
85
+ role: "initiator",
86
+ identity: aliceIdentity,
87
+ peerIdentityEd25519PublicKey: bobIdentity.ed25519PublicKey,
88
+ channel: {
89
+ channelId,
90
+ localFingerprint: aliceFingerprint,
91
+ remoteFingerprint: bobFingerprint,
92
+ },
93
+ transport: {
94
+ send: aliceToBob.send,
95
+ recv: bobToAlice.recv,
96
+ },
97
+ mode: "nopin",
98
+ pqMode: "hybrid-mlkem768",
99
+ crypto: cryptoOptions,
100
+ }),
101
+ createSession({
102
+ role: "responder",
103
+ identity: bobIdentity,
104
+ peerIdentityEd25519PublicKey: aliceIdentity.ed25519PublicKey,
105
+ channel: {
106
+ channelId,
107
+ localFingerprint: bobFingerprint,
108
+ remoteFingerprint: aliceFingerprint,
109
+ },
110
+ transport: {
111
+ send: bobToAlice.send,
112
+ recv: aliceToBob.recv,
113
+ },
114
+ mode: "nopin",
115
+ pqMode: "hybrid-mlkem768",
116
+ crypto: cryptoOptions,
117
+ }),
118
+ ]);
119
+
120
+ const encoder = new TextEncoder();
121
+ const decoder = new TextDecoder();
122
+ const envelope = await alice.encrypt(encoder.encode("hello"));
123
+ console.log(decoder.decode(await bob.decrypt(envelope)));
124
+
125
+ // serialize() returns a plaintext secret snapshot.
126
+ const snapshot = await alice.serialize();
127
+ await alice.destroy();
128
+ const restoredAlice = await restoreSession(snapshot, cryptoOptions);
129
+ snapshot.fill(0);
130
+
131
+ const reply = await bob.encrypt(encoder.encode("still synchronized"));
132
+ console.log(decoder.decode(await restoredAlice.decrypt(reply)));
133
+
134
+ await Promise.all([restoredAlice.destroy(), bob.destroy()]);
135
+ aliceIdentity.ed25519SecretKey.fill(0);
136
+ aliceIdentity.x25519SecretKey.fill(0);
137
+ bobIdentity.ed25519SecretKey.fill(0);
138
+ bobIdentity.x25519SecretKey.fill(0);
139
+ ```
140
+
141
+ The repository version is
142
+ [`examples/standalone-e2ee.ts`](../examples/standalone-e2ee.ts)
143
+ and runs with:
144
+
145
+ ```sh
146
+ bun run examples/standalone-e2ee.ts
147
+ ```
148
+
149
+ ## Transport contract
150
+
151
+ The entire handshake adapter is:
152
+
153
+ ```ts
154
+ interface HandshakeTransport {
155
+ send(bytes: Uint8Array): void | Promise<void>;
156
+ recv(): Promise<Uint8Array>;
157
+ }
158
+ ```
159
+
160
+ The application must provide one full-duplex transport with these semantics:
161
+
162
+ - reliable, ordered, message-delimited delivery for handshake byte arrays;
163
+ - every `send(bytes)` becomes one `recv()` value on the peer, without
164
+ concatenation, splitting, mutation, or unrelated application frames;
165
+ - asynchronous send failures reject the returned promise; and
166
+ - closure, timeout, and cancellation reject pending operations rather than
167
+ hanging forever.
168
+
169
+ TCP and WebSocket adapters therefore need explicit message framing and routing.
170
+ Run the initiator and responder calls concurrently. The roles, auth mode, exact
171
+ ML-KEM suite, channel ID, identities, and endpoint fingerprints must describe
172
+ the same session from opposite ends. There is no suite negotiation or fallback.
173
+
174
+ `HandshakeTransport` carries only handshake flights. After creation, the
175
+ application serializes the returned `EncryptedSessionMessage` structure over
176
+ its normal message transport. p2party intentionally does not prescribe an
177
+ outer CBOR/JSON/stream framing; preserve `protocolVersion`, `root`, frame
178
+ ordering, and every frame byte exactly.
179
+
180
+ ### A binary envelope header
181
+
182
+ Do not pass `EncryptedSessionMessage` through naïve JSON: `Uint8Array` values
183
+ do not round-trip as bytes. One compact message-delimited codec is a 73-byte
184
+ header followed by the fixed-size frames:
185
+
186
+ ```ts
187
+ import type { EncryptedSessionMessage } from "p2party/session";
188
+
189
+ import { PROTOCOL_VERSION, WIRE_CHUNK_FRAME_LEN } from "p2party/session";
190
+
191
+ const MAGIC = Uint8Array.of(0x50, 0x32, 0x50, 0x45); // "P2PE"
192
+ const ROOT_BYTES = 64;
193
+ const FRAME_BYTES = WIRE_CHUNK_FRAME_LEN; // 65,490
194
+ const HEADER_BYTES = MAGIC.length + 1 + ROOT_BYTES + 4;
195
+
196
+ export const encodeEnvelopeHeader = (
197
+ message: EncryptedSessionMessage,
198
+ ): Uint8Array => {
199
+ if (message.protocolVersion !== PROTOCOL_VERSION)
200
+ throw new Error("unsupported protocol");
201
+ if (message.root.length !== ROOT_BYTES) throw new Error("invalid root");
202
+ if (message.frames.length < 1 || message.frames.length > 0xffff_ffff)
203
+ throw new Error("invalid frame count");
204
+ if (message.frames.some((frame) => frame.length !== FRAME_BYTES))
205
+ throw new Error("invalid frame length");
206
+
207
+ const header = new Uint8Array(HEADER_BYTES);
208
+ header.set(MAGIC, 0);
209
+ header[MAGIC.length] = message.protocolVersion;
210
+ header.set(message.root, MAGIC.length + 1);
211
+ new DataView(header.buffer).setUint32(
212
+ MAGIC.length + 1 + ROOT_BYTES,
213
+ message.frames.length,
214
+ false,
215
+ );
216
+ return header;
217
+ };
218
+
219
+ export const decodeEnvelopeHeader = (
220
+ header: Uint8Array,
221
+ maxFrames: number,
222
+ ): { root: Uint8Array; frameCount: number } => {
223
+ if (header.length !== HEADER_BYTES) throw new Error("invalid header length");
224
+ if (MAGIC.some((byte, index) => header[index] !== byte))
225
+ throw new Error("invalid envelope magic");
226
+ if (header[MAGIC.length] !== PROTOCOL_VERSION)
227
+ throw new Error("unsupported protocol");
228
+
229
+ const frameCount = new DataView(
230
+ header.buffer,
231
+ header.byteOffset,
232
+ header.byteLength,
233
+ ).getUint32(MAGIC.length + 1 + ROOT_BYTES, false);
234
+ if (frameCount < 1 || frameCount > maxFrames)
235
+ throw new Error("frame count exceeds policy");
236
+
237
+ return {
238
+ root: header.slice(MAGIC.length + 1, MAGIC.length + 1 + ROOT_BYTES),
239
+ frameCount,
240
+ };
241
+ };
242
+ ```
243
+
244
+ Send the header as one record, then each frame as one record. The receiver
245
+ must set `maxFrames` from its own message-size policy, require exactly
246
+ `frameCount` records of exactly 65,490 bytes, reject surplus or missing
247
+ records, and then call
248
+ `session.decrypt({ protocolVersion: PROTOCOL_VERSION, root, frames })`. A TCP
249
+ adapter still
250
+ needs an authenticated record type or length prefix around the header and
251
+ frames. This codec preserves bytes; it does not hide the number or timing of
252
+ records.
253
+
254
+ ## Identity and channel binding
255
+
256
+ `generateSessionIdentity()` returns:
257
+
258
+ ```ts
259
+ interface GeneratedSessionIdentity {
260
+ ed25519PublicKey: Uint8Array; // 32 bytes
261
+ ed25519SecretKey: Uint8Array; // 64 bytes
262
+ x25519PublicKey: Uint8Array; // 32 bytes
263
+ x25519SecretKey: Uint8Array; // 32 bytes
264
+ x25519CrossSignature: Uint8Array; // 64 bytes
265
+ }
266
+ ```
267
+
268
+ The critical naming rule is:
269
+
270
+ > `x25519SecretKey` is the long-term X25519 identity-DH secret. It is never the
271
+ > Ed25519 signing secret.
272
+
273
+ Ed25519 anchors the externally pinned identity and cross-signs the dedicated
274
+ X25519 public key. The interactive 3DH handshake proves possession of the
275
+ X25519 secret. Persist both secret keys with an OS keystore or equivalent, and
276
+ pin the peer's Ed25519 public key through a trusted directory, QR exchange, or
277
+ an explicit trust-on-first-use policy. Merely receiving that key over the same
278
+ untrusted connection is not authentication.
279
+
280
+ To migrate an existing Ed25519 identity, provide its matching 32-byte public
281
+ key and 64-byte secret key. p2party validates their consistency, generates a
282
+ fresh dedicated X25519 identity, and cross-signs it:
283
+
284
+ ```ts
285
+ const identity = await generateSessionIdentity({
286
+ wasmBinary,
287
+ ed25519KeyPair: {
288
+ publicKey: existingEd25519PublicKey,
289
+ secretKey: existingEd25519SecretKey,
290
+ },
291
+ });
292
+ ```
293
+
294
+ Imported local identities are also checked before session creation: the
295
+ X25519 public and secret keys must match, and the 64-byte cross-signature must
296
+ verify under the 32-byte Ed25519 public key. The peer Ed25519 key supplied to
297
+ `createSession()` is exactly 32 bytes.
298
+
299
+ `SessionChannelBinding` contains:
300
+
301
+ ```ts
302
+ interface SessionChannelBinding {
303
+ channelId: Uint8Array;
304
+ localFingerprint: Uint8Array; // exactly 32 bytes
305
+ remoteFingerprint: Uint8Array; // exactly 32 bytes
306
+ }
307
+ ```
308
+
309
+ Both peers use the same non-empty `channelId`. Their local and remote
310
+ fingerprints are reversed. For WebRTC these are SHA-256 DTLS certificate
311
+ fingerprints. A custom transport should derive equivalent 32-byte endpoint
312
+ bindings from its authenticated connection context. Random shared values, as
313
+ used by the single-process example, demonstrate the API but do not independently
314
+ authenticate a real network path.
315
+
316
+ ## Authentication and suite selection
317
+
318
+ No-PIN creation uses:
319
+
320
+ ```ts
321
+ {
322
+ mode: "nopin";
323
+ }
324
+ ```
325
+
326
+ PIN creation uses:
327
+
328
+ ```ts
329
+ {
330
+ mode: "pin";
331
+ pin: Uint8Array;
332
+ }
333
+ ```
334
+
335
+ Both peers must provide the same non-empty PIN. The implementation copies
336
+ sensitive inputs it needs; the caller still owns and should wipe its original
337
+ buffer. PIN mode adds exact draft-21 CPace to interactive 3DH and the selected
338
+ ML-KEM secret.
339
+
340
+ `pqMode` is exactly one of `hybrid-mlkem512`, `hybrid-mlkem768`, or
341
+ `hybrid-mlkem1024`, and defaults to `hybrid-mlkem768`. Both peers choose the
342
+ same value out of band. A mismatch fails the transcript; it is never a request
343
+ to downgrade.
344
+
345
+ ## Encrypt, decrypt, snapshot, destroy
346
+
347
+ A live session exposes:
348
+
349
+ ```ts
350
+ interface SessionControlOutput {
351
+ readonly frame: Uint8Array | null;
352
+ readonly requiresPersistBeforeSend: boolean;
353
+ }
354
+
355
+ interface P2PartySession {
356
+ readonly protocolVersion: 4;
357
+ readonly pqMode: "hybrid-mlkem512" | "hybrid-mlkem768" | "hybrid-mlkem1024";
358
+ readonly canEncrypt: boolean;
359
+ /** Current authenticated PQ epoch; 0 before any healing exchange. */
360
+ readonly pqEpoch: bigint;
361
+ /** True while a sparse-PQ healing exchange blocks application traffic. */
362
+ readonly healingInProgress: boolean;
363
+ encrypt(plaintext: Uint8Array): Promise<EncryptedSessionMessage>;
364
+ decrypt(message: EncryptedSessionMessage): Promise<Uint8Array>;
365
+ prepareHealing(): Promise<SessionControlOutput>;
366
+ acceptControlFrame(frame: Uint8Array): Promise<SessionControlOutput>;
367
+ pendingControl(): Promise<Uint8Array | null>;
368
+ serialize(): Promise<Uint8Array>;
369
+ destroy(): Promise<void>;
370
+ }
371
+ ```
372
+
373
+ Each encrypted envelope has one authenticated Merkle root and one or more
374
+ uniform protocol-v4 frames. Either role may send first, and simultaneous first
375
+ messages are supported. Ratchet state advances transactionally: a failed
376
+ decrypt does not commit the candidate receive state.
377
+
378
+ ## Sparse post-quantum healing
379
+
380
+ The bootstrap ML-KEM exchange protects the initial root. Healing periodically
381
+ re-runs it so a later post-quantum compromise cannot unwind an old session.
382
+ The session owns the state machine; the caller owns scheduling and transport.
383
+
384
+ Control frames are the same 65,490-byte size as chunk frames, so the outer
385
+ framing MUST record which kind a record is — the session will reject a control
386
+ frame handed to `decrypt()` and vice versa.
387
+
388
+ **The persist-before-send contract.** Whenever any of the three methods returns
389
+ a `frame` with `requiresPersistBeforeSend`, persist `serialize()` _before_
390
+ putting that frame on the wire. Sending first and crashing before the write
391
+ forks the OFFER/ADVANCE/ACK sequence: the peer advances to an epoch this side
392
+ has no record of, and every later message fails to decrypt.
393
+
394
+ ```ts
395
+ const emit = async (output: SessionControlOutput) => {
396
+ if (!output.frame) return;
397
+ // Order matters. Never move the send above the write.
398
+ if (output.requiresPersistBeforeSend)
399
+ await storeSnapshot(await session.serialize());
400
+ await transport.sendControlFrame(output.frame);
401
+ };
402
+
403
+ // Inbound: route by your own record type, not by frame length.
404
+ await emit(await session.acceptControlFrame(frame));
405
+
406
+ // Outbound cadence — the caller decides. `prepareHealing()` returns a null
407
+ // frame when an exchange is not due or it is not this side's turn, so calling
408
+ // it on a timer or every N messages is safe and idempotent.
409
+ await emit(await session.prepareHealing());
410
+
411
+ // Retransmit a dropped flight without mutating state.
412
+ const retry = await session.pendingControl();
413
+ if (retry) await transport.sendControlFrame(retry);
414
+ ```
415
+
416
+ While `healingInProgress` is true the session blocks application traffic:
417
+ `encrypt()` **throws** `session: sparse-PQ healing is in progress` rather than
418
+ queueing. An exchange is normally brief, so check `healingInProgress` before
419
+ offering a send, and treat the throw as retryable rather than as a lost
420
+ message.
421
+
422
+ `serialize()` waits for in-flight encryption and returns the current ratchet as
423
+ a plaintext secret blob. Before persistence:
424
+
425
+ 1. encrypt it with authenticated encryption under a device-bound storage key;
426
+ 2. bind it to the account, peer, room/channel, and suite in associated data;
427
+ 3. add a monotonic version or equivalent rollback guard; and
428
+ 4. replace the previous snapshot atomically.
429
+
430
+ Confidentiality without rollback protection is insufficient: restoring an old
431
+ ratchet can reuse state and violate forward-security assumptions. Never send a
432
+ snapshot to the peer, sync it through an unauthenticated store, log it, or
433
+ place it in browser storage as plaintext.
434
+
435
+ After `restoreSession(snapshot, cryptoOptions)`, wipe the caller's snapshot
436
+ buffer.
437
+ Call `destroy()` on replaced and shutdown sessions; after destruction,
438
+ `canEncrypt` is false. Wipe caller-owned identity secrets when their lifecycle
439
+ ends.
440
+
441
+ ## WASM behavior
442
+
443
+ `SessionCryptoOptions` is:
444
+
445
+ ```ts
446
+ interface SessionCryptoOptions {
447
+ wasmBinary?: ArrayBuffer | Uint8Array;
448
+ }
449
+ ```
450
+
451
+ Supplying bytes is the reproducible, offline-safe path. Resolve the exported
452
+ `p2party/libcrypto.wasm` package subpath or pin a self-hosted copy from the same
453
+ release. When `wasmBinary` is omitted, the loader fetches the immutable
454
+ versioned p2party CDN artifact and checks the build-pinned SHA-384 SRI.
455
+
456
+ See [Protocol-v4 security](protocol-v4-security.md) for the exact claims and
457
+ non-claims of the session this API constructs.
@@ -0,0 +1,103 @@
1
+ # Wire format
2
+
3
+ Every byte here is derived from
4
+ [`src/utils/constants.ts`](../src/utils/constants.ts), which is the single
5
+ source of truth and is byte-matched in `utils.h`. For what these frames do and
6
+ do not protect, read the
7
+ [protocol-v4 security boundary](protocol-v4-security.md).
8
+
9
+
10
+ ## Outer frame types
11
+
12
+ One tag byte leads every frame on a data channel.
13
+
14
+ | Tag | Name | Size on the wire | Carries |
15
+ | --- | ------------ | ---------------- | ------------------------------- |
16
+ | 1 | `HANDSHAKE` | step-dependent | HELLO, CONFIRM, FINISH |
17
+ | 2 | `CHUNK` | 65,490 B | one fixed application cell |
18
+ | 3 | `RECEIPT` | 65 B | SHA-512 acknowledgement token |
19
+ | 4 | `COVER` | 65,490 B | a scheduled cell, real or decoy |
20
+ | 5 | `PQ_CONTROL` | 65,490 B | sparse-PQ OFFER / ADVANCE / ACK |
21
+
22
+ Tags 2, 4, and 5 are deliberately identical in size. An observer cannot tell an
23
+ application cell from a decoy or from a healing exchange by looking at the wire.
24
+
25
+ ## Chunk frame — 65,490 bytes
26
+
27
+ ```text
28
+ 0 1 33 41 49 57 69 65,474 65,490
29
+ +--------+---------------------+-------+-------+---------+----------+-------------------------+---------+
30
+ | type=2 | ratchet dhPub (32) | N (8) | PN(8) | pqEpoch | nonce | ciphertext (65,405) | tag(16) |
31
+ | (1) | | BE | BE | (8) BE | (12) | | |
32
+ +--------+---------------------+-------+-------+---------+----------+-------------------------+---------+
33
+ |<------------------ AAD: 57 bytes ------------------->| |
34
+ |<------------------- clear header: 69 bytes ---------------------->|
35
+ ```
36
+
37
+ - The 57-byte AAD is authenticated but excludes the fresh random nonce.
38
+ - `pqEpoch` is an unsigned 64-bit counter. v3 used a single byte; v4 widened it
39
+ so the sparse-PQ healing epoch cannot wrap.
40
+ - The plaintext cell is always 65,405 bytes, so at most **61,912 bytes** are
41
+ caller payload; the remainder is metadata, the Merkle proof, and padding.
42
+ - ChaCha20-Poly1305 supplies the 16-byte tag.
43
+
44
+ ## Receipt frame — 65 bytes
45
+
46
+ ```text
47
+ 0 1 65
48
+ +--------+-----------------------------------------------------------+
49
+ | type=3 | SHA-512 receipt token (64) |
50
+ +--------+-----------------------------------------------------------+
51
+ ```
52
+
53
+ Both per-chunk acknowledgements and the terminal content-hash acknowledgement
54
+ use this exact geometry, so a completion is not distinguishable by size.
55
+
56
+ ## Handshake ladder
57
+
58
+ ```text
59
+ initiator responder
60
+ | |
61
+ |-- HELLO = tag ‖ sid(32) ‖ EK(32) ‖ Y(32) ‖ idX25519(32) |
62
+ | ‖ crossSig(64) ‖ mlkemPub(pk) ‖ mlkemCt(0…) --> |
63
+ | |
64
+ | <-- HELLO = same layout; pub all-zero, ct encapsulates ----- |
65
+ | |
66
+ | <-- CONFIRM = tag ‖ dhPub(32) ‖ mac(64) --------------------- |
67
+ |-- CONFIRM = tag ‖ dhPub(32) ‖ mac(64) --------------------> |
68
+ | <-- FINISH = tag ‖ mac(64) --------------------------------- |
69
+ | |
70
+ established established
71
+ ```
72
+
73
+ The unused fixed-width KEM field must be canonical all-zero — a non-zero
74
+ spelling poisons the transcript rather than being ignored. The initiator is
75
+ established only after verifying FINISH; the responder after sending it.
76
+
77
+ ## Sparse post-quantum healing
78
+
79
+ ```text
80
+ A B
81
+ |-- OFFER (new ML-KEM public key, epoch e+1) ----> |
82
+ | <-- ADVANCE (echoes the complete OFFER, + ct) ----- |
83
+ |-- ACK (confirms the epoch is live) ----------> |
84
+ ```
85
+
86
+ An ADVANCE embeds the entire OFFER it answers, so a fork is byte-detectable
87
+ rather than something both sides have to reconcile. Each side persists its
88
+ mutated state **before** dispatching, and application traffic is blocked while
89
+ an epoch is in flight.
90
+
91
+ ## Room policy — 32 bytes
92
+
93
+ A room's policy is a fixed 32-byte record (magic `"P2RP"`) that pins the ML-KEM
94
+ suite, PIN mode, rendezvous mode, and the cover schedule. It is immutable after
95
+ first contact and hashed into the handshake transcript, so two peers that
96
+ disagree about it fail to authenticate rather than negotiating. Its canonical
97
+ base64url spelling is 43 characters — the same codec as the room capability,
98
+ which rejects non-canonical spellings so the final sextet's unused bits cannot
99
+ carry a watermark.
100
+
101
+ Scheduled cover has a hard floor: `cadence / (lanes × frames) >= 25 ms`.
102
+ Validate a schedule with `p2party.validateRoomPolicyV1()` before building a
103
+ policy rather than discovering the rejection at connect time.