@cipherman/pake-js 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -13,8 +13,8 @@ All notable changes to this project will be documented here. The format follows
13
13
  - CPace per `draft-irtf-cfrg-cpace-20`:
14
14
  - `CPACE-RISTR255-SHA512` — **verified byte-for-byte against draft-20 Appendix B.3** (generator_string, SHA-512 hash, calculate_generator, Ya/Yb public shares, shared secret K, ISK_IR, ISK_SY, scalar_mult_vfy valid/invalid cases).
15
15
  - Stateless function-only public API.
16
- - Universal build (ESM + CJS, Node ≥18, Deno, Bun, browsers) via tsup.
17
- - CI on Node 18/20/22 with typecheck, lint, tests, bundle size, `npm audit`, CodeQL.
16
+ - Universal build (ESM + CJS, Node ≥22, Deno, Bun, browsers) via tsup.
17
+ - CI on Node 22 with typecheck, lint, tests, bundle size, `npm audit` (runtime deps), CodeQL.
18
18
  - Release workflow with npm provenance (SLSA) via GitHub Actions OIDC and CycloneDX SBOM attached to every GitHub release.
19
19
  - `SECURITY.md`, `THREAT_MODEL.md`, `CLAUDE.md`.
20
20
 
package/README.md CHANGED
@@ -4,7 +4,7 @@ Auditable, standards-based **Password-Authenticated Key Exchange** for JavaScrip
4
4
 
5
5
  - **Protocols**: SPAKE2+ (RFC 9383) and CPace (draft-irtf-cfrg-cpace-20)
6
6
  - **Runtime dependency**: exactly one — [`@noble/curves`](https://github.com/paulmillr/noble-curves)
7
- - **Platforms**: Node ≥18, Deno, Bun, and every modern browser (any framework)
7
+ - **Platforms**: Node ≥22, Deno, Bun, and every modern browser (any framework)
8
8
  - **API**: fully stateless — plain functions in, plain objects out
9
9
  - **License**: MIT
10
10
 
@@ -36,83 +36,515 @@ npm install @cipherman/pake-js
36
36
 
37
37
  > The edwards25519 SPAKE2+ suite is marked SPEC-VERIFY: the M, N constants must be cross-checked against RFC 9383 and the appendix test vectors must pass before production use. See [`THREAT_MODEL.md`](./THREAT_MODEL.md) §R2.
38
38
 
39
- ## SPAKE2+ example (P-256 / SHA-256)
39
+ ## Alice logs in to Bob's server (SPAKE2+ / P-256 / SHA-256)
40
+
41
+ A complete SPAKE2+ walkthrough, narrated step by step. The full runnable version is in [test/examples/alice-bob.test.ts](./test/examples/alice-bob.test.ts) and runs as part of `npm test` — if this README drifts from the code, CI fails on the next push.
42
+
43
+ ### The scenario
44
+
45
+ Alice has a password. She wants to log in to Bob's server using that password, with these requirements:
46
+
47
+ - **Bob never sees the password, not even once.** Not at registration, not at login.
48
+ - **A network eavesdropper learns nothing.** Recording the session does not let an attacker try password guesses offline.
49
+ - **An active attacker pretending to be one side** gets at most **one** password guess per session attempt. Any wrong guess is detectable and can be rate-limited at the application level.
50
+ - **If Bob's database is stolen later**, the attacker still can't recover the password without running a memory-hard function over *every* candidate — the verifier stored on the server is a "hardened" form of the password, not a replayable secret.
51
+ - **At the end**, Alice and Bob both hold the *same* 32-byte session key that nobody else could have computed, and each side has cryptographic proof that the other actually derived it correctly.
52
+
53
+ SPAKE2+ gives all of these. The entire protocol is one round trip (shareP → shareV) plus one round of key confirmation (confirmP → confirmV).
54
+
55
+ ### What each side holds
56
+
57
+ | Value | Who holds it | What it is |
58
+ | --- | --- | --- |
59
+ | `password` | Alice only | The low-entropy secret she types. **Never given to pake-js.** |
60
+ | `mhfOutput` | Alice only | 80+ bytes from running scrypt/Argon2id over `(password, salt, idProver, idVerifier)`. |
61
+ | `w0`, `w1` | Alice (both), Bob (`w0` only) | Two scalars derived from `mhfOutput`. `w1` is Alice's private key; `w0` is shared. |
62
+ | `L` | Bob only | `w1 · G` — a public point that proves Bob knows `w1` was once committed to, without revealing `w1`. |
63
+ | `x`, `y` | Alice / Bob (ephemeral) | Fresh random scalars sampled once per session. Discarded after the exchange. |
64
+ | `shareP`, `shareV` | Sent over the wire | Elliptic-curve points: `x·G + w0·M` and `y·G + w0·N`. Look uniformly random to an observer. |
65
+ | `Z`, `V` | Each side derives independently | Two more points that only agree if both sides started from the same password. The whole protocol's security hinges on these. |
66
+ | `K_main`, `K_confirmP`, `K_confirmV`, `K_shared` | Each side derives independently | Keys produced by hashing the full transcript. Identical on both sides iff the password was right. |
67
+ | `confirmP`, `confirmV` | Sent over the wire | HMACs that let each side prove to the other "I derived the same keys you did." |
68
+
69
+ `M` and `N` in the formulas above are two fixed group generators defined in RFC 9383 Appendix A for the P-256 suite. pake-js hard-codes them in [src/spake2plus/p256.ts](./src/spake2plus/p256.ts) and gates them with the RFC test vectors.
70
+
71
+ ### Stage 1: Registration — runs exactly once, when Alice creates her account
72
+
73
+ Alice's client never ships the password to Bob. Instead it runs a **memory-hard key-derivation function** (Argon2id or scrypt, with a per-user salt) and feeds the result into `spake2plus.p256.deriveScalars`. This gives her two scalars, `w0` and `w1`.
74
+
75
+ She keeps both scalars client-side (or re-derives them from the password on every login). She computes `L = w1·G` and sends `(w0, L)` to Bob over an **already-authenticated** channel — registration is not the moment SPAKE2+ protects against MITM; you need TLS (or an out-of-band confirmation code) to get the verifier into Bob's database safely.
76
+
77
+ Bob stores `(w0, L)` against Alice's account. Importantly: Bob never stores `w1` and never sees the password. If his database is stolen tomorrow, the attacker gets `w0` and `L` — but recovering the password from those still requires running the memory-hard function over every guess, which is exactly the work factor Alice's MHF choice bought her.
40
78
 
41
79
  ```ts
42
80
  import { spake2plus } from "@cipherman/pake-js";
43
81
 
44
- // ---- 1. Registration (server-side, one-time) ---------------------------
45
- // You run your memory-hard function first. pake-js does NOT bundle an MHF
46
- // use scrypt, Argon2id, or PBKDF2 with a strong iteration count. Output must
47
- // be >= 80 bytes.
48
- const mhfOutput = await yourMhf(password, { salt: perUserSalt, dkLen: 80 });
82
+ // pake-js does NOT bundle an MHF on purpose — pick scrypt, Argon2id, or PBKDF2
83
+ // with a strong iteration count. Output must be >= 80 bytes (2 * (32 + 8),
84
+ // per the k=64-bit safety margin in RFC 9383 §4).
85
+ const mhfOutput = await runYourMhf(
86
+ "correct horse battery staple", // the password
87
+ perUserSalt, // 16+ bytes, stored server-side per account
88
+ 80, // output length
89
+ );
49
90
 
50
91
  const { w0, w1 } = spake2plus.p256.deriveScalars(mhfOutput);
51
92
  const L = spake2plus.p256.registerVerifier(w1);
52
- // Server stores (w0, L). Client keeps (w0, w1) — or re-derives per session.
53
93
 
54
- // ---- 2. Protocol exchange ----------------------------------------------
55
- const client = spake2plus.p256.clientStart(w0);
56
- send(client.shareP); // -> server
94
+ // Ship (w0, L) to Bob over a channel that is already authenticated (TLS is fine).
95
+ // Bob's database:
96
+ // users[alice].w0 = w0;
97
+ // users[alice].L = L;
98
+ // Alice keeps w0 and w1 — or re-derives them from the password on every login.
99
+ ```
100
+
101
+ ### Stage 2: Login round — Alice starts the exchange
102
+
103
+ On every login attempt, Alice samples a fresh random scalar `x` and computes `shareP = x·G + w0·M`. That point is what she sends to Bob. The `w0·M` term is what makes SPAKE2+ a PAKE instead of plain Diffie-Hellman: it binds the handshake to the password, but in a way that doesn't let anyone recover `w0` from observing `shareP`.
104
+
105
+ ```ts
106
+ const alice = spake2plus.p256.clientStart(w0);
107
+ // alice.x — Alice's private ephemeral scalar (keep this local, discard after use)
108
+ // alice.shareP — the 65-byte uncompressed point to send on the wire
109
+
110
+ sendToBob(alice.shareP);
111
+ ```
112
+
113
+ ### Stage 3: Bob responds
114
+
115
+ When Bob receives `shareP`, he looks up Alice's `(w0, L)` in his database, samples his own fresh random scalar `y`, and computes three things:
116
+
117
+ - `shareV = y·G + w0·N` — his public half of the exchange, which he sends back to Alice.
118
+ - `Z = y·(shareP − w0·M)` — a shared point that only equals Alice's version if she started from the same `w0`.
119
+ - `V = y·L` — a second shared point that only equals Alice's version if she started from the same `w1`. This is the "+" in SPAKE2**+**: it binds the final key to a proof that Alice knows `w1`, not just `w0`, which is what prevents a server-database compromise from letting the attacker impersonate Alice.
120
+
121
+ Bob returns `shareV` over the wire but keeps `Z` and `V` local.
122
+
123
+ ```ts
124
+ const bob = spake2plus.p256.serverRespond({
125
+ w0, // users[alice].w0 from Bob's DB
126
+ L, // users[alice].L from Bob's DB
127
+ shareP: alice.shareP,
128
+ });
129
+ // bob.y, bob.shareV, bob.Z, bob.V
130
+
131
+ sendToAlice(bob.shareV);
132
+ ```
133
+
134
+ ### Stage 4: Alice finishes the handshake
57
135
 
58
- const server = spake2plus.p256.serverRespond({ w0, L, shareP: client.shareP });
59
- send(server.shareV); // -> client
136
+ Alice takes Bob's `shareV` and computes her own `Z` and `V` using her private `x` and `w1`. If the password was right, her `(Z, V)` exactly matches Bob's `(Z, V)` — bit for bit. If the password was wrong, they diverge, and every key derived from them will diverge in the next step.
60
137
 
61
- const cf = spake2plus.p256.clientFinish({
138
+ Alice never sends `Z` or `V` on the wire. They stay local.
139
+
140
+ ```ts
141
+ const aliceZV = spake2plus.p256.clientFinish({
62
142
  w0,
63
143
  w1,
64
- x: client.x,
65
- shareV: server.shareV,
144
+ x: alice.x,
145
+ shareV: bob.shareV,
66
146
  });
147
+ // aliceZV.Z, aliceZV.V
148
+ ```
149
+
150
+ ### Stage 5: Key derivation — the full transcript
151
+
152
+ Now both sides build a **transcript** — an unambiguous concatenation of everything the protocol touched: the app context, the two identities, the fixed M and N, both public shares, both derived points, and `w0`. RFC 9383 §4 specifies the layout down to the byte; pake-js does it for you in `deriveKeys`.
153
+
154
+ The transcript gets hashed to `K_main`, then `K_main` feeds HKDF twice: once with the label `"ConfirmationKeys"` to produce the pair `(K_confirmP, K_confirmV)` used for mutual authentication, and once with the label `"SharedKey"` to produce `K_shared`, the session key.
67
155
 
68
- // ---- 3. Key derivation (both sides) ------------------------------------
69
- const base = {
70
- context: new TextEncoder().encode("my-app v1"),
156
+ `K_shared` is 32 bytes (SHA-256 output length for this suite). **Do not use it as an AEAD key directly.** Feed it through one more HKDF with an application-specific label so that rotating the application label rotates the session keys without re-running the whole PAKE.
157
+
158
+ ```ts
159
+ const transcriptFields = {
160
+ context: new TextEncoder().encode("cipherman-demo v1"),
71
161
  idProver: new TextEncoder().encode("alice@example.test"),
72
- idVerifier: new TextEncoder().encode("server.example.test"),
162
+ idVerifier: new TextEncoder().encode("bob.example.test"),
73
163
  w0,
74
- shareP: client.shareP,
75
- shareV: server.shareV,
164
+ shareP: alice.shareP,
165
+ shareV: bob.shareV,
76
166
  };
77
167
 
78
- const clientKeys = spake2plus.p256.deriveKeys({ ...base, Z: cf.Z, V: cf.V });
79
- const serverKeys = spake2plus.p256.deriveKeys({ ...base, Z: server.Z, V: server.V });
168
+ const aliceKeys = spake2plus.p256.deriveKeys({
169
+ ...transcriptFields,
170
+ Z: aliceZV.Z,
171
+ V: aliceZV.V,
172
+ });
173
+
174
+ const bobKeys = spake2plus.p256.deriveKeys({
175
+ ...transcriptFields,
176
+ Z: bob.Z,
177
+ V: bob.V,
178
+ });
179
+
180
+ // If Alice typed the password correctly, these are now equal:
181
+ // aliceKeys.K_main == bobKeys.K_main
182
+ // aliceKeys.K_confirmP == bobKeys.K_confirmP
183
+ // aliceKeys.K_confirmV == bobKeys.K_confirmV
184
+ // aliceKeys.K_shared == bobKeys.K_shared
185
+ // If she didn't, all four pairs diverge. Neither side yet knows which case they're in.
186
+ ```
187
+
188
+ ### Stage 6: Key confirmation
189
+
190
+ Neither party has any cryptographic evidence that the *other* party got to the same `K_shared`. That evidence comes from the last exchange: each side sends a short MAC proving "I derived the same confirmation keys you did."
80
191
 
81
- // ---- 4. Key confirmation ------------------------------------------------
82
- if (
83
- !spake2plus.p256.verifyConfirmation(clientKeys.confirmV, serverKeys.confirmV)
84
- ) {
85
- throw new Error("server confirmation failed");
192
+ Specifically, per RFC 9383 §4:
193
+
194
+ - `confirmP = HMAC(K_confirmP, shareV)` — Alice MACs Bob's share with the first confirmation key.
195
+ - `confirmV = HMAC(K_confirmV, shareP)` — Bob MACs Alice's share with the second.
196
+
197
+ Each side sends its MAC; each side verifies the incoming MAC in constant time. A mismatch on either side means one of three things: wrong password, a bug, or an active MITM. In all three cases the session must be aborted — and because the attacker learned nothing, all they accomplished was one failed online guess.
198
+
199
+ ```ts
200
+ // Alice -> Bob: aliceKeys.confirmP
201
+ // Bob -> Alice: bobKeys.confirmV
202
+
203
+ const bobAcceptsAlice = spake2plus.p256.verifyConfirmation(
204
+ bobKeys.confirmP, // what Bob expects Alice to have computed
205
+ receivedFromAlice, // what actually arrived on the wire
206
+ );
207
+ const aliceAcceptsBob = spake2plus.p256.verifyConfirmation(
208
+ aliceKeys.confirmV, // what Alice expects Bob to have computed
209
+ receivedFromBob,
210
+ );
211
+
212
+ if (!bobAcceptsAlice) {
213
+ throw new Error("Bob: Alice's key confirmation failed — wrong password or MITM");
214
+ }
215
+ if (!aliceAcceptsBob) {
216
+ throw new Error("Alice: Bob's key confirmation failed — wrong password or MITM");
86
217
  }
87
- // clientKeys.K_shared === serverKeys.K_shared (32 bytes for SHA-256).
88
- // Feed K_shared into an application-level KDF; do not use as a session key directly.
218
+
219
+ // Past this line, Alice and Bob are mutually authenticated AND share a 32-byte key.
220
+ // aliceKeys.K_shared and bobKeys.K_shared are identical. Use them via an
221
+ // application-level HKDF to derive the actual transport keys:
222
+ //
223
+ // const transportKey = hkdf(sha256, aliceKeys.K_shared, /* salt */ sid, /* info */ utf8("transport/v1"), 32);
89
224
  ```
90
225
 
91
- ## CPace example (Ristretto255 / SHA-512)
226
+ ### What goes over the wire, and what doesn't
227
+
228
+ Exactly four values cross the network in a full SPAKE2+ exchange:
229
+
230
+ 1. `shareP` — Alice → Bob (65 bytes uncompressed P-256)
231
+ 2. `shareV` — Bob → Alice (65 bytes uncompressed P-256)
232
+ 3. `confirmP` — Alice → Bob (32 bytes HMAC-SHA-256)
233
+ 4. `confirmV` — Bob → Alice (32 bytes HMAC-SHA-256)
234
+
235
+ Everything else — the password, `mhfOutput`, `w0`, `w1`, `x`, `y`, `Z`, `V`, `K_main`, `K_confirmP`, `K_confirmV`, `K_shared` — stays on the machine that computed it. A passive attacker capturing the full exchange sees four byte strings that look uniformly random. An active attacker who injects, replays, or reorders messages causes the confirmation step to fail; they gain nothing beyond the knowledge that *this attempt* failed, which is why rate-limiting at the application layer is what turns "at most one guess per session" into a meaningful security property.
236
+
237
+ ## Alice and Bob pair a Bluetooth device (CPace / Ristretto255 / SHA-512)
238
+
239
+ CPace is the **balanced** PAKE in pake-js. Unlike SPAKE2+, there is no client and no server — there are just two peers who happen to share a low-entropy secret (a 6-digit pairing code, a PIN, a pre-shared phrase) and want to turn that secret into a strong 64-byte key. The full runnable version lives alongside the SPAKE2+ one in [test/examples/alice-bob.test.ts](./test/examples/alice-bob.test.ts).
240
+
241
+ ### When to reach for CPace instead of SPAKE2+
242
+
243
+ - You're pairing two physical devices that briefly display or exchange a code (BLE pairing, QR-code enrollment, NFC tap-to-pair).
244
+ - There's no notion of a "database of verifiers" — the shared secret is generated on the fly and used once.
245
+ - Both sides are equally trusted. Neither is enrolling the other; they're meeting as equals.
246
+ - The session ID (`sid`) can be agreed out of band — typically both sides already have it from the discovery phase.
247
+
248
+ CPace in pake-js uses **Ristretto255** as the group and **SHA-512** as the hash. The ciphersuite name is `CPACE-RISTR255-SHA512` and the whole implementation is gated by the draft-20 Appendix B.3 test vectors.
249
+
250
+ ### The inputs both sides need to already have
251
+
252
+ | Value | How it's obtained | Required |
253
+ | --- | --- | --- |
254
+ | `PRS` | "Password-Related String" — the shared low-entropy secret. Ideally the output of a memory-hard KDF over the password, but a PIN-encoded-as-bytes also works for short-lived pairing flows. | yes |
255
+ | `sid` | Session identifier. 16+ bytes of fresh randomness both sides agree on (exchanged during discovery, or constructed from `random_a ∥ random_b`). Ensures every session derives a unique key. | yes |
256
+ | `CI` | Channel identifier. Optional binding to the underlying transport (e.g. the BLE link layer keys, a TLS exporter). Empty if you don't need it. | no |
257
+ | `ADa`, `ADb` | Associated data each side wants baked into the transcript (e.g. device serial numbers, roles). Empty by default. | no |
258
+
259
+ ### Stage 1: Each party independently computes its public share
260
+
261
+ Both sides hash `(PRS, sid, CI)` into a common point `g` on the Ristretto255 group — this is CPace's "calculate_generator" step, and it's the piece that binds the handshake to the shared secret without letting an observer recover it.
262
+
263
+ Each party then samples a fresh random scalar (the `ephemeralSecret`) and publishes `share = ephemeralSecret · g`. Because `g` depends on `PRS`, only parties who know the same `PRS` will land on the same curve, and only they can complete the handshake.
92
264
 
93
265
  ```ts
94
266
  import { cpace } from "@cipherman/pake-js";
95
267
 
96
- const PRS = await yourMhf(password, { salt });
97
- const sid = await agreedSessionId(); // 16+ bytes of agreed randomness
98
- const CI = new TextEncoder().encode("tls-exporter:abc123"); // optional channel binding
268
+ const PRS = new TextEncoder().encode("pairing code: 482913"); // or MHF output
269
+ const sid = await agreedSessionId(); // 16+ bytes of freshness, exchanged during discovery
270
+ const CI = new TextEncoder().encode("ble-pairing:alice<->bob"); // optional channel binding
271
+
272
+ // Each party runs init independently. Both use the same (PRS, sid, CI).
273
+ const alice = cpace.ristretto255.init({ PRS, sid, CI });
274
+ const bob = cpace.ristretto255.init({ PRS, sid, CI });
275
+
276
+ // alice.ephemeralSecret — 32 bytes, keep local, discard after the handshake
277
+ // alice.share — 32 bytes Ristretto255 point, send to Bob
278
+
279
+ sendToBob(alice.share);
280
+ sendToAlice(bob.share);
281
+ ```
282
+
283
+ ### Stage 2: Each side derives the ISK
284
+
285
+ Once both shares are exchanged, each party computes the shared secret `K = ephemeralSecret · peerShare`, then hashes a transcript containing the domain-separation tag `"CPaceRistretto255_ISK"`, `sid`, `K`, and both `(share, AD)` pairs. The result — the **Intermediate Session Key**, or ISK — is 64 bytes of SHA-512 output.
286
+
287
+ CPace defines two ordering rules for the transcript, and pake-js exposes both:
99
288
 
100
- // Each party independently:
101
- const me = cpace.ristretto255.init({ PRS, sid, CI });
102
- send(me.share);
289
+ - **Initiator / responder** (`deriveIskInitiatorResponder`): one party declared itself the initiator when they started the exchange (maybe because they sent the first packet). The initiator's `(share, AD)` goes first in the transcript; the responder's goes second. Both sides must agree on who was which.
290
+ - **Symmetric** (`deriveIskSymmetric`): neither side has a role. This happens when, say, two BLE devices simultaneously broadcast discovery packets and there's no natural "first mover". Each side concatenates its own `(share, AD)` and the peer's in a lexicographic (order-independent) way, so both parties land on the same ISK without any coordination about who was first.
103
291
 
104
- // After receiving the peer's share:
105
- const isk = cpace.ristretto255.deriveIskInitiatorResponder({
106
- ephemeralSecret: me.ephemeralSecret,
107
- ownShare: me.share,
108
- peerShare: receivedFromPeer,
292
+ The example below uses the initiator/responder form, which is what most pairing flows actually look like (one device scans, the other is discovered).
293
+
294
+ ```ts
295
+ const aliceIsk = cpace.ristretto255.deriveIskInitiatorResponder({
296
+ ephemeralSecret: alice.ephemeralSecret,
297
+ ownShare: alice.share,
298
+ peerShare: receivedFromBob,
299
+ ownAD: new TextEncoder().encode("alice-phone"),
300
+ peerAD: new TextEncoder().encode("bob-earbuds"),
109
301
  sid,
110
302
  role: "initiator",
111
303
  });
112
- // isk is 64 bytes of SHA-512 output.
304
+
305
+ const bobIsk = cpace.ristretto255.deriveIskInitiatorResponder({
306
+ ephemeralSecret: bob.ephemeralSecret,
307
+ ownShare: bob.share,
308
+ peerShare: receivedFromAlice,
309
+ ownAD: new TextEncoder().encode("bob-earbuds"),
310
+ peerAD: new TextEncoder().encode("alice-phone"),
311
+ sid,
312
+ role: "responder",
313
+ });
314
+
315
+ // aliceIsk.length === bobIsk.length === 64
316
+ // If both sides used the same PRS, sid, and CI: aliceIsk === bobIsk
317
+ // If anything differed by a single byte: they diverge completely
318
+ ```
319
+
320
+ ### CPace worked example: every byte, step by step
321
+
322
+ This section walks through a *complete* CPace Ristretto255 exchange using the official test vector from [draft-irtf-cfrg-cpace-20 §B.3](https://www.ietf.org/archive/id/draft-irtf-cfrg-cpace-20.txt). Every hex value below is from the draft text and is asserted byte-for-byte by [test/vectors/cpace-ristretto255-sha512.test.ts](./test/vectors/cpace-ristretto255-sha512.test.ts) against pake-js. You can run `npm run test:vectors` and watch it pass.
323
+
324
+ Normally you would **never** know the ephemeral scalars `ya` and `yb` ahead of time — they are freshly sampled randomness that gets wiped at the end of the session. The only reason we know them here is that the spec needs fixed inputs to produce a reproducible vector. Treat this walkthrough as "what is happening inside the black box," not as how you would call the public API.
325
+
326
+ #### Inputs — what Alice and Bob both know going in
327
+
328
+ ```text
329
+ PRS (8 bytes) = 50 61 73 73 77 6f 72 64 # ASCII: "Password"
330
+ CI (24 bytes) = 0b 41 5f 69 6e 69 74 69 61 74 6f 72
331
+ 0b 42 5f 72 65 73 70 6f 6e 64 65 72 # channel identifier
332
+ sid (16 bytes) = 7e 4b 47 91 d6 a8 ef 01 9b 93 6c 79 fb 7f 2c 57 # session id
333
+ ADa (3 bytes) = 41 44 61 # ASCII: "ADa"
334
+ ADb (3 bytes) = 41 44 62 # ASCII: "ADb"
335
+ ```
336
+
337
+ Both parties feed the same `PRS`, `CI`, `sid` into the protocol. The `ADa` / `ADb` values are small pieces of "who I am" that each party binds into its side of the transcript.
338
+
339
+ #### Step 1 — Build the `generator_string` (170 bytes)
340
+
341
+ CPace concatenates the domain-separation tag `"CPaceRistretto255"`, the `PRS`, a carefully-sized zero-pad block, the `CI`, and the `sid`, each prefixed with its LEB128 length. The zero-pad is sized so that the first hash block (128 bytes for SHA-512) is filled exactly by the domain tag, the `PRS`, and the padding — this prevents length-extension-style confusion between the secret `PRS` and the public fields.
342
+
343
+ ```text
344
+ generator_string (170 bytes) =
345
+ 11 # leb128(17)
346
+ 43 50 61 63 65 52 69 73 74 72 65 74 74 6f 32 35 35 # "CPaceRistretto255"
347
+ 08 # leb128(8)
348
+ 50 61 73 73 77 6f 72 64 # "Password"
349
+ 64 # leb128(100): zero-pad length
350
+ 00 × 100 # 100 bytes of zero-padding
351
+ 18 # leb128(24)
352
+ 0b 41 5f 69 6e 69 74 69 61 74 6f 72
353
+ 0b 42 5f 72 65 73 70 6f 6e 64 65 72 # CI
354
+ 10 # leb128(16)
355
+ 7e 4b 47 91 d6 a8 ef 01 9b 93 6c 79 fb 7f 2c 57 # sid
356
+ ```
357
+
358
+ Hashed through SHA-512, this produces 64 bytes of uniformly-random-looking output:
359
+
360
+ ```text
361
+ SHA-512(generator_string) =
362
+ da 6d 3d dc 88 02 fc a9 05 87 55 ff d3 eb de 08
363
+ a9 c2 c7 49 45 90 1a 25 84 82 a2 88 b6 66 3a f0
364
+ 6b f6 45 c9 3c d1 c5 15 12 30 71 99 c8 0e 84 90
365
+ 89 16 d9 83 b3 4a f7 72 05 f9 08 51 a6 57 ee 27
366
+ ```
367
+
368
+ #### Step 2 — Map those 64 bytes onto the Ristretto255 curve → `g`
369
+
370
+ The 64-byte hash is fed into the Ristretto255 **one-way map** (RFC 9496 §4). It deterministically produces a point on the curve, encoded as 32 bytes:
371
+
372
+ ```text
373
+ g (32 bytes) = 22 2b 6b 19 5f e8 4b 16 52 ba db 6f 6a 3a e3 d2
374
+ 43 41 e7 30 69 67 f0 b8 11 5b 40 d5 69 8c 7e 56
375
+ ```
376
+
377
+ **This is the "tinted starting point" from the plain-English explanation.** Both Alice and Bob compute exactly this `g` from the same inputs. Anybody else — anyone who does not know `PRS` — would derive a *different* `g` and from then on would be doing arithmetic on the wrong curve element.
378
+
379
+ #### Step 3 — Alice rolls a random scalar `ya`, sends `Ya = ya · g`
380
+
381
+ Alice's ephemeral secret (little-endian, 32 bytes):
382
+
383
+ ```text
384
+ ya = da 3d 23 70 0a 9e 56 99 25 8a ef 94 dc 06 0d fd
385
+ a5 eb b6 1f 02 a5 ea 77 fa d5 3f 4f f0 97 6d 08
386
+ ```
387
+
388
+ She keeps `ya` private forever. She computes `Ya = ya · g` and sends only `Ya`:
389
+
390
+ ```text
391
+ Ya (32 bytes) = d6 ba c4 80 f2 c3 86 c3 94 ef c7 c4 7a db 99 25
392
+ dc d2 63 0b 64 f2 40 c5 0f 8d 0e ec 48 2b 91 57
393
+ ```
394
+
395
+ #### Step 4 — Bob rolls a random scalar `yb`, sends `Yb = yb · g`
396
+
397
+ Bob's ephemeral secret (also little-endian, 32 bytes):
398
+
399
+ ```text
400
+ yb = d2 31 6b 45 47 18 c3 53 62 d8 3d 69 df 63 20 f3
401
+ 85 78 ed 59 84 65 14 35 e2 94 97 62 d9 00 b8 0d
402
+ ```
403
+
404
+ Bob keeps `yb` private forever. He sends `Yb`:
405
+
406
+ ```text
407
+ Yb (32 bytes) = 3e a7 e0 b1 95 60 d7 c0 b0 f5 73 4f 63 b9 55 28
408
+ 6d fa 82 32 b5 eb e6 33 24 e2 d9 e7 43 3f 72 58
113
409
  ```
114
410
 
115
- A symmetric (order-independent) variant is available via `deriveIskSymmetric`.
411
+ #### Step 5 Both sides compute the same shared point `K`
412
+
413
+ Alice computes `K = ya · Yb` (her own secret times Bob's public share). Bob computes `K = yb · Ya` (his own secret times Alice's public share). Because scalar multiplication on an abelian group commutes, both sides arrive at the **same** 32-byte point:
414
+
415
+ ```text
416
+ K (32 bytes) = 80 b6 9a 8a 76 45 7a b6 a4 d7 f8 87 a4 bf 6b 55
417
+ a2 f8 0a c1 9c 33 3f 91 7a 05 fc 98 87 c8 b4 0f
418
+ ```
419
+
420
+ Spec abort condition: if `K` is the identity element, either the peer's share was invalid or someone is trying to break the protocol. pake-js throws in that case. Here `K` is clearly non-identity, so both sides continue.
421
+
422
+ #### Step 6 — Build the transcript and hash it to get the 64-byte `ISK`
423
+
424
+ In the initiator/responder setting, both sides compute:
425
+
426
+ ```text
427
+ transcript = lv_cat( "CPaceRistretto255_ISK" , sid , K )
428
+ || lv_cat( Ya , ADa )
429
+ || lv_cat( Yb , ADb )
430
+ ```
431
+
432
+ Plugging in the real bytes, the full pre-hash input is 146 bytes:
433
+
434
+ ```text
435
+ prefix (43 bytes):
436
+ 15 # leb128(21)
437
+ 43 50 61 63 65 52 69 73 74 72 65 74 74 6f 32 35 35
438
+ 5f 49 53 4b # "CPaceRistretto255_ISK"
439
+ 10 # leb128(16)
440
+ 7e 4b 47 91 d6 a8 ef 01 9b 93 6c 79 fb 7f 2c 57 # sid
441
+ 20 # leb128(32)
442
+ 80 b6 9a 8a 76 45 7a b6 a4 d7 f8 87 a4 bf 6b 55
443
+ a2 f8 0a c1 9c 33 3f 91 7a 05 fc 98 87 c8 b4 0f # K
444
+
445
+ initiator half (37 bytes):
446
+ 20 # leb128(32)
447
+ d6 ba c4 80 f2 c3 86 c3 94 ef c7 c4 7a db 99 25
448
+ dc d2 63 0b 64 f2 40 c5 0f 8d 0e ec 48 2b 91 57 # Ya
449
+ 03 # leb128(3)
450
+ 41 44 61 # ADa
451
+
452
+ responder half (37 bytes):
453
+ 20 # leb128(32)
454
+ 3e a7 e0 b1 95 60 d7 c0 b0 f5 73 4f 63 b9 55 28
455
+ 6d fa 82 32 b5 eb e6 33 24 e2 d9 e7 43 3f 72 58 # Yb
456
+ 03 # leb128(3)
457
+ 41 44 62 # ADb
458
+ ```
459
+
460
+ Feed all 146 bytes through SHA-512 and you get the 64-byte **ISK** — the Intermediate Session Key that both Alice and Bob land on:
461
+
462
+ ```text
463
+ ISK_IR (64 bytes) =
464
+ b6 9e ff bf 61 b5 1d 56 40 1c 0f 65 60 1a be 42
465
+ 8d e8 20 6f ea af 0e 32 19 88 96 dc ae 7b 35 cd
466
+ 2b 38 95 0a 39 df d5 d4 a7 91 64 61 4c 29 84 f7
467
+ da a4 60 b5 88 c1 e8 0c 3f a2 06 8a f7 90 04 47
468
+ ```
469
+
470
+ That's the handshake done. Alice used `ya` and Bob's `Yb`; Bob used `yb` and Alice's `Ya`; they both hashed the same transcript bytes and got the same 64 bytes of session key.
471
+
472
+ #### Quick sanity comparison
473
+
474
+ Here's what an observer on the wire saw for the entire session:
475
+
476
+ | Direction | Bytes | What it looks like |
477
+ | --- | --- | --- |
478
+ | Alice → Bob | `Ya` (32 B) | `d6bac480…482b9157` |
479
+ | Bob → Alice | `Yb` (32 B) | `3ea7e0b1…433f7258` |
480
+
481
+ That's it. **64 bytes total, both statistically indistinguishable from random.** No `PRS`, no `ya`, no `yb`, no `g`, no `K`, no `ISK`. An attacker capturing this stream learns nothing they can run through a password cracker later; the only thing they can do is actively play man-in-the-middle and try to complete the handshake with a *guessed* `PRS`, which fails unless they guess right on the first try.
482
+
483
+ #### Reproduce it yourself
484
+
485
+ ```bash
486
+ # Run just the CPace vector test — every assertion below is checked byte-for-byte.
487
+ npx vitest run test/vectors/cpace-ristretto255-sha512.test.ts
488
+ ```
489
+
490
+ Or in plain code (using the internal deterministic helpers that the vector test uses):
491
+
492
+ ```ts
493
+ import {
494
+ __calculateGeneratorEncoded,
495
+ __initWithScalar,
496
+ deriveIskInitiatorResponder,
497
+ } from "@cipherman/pake-js/cpace";
498
+
499
+ const hex = (s: string) => new Uint8Array(s.match(/../g)!.map((b) => parseInt(b, 16)));
500
+ const leScalar = (h: string) => {
501
+ const b = hex(h);
502
+ let x = 0n;
503
+ for (let i = b.length - 1; i >= 0; i--) x = (x << 8n) | BigInt(b[i]!);
504
+ return x;
505
+ };
506
+
507
+ const PRS = hex("50617373776f7264");
508
+ const CI = hex("0b415f696e69746961746f720b425f726573706f6e646572");
509
+ const sid = hex("7e4b4791d6a8ef019b936c79fb7f2c57");
510
+ const ADa = hex("414461");
511
+ const ADb = hex("414462");
512
+ const ya = leScalar("da3d23700a9e5699258aef94dc060dfda5ebb61f02a5ea77fad53f4ff0976d08");
513
+ const yb = leScalar("d2316b454718c35362d83d69df6320f38578ed5984651435e2949762d900b80d");
514
+
515
+ console.log("g =", toHex(__calculateGeneratorEncoded(PRS, CI, sid)));
516
+ // -> 222b6b195fe84b1652badb6f6a3ae3d24341e7306967f0b8115b40d5698c7e56
517
+
518
+ const alice = __initWithScalar({ PRS, sid, CI }, ya);
519
+ const bob = __initWithScalar({ PRS, sid, CI }, yb);
520
+
521
+ console.log("Ya =", toHex(alice.share));
522
+ // -> d6bac480f2c386c394efc7c47adb9925dcd2630b64f240c50f8d0eec482b9157
523
+ console.log("Yb =", toHex(bob.share));
524
+ // -> 3ea7e0b19560d7c0b0f5734f63b955286dfa8232b5ebe63324e2d9e7433f7258
525
+
526
+ const iskAlice = deriveIskInitiatorResponder({
527
+ ephemeralSecret: alice.ephemeralSecret,
528
+ ownShare: alice.share,
529
+ peerShare: bob.share,
530
+ ownAD: ADa,
531
+ peerAD: ADb,
532
+ sid,
533
+ role: "initiator",
534
+ });
535
+ console.log("ISK =", toHex(iskAlice));
536
+ // -> b69effbf61b51d56401c0f65601abe428de8206feaaf0e32198896dcae7b35cd
537
+ // 2b38950a39dfd5d4a79164614c2984f7daa460b588c1e80c3fa2068af7900447
538
+ ```
539
+
540
+ The `__initWithScalar` / `__calculateGeneratorEncoded` helpers are prefixed with `__` because they are **internal, for deterministic tests only** — production code must use `cpace.ristretto255.init()` which samples its scalars from the platform CSPRNG. They are documented here purely so you can verify the spec vector against the library on your own machine.
541
+
542
+ ### CPace-specific notes
543
+
544
+ - **There is no explicit key confirmation step in vanilla CPace.** Confirmation is implicit: if both sides compute the same ISK, the first application-level message encrypted under the ISK will either decrypt correctly (success) or fail the AEAD tag check (abort). You can build an explicit challenge/response on top if your application needs a dedicated round before sending payload.
545
+ - **`scalar_mult_vfy` aborts on two specific inputs**: an undecodable Ristretto255 encoding and the identity element. pake-js throws in both cases; handle the throw as "peer is malicious or broken, drop the session."
546
+ - **The shared secret stays in the Ristretto255 group.** CPace never exposes it to code that might compare bytes non-constant-time; the only way out is the ISK.
547
+ - **Reusing `sid` across sessions breaks the protocol's freshness guarantee.** If both sides re-derive the same `(PRS, sid)` they'll compute the same ISK, which is what you'd want for protocol-level testing but NOT in production. In production, `sid` must change on every session.
116
548
 
117
549
  ## Design rules
118
550
 
package/THREAT_MODEL.md CHANGED
@@ -77,7 +77,7 @@ Accepting the identity element as a peer share degrades SPAKE2+ to zero-entropy
77
77
 
78
78
  A silent fallback to `Math.random` on a constrained runtime would be catastrophic.
79
79
 
80
- **Mitigation**: `randomBytes` throws loudly if `globalThis.crypto.getRandomValues` is missing. No fallback. CI runs on Node 18, 20, 22 where WebCrypto is built-in.
80
+ **Mitigation**: `randomBytes` throws loudly if `globalThis.crypto.getRandomValues` is missing. No fallback. CI runs on Node 22 where WebCrypto is built-in.
81
81
 
82
82
  ### R6 — Supply chain
83
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cipherman/pake-js",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Auditable, standards-based Password-Authenticated Key Exchange (SPAKE2+ / CPace) for Node.js, Deno, Bun, and browsers. Built for regulated environments.",
5
5
  "keywords": [
6
6
  "pake",
@@ -58,7 +58,7 @@
58
58
  "LICENSE"
59
59
  ],
60
60
  "engines": {
61
- "node": ">=18"
61
+ "node": ">=22"
62
62
  },
63
63
  "sideEffects": false,
64
64
  "scripts": {
@@ -70,7 +70,7 @@
70
70
  "test:watch": "vitest",
71
71
  "test:vectors": "vitest run test/vectors",
72
72
  "test:coverage": "vitest run --coverage",
73
- "audit": "npm audit --audit-level=moderate",
73
+ "audit": "npm audit --audit-level=moderate --omit=dev",
74
74
  "size": "size-limit",
75
75
  "prepublishOnly": "npm run clean && npm run typecheck && npm run lint && npm run test && npm run build"
76
76
  },
@@ -82,12 +82,12 @@
82
82
  "@types/node": "^20.14.0",
83
83
  "@typescript-eslint/eslint-plugin": "^8.0.0",
84
84
  "@typescript-eslint/parser": "^8.0.0",
85
- "@vitest/coverage-v8": "^2.1.0",
85
+ "@vitest/coverage-v8": "^4.1.4",
86
86
  "eslint": "^9.0.0",
87
87
  "size-limit": "^11.1.6",
88
88
  "tsup": "^8.3.0",
89
89
  "typescript": "^5.6.0",
90
- "vitest": "^2.1.0"
90
+ "vitest": "^4.1.4"
91
91
  },
92
92
  "size-limit": [
93
93
  {