@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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Serialization bridge between runtime States and PbV1State protobuf.
6
+ *
7
+ * Converts the runtime discriminated-union States objects to/from their
8
+ * protobuf representations so that the top-level API can persist state
9
+ * as opaque bytes.
10
+ */
11
+
12
+ import type { PbV1State, PbChunkedState } from "../../proto/pq-ratchet-types.js";
13
+ import { Authenticator } from "../../authenticator.js";
14
+ import { PolyEncoder, PolyDecoder } from "../../encoding/polynomial.js";
15
+
16
+ // Unchunked state classes
17
+ import * as ucSendEk from "../unchunked/send-ek.js";
18
+ import * as ucSendCt from "../unchunked/send-ct.js";
19
+
20
+ // Chunked state classes
21
+ import * as sendEk from "./send-ek.js";
22
+ import * as sendCt from "./send-ct.js";
23
+
24
+ import type { States } from "./states.js";
25
+ import type { Epoch } from "../../types.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // States -> PbV1State
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Serialize a runtime States object into PbV1State for protobuf encoding.
33
+ * Stores the epoch as field 12 so it can be recovered on deserialization.
34
+ */
35
+ export function statesToPb(s: States): PbV1State {
36
+ const epoch = s.state.epoch;
37
+ return { innerState: chunkedStateToPb(s), epoch };
38
+ }
39
+
40
+ function chunkedStateToPb(s: States): PbChunkedState {
41
+ switch (s.tag) {
42
+ case "keysUnsampled": {
43
+ const st = s.state;
44
+ return {
45
+ type: "keysUnsampled",
46
+ uc: { auth: st.uc.auth.toProto() },
47
+ };
48
+ }
49
+ case "keysSampled": {
50
+ const st = s.state;
51
+ return {
52
+ type: "keysSampled",
53
+ uc: {
54
+ auth: st.uc.auth.toProto(),
55
+ ek: Uint8Array.from(st.uc.ek),
56
+ dk: Uint8Array.from(st.uc.dk),
57
+ hdr: new Uint8Array(0),
58
+ hdrMac: new Uint8Array(0),
59
+ },
60
+ sendingHdr: st.sendingHdr.toProto(),
61
+ };
62
+ }
63
+ case "headerSent": {
64
+ const st = s.state;
65
+ return {
66
+ type: "headerSent",
67
+ uc: {
68
+ auth: st.uc.auth.toProto(),
69
+ ek: new Uint8Array(0),
70
+ dk: Uint8Array.from(st.uc.dk),
71
+ },
72
+ sendingEk: st.sendingEk.toProto(),
73
+ receivingCt1: st.receivingCt1.toProto(),
74
+ };
75
+ }
76
+ case "ct1Received": {
77
+ const st = s.state;
78
+ return {
79
+ type: "ct1Received",
80
+ uc: {
81
+ auth: st.uc.auth.toProto(),
82
+ dk: Uint8Array.from(st.uc.dk),
83
+ ct1: Uint8Array.from(st.uc.ct1),
84
+ },
85
+ sendingEk: st.sendingEk.toProto(),
86
+ };
87
+ }
88
+ case "ekSentCt1Received": {
89
+ const st = s.state;
90
+ return {
91
+ type: "ekSentCt1Received",
92
+ uc: {
93
+ auth: st.uc.auth.toProto(),
94
+ dk: Uint8Array.from(st.uc.dk),
95
+ ct1: Uint8Array.from(st.uc.ct1),
96
+ },
97
+ receivingCt2: st.receivingCt2.toProto(),
98
+ };
99
+ }
100
+ case "noHeaderReceived": {
101
+ const st = s.state;
102
+ return {
103
+ type: "noHeaderReceived",
104
+ uc: { auth: st.uc.auth.toProto() },
105
+ receivingHdr: st.receivingHdr.toProto(),
106
+ };
107
+ }
108
+ case "headerReceived": {
109
+ const st = s.state;
110
+ return {
111
+ type: "headerReceived",
112
+ uc: {
113
+ auth: st.uc.auth.toProto(),
114
+ hdr: Uint8Array.from(st.uc.hdr),
115
+ es: new Uint8Array(0),
116
+ ct1: new Uint8Array(0),
117
+ ss: new Uint8Array(0),
118
+ },
119
+ receivingEk: st.receivingEk.toProto(),
120
+ };
121
+ }
122
+ case "ct1Sampled": {
123
+ const st = s.state;
124
+ return {
125
+ type: "ct1Sampled",
126
+ uc: {
127
+ auth: st.uc.auth.toProto(),
128
+ hdr: Uint8Array.from(st.uc.hdr),
129
+ es: Uint8Array.from(st.uc.es),
130
+ ct1: Uint8Array.from(st.uc.ct1),
131
+ },
132
+ sendingCt1: st.sendingCt1.toProto(),
133
+ receivingEk: st.receivingEk.toProto(),
134
+ };
135
+ }
136
+ case "ekReceivedCt1Sampled": {
137
+ const st = s.state;
138
+ return {
139
+ type: "ekReceivedCt1Sampled",
140
+ uc: {
141
+ auth: st.uc.auth.toProto(),
142
+ hdr: new Uint8Array(0),
143
+ es: Uint8Array.from(st.uc.es),
144
+ ek: Uint8Array.from(st.uc.ek),
145
+ ct1: Uint8Array.from(st.uc.ct1),
146
+ },
147
+ sendingCt1: st.sendingCt1.toProto(),
148
+ };
149
+ }
150
+ case "ct1Acknowledged": {
151
+ const st = s.state;
152
+ return {
153
+ type: "ct1Acknowledged",
154
+ uc: {
155
+ auth: st.uc.auth.toProto(),
156
+ hdr: Uint8Array.from(st.uc.hdr),
157
+ es: Uint8Array.from(st.uc.es),
158
+ ct1: Uint8Array.from(st.uc.ct1),
159
+ },
160
+ receivingEk: st.receivingEk.toProto(),
161
+ };
162
+ }
163
+ case "ct2Sampled": {
164
+ const st = s.state;
165
+ return {
166
+ type: "ct2Sampled",
167
+ uc: { auth: st.uc.auth.toProto() },
168
+ sendingCt2: st.sendingCt2.toProto(),
169
+ };
170
+ }
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // PbV1State -> States
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Deserialize a PbV1State back into a runtime States object.
180
+ *
181
+ * @param pb - The protobuf V1 state
182
+ * @returns The reconstructed runtime States
183
+ */
184
+ export function statesFromPb(pb: PbV1State): States {
185
+ if (pb.innerState === undefined) {
186
+ throw new Error("PbV1State has no innerState");
187
+ }
188
+ const epoch = pb.epoch ?? 1n;
189
+ return chunkedStateFromPb(pb.innerState, epoch);
190
+ }
191
+
192
+ function chunkedStateFromPb(cs: PbChunkedState, epoch: Epoch): States {
193
+ switch (cs.type) {
194
+ case "keysUnsampled": {
195
+ const auth = authFromPb(cs.uc.auth);
196
+ const ucState = new ucSendEk.KeysUnsampled(epoch, auth);
197
+ return { tag: "keysUnsampled", state: new sendEk.KeysUnsampled(ucState) };
198
+ }
199
+ case "keysSampled": {
200
+ const auth = authFromPb(cs.uc.auth);
201
+ const ucState = new ucSendEk.HeaderSent(epoch, auth, cs.uc.ek, cs.uc.dk);
202
+ const encoder = PolyEncoder.fromProto(cs.sendingHdr);
203
+ return { tag: "keysSampled", state: new sendEk.KeysSampled(ucState, encoder) };
204
+ }
205
+ case "headerSent": {
206
+ const auth = authFromPb(cs.uc.auth);
207
+ const ucState = new ucSendEk.EkSent(epoch, auth, cs.uc.dk);
208
+ const encoder = PolyEncoder.fromProto(cs.sendingEk);
209
+ const decoder = PolyDecoder.fromProto(cs.receivingCt1);
210
+ return { tag: "headerSent", state: new sendEk.HeaderSent(ucState, encoder, decoder) };
211
+ }
212
+ case "ct1Received": {
213
+ const auth = authFromPb(cs.uc.auth);
214
+ const ucState = new ucSendEk.EkSentCt1Received(epoch, auth, cs.uc.dk, cs.uc.ct1);
215
+ const encoder = PolyEncoder.fromProto(cs.sendingEk);
216
+ return { tag: "ct1Received", state: new sendEk.Ct1Received(ucState, encoder) };
217
+ }
218
+ case "ekSentCt1Received": {
219
+ const auth = authFromPb(cs.uc.auth);
220
+ const ucState = new ucSendEk.EkSentCt1Received(epoch, auth, cs.uc.dk, cs.uc.ct1);
221
+ const decoder = PolyDecoder.fromProto(cs.receivingCt2);
222
+ return { tag: "ekSentCt1Received", state: new sendEk.EkSentCt1Received(ucState, decoder) };
223
+ }
224
+ case "noHeaderReceived": {
225
+ const auth = authFromPb(cs.uc.auth);
226
+ const ucState = new ucSendCt.NoHeaderReceived(epoch, auth);
227
+ const decoder = PolyDecoder.fromProto(cs.receivingHdr);
228
+ return { tag: "noHeaderReceived", state: new sendCt.NoHeaderReceived(ucState, decoder) };
229
+ }
230
+ case "headerReceived": {
231
+ const auth = authFromPb(cs.uc.auth);
232
+ const ucState = new ucSendCt.HeaderReceived(epoch, auth, cs.uc.hdr);
233
+ const decoder = PolyDecoder.fromProto(cs.receivingEk);
234
+ return { tag: "headerReceived", state: new sendCt.HeaderReceived(ucState, decoder) };
235
+ }
236
+ case "ct1Sampled": {
237
+ const auth = authFromPb(cs.uc.auth);
238
+ // Ct1Sent stores (epoch, auth, hdr, es, ct1) -- ct1 needed for MAC in sendCt2
239
+ const ucState = new ucSendCt.Ct1Sent(epoch, auth, cs.uc.hdr, cs.uc.es, cs.uc.ct1);
240
+ const encoder = PolyEncoder.fromProto(cs.sendingCt1);
241
+ const decoder = PolyDecoder.fromProto(cs.receivingEk);
242
+ return { tag: "ct1Sampled", state: new sendCt.Ct1Sampled(ucState, encoder, decoder) };
243
+ }
244
+ case "ekReceivedCt1Sampled": {
245
+ const auth = authFromPb(cs.uc.auth);
246
+ // Ct1SentEkReceived stores (epoch, auth, es, ek, ct1) -- ct1 needed for MAC in sendCt2
247
+ const ucState = new ucSendCt.Ct1SentEkReceived(epoch, auth, cs.uc.es, cs.uc.ek, cs.uc.ct1);
248
+ const encoder = PolyEncoder.fromProto(cs.sendingCt1);
249
+ return {
250
+ tag: "ekReceivedCt1Sampled",
251
+ state: new sendCt.EkReceivedCt1Sampled(ucState, encoder),
252
+ };
253
+ }
254
+ case "ct1Acknowledged": {
255
+ const auth = authFromPb(cs.uc.auth);
256
+ // Ct1Sent stores (epoch, auth, hdr, es, ct1) -- ct1 needed for MAC in sendCt2
257
+ const ucState = new ucSendCt.Ct1Sent(epoch, auth, cs.uc.hdr, cs.uc.es, cs.uc.ct1);
258
+ const decoder = PolyDecoder.fromProto(cs.receivingEk);
259
+ return { tag: "ct1Acknowledged", state: new sendCt.Ct1Acknowledged(ucState, decoder) };
260
+ }
261
+ case "ct2Sampled": {
262
+ const auth = authFromPb(cs.uc.auth);
263
+ const ucState = new ucSendCt.Ct2Sent(epoch, auth);
264
+ const encoder = PolyEncoder.fromProto(cs.sendingCt2);
265
+ return { tag: "ct2Sampled", state: new sendCt.Ct2Sampled(ucState, encoder) };
266
+ }
267
+ }
268
+ }
269
+
270
+ function authFromPb(pb: { rootKey: Uint8Array; macKey: Uint8Array } | undefined): Authenticator {
271
+ if (pb === undefined) {
272
+ return Authenticator.fromProto({
273
+ rootKey: new Uint8Array(32),
274
+ macKey: new Uint8Array(32),
275
+ });
276
+ }
277
+ return Authenticator.fromProto(pb);
278
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Chunked state machine dispatcher for SPQR V1.
6
+ *
7
+ * Provides the top-level send/recv interface that dispatches to the
8
+ * appropriate chunked send_ek or send_ct state, handling epoch
9
+ * validation and state transitions.
10
+ */
11
+
12
+ import type { Chunk } from "../../encoding/polynomial.js";
13
+ import type { Epoch, EpochSecret, RandomBytes } from "../../types.js";
14
+ import { SpqrError, SpqrErrorCode } from "../../error.js";
15
+ import { Authenticator } from "../../authenticator.js";
16
+ import * as unchunked from "../unchunked/send-ek.js";
17
+ import * as sendEk from "./send-ek.js";
18
+ import * as sendCt from "./send-ct.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Message types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type MessagePayload =
25
+ | { type: "none" }
26
+ | { type: "hdr"; chunk: Chunk }
27
+ | { type: "ek"; chunk: Chunk }
28
+ | { type: "ekCt1Ack"; chunk: Chunk }
29
+ | { type: "ct1Ack" }
30
+ | { type: "ct1"; chunk: Chunk }
31
+ | { type: "ct2"; chunk: Chunk };
32
+
33
+ export interface Message {
34
+ epoch: Epoch;
35
+ payload: MessagePayload;
36
+ }
37
+
38
+ export interface SendResult {
39
+ msg: Message;
40
+ key: EpochSecret | null;
41
+ state: States;
42
+ }
43
+
44
+ export interface RecvResult {
45
+ key: EpochSecret | null;
46
+ state: States;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // States discriminated union
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export type States =
54
+ | { tag: "keysUnsampled"; state: sendEk.KeysUnsampled }
55
+ | { tag: "keysSampled"; state: sendEk.KeysSampled }
56
+ | { tag: "headerSent"; state: sendEk.HeaderSent }
57
+ | { tag: "ct1Received"; state: sendEk.Ct1Received }
58
+ | { tag: "ekSentCt1Received"; state: sendEk.EkSentCt1Received }
59
+ | { tag: "noHeaderReceived"; state: sendCt.NoHeaderReceived }
60
+ | { tag: "headerReceived"; state: sendCt.HeaderReceived }
61
+ | { tag: "ct1Sampled"; state: sendCt.Ct1Sampled }
62
+ | { tag: "ekReceivedCt1Sampled"; state: sendCt.EkReceivedCt1Sampled }
63
+ | { tag: "ct1Acknowledged"; state: sendCt.Ct1Acknowledged }
64
+ | { tag: "ct2Sampled"; state: sendCt.Ct2Sampled };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Epoch accessor
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function getEpoch(s: States): Epoch {
71
+ return s.state.epoch;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Initialization
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Initialize Alice (send_ek side) at epoch 1.
80
+ */
81
+ export function initA(authKey: Uint8Array): States {
82
+ const auth = Authenticator.create(authKey, 1n);
83
+ const ucState = new unchunked.KeysUnsampled(1n, auth);
84
+ return {
85
+ tag: "keysUnsampled",
86
+ state: new sendEk.KeysUnsampled(ucState),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Initialize Bob (send_ct side) at epoch 1.
92
+ */
93
+ export function initB(authKey: Uint8Array): States {
94
+ return {
95
+ tag: "noHeaderReceived",
96
+ state: sendCt.NoHeaderReceived.create(authKey),
97
+ };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Send
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Produce the next outgoing message from the current state.
106
+ */
107
+ export function send(current: States, rng: RandomBytes): SendResult {
108
+ const epoch = getEpoch(current);
109
+
110
+ switch (current.tag) {
111
+ case "keysUnsampled": {
112
+ const [next, chunk] = current.state.sendHdrChunk(rng);
113
+ return {
114
+ msg: { epoch, payload: { type: "hdr", chunk } },
115
+ key: null,
116
+ state: { tag: "keysSampled", state: next },
117
+ };
118
+ }
119
+
120
+ case "keysSampled": {
121
+ const [next, chunk] = current.state.sendHdrChunk();
122
+ return {
123
+ msg: { epoch, payload: { type: "hdr", chunk } },
124
+ key: null,
125
+ state: { tag: "keysSampled", state: next },
126
+ };
127
+ }
128
+
129
+ case "headerSent": {
130
+ const [next, chunk] = current.state.sendEkChunk();
131
+ return {
132
+ msg: { epoch, payload: { type: "ek", chunk } },
133
+ key: null,
134
+ state: { tag: "headerSent", state: next },
135
+ };
136
+ }
137
+
138
+ case "ct1Received": {
139
+ const [next, chunk] = current.state.sendEkChunk();
140
+ return {
141
+ msg: { epoch, payload: { type: "ekCt1Ack", chunk } },
142
+ key: null,
143
+ state: { tag: "ct1Received", state: next },
144
+ };
145
+ }
146
+
147
+ case "ekSentCt1Received": {
148
+ return {
149
+ msg: { epoch, payload: { type: "ct1Ack" } },
150
+ key: null,
151
+ state: current,
152
+ };
153
+ }
154
+
155
+ case "noHeaderReceived": {
156
+ return {
157
+ msg: { epoch, payload: { type: "none" } },
158
+ key: null,
159
+ state: current,
160
+ };
161
+ }
162
+
163
+ case "headerReceived": {
164
+ const [next, chunk, epochSecret] = current.state.sendCt1Chunk(rng);
165
+ return {
166
+ msg: { epoch, payload: { type: "ct1", chunk } },
167
+ key: epochSecret,
168
+ state: { tag: "ct1Sampled", state: next },
169
+ };
170
+ }
171
+
172
+ case "ct1Sampled": {
173
+ const [next, chunk] = current.state.sendCt1Chunk();
174
+ return {
175
+ msg: { epoch, payload: { type: "ct1", chunk } },
176
+ key: null,
177
+ state: { tag: "ct1Sampled", state: next },
178
+ };
179
+ }
180
+
181
+ case "ekReceivedCt1Sampled": {
182
+ const [next, chunk] = current.state.sendCt1Chunk();
183
+ return {
184
+ msg: { epoch, payload: { type: "ct1", chunk } },
185
+ key: null,
186
+ state: { tag: "ekReceivedCt1Sampled", state: next },
187
+ };
188
+ }
189
+
190
+ case "ct1Acknowledged": {
191
+ return {
192
+ msg: { epoch, payload: { type: "none" } },
193
+ key: null,
194
+ state: current,
195
+ };
196
+ }
197
+
198
+ case "ct2Sampled": {
199
+ const [next, chunk] = current.state.sendCt2Chunk();
200
+ return {
201
+ msg: { epoch, payload: { type: "ct2", chunk } },
202
+ key: null,
203
+ state: { tag: "ct2Sampled", state: next },
204
+ };
205
+ }
206
+ }
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Recv
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Process an incoming message and transition the state.
215
+ */
216
+ export function recv(current: States, msg: Message): RecvResult {
217
+ const stateEpoch = getEpoch(current);
218
+
219
+ // Handle epoch mismatch: messages from the past are ignored
220
+ if (msg.epoch < stateEpoch) {
221
+ return { key: null, state: current };
222
+ }
223
+
224
+ // Messages from the future are an error (except ct2Sampled epoch+1)
225
+ if (msg.epoch > stateEpoch) {
226
+ if (current.tag === "ct2Sampled" && msg.epoch === stateEpoch + 1n) {
227
+ // Next epoch -- transition to KeysUnsampled and return immediately.
228
+ // The incoming message payload is NOT processed in the new state
229
+ // (matches Rust: the peer will retransmit via erasure coding).
230
+ const next = current.state.recvNextEpoch(msg.epoch);
231
+ return { key: null, state: { tag: "keysUnsampled", state: next } };
232
+ }
233
+ throw new SpqrError(
234
+ `Epoch too far ahead: state=${stateEpoch}, msg=${msg.epoch}`,
235
+ SpqrErrorCode.EpochOutOfRange,
236
+ );
237
+ }
238
+
239
+ // msg.epoch === stateEpoch -- process the payload
240
+ const payload = msg.payload;
241
+
242
+ switch (current.tag) {
243
+ case "keysUnsampled": {
244
+ // Waiting to send -- nothing to receive
245
+ return { key: null, state: current };
246
+ }
247
+
248
+ case "keysSampled": {
249
+ if (payload.type === "ct1") {
250
+ const next = current.state.recvCt1Chunk(msg.epoch, payload.chunk);
251
+ return { key: null, state: { tag: "headerSent", state: next } };
252
+ }
253
+ return { key: null, state: current };
254
+ }
255
+
256
+ case "headerSent": {
257
+ if (payload.type === "ct1") {
258
+ const result = current.state.recvCt1Chunk(msg.epoch, payload.chunk);
259
+ if (result.done) {
260
+ return {
261
+ key: null,
262
+ state: { tag: "ct1Received", state: result.state },
263
+ };
264
+ }
265
+ return {
266
+ key: null,
267
+ state: { tag: "headerSent", state: result.state },
268
+ };
269
+ }
270
+ return { key: null, state: current };
271
+ }
272
+
273
+ case "ct1Received": {
274
+ if (payload.type === "ct2") {
275
+ const next = current.state.recvCt2Chunk(msg.epoch, payload.chunk);
276
+ return {
277
+ key: null,
278
+ state: { tag: "ekSentCt1Received", state: next },
279
+ };
280
+ }
281
+ return { key: null, state: current };
282
+ }
283
+
284
+ case "ekSentCt1Received": {
285
+ if (payload.type === "ct2") {
286
+ const result = current.state.recvCt2Chunk(msg.epoch, payload.chunk);
287
+ if (result.done) {
288
+ return {
289
+ key: result.epochSecret,
290
+ state: { tag: "noHeaderReceived", state: result.state },
291
+ };
292
+ }
293
+ return {
294
+ key: null,
295
+ state: { tag: "ekSentCt1Received", state: result.state },
296
+ };
297
+ }
298
+ return { key: null, state: current };
299
+ }
300
+
301
+ case "noHeaderReceived": {
302
+ if (payload.type === "hdr") {
303
+ const result = current.state.recvHdrChunk(msg.epoch, payload.chunk);
304
+ if (result.done) {
305
+ return {
306
+ key: null,
307
+ state: { tag: "headerReceived", state: result.state },
308
+ };
309
+ }
310
+ return {
311
+ key: null,
312
+ state: { tag: "noHeaderReceived", state: result.state },
313
+ };
314
+ }
315
+ return { key: null, state: current };
316
+ }
317
+
318
+ case "headerReceived": {
319
+ // Waiting to send ct1 -- nothing to receive in this state
320
+ return { key: null, state: current };
321
+ }
322
+
323
+ case "ct1Sampled": {
324
+ if (payload.type === "ek") {
325
+ const result = current.state.recvEkChunk(msg.epoch, payload.chunk, false);
326
+ return mapCt1SampledResult(result);
327
+ }
328
+ if (payload.type === "ekCt1Ack") {
329
+ const result = current.state.recvEkChunk(msg.epoch, payload.chunk, true);
330
+ return mapCt1SampledResult(result);
331
+ }
332
+ return { key: null, state: current };
333
+ }
334
+
335
+ case "ekReceivedCt1Sampled": {
336
+ if (payload.type === "ct1Ack" || payload.type === "ekCt1Ack") {
337
+ const next = current.state.recvCt1Ack(msg.epoch);
338
+ return {
339
+ key: null,
340
+ state: { tag: "ct2Sampled", state: next },
341
+ };
342
+ }
343
+ return { key: null, state: current };
344
+ }
345
+
346
+ case "ct1Acknowledged": {
347
+ if (payload.type === "ek" || payload.type === "ekCt1Ack") {
348
+ const chunk = payload.type === "ek" ? payload.chunk : payload.chunk;
349
+ const result = current.state.recvEkChunk(msg.epoch, chunk);
350
+ if (result.done) {
351
+ return {
352
+ key: result.epochSecret,
353
+ state: { tag: "ct2Sampled", state: result.state },
354
+ };
355
+ }
356
+ return {
357
+ key: null,
358
+ state: { tag: "ct1Acknowledged", state: result.state },
359
+ };
360
+ }
361
+ return { key: null, state: current };
362
+ }
363
+
364
+ case "ct2Sampled": {
365
+ // If we receive a message for the current epoch, nothing to do
366
+ // (next epoch handled above)
367
+ return { key: null, state: current };
368
+ }
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Helpers
374
+ // ---------------------------------------------------------------------------
375
+
376
+ function mapCt1SampledResult(result: sendCt.Ct1SampledRecvChunk): RecvResult {
377
+ switch (result.tag) {
378
+ case "done":
379
+ return {
380
+ key: result.epochSecret,
381
+ state: { tag: "ct2Sampled", state: result.state },
382
+ };
383
+ case "stillSending":
384
+ return {
385
+ key: result.epochSecret,
386
+ state: { tag: "ekReceivedCt1Sampled", state: result.state },
387
+ };
388
+ case "stillReceiving":
389
+ return {
390
+ key: null,
391
+ state: { tag: "ct1Acknowledged", state: result.state },
392
+ };
393
+ case "stillReceivingStillSending":
394
+ return {
395
+ key: null,
396
+ state: { tag: "ct1Sampled", state: result.state },
397
+ };
398
+ }
399
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * SPQR V1 protocol layer.
6
+ */
7
+
8
+ export * as chunked from "./chunked/index.js";
9
+ export * as unchunked from "./unchunked/index.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Unchunked state machine for SPQR V1.
6
+ */
7
+
8
+ // send_ek states
9
+ export { KeysUnsampled, HeaderSent, EkSent, EkSentCt1Received } from "./send-ek.js";
10
+ export type { RecvCt2Result } from "./send-ek.js";
11
+
12
+ // send_ct states
13
+ export {
14
+ NoHeaderReceived,
15
+ HeaderReceived,
16
+ Ct1Sent,
17
+ Ct1SentEkReceived,
18
+ Ct2Sent,
19
+ } from "./send-ct.js";
20
+ export type { SendCt2Result } from "./send-ct.js";