@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 +2 -2
- package/README.md +477 -45
- package/THREAT_MODEL.md +1 -1
- package/package.json +5 -5
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 ≥
|
|
17
|
-
- CI on Node
|
|
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 ≥
|
|
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+
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
65
|
-
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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("
|
|
162
|
+
idVerifier: new TextEncoder().encode("bob.example.test"),
|
|
73
163
|
w0,
|
|
74
|
-
shareP:
|
|
75
|
-
shareV:
|
|
164
|
+
shareP: alice.shareP,
|
|
165
|
+
shareV: bob.shareV,
|
|
76
166
|
};
|
|
77
167
|
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
//
|
|
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
|
-
|
|
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 =
|
|
97
|
-
const sid = await agreedSessionId(); // 16+ bytes of
|
|
98
|
-
const CI
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": ">=
|
|
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": "^
|
|
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": "^
|
|
90
|
+
"vitest": "^4.1.4"
|
|
91
91
|
},
|
|
92
92
|
"size-limit": [
|
|
93
93
|
{
|