@bcts/spqr 1.0.0-alpha.21

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/src/index.ts ADDED
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Top-level public API for the SPQR protocol.
6
+ *
7
+ * Matches Signal's Rust `lib.rs` interface. All state is serialized as
8
+ * opaque protobuf bytes (Uint8Array) so that callers never need to touch
9
+ * internal types.
10
+ *
11
+ * Exported functions:
12
+ * - emptyState() -> empty serialized state (V0)
13
+ * - initialState(p) -> create initial serialized state
14
+ * - send(state, rng) -> produce next message + advance state
15
+ * - recv(state, msg) -> consume incoming message + advance state
16
+ * - currentVersion(s) -> inspect version negotiation status
17
+ */
18
+
19
+ import {
20
+ type States,
21
+ initA,
22
+ initB,
23
+ send as chunkedSend,
24
+ recv as chunkedRecv,
25
+ } from "./v1/chunked/index.js";
26
+ import { serializeMessage, deserializeMessage } from "./v1/chunked/message.js";
27
+ import { statesToPb, statesFromPb } from "./v1/chunked/serialize.js";
28
+ import { Chain } from "./chain.js";
29
+ import { encodePqRatchetState, decodePqRatchetState } from "./proto/index.js";
30
+ import type { PbPqRatchetState, PbVersionNegotiation } from "./proto/pq-ratchet-types.js";
31
+ import { SpqrError, SpqrErrorCode } from "./error.js";
32
+ import {
33
+ Version,
34
+ Direction,
35
+ type Params,
36
+ type Secret,
37
+ type Send,
38
+ type Recv,
39
+ type CurrentVersion,
40
+ type SerializedState,
41
+ type SerializedMessage,
42
+ type RandomBytes,
43
+ type ChainParams,
44
+ } from "./types.js";
45
+
46
+ // Re-export public types
47
+ export {
48
+ Version,
49
+ Direction,
50
+ type Params,
51
+ type Secret,
52
+ type Send,
53
+ type Recv,
54
+ type CurrentVersion,
55
+ type SerializedState,
56
+ type SerializedMessage,
57
+ type RandomBytes,
58
+ type ChainParams,
59
+ };
60
+ export { SpqrError, SpqrErrorCode } from "./error.js";
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // emptyState
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Return an empty (V0) serialized state.
68
+ */
69
+ export function emptyState(): SerializedState {
70
+ return new Uint8Array(0);
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // initialState
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Create an initial serialized state from parameters.
79
+ *
80
+ * For V0, returns an empty state. For V1+, initializes the inner V1
81
+ * state machine and version negotiation.
82
+ */
83
+ export function initialState(params: Params): SerializedState {
84
+ if (params.version === Version.V0) {
85
+ return emptyState();
86
+ }
87
+
88
+ // Initialize the V1 inner state
89
+ const inner = initInner(params.version, params.direction, params.authKey);
90
+
91
+ // Build version negotiation
92
+ const versionNegotiation: PbVersionNegotiation = {
93
+ authKey: Uint8Array.from(params.authKey),
94
+ direction: params.direction,
95
+ minVersion: params.minVersion,
96
+ chainParams: {
97
+ maxJump: params.chainParams.maxJump,
98
+ maxOooKeys: params.chainParams.maxOooKeys,
99
+ },
100
+ };
101
+
102
+ const pbState: PbPqRatchetState = {
103
+ versionNegotiation,
104
+ chain: undefined,
105
+ v1: inner,
106
+ };
107
+
108
+ return encodePqRatchetState(pbState);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // send
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Produce the next outgoing message from the current state.
117
+ *
118
+ * Returns the updated state, serialized message, and optional message key.
119
+ */
120
+ export function send(state: SerializedState, rng: RandomBytes): Send {
121
+ // V0: empty state passthrough
122
+ if (state.length === 0) {
123
+ return { state: new Uint8Array(0), msg: new Uint8Array(0), key: null };
124
+ }
125
+
126
+ const statePb = decodePqRatchetState(state);
127
+
128
+ if (statePb.v1 === undefined) {
129
+ // No V1 inner => V0
130
+ return { state: new Uint8Array(0), msg: new Uint8Array(0), key: null };
131
+ }
132
+
133
+ // Deserialize runtime states from protobuf
134
+ const runtimeStates = statesFromPb(statePb.v1);
135
+
136
+ // Execute the chunked send
137
+ const sendResult = chunkedSend(runtimeStates, rng);
138
+
139
+ // Get or create chain
140
+ let chain: Chain | undefined;
141
+ if (statePb.chain !== undefined) {
142
+ chain = Chain.fromProto(statePb.chain);
143
+ } else if (statePb.versionNegotiation !== undefined) {
144
+ const vn = statePb.versionNegotiation;
145
+ if ((vn.minVersion as Version) > Version.V0) {
146
+ chain = chainFromVersionNegotiation(vn);
147
+ }
148
+ } else {
149
+ throw new SpqrError(
150
+ "Chain not available and no version negotiation",
151
+ SpqrErrorCode.ChainNotAvailable,
152
+ );
153
+ }
154
+
155
+ let index: number;
156
+ let msgKey: Uint8Array;
157
+ let chainPb: typeof statePb.chain;
158
+
159
+ if (chain === undefined) {
160
+ // No chain (min_version === V0, still negotiating)
161
+ if (sendResult.key !== null) {
162
+ // Should not happen in V0 min_version case during negotiation
163
+ throw new SpqrError("Unexpected epoch secret without chain", SpqrErrorCode.ChainNotAvailable);
164
+ }
165
+ index = 0;
166
+ msgKey = new Uint8Array(0);
167
+ chainPb = undefined;
168
+ } else {
169
+ if (sendResult.key !== null) {
170
+ // Epoch secret epoch matches chain expectation:
171
+ // state machine epoch N maps directly to chain addEpoch(N)
172
+ // since chain.currentEpoch starts at 0 and expects N = currentEpoch + 1
173
+ chain.addEpoch(sendResult.key);
174
+ }
175
+ const msgEpoch = sendResult.msg.epoch - 1n;
176
+ const [sendIndex, sendKey] = chain.sendKey(msgEpoch);
177
+ index = sendIndex;
178
+ msgKey = sendKey;
179
+ chainPb = chain.toProto();
180
+ }
181
+
182
+ // Serialize message
183
+ const serializedMsg = serializeMessage(sendResult.msg, index);
184
+
185
+ // Serialize updated state
186
+ const v1Pb = statesToPb(sendResult.state);
187
+ const newStatePb: PbPqRatchetState = {
188
+ versionNegotiation: statePb.versionNegotiation, // preserved on send
189
+ chain: chainPb,
190
+ v1: v1Pb,
191
+ };
192
+
193
+ return {
194
+ state: encodePqRatchetState(newStatePb),
195
+ msg: serializedMsg,
196
+ key: msgKey.length === 0 ? null : msgKey,
197
+ };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // recv
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Process an incoming message and transition the state.
206
+ *
207
+ * Returns the updated state and optional message key.
208
+ */
209
+ export function recv(state: SerializedState, msg: SerializedMessage): Recv {
210
+ // V0: empty state passthrough
211
+ if (state.length === 0 && msg.length === 0) {
212
+ return { state: new Uint8Array(0), key: null };
213
+ }
214
+
215
+ // Decode the pre-negotiated state
216
+ const prenegotiatedPb =
217
+ state.length === 0
218
+ ? ({ v1: undefined, chain: undefined, versionNegotiation: undefined } as PbPqRatchetState)
219
+ : decodePqRatchetState(state);
220
+
221
+ // Determine message version
222
+ const msgVer = msgVersion(msg);
223
+ if (msgVer === undefined) {
224
+ // Unknown version, ignore the message
225
+ return { state: Uint8Array.from(state), key: null };
226
+ }
227
+
228
+ const stateVer = stateVersion(prenegotiatedPb);
229
+
230
+ // Version negotiation
231
+ let statePb: PbPqRatchetState;
232
+ if (msgVer >= stateVer) {
233
+ // Equal or greater version -- proceed with current state
234
+ statePb = prenegotiatedPb;
235
+ } else {
236
+ // Message version < state version -- negotiate down
237
+ const vn = prenegotiatedPb.versionNegotiation;
238
+ if (vn === undefined) {
239
+ throw new SpqrError(
240
+ `Version mismatch: state=${stateVer}, msg=${msgVer}, no negotiation available`,
241
+ SpqrErrorCode.VersionMismatch,
242
+ );
243
+ }
244
+ if (msgVer < (vn.minVersion as Version)) {
245
+ throw new SpqrError(
246
+ `Minimum version not met: min=${vn.minVersion}, msg=${msgVer}`,
247
+ SpqrErrorCode.MinimumVersion,
248
+ );
249
+ }
250
+
251
+ // Negotiate down to the message version
252
+ const inner = initInner(msgVer, vn.direction as Direction, Uint8Array.from(vn.authKey));
253
+ const chainResult = chainFrom(prenegotiatedPb.chain, vn);
254
+ statePb = {
255
+ v1: inner,
256
+ versionNegotiation: undefined, // disallow further negotiation
257
+ chain: chainResult,
258
+ };
259
+ }
260
+
261
+ // Process the message
262
+ if (statePb.v1 === undefined) {
263
+ // V0 state
264
+ return { state: new Uint8Array(0), key: null };
265
+ }
266
+
267
+ // Deserialize the message
268
+ const { msg: sckaMsg, index } = deserializeMessage(msg);
269
+
270
+ // Deserialize runtime states from protobuf
271
+ const runtimeStates = statesFromPb(statePb.v1);
272
+
273
+ // Execute the chunked recv
274
+ const recvResult = chunkedRecv(runtimeStates, sckaMsg);
275
+
276
+ // Chain key derivation
277
+ const msgKeyEpoch = sckaMsg.epoch - 1n;
278
+ const chainObj = chainFromState(statePb.chain, statePb.versionNegotiation);
279
+
280
+ if (recvResult.key !== null) {
281
+ // Epoch secret epoch matches chain expectation directly
282
+ chainObj.addEpoch(recvResult.key);
283
+ }
284
+
285
+ let msgKey: Uint8Array;
286
+ if (msgKeyEpoch === 0n && index === 0) {
287
+ // First message has no chain key
288
+ msgKey = new Uint8Array(0);
289
+ } else {
290
+ msgKey = chainObj.recvKey(msgKeyEpoch, index);
291
+ }
292
+
293
+ // Serialize the updated state
294
+ const v1Pb = statesToPb(recvResult.state);
295
+ const newStatePb: PbPqRatchetState = {
296
+ versionNegotiation: undefined, // cleared on recv
297
+ chain: chainObj.toProto(),
298
+ v1: v1Pb,
299
+ };
300
+
301
+ return {
302
+ state: encodePqRatchetState(newStatePb),
303
+ key: msgKey.length === 0 ? null : msgKey,
304
+ };
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // currentVersion
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * Inspect the current version negotiation status of a serialized state.
313
+ */
314
+ export function currentVersion(state: SerializedState): CurrentVersion {
315
+ if (state.length === 0) {
316
+ return { type: "negotiation_complete", version: Version.V0 };
317
+ }
318
+
319
+ const statePb = decodePqRatchetState(state);
320
+ const version = statePb.v1 !== undefined ? Version.V1 : Version.V0;
321
+
322
+ if (statePb.versionNegotiation !== undefined) {
323
+ return {
324
+ type: "still_negotiating",
325
+ version,
326
+ minVersion: statePb.versionNegotiation.minVersion as Version,
327
+ };
328
+ }
329
+ return { type: "negotiation_complete", version };
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Internal helpers
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * Initialize the V1 inner state based on version and direction.
338
+ */
339
+ function initInner(
340
+ version: Version,
341
+ direction: Direction,
342
+ authKey: Uint8Array,
343
+ ): PbPqRatchetState["v1"] {
344
+ if (version === Version.V0) {
345
+ return undefined;
346
+ }
347
+
348
+ // V1
349
+ let states: States;
350
+ if (direction === Direction.A2B) {
351
+ states = initA(authKey);
352
+ } else {
353
+ states = initB(authKey);
354
+ }
355
+ return statesToPb(states);
356
+ }
357
+
358
+ /**
359
+ * Extract version from a serialized message.
360
+ * Empty msg -> V0. msg[0]: 0 -> V0, 1 -> V1, else undefined.
361
+ */
362
+ function msgVersion(msg: SerializedMessage): Version | undefined {
363
+ if (msg.length === 0) return Version.V0;
364
+ const v = msg[0];
365
+ if (v === 0) return Version.V0;
366
+ if (v === 1) return Version.V1;
367
+ return undefined;
368
+ }
369
+
370
+ /**
371
+ * Extract version from the decoded state.
372
+ * No v1 inner -> V0. Has v1 inner -> V1.
373
+ */
374
+ function stateVersion(state: PbPqRatchetState): Version {
375
+ return state.v1 !== undefined ? Version.V1 : Version.V0;
376
+ }
377
+
378
+ /**
379
+ * Create a Chain from version negotiation parameters.
380
+ */
381
+ function chainFromVersionNegotiation(vn: PbVersionNegotiation): Chain {
382
+ const chainParams: ChainParams = vn.chainParams ?? {
383
+ maxJump: 25000,
384
+ maxOooKeys: 2000,
385
+ };
386
+ return Chain.create(Uint8Array.from(vn.authKey), vn.direction as Direction, chainParams);
387
+ }
388
+
389
+ /**
390
+ * Get or create a Chain from the existing chain proto and version negotiation.
391
+ * Prefers existing chain, falls back to creating from version negotiation.
392
+ */
393
+ function chainFrom(
394
+ chainPb: PbPqRatchetState["chain"],
395
+ vn: PbVersionNegotiation | undefined,
396
+ ): PbPqRatchetState["chain"] {
397
+ if (chainPb !== undefined) return chainPb;
398
+ if (vn !== undefined) return chainFromVersionNegotiation(vn).toProto();
399
+ return undefined;
400
+ }
401
+
402
+ /**
403
+ * Get a Chain object from state, creating from vn if needed.
404
+ */
405
+ function chainFromState(
406
+ chainPb: PbPqRatchetState["chain"],
407
+ vn: PbVersionNegotiation | undefined,
408
+ ): Chain {
409
+ if (chainPb !== undefined) return Chain.fromProto(chainPb);
410
+ if (vn !== undefined) return chainFromVersionNegotiation(vn);
411
+ throw new SpqrError(
412
+ "Chain not available and no version negotiation",
413
+ SpqrErrorCode.ChainNotAvailable,
414
+ );
415
+ }
package/src/kdf.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * KDF wrappers for SPQR (HKDF-SHA256 and HMAC-SHA256).
6
+ */
7
+
8
+ import { hkdf } from "@noble/hashes/hkdf.js";
9
+ import { sha256 } from "@noble/hashes/sha2.js";
10
+ import { hmac } from "@noble/hashes/hmac.js";
11
+
12
+ /**
13
+ * HKDF-SHA256 key derivation.
14
+ * @param ikm Input key material
15
+ * @param salt Salt (use ZERO_SALT for empty)
16
+ * @param info Context info string or bytes
17
+ * @param length Output length in bytes
18
+ */
19
+ export function hkdfSha256(
20
+ ikm: Uint8Array,
21
+ salt: Uint8Array,
22
+ info: Uint8Array | string,
23
+ length: number,
24
+ ): Uint8Array {
25
+ const infoBytes = typeof info === "string" ? new TextEncoder().encode(info) : info;
26
+ return hkdf(sha256, ikm, salt, infoBytes, length);
27
+ }
28
+
29
+ /**
30
+ * HMAC-SHA256 computation.
31
+ */
32
+ export function hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array {
33
+ return hmac(sha256, key, data);
34
+ }