@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,231 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Unchunked send_ct state machine for SPQR V1.
6
+ *
7
+ * Ported from Signal's spqr crate: v1/unchunked/send_ct.rs
8
+ *
9
+ * True incremental ML-KEM-768 implementation (Phase 9):
10
+ *
11
+ * - sendCt1(rng) performs encaps1 using only the header, producing
12
+ * a REAL ct1 and shared secret. The epoch secret is derived here.
13
+ *
14
+ * - recvEk(ek) validates the encapsulation key against the header
15
+ * and stores it for encaps2.
16
+ *
17
+ * - sendCt2() calls encaps2(ek, es) to produce ct2, then MACs ct1||ct2.
18
+ *
19
+ * State transitions:
20
+ * NoHeaderReceived --recvHeader(epoch, hdr, mac)--> HeaderReceived
21
+ * HeaderReceived --sendCt1(rng)----------------> Ct1Sent + REAL ct1 + EpochSecret
22
+ * Ct1Sent --recvEk(ek)------------------> Ct1SentEkReceived
23
+ * Ct1SentEkReceived --sendCt2()-------------------> Ct2Sent + (ct2, mac)
24
+ * Ct2Sent (terminal -- caller creates next KeysUnsampled)
25
+ */
26
+
27
+ import { type Authenticator } from "../../authenticator.js";
28
+ import { hkdfSha256 } from "../../kdf.js";
29
+ import { concat, bigintToBE8 } from "../../util.js";
30
+ import { ZERO_SALT, LABEL_SCKA_KEY } from "../../constants.js";
31
+ import { encaps1, encaps2, ekMatchesHeader } from "../../incremental-mlkem768.js";
32
+ import { SpqrError, SpqrErrorCode } from "../../error.js";
33
+ import type { Epoch, EpochSecret, RandomBytes } from "../../types.js";
34
+
35
+ // Pre-encode the SCKA label
36
+ const SCKA_KEY_LABEL = new TextEncoder().encode(LABEL_SCKA_KEY);
37
+
38
+ /**
39
+ * Derive the epoch secret from the KEM shared secret.
40
+ *
41
+ * HKDF-SHA256(ikm=sharedSecret, salt=ZERO_SALT,
42
+ * info=LABEL_SCKA_KEY || epoch_be8, length=32)
43
+ *
44
+ * Matches Rust: info = [b"Signal_PQCKA_V1_MLKEM768:SCKA Key", epoch.to_be_bytes()].concat()
45
+ */
46
+ function deriveEpochSecret(epoch: Epoch, sharedSecret: Uint8Array): EpochSecret {
47
+ const info = concat(SCKA_KEY_LABEL, bigintToBE8(epoch));
48
+ const secret = hkdfSha256(sharedSecret, ZERO_SALT, info, 32);
49
+ return { epoch, secret };
50
+ }
51
+
52
+ /** Result of Ct1SentEkReceived.sendCt2 */
53
+ export interface SendCt2Result {
54
+ /** Next state (terminal for this epoch's send_ct) */
55
+ state: Ct2Sent;
56
+ /** The 128-byte second ciphertext fragment */
57
+ ct2: Uint8Array;
58
+ /** The HMAC-SHA256 MAC over the full ciphertext (ct1 || ct2) */
59
+ mac: Uint8Array;
60
+ }
61
+
62
+ // ---- State: NoHeaderReceived ----
63
+
64
+ /**
65
+ * Waiting to receive the header from the send_ek peer.
66
+ */
67
+ export class NoHeaderReceived {
68
+ constructor(
69
+ public readonly epoch: Epoch,
70
+ public readonly auth: Authenticator,
71
+ ) {}
72
+
73
+ /**
74
+ * Receive the header and verify its MAC.
75
+ *
76
+ * @param epoch - The epoch for this exchange (must match current epoch)
77
+ * @param hdr - The 64-byte public key header
78
+ * @param mac - The 32-byte HMAC-SHA256 MAC over the header
79
+ * @returns Next state
80
+ * @throws {AuthenticatorError} If the header MAC is invalid
81
+ */
82
+ recvHeader(epoch: Epoch, hdr: Uint8Array, mac: Uint8Array): HeaderReceived {
83
+ // Verify header MAC
84
+ this.auth.verifyHdr(epoch, hdr, mac);
85
+
86
+ return new HeaderReceived(this.epoch, this.auth, hdr);
87
+ }
88
+ }
89
+
90
+ // ---- State: HeaderReceived ----
91
+
92
+ /**
93
+ * The header has been received and verified. Ready to produce ct1.
94
+ *
95
+ * In the true incremental ML-KEM approach, sendCt1 performs encaps1
96
+ * using only the header (rho + H(ek)), producing REAL ct1 and shared
97
+ * secret. The epoch secret is derived here.
98
+ */
99
+ export class HeaderReceived {
100
+ constructor(
101
+ public readonly epoch: Epoch,
102
+ public readonly auth: Authenticator,
103
+ public readonly hdr: Uint8Array,
104
+ ) {}
105
+
106
+ /**
107
+ * Generate encapsulation randomness and produce REAL ct1.
108
+ *
109
+ * Performs encaps1 using the header to produce:
110
+ * - Real ct1 (960 bytes)
111
+ * - Real shared secret -> epoch secret
112
+ * - Encapsulation state for later encaps2
113
+ *
114
+ * The authenticator is updated with the derived epoch secret.
115
+ *
116
+ * @param rng - Random byte generator
117
+ * @returns [nextState, real_ct1, epochSecret]
118
+ */
119
+ sendCt1(rng: RandomBytes): [Ct1Sent, Uint8Array, EpochSecret] {
120
+ const { ct1, es, sharedSecret } = encaps1(this.hdr, rng);
121
+
122
+ // Derive epoch secret (with epoch in HKDF info, matching Rust)
123
+ const epochSecret = deriveEpochSecret(this.epoch, sharedSecret);
124
+
125
+ // Update authenticator with the HKDF-derived secret at current epoch
126
+ const auth = this.auth.clone();
127
+ auth.update(this.epoch, epochSecret.secret);
128
+
129
+ const nextState = new Ct1Sent(this.epoch, auth, this.hdr, es, ct1);
130
+
131
+ return [nextState, ct1, epochSecret];
132
+ }
133
+ }
134
+
135
+ // ---- State: Ct1Sent ----
136
+
137
+ /**
138
+ * Real ct1 has been produced. Waiting for the encapsulation key.
139
+ *
140
+ * Stores hdr, es(2080), and ct1(960) for the encaps2 phase.
141
+ */
142
+ export class Ct1Sent {
143
+ constructor(
144
+ public readonly epoch: Epoch,
145
+ public readonly auth: Authenticator,
146
+ public readonly hdr: Uint8Array,
147
+ public readonly es: Uint8Array,
148
+ public readonly ct1: Uint8Array,
149
+ ) {}
150
+
151
+ /**
152
+ * Receive the encapsulation key and validate it against the header.
153
+ *
154
+ * In the true incremental approach, this simply validates and stores
155
+ * the ek for later use in sendCt2. No encapsulation happens here.
156
+ *
157
+ * @param ek - The 1152-byte encapsulation key from the send_ek peer
158
+ * @returns Next state
159
+ * @throws {SpqrError} If the ek does not match the header
160
+ */
161
+ recvEk(ek: Uint8Array): Ct1SentEkReceived {
162
+ if (!ekMatchesHeader(ek, this.hdr)) {
163
+ throw new SpqrError(
164
+ "Encapsulation key does not match header",
165
+ SpqrErrorCode.ErroneousDataReceived,
166
+ );
167
+ }
168
+
169
+ return new Ct1SentEkReceived(this.epoch, this.auth, this.es, ek, this.ct1);
170
+ }
171
+ }
172
+
173
+ // ---- State: Ct1SentEkReceived ----
174
+
175
+ /**
176
+ * The encapsulation key has been received and validated.
177
+ * Ready to send ct2.
178
+ *
179
+ * Stores es(2080), ek(1152), and ct1(960) for encaps2 + MAC.
180
+ */
181
+ export class Ct1SentEkReceived {
182
+ constructor(
183
+ public readonly epoch: Epoch,
184
+ public readonly auth: Authenticator,
185
+ public readonly es: Uint8Array,
186
+ public readonly ek: Uint8Array,
187
+ public readonly ct1: Uint8Array,
188
+ ) {}
189
+
190
+ /**
191
+ * Produce ct2 by calling encaps2, then MAC over ct1 || ct2.
192
+ *
193
+ * @returns Result with next state, ct2, and MAC
194
+ */
195
+ sendCt2(): SendCt2Result {
196
+ // encaps2 produces ct2 only
197
+ const ct2 = encaps2(this.ek, this.es);
198
+
199
+ // MAC over the full ciphertext: ct1 || ct2
200
+ const fullCt = concat(this.ct1, ct2);
201
+ const mac = this.auth.macCt(this.epoch, fullCt);
202
+
203
+ const state = new Ct2Sent(this.epoch, this.auth);
204
+
205
+ return {
206
+ state,
207
+ ct2,
208
+ mac,
209
+ };
210
+ }
211
+ }
212
+
213
+ // ---- State: Ct2Sent ----
214
+
215
+ /**
216
+ * Terminal state for this epoch's send_ct exchange.
217
+ *
218
+ * The caller is responsible for creating the next epoch's
219
+ * send_ek::KeysUnsampled state from the epoch and auth.
220
+ */
221
+ export class Ct2Sent {
222
+ constructor(
223
+ public readonly epoch: Epoch,
224
+ public readonly auth: Authenticator,
225
+ ) {}
226
+
227
+ /** The next epoch for the send_ek side */
228
+ get nextEpoch(): Epoch {
229
+ return this.epoch + 1n;
230
+ }
231
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Copyright © 2025 Signal Messenger, LLC
3
+ * Copyright © 2026 Parity Technologies
4
+ *
5
+ * Unchunked send_ek state machine for SPQR V1.
6
+ *
7
+ * Ported from Signal's spqr crate: v1/unchunked/send_ek.rs
8
+ *
9
+ * The send_ek side generates an ML-KEM-768 keypair, sends the public key
10
+ * header (hdr) and encapsulation key (ek), then receives ct1 and ct2 from
11
+ * the send_ct peer. On receiving ct2, it performs decapsulation to derive
12
+ * the shared epoch secret.
13
+ *
14
+ * State transitions:
15
+ * KeysUnsampled --sendHeader(rng)--> HeaderSent + (hdr, mac)
16
+ * HeaderSent --sendEk()---------> EkSent + ek
17
+ * EkSent --recvCt1(ct1)-----> EkSentCt1Received
18
+ * EkSentCt1Received --recvCt2(ct2, mac)--> RecvCt2Result (epoch, auth, epochSecret)
19
+ *
20
+ * The caller is responsible for constructing the next-epoch send_ct state
21
+ * (NoHeaderReceived) from the RecvCt2Result, avoiding circular imports.
22
+ */
23
+
24
+ import { type Authenticator } from "../../authenticator.js";
25
+ import { hkdfSha256 } from "../../kdf.js";
26
+ import { concat, bigintToBE8 } from "../../util.js";
27
+ import { ZERO_SALT, LABEL_SCKA_KEY } from "../../constants.js";
28
+ import { generate, decaps } from "../../incremental-mlkem768.js";
29
+ import type { Epoch, EpochSecret, RandomBytes } from "../../types.js";
30
+
31
+ // Pre-encode the SCKA label
32
+ const SCKA_KEY_LABEL = new TextEncoder().encode(LABEL_SCKA_KEY);
33
+
34
+ /**
35
+ * Derive the epoch secret from the KEM shared secret.
36
+ *
37
+ * HKDF-SHA256(ikm=sharedSecret, salt=ZERO_SALT,
38
+ * info=LABEL_SCKA_KEY || epoch_be8, length=32)
39
+ *
40
+ * Matches Rust: info = [b"Signal_PQCKA_V1_MLKEM768:SCKA Key", epoch.to_be_bytes()].concat()
41
+ */
42
+ function deriveEpochSecret(epoch: Epoch, sharedSecret: Uint8Array): EpochSecret {
43
+ const info = concat(SCKA_KEY_LABEL, bigintToBE8(epoch));
44
+ const secret = hkdfSha256(sharedSecret, ZERO_SALT, info, 32);
45
+ return { epoch, secret };
46
+ }
47
+
48
+ /** Result of EkSentCt1Received.recvCt2 */
49
+ export interface RecvCt2Result {
50
+ /** The next epoch (current + 1) */
51
+ nextEpoch: Epoch;
52
+ /** The updated authenticator for the next epoch */
53
+ auth: Authenticator;
54
+ /** The derived epoch secret for chain advancement */
55
+ epochSecret: EpochSecret;
56
+ }
57
+
58
+ // ---- State: KeysUnsampled ----
59
+
60
+ /**
61
+ * Initial send_ek state. No keypair has been generated yet.
62
+ */
63
+ export class KeysUnsampled {
64
+ constructor(
65
+ public readonly epoch: Epoch,
66
+ public readonly auth: Authenticator,
67
+ ) {}
68
+
69
+ /**
70
+ * Generate an ML-KEM-768 keypair and produce the header + MAC.
71
+ *
72
+ * @param rng - Random byte generator
73
+ * @returns [nextState, hdr, hdrMac]
74
+ */
75
+ sendHeader(rng: RandomBytes): [HeaderSent, Uint8Array, Uint8Array] {
76
+ const keys = generate(rng);
77
+ const mac = this.auth.macHdr(this.epoch, keys.hdr);
78
+
79
+ const nextState = new HeaderSent(this.epoch, this.auth, keys.ek, keys.dk);
80
+
81
+ return [nextState, keys.hdr, mac];
82
+ }
83
+ }
84
+
85
+ // ---- State: HeaderSent ----
86
+
87
+ /**
88
+ * The header has been sent to the peer. Ready to send the encapsulation key.
89
+ */
90
+ export class HeaderSent {
91
+ constructor(
92
+ public readonly epoch: Epoch,
93
+ public readonly auth: Authenticator,
94
+ public readonly ek: Uint8Array,
95
+ public readonly dk: Uint8Array,
96
+ ) {}
97
+
98
+ /**
99
+ * Produce the encapsulation key to send to the peer.
100
+ *
101
+ * @returns [nextState, ek]
102
+ */
103
+ sendEk(): [EkSent, Uint8Array] {
104
+ const nextState = new EkSent(this.epoch, this.auth, this.dk);
105
+ return [nextState, this.ek];
106
+ }
107
+ }
108
+
109
+ // ---- State: EkSent ----
110
+
111
+ /**
112
+ * Both the header and encapsulation key have been sent.
113
+ * Waiting to receive ct1 from the send_ct peer.
114
+ */
115
+ export class EkSent {
116
+ constructor(
117
+ public readonly epoch: Epoch,
118
+ public readonly auth: Authenticator,
119
+ public readonly dk: Uint8Array,
120
+ ) {}
121
+
122
+ /**
123
+ * Receive the first ciphertext fragment from the peer.
124
+ *
125
+ * @param ct1 - The 960-byte first ciphertext fragment
126
+ * @returns Next state
127
+ */
128
+ recvCt1(ct1: Uint8Array): EkSentCt1Received {
129
+ return new EkSentCt1Received(this.epoch, this.auth, this.dk, ct1);
130
+ }
131
+ }
132
+
133
+ // ---- State: EkSentCt1Received ----
134
+
135
+ /**
136
+ * ct1 has been received. Waiting for ct2 to complete decapsulation.
137
+ */
138
+ export class EkSentCt1Received {
139
+ constructor(
140
+ public readonly epoch: Epoch,
141
+ public readonly auth: Authenticator,
142
+ public readonly dk: Uint8Array,
143
+ public readonly ct1: Uint8Array,
144
+ ) {}
145
+
146
+ /**
147
+ * Receive ct2 and MAC, perform decapsulation, verify the MAC,
148
+ * and derive the epoch secret.
149
+ *
150
+ * The caller constructs the next send_ct::NoHeaderReceived state from
151
+ * the returned nextEpoch and auth.
152
+ *
153
+ * @param ct2 - The 128-byte second ciphertext fragment
154
+ * @param mac - The 32-byte HMAC-SHA256 MAC over the full ciphertext
155
+ * @returns Result containing next epoch, updated auth, and epoch secret
156
+ * @throws {AuthenticatorError} If the ciphertext MAC is invalid
157
+ */
158
+ recvCt2(ct2: Uint8Array, mac: Uint8Array): RecvCt2Result {
159
+ // Decapsulate to recover shared secret
160
+ const sharedSecret = decaps(this.dk, this.ct1, ct2);
161
+
162
+ // Derive epoch secret (with epoch in HKDF info, matching Rust)
163
+ const epochSecret = deriveEpochSecret(this.epoch, sharedSecret);
164
+
165
+ // Update authenticator with the HKDF-derived secret at current epoch
166
+ const auth = this.auth.clone();
167
+ auth.update(this.epoch, epochSecret.secret);
168
+
169
+ // Verify the ciphertext MAC: MAC over ct1 || ct2
170
+ const fullCt = concat(this.ct1, ct2);
171
+ auth.verifyCt(this.epoch, fullCt, mac);
172
+
173
+ const nextEpoch = this.epoch + 1n;
174
+
175
+ return { nextEpoch, auth, epochSecret };
176
+ }
177
+ }