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
|
@@ -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.
|