···11+MIT License
22+33+Copyright (c) 2025 Thomas Gazagnaire
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+101
README.md
···11+# spake2
22+33+SPAKE2 and SPAKE2+ Password-Authenticated Key Exchange for OCaml.
44+55+## Overview
66+77+This library implements the SPAKE2 (RFC 9382) and SPAKE2+ protocols for
88+password-authenticated key exchange. These protocols allow two parties who
99+share a password to derive a strong shared secret key without revealing the
1010+password to eavesdroppers or allowing offline dictionary attacks.
1111+1212+- **SPAKE2**: Both parties derive the same values from the password
1313+- **SPAKE2+**: Augmented PAKE where the server stores only a verifier, not password-equivalent data
1414+1515+## Security Notice
1616+1717+This implementation uses Zarith for P-256 elliptic curve arithmetic, which is
1818+**not constant-time**. This means the implementation has timing side-channel
1919+vulnerabilities. For high-security deployments, consider using hardware security
2020+modules or ensuring operations occur on trusted networks only.
2121+2222+## Installation
2323+2424+```
2525+opam install spake2
2626+```
2727+2828+## Usage
2929+3030+### SPAKE2
3131+3232+```ocaml
3333+let password = "secret" in
3434+3535+(* Party A *)
3636+let state_a, msg_a = Spake2.init ~password `A in
3737+(* send msg_a to B, receive msg_b from B *)
3838+let key_a = Spake2.finish ~context:"myapp" state_a msg_b in
3939+4040+(* Party B *)
4141+let state_b, msg_b = Spake2.init ~password `B in
4242+(* send msg_b to A, receive msg_a from A *)
4343+let key_b = Spake2.finish ~context:"myapp" state_b msg_a in
4444+4545+(* key_a = key_b *)
4646+```
4747+4848+### SPAKE2+
4949+5050+```ocaml
5151+(* Setup: derive verifier data from password *)
5252+let salt = Spake2.Plus.generate_salt () in
5353+let iterations = 1000 in
5454+let w0, w1 = Spake2.Plus.derive_w ~password ~salt ~iterations in
5555+let l = Spake2.Plus.compute_l ~w1 in
5656+(* Server stores: w0, l, salt, iterations (NOT the password or w1) *)
5757+5858+(* Protocol run *)
5959+let context = "myapp" in
6060+let prover_state, pa = Spake2.Plus.prover_init ~w0 ~w1 ~context in
6161+let verifier_state, pb = Spake2.Plus.verifier_init ~w0 ~l ~context in
6262+6363+(* Exchange pa and pb *)
6464+let Ok (ke_prover, ca, _) = Spake2.Plus.prover_finish prover_state pb in
6565+let Ok (ke_verifier, cb, _) = Spake2.Plus.verifier_finish verifier_state pa in
6666+6767+(* ke_prover = ke_verifier *)
6868+(* ca and cb can be exchanged for key confirmation *)
6969+```
7070+7171+## API
7272+7373+### SPAKE2
7474+7575+- `Spake2.init ~password role` - Initialize protocol for `A or `B
7676+- `Spake2.finish ?context ?id_a ?id_b state peer_msg` - Complete protocol
7777+7878+### SPAKE2+
7979+8080+- `Spake2.Plus.derive_w ~password ~salt ~iterations` - Derive w0, w1 from password
8181+- `Spake2.Plus.compute_l ~w1` - Compute L for server storage
8282+- `Spake2.Plus.prover_init ~w0 ~w1 ~context` - Initialize as prover (client)
8383+- `Spake2.Plus.verifier_init ~w0 ~l ~context` - Initialize as verifier (server)
8484+- `Spake2.Plus.prover_finish state pb` - Complete as prover
8585+- `Spake2.Plus.verifier_finish state pa` - Complete as verifier
8686+8787+### P-256 Curve
8888+8989+- `Spake2.P256.scalar_mult k p` - Scalar multiplication
9090+- `Spake2.P256.add p q` - Point addition
9191+- `Spake2.P256.to_bytes p` - Encode point (SEC1 uncompressed)
9292+- `Spake2.P256.of_bytes s` - Decode and validate point
9393+9494+## References
9595+9696+- [RFC 9382 - SPAKE2](https://www.rfc-editor.org/rfc/rfc9382.html)
9797+- [SPAKE2+ Draft](https://datatracker.ietf.org/doc/draft-bar-cfrg-spake2plus/)
9898+9999+## License
100100+101101+MIT License. See [LICENSE.md](LICENSE.md) for details.
+32
dune-project
···11+(lang dune 3.0)
22+33+(name spake2)
44+55+(generate_opam_files true)
66+77+(license MIT)
88+(authors "Thomas Gazagnaire <thomas@gazagnaire.org>")
99+(maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>")
1010+(homepage "https://github.com/samoht/ocaml-spake2")
1111+(bug_reports "https://github.com/samoht/ocaml-spake2/issues")
1212+1313+(package
1414+ (name spake2)
1515+ (synopsis "SPAKE2 and SPAKE2+ Password-Authenticated Key Exchange")
1616+ (description
1717+ "Implementation of the SPAKE2 and SPAKE2+ protocols for password-authenticated
1818+ key exchange. SPAKE2 (RFC 9382) allows two parties who share a password to
1919+ derive a strong shared secret key, without revealing the password to
2020+ eavesdroppers or allowing offline dictionary attacks. SPAKE2+ adds verifier
2121+ storage security so the server doesn't store password-equivalent data.")
2222+ (depends
2323+ (ocaml (>= 4.08))
2424+ (zarith (>= 1.12))
2525+ (digestif (>= 1.2.0))
2626+ (kdf (>= 0.1))
2727+ (pbkdf2 (>= 0.1))
2828+ (crypto-rng (>= 1.0.0))
2929+ (crypto-ec (>= 1.0.0))
3030+ (logs (>= 0.7.0))
3131+ (alcotest :with-test)
3232+ (crowbar :with-test)))
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Crowbar
77+88+let () = Crypto_rng_unix.use_default ()
99+1010+let test_spake2_roundtrip password =
1111+ if String.length password = 0 then ()
1212+ else
1313+ let state_a, msg_a = Spake2.init ~password `A in
1414+ let state_b, msg_b = Spake2.init ~password `B in
1515+ match (Spake2.finish state_a msg_b, Spake2.finish state_b msg_a) with
1616+ | Ok key_a, Ok key_b -> check_eq ~pp:Format.pp_print_string key_a key_b
1717+ | Error e, _ -> failwith ("A failed: " ^ e)
1818+ | _, Error e -> failwith ("B failed: " ^ e)
1919+2020+let test_spake2_plus_roundtrip password salt =
2121+ if String.length password = 0 || String.length salt < 16 then ()
2222+ else
2323+ let iterations = 1000 in
2424+ let context = "fuzz" in
2525+ let w0, w1 = Spake2.Plus.derive_w ~password ~salt ~iterations in
2626+ let l = Spake2.Plus.compute_l ~w1 in
2727+ let prover_state, pa = Spake2.Plus.prover_init ~w0 ~w1 ~context in
2828+ let verifier_state, pb = Spake2.Plus.verifier_init ~w0 ~l ~context in
2929+ match
3030+ ( Spake2.Plus.prover_finish prover_state pb,
3131+ Spake2.Plus.verifier_finish verifier_state pa )
3232+ with
3333+ | Ok (ke_p, _, _), Ok (ke_v, _, _) ->
3434+ check_eq ~pp:Format.pp_print_string ke_p ke_v
3535+ | Error e, _ -> failwith ("prover failed: " ^ e)
3636+ | _, Error e -> failwith ("verifier failed: " ^ e)
3737+3838+let test_p256_point_encoding_roundtrip x_bytes y_bytes =
3939+ if String.length x_bytes < 32 || String.length y_bytes < 32 then ()
4040+ else
4141+ let x_bytes = String.sub x_bytes 0 32 in
4242+ let y_bytes = String.sub y_bytes 0 32 in
4343+ let encoded = "\x04" ^ x_bytes ^ y_bytes in
4444+ match Spake2.P256.of_bytes encoded with
4545+ | Ok point ->
4646+ let re_encoded = Spake2.P256.to_bytes point in
4747+ check_eq ~pp:Format.pp_print_string encoded re_encoded
4848+ | Error _ -> ()
4949+5050+let test_p256_negate_involutive () =
5151+ (* Test that negate(negate(G)) = G *)
5252+ let g = Spake2.P256.generator in
5353+ let neg_g = Spake2.P256.negate g in
5454+ let neg_neg_g = Spake2.P256.negate neg_g in
5555+ let g_bytes = Spake2.P256.to_bytes g in
5656+ let result_bytes = Spake2.P256.to_bytes neg_neg_g in
5757+ check_eq ~pp:Format.pp_print_string g_bytes result_bytes
5858+5959+let test_p256_add_commutative () =
6060+ (* Test that G + M = M + G *)
6161+ let g = Spake2.P256.generator in
6262+ let m = Spake2.P256.m in
6363+ let g_plus_m = Spake2.P256.add g m in
6464+ let m_plus_g = Spake2.P256.add m g in
6565+ let bytes1 = Spake2.P256.to_bytes g_plus_m in
6666+ let bytes2 = Spake2.P256.to_bytes m_plus_g in
6767+ check_eq ~pp:Format.pp_print_string bytes1 bytes2
6868+6969+let () =
7070+ add_test ~name:"spake2: roundtrip" [ bytes ] (fun password ->
7171+ test_spake2_roundtrip password);
7272+ add_test ~name:"spake2+: roundtrip" [ bytes; bytes ] (fun password salt ->
7373+ test_spake2_plus_roundtrip password salt);
7474+ add_test ~name:"p256: point encoding roundtrip" [ bytes; bytes ] (fun x y ->
7575+ test_p256_point_encoding_roundtrip x y);
7676+ (* Run constant property tests once with dummy input *)
7777+ add_test ~name:"p256: negate involutive"
7878+ [ const () ]
7979+ (fun () -> test_p256_negate_involutive ());
8080+ add_test ~name:"p256: add commutative"
8181+ [ const () ]
8282+ (fun () -> test_p256_add_commutative ())
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** SPAKE2 and SPAKE2+ Password-Authenticated Key Exchange
77+88+ This implementation uses mirage-crypto-ec for P-256 elliptic curve
99+ operations, which provides constant-time arithmetic using code generated by
1010+ {{:https://github.com/mit-plv/fiat-crypto}fiat-crypto}. This protects
1111+ against timing side-channel attacks on scalar multiplication and point
1212+ operations. *)
1313+1414+let log_src = Logs.Src.create "spake2"
1515+1616+module Log = (val Logs.src_log log_src : Logs.LOG)
1717+1818+let ( let* ) = Result.bind
1919+2020+let hkdf_derive ~salt ~ikm ~info ~length =
2121+ let prk = Hkdf.extract ~hash:`SHA256 ~salt ikm in
2222+ Hkdf.expand ~hash:`SHA256 ~prk ~info length
2323+2424+type role = [ `A | `B ]
2525+2626+(** {1 Utility Functions} *)
2727+2828+let z_to_bytes n z =
2929+ let buf = Bytes.make n '\x00' in
3030+ let rec fill i v =
3131+ if i < 0 || Z.equal v Z.zero then ()
3232+ else (
3333+ Bytes.set buf i (Char.chr (Z.to_int (Z.logand v (Z.of_int 0xff))));
3434+ fill (i - 1) (Z.shift_right v 8))
3535+ in
3636+ fill (n - 1) z;
3737+ Bytes.to_string buf
3838+3939+let z_to_bytes32 z = z_to_bytes 32 z
4040+4141+let bytes_to_z s =
4242+ let len = String.length s in
4343+ let result = ref Z.zero in
4444+ for i = 0 to len - 1 do
4545+ result := Z.add (Z.shift_left !result 8) (Z.of_int (Char.code s.[i]))
4646+ done;
4747+ !result
4848+4949+(** {1 Cryptographic Utilities} *)
5050+5151+let sha256 data = Digestif.SHA256.(digest_string data |> to_raw_string)
5252+5353+let hmac_sha256 ~key data =
5454+ Digestif.SHA256.(hmac_string ~key data |> to_raw_string)
5555+5656+(** {1 P-256 Elliptic Curve}
5757+5858+ Uses mirage-crypto-ec for constant-time operations. *)
5959+6060+module P256 = struct
6161+ module Ec = Crypto_ec.P256.Point
6262+6363+ (** The curve order for scalar arithmetic *)
6464+ let order =
6565+ Z.of_string
6666+ "0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"
6767+6868+ (** The field prime for point negation *)
6969+ let prime =
7070+ Z.of_string
7171+ "0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff"
7272+7373+ type point = Ec.point
7474+7575+ (** SPAKE2 M point in SEC1 uncompressed format *)
7676+ let m_bytes =
7777+ "\x04\x88\x6e\x2f\x97\xac\xe4\x6e\x55\xba\x9d\xd7\x24\x25\x79\xf2\x99\x3b\x64\xe1\x6e\xf3\xdc\xab\x95\xaf\xd4\x97\x33\x3d\x8f\xa1\x2f\x5f\xf3\x55\x16\x3e\x43\xce\x22\x4e\x0b\x0e\x65\xff\x02\xac\x8e\x5c\x7b\xe0\x94\x19\xc7\x85\xe0\xca\x54\x7d\x55\xa1\x2e\x2d\x20"
7878+7979+ (** SPAKE2 N point in SEC1 uncompressed format *)
8080+ let n_bytes =
8181+ "\x04\xd8\xbb\xd6\xc6\x39\xc6\x29\x37\xb0\x4d\x99\x7f\x38\xc3\x77\x07\x19\xc6\x29\xd7\x01\x4d\x49\xa2\x4b\x4f\x98\xba\xa1\x29\x2b\x49\x07\xd6\x0a\xa6\xbf\xad\xe4\x50\x08\xa6\x36\x33\x7f\x51\x68\xc6\x4d\x9b\xd3\x60\x34\x80\x8c\xd5\x64\x49\x0b\x1e\x65\x6e\xdb\xe7"
8282+8383+ let m =
8484+ match Ec.of_octets m_bytes with
8585+ | Ok p -> p
8686+ | Error _ -> failwith "Invalid SPAKE2 M constant"
8787+8888+ let n =
8989+ match Ec.of_octets n_bytes with
9090+ | Ok p -> p
9191+ | Error _ -> failwith "Invalid SPAKE2 N constant"
9292+9393+ let generator = Ec.generator
9494+ let add = Ec.add
9595+ let scalar_mult scalar pt = Ec.scalar_mult scalar pt
9696+ let scalar_mult_base scalar = Ec.scalar_mult scalar Ec.generator
9797+ let to_bytes pt = Ec.to_octets pt
9898+9999+ (** Negate a point: -P = (x, p - y). We parse the SEC1 encoding, negate the
100100+ y-coordinate, and re-encode. This is a single arithmetic operation, not
101101+ timing-sensitive. *)
102102+ let negate pt =
103103+ let octets = Ec.to_octets pt in
104104+ if String.length octets = 1 && octets.[0] = '\x00' then pt
105105+ else
106106+ let x_bytes = String.sub octets 1 32 in
107107+ let y_bytes = String.sub octets 33 32 in
108108+ let y = bytes_to_z y_bytes in
109109+ let neg_y = Z.sub prime y in
110110+ let neg_y_bytes = z_to_bytes32 neg_y in
111111+ let new_octets = "\x04" ^ x_bytes ^ neg_y_bytes in
112112+ match Ec.of_octets new_octets with
113113+ | Ok p -> p
114114+ | Error _ -> failwith "negate: invalid result"
115115+116116+ let of_bytes s =
117117+ match Ec.of_octets s with
118118+ | Ok p -> Ok p
119119+ | Error e -> Error (Format.asprintf "%a" Crypto_ec.pp_error e)
120120+121121+ (** Convert a scalar represented as Z.t to the constant-time scalar type *)
122122+ let scalar_of_z z =
123123+ let z = Z.erem z order in
124124+ let z = if Z.lt z Z.zero then Z.add z order else z in
125125+ let bytes = z_to_bytes32 z in
126126+ match Ec.scalar_of_octets bytes with
127127+ | Ok s -> s
128128+ | Error _ -> (
129129+ (* If scalar is 0, use 1 instead (edge case) *)
130130+ match Ec.scalar_of_octets (z_to_bytes32 Z.one) with
131131+ | Ok s -> s
132132+ | Error _ -> failwith "scalar_of_z: cannot create scalar")
133133+134134+ (** Generate a random scalar in [1, order-1] *)
135135+ let random_scalar () =
136136+ let rec try_generate () =
137137+ let bytes = Crypto_rng.generate 32 in
138138+ match Ec.scalar_of_octets bytes with
139139+ | Ok s -> s
140140+ | Error _ -> try_generate ()
141141+ in
142142+ try_generate ()
143143+end
144144+145145+(** {1 SPAKE2 Protocol} *)
146146+147147+type state = {
148148+ role : role;
149149+ w : P256.Ec.scalar;
150150+ scalar : P256.Ec.scalar;
151151+ my_share : string;
152152+}
153153+154154+let derive_w_from_password password =
155155+ let hash = sha256 password in
156156+ let w = Z.erem (bytes_to_z hash) P256.order in
157157+ let w = if Z.equal w Z.zero then Z.one else w in
158158+ P256.scalar_of_z w
159159+160160+let init ~password role =
161161+ let w = derive_w_from_password password in
162162+ let scalar = P256.random_scalar () in
163163+ let blind_point = match role with `A -> P256.m | `B -> P256.n in
164164+ let w_times_blind = P256.scalar_mult w blind_point in
165165+ let scalar_times_g = P256.scalar_mult_base scalar in
166166+ let share_point = P256.add w_times_blind scalar_times_g in
167167+ let my_share = P256.to_bytes share_point in
168168+ Log.debug (fun f ->
169169+ f "SPAKE2 init: role=%s share=%d bytes"
170170+ (match role with `A -> "A" | `B -> "B")
171171+ (String.length my_share));
172172+ ({ role; w; scalar; my_share }, my_share)
173173+174174+let compute_transcript ~context ~id_a ~id_b ~pa ~pb ~k =
175175+ let add_length_prefixed buf s =
176176+ let len = String.length s in
177177+ Buffer.add_char buf (Char.chr ((len lsr 56) land 0xff));
178178+ Buffer.add_char buf (Char.chr ((len lsr 48) land 0xff));
179179+ Buffer.add_char buf (Char.chr ((len lsr 40) land 0xff));
180180+ Buffer.add_char buf (Char.chr ((len lsr 32) land 0xff));
181181+ Buffer.add_char buf (Char.chr ((len lsr 24) land 0xff));
182182+ Buffer.add_char buf (Char.chr ((len lsr 16) land 0xff));
183183+ Buffer.add_char buf (Char.chr ((len lsr 8) land 0xff));
184184+ Buffer.add_char buf (Char.chr (len land 0xff));
185185+ Buffer.add_string buf s
186186+ in
187187+ let buf = Buffer.create 256 in
188188+ add_length_prefixed buf context;
189189+ add_length_prefixed buf id_a;
190190+ add_length_prefixed buf id_b;
191191+ add_length_prefixed buf pa;
192192+ add_length_prefixed buf pb;
193193+ add_length_prefixed buf k;
194194+ sha256 (Buffer.contents buf)
195195+196196+let finish ?(context = "") ?(id_a = "") ?(id_b = "") state peer_share =
197197+ let* peer_point = P256.of_bytes peer_share in
198198+ let peer_blind = match state.role with `A -> P256.n | `B -> P256.m in
199199+ let w_times_peer_blind = P256.scalar_mult state.w peer_blind in
200200+ let peer_unblinded = P256.add peer_point (P256.negate w_times_peer_blind) in
201201+ let k_point = P256.scalar_mult state.scalar peer_unblinded in
202202+ let k = P256.to_bytes k_point in
203203+ let pa, pb =
204204+ match state.role with
205205+ | `A -> (state.my_share, peer_share)
206206+ | `B -> (peer_share, state.my_share)
207207+ in
208208+ let transcript = compute_transcript ~context ~id_a ~id_b ~pa ~pb ~k in
209209+ let shared_secret =
210210+ hkdf_derive ~salt:"" ~ikm:transcript ~info:"SPAKE2" ~length:32
211211+ in
212212+ Log.debug (fun f -> f "SPAKE2 finish: derived 32-byte shared secret");
213213+ Ok shared_secret
214214+215215+(** {1 SPAKE2+ Protocol} *)
216216+217217+module Plus = struct
218218+ let default_iterations = 1000
219219+ let generate_salt () = Crypto_rng.generate 32
220220+221221+ let derive_w ~password ~salt ~iterations =
222222+ let ws = Pbkdf2.derive ~password ~salt ~iterations ~length:80 in
223223+ let w0s = String.sub ws 0 40 in
224224+ let w1s = String.sub ws 40 40 in
225225+ let w0 = Z.erem (bytes_to_z w0s) P256.order in
226226+ let w1 = Z.erem (bytes_to_z w1s) P256.order in
227227+ (z_to_bytes32 w0, z_to_bytes32 w1)
228228+229229+ let compute_l ~w1 =
230230+ let w1_scalar = P256.scalar_of_z (bytes_to_z w1) in
231231+ let l_point = P256.scalar_mult_base w1_scalar in
232232+ P256.to_bytes l_point
233233+234234+ type prover_state = {
235235+ w0 : string;
236236+ w1 : string;
237237+ x : P256.Ec.scalar;
238238+ pa : string;
239239+ context : string;
240240+ }
241241+242242+ type verifier_state = {
243243+ w0 : string;
244244+ l : string;
245245+ y : P256.Ec.scalar;
246246+ pb : string;
247247+ context : string;
248248+ }
249249+250250+ let compute_tt ~context ~pa ~pb ~z ~v ~w0 =
251251+ let add_length_prefixed buf s =
252252+ let len = String.length s in
253253+ Buffer.add_char buf (Char.chr ((len lsr 24) land 0xff));
254254+ Buffer.add_char buf (Char.chr ((len lsr 16) land 0xff));
255255+ Buffer.add_char buf (Char.chr ((len lsr 8) land 0xff));
256256+ Buffer.add_char buf (Char.chr (len land 0xff));
257257+ Buffer.add_string buf s
258258+ in
259259+ let buf = Buffer.create 512 in
260260+ add_length_prefixed buf context;
261261+ add_length_prefixed buf "";
262262+ add_length_prefixed buf "";
263263+ add_length_prefixed buf pa;
264264+ add_length_prefixed buf pb;
265265+ add_length_prefixed buf z;
266266+ add_length_prefixed buf v;
267267+ add_length_prefixed buf w0;
268268+ sha256 (Buffer.contents buf)
269269+270270+ let derive_keys ~tt =
271271+ let ka = hkdf_derive ~salt:"" ~ikm:tt ~info:"ConfirmationKeys" ~length:64 in
272272+ let ke = hkdf_derive ~salt:"" ~ikm:tt ~info:"SharedSecret" ~length:32 in
273273+ let kca = String.sub ka 0 32 in
274274+ let kcb = String.sub ka 32 32 in
275275+ (kca, kcb, ke)
276276+277277+ let prover_init ~w0 ~w1 ~context =
278278+ let x = P256.random_scalar () in
279279+ let w0_scalar = P256.scalar_of_z (bytes_to_z w0) in
280280+ let x_g = P256.scalar_mult_base x in
281281+ let w0_m = P256.scalar_mult w0_scalar P256.m in
282282+ let pa_point = P256.add x_g w0_m in
283283+ let pa = P256.to_bytes pa_point in
284284+ Log.debug (fun f -> f "SPAKE2+ prover init: pA=%d bytes" (String.length pa));
285285+ ({ w0; w1; x; pa; context }, pa)
286286+287287+ let prover_finish (state : prover_state) pb_bytes =
288288+ let* pb = P256.of_bytes pb_bytes in
289289+ let w0_scalar = P256.scalar_of_z (bytes_to_z state.w0) in
290290+ let w1_scalar = P256.scalar_of_z (bytes_to_z state.w1) in
291291+ let w0_n = P256.scalar_mult w0_scalar P256.n in
292292+ let pb_minus_w0n = P256.add pb (P256.negate w0_n) in
293293+ let z_point = P256.scalar_mult state.x pb_minus_w0n in
294294+ let v_point = P256.scalar_mult w1_scalar pb_minus_w0n in
295295+ let z = P256.to_bytes z_point in
296296+ let v = P256.to_bytes v_point in
297297+ let tt =
298298+ compute_tt ~context:state.context ~pa:state.pa ~pb:pb_bytes ~z ~v
299299+ ~w0:state.w0
300300+ in
301301+ let kca, kcb, ke = derive_keys ~tt in
302302+ let ca = hmac_sha256 ~key:kca pb_bytes in
303303+ Log.debug (fun f ->
304304+ f "SPAKE2+ prover finish: ke=%d cA=%d bytes" (String.length ke)
305305+ (String.length ca));
306306+ Ok (ke, ca, kcb)
307307+308308+ let verifier_init ~w0 ~l ~context =
309309+ let y = P256.random_scalar () in
310310+ let w0_scalar = P256.scalar_of_z (bytes_to_z w0) in
311311+ let y_g = P256.scalar_mult_base y in
312312+ let w0_n = P256.scalar_mult w0_scalar P256.n in
313313+ let pb_point = P256.add y_g w0_n in
314314+ let pb = P256.to_bytes pb_point in
315315+ Log.debug (fun f ->
316316+ f "SPAKE2+ verifier init: pB=%d bytes" (String.length pb));
317317+ ({ w0; l; y; pb; context }, pb)
318318+319319+ let verifier_finish state pa_bytes =
320320+ let* pa = P256.of_bytes pa_bytes in
321321+ let* l_point = P256.of_bytes state.l in
322322+ let w0_scalar = P256.scalar_of_z (bytes_to_z state.w0) in
323323+ let w0_m = P256.scalar_mult w0_scalar P256.m in
324324+ let pa_minus_w0m = P256.add pa (P256.negate w0_m) in
325325+ let z_point = P256.scalar_mult state.y pa_minus_w0m in
326326+ let v_point = P256.scalar_mult state.y l_point in
327327+ let z = P256.to_bytes z_point in
328328+ let v = P256.to_bytes v_point in
329329+ let tt =
330330+ compute_tt ~context:state.context ~pa:pa_bytes ~pb:state.pb ~z ~v
331331+ ~w0:state.w0
332332+ in
333333+ let kca, kcb, ke = derive_keys ~tt in
334334+ let cb = hmac_sha256 ~key:kcb pa_bytes in
335335+ Log.debug (fun f ->
336336+ f "SPAKE2+ verifier finish: ke=%d cB=%d bytes" (String.length ke)
337337+ (String.length cb));
338338+ Ok (ke, cb, kca)
339339+end
+235
lib/spake2.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+(** SPAKE2 and SPAKE2+ Password-Authenticated Key Exchange
77+88+ SPAKE2 and SPAKE2+ are Password-Authenticated Key Exchange (PAKE) protocols
99+ that allow two parties who share a low-entropy password to derive a strong
1010+ shared secret key, without revealing the password to eavesdroppers or
1111+ allowing offline dictionary attacks.
1212+1313+ {b SPAKE2} (RFC 9382) is the simpler variant where both parties derive the
1414+ same values from the password.
1515+1616+ {b SPAKE2+} adds "augmented PAKE" properties: the verifier (server) stores
1717+ only a password verifier, not the password itself. This protects against
1818+ server compromise.
1919+2020+ {1 Security}
2121+2222+ This implementation uses mirage-crypto-ec for P-256 elliptic curve
2323+ operations, which provides constant-time arithmetic using code generated by
2424+ {{:https://github.com/mit-plv/fiat-crypto}fiat-crypto}. This protects
2525+ against timing side-channel attacks on scalar multiplication and point
2626+ operations.
2727+2828+ {1 Protocol Overview}
2929+3030+ {2 SPAKE2}
3131+3232+ Both parties share password [w]:
3333+ + A generates random scalar [x] and sends [pA = w*M + x*G] to B
3434+ + B generates random scalar [y] and sends [pB = w*N + y*G] to A
3535+ + A computes [K = x*(pB - w*N) = x*y*G]
3636+ + B computes [K = y*(pA - w*M) = x*y*G]
3737+ + Both derive the shared secret from [K] using a KDF
3838+3939+ {2 SPAKE2+}
4040+4141+ Prover (client) has password, Verifier (server) has [w0] and [L = w1*G]:
4242+ + Prover: [pA = w0*M + x*G]
4343+ + Verifier: [pB = w0*N + y*G]
4444+ + Prover computes [Z = x*(pB - w0*N)], [V = w1*(pB - w0*N)]
4545+ + Verifier computes [Z = y*(pA - w0*M)], [V = y*L]
4646+ + Both derive keys from transcript including [Z] and [V]
4747+4848+ {1 Usage}
4949+5050+ {2 SPAKE2 Example}
5151+5252+ {[
5353+ let password = "secret" in
5454+5555+ (* Party A *)
5656+ let state_a, msg_a = Spake2.init ~password `A in
5757+ (* send msg_a to B, receive msg_b from B *)
5858+ let key_a = Spake2.finish ~context:"myapp" state_a msg_b in
5959+6060+ (* Party B *)
6161+ let state_b, msg_b = Spake2.init ~password `B in
6262+ (* send msg_b to A, receive msg_a from A *)
6363+ let key_b = Spake2.finish ~context:"myapp" state_b msg_a in
6464+6565+ (* key_a = key_b *)
6666+ ]}
6767+6868+ {2 SPAKE2+ Example}
6969+7070+ {[
7171+ (* Setup: derive verifier data from password *)
7272+ let w0, w1 = Spake2.Plus.derive_w ~password ~salt ~iterations in
7373+ let l = Spake2.Plus.compute_l ~w1 in
7474+ (* Server stores: w0, l (NOT the password or w1) *)
7575+7676+ (* Protocol run *)
7777+ let prover_state, pa = Spake2.Plus.prover_init ~w0 ~w1 ~context in
7878+ let verifier_state, pb = Spake2.Plus.verifier_init ~w0 ~l ~context in
7979+8080+ let prover_result = Spake2.Plus.prover_finish prover_state pb in
8181+ let verifier_result = Spake2.Plus.verifier_finish verifier_state pa in
8282+ ]} *)
8383+8484+(** {1 Types} *)
8585+8686+type role = [ `A | `B ]
8787+(** The role of a party in the SPAKE2 protocol. *)
8888+8989+(** {1 P-256 Curve} *)
9090+9191+module P256 : sig
9292+ (** P-256 (secp256r1) elliptic curve operations.
9393+9494+ Uses mirage-crypto-ec for constant-time arithmetic. *)
9595+9696+ (** {2 Types} *)
9797+9898+ type point = Crypto_ec.P256.Point.point
9999+ (** Opaque point type from mirage-crypto-ec. *)
100100+101101+ (** {2 Constants} *)
102102+103103+ val order : Z.t
104104+ (** The curve order n. *)
105105+106106+ val generator : point
107107+ (** The generator point G. *)
108108+109109+ val m : point
110110+ (** The SPAKE2 M point (RFC 9382). *)
111111+112112+ val n : point
113113+ (** The SPAKE2 N point (RFC 9382). *)
114114+115115+ (** {2 Operations} *)
116116+117117+ val add : point -> point -> point
118118+ (** [add p q] computes [P + Q] (constant-time). *)
119119+120120+ val negate : point -> point
121121+ (** [negate p] computes [-P]. *)
122122+123123+ (** {2 Encoding} *)
124124+125125+ val to_bytes : point -> string
126126+ (** [to_bytes p] encodes [p] in SEC1 uncompressed format (65 bytes). *)
127127+128128+ val of_bytes : string -> (point, string) result
129129+ (** [of_bytes s] decodes a SEC1 uncompressed point, validating it. *)
130130+end
131131+132132+(** {1 SPAKE2 (RFC 9382)} *)
133133+134134+type state
135135+(** Opaque state for the SPAKE2 protocol. *)
136136+137137+val init : password:string -> role -> state * string
138138+(** [init ~password role] initializes SPAKE2 for the given [role].
139139+140140+ @param password The shared password
141141+ @param role Either [`A] or [`B]
142142+ @return [(state, message)] where [message] is the public share to send *)
143143+144144+val finish :
145145+ ?context:string ->
146146+ ?id_a:string ->
147147+ ?id_b:string ->
148148+ state ->
149149+ string ->
150150+ (string, string) result
151151+(** [finish ?context ?id_a ?id_b state peer_message] completes the protocol.
152152+153153+ @param context Optional context string for key derivation
154154+ @param id_a Optional identity of party A
155155+ @param id_b Optional identity of party B
156156+ @param state The state from {!init}
157157+ @param peer_message The message received from the peer
158158+ @return [Ok shared_secret] (32 bytes) or [Error msg] *)
159159+160160+(** {1 SPAKE2+ (Augmented PAKE)} *)
161161+162162+module Plus : sig
163163+ (** SPAKE2+ adds verifier storage security: the server stores only [w0] and
164164+ [L = w1*G], not password-equivalent data. *)
165165+166166+ (** {2 Key Derivation} *)
167167+168168+ val derive_w :
169169+ password:string -> salt:string -> iterations:int -> string * string
170170+ (** [derive_w ~password ~salt ~iterations] derives [w0] and [w1] using
171171+ PBKDF2-SHA256.
172172+173173+ @param password The user's password
174174+ @param salt Random salt (should be at least 16 bytes)
175175+ @param iterations PBKDF2 iteration count (minimum 1000)
176176+ @return [(w0, w1)] where each is 32 bytes *)
177177+178178+ val compute_l : w1:string -> string
179179+ (** [compute_l ~w1] computes [L = w1*G] for server storage.
180180+181181+ @return L encoded as SEC1 uncompressed point (65 bytes) *)
182182+183183+ (** {2 Prover (Client)} *)
184184+185185+ type prover_state
186186+ (** Prover protocol state. *)
187187+188188+ val prover_init :
189189+ w0:string -> w1:string -> context:string -> prover_state * string
190190+ (** [prover_init ~w0 ~w1 ~context] initiates SPAKE2+ as prover.
191191+192192+ @return [(state, pA)] where pA is sent to the verifier *)
193193+194194+ val prover_finish :
195195+ prover_state -> string -> (string * string * string, string) result
196196+ (** [prover_finish state pB] processes the verifier's message.
197197+198198+ @return
199199+ [Ok (shared_key, confirmation_a, expected_confirmation_b)] or error *)
200200+201201+ (** {2 Verifier (Server)} *)
202202+203203+ type verifier_state
204204+ (** Verifier protocol state. *)
205205+206206+ val verifier_init :
207207+ w0:string -> l:string -> context:string -> verifier_state * string
208208+ (** [verifier_init ~w0 ~l ~context] initiates SPAKE2+ as verifier.
209209+210210+ @param l The stored [L = w1*G] value
211211+ @return [(state, pB)] where pB is sent to the prover *)
212212+213213+ val verifier_finish :
214214+ verifier_state -> string -> (string * string * string, string) result
215215+ (** [verifier_finish state pA] processes the prover's message.
216216+217217+ @return
218218+ [Ok (shared_key, confirmation_b, expected_confirmation_a)] or error *)
219219+220220+ (** {2 Utilities} *)
221221+222222+ val generate_salt : unit -> string
223223+ (** [generate_salt ()] generates a random 32-byte salt. *)
224224+225225+ val default_iterations : int
226226+ (** Default PBKDF2 iteration count (1000). *)
227227+end
228228+229229+(** {1 Cryptographic Utilities} *)
230230+231231+val sha256 : string -> string
232232+(** [sha256 data] computes SHA-256 hash. *)
233233+234234+val hmac_sha256 : key:string -> string -> string
235235+(** [hmac_sha256 ~key data] computes HMAC-SHA256. *)
+42
spake2.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "SPAKE2 and SPAKE2+ Password-Authenticated Key Exchange"
44+description: """
55+Implementation of the SPAKE2 and SPAKE2+ protocols for password-authenticated
66+ key exchange. SPAKE2 (RFC 9382) allows two parties who share a password to
77+ derive a strong shared secret key, without revealing the password to
88+ eavesdroppers or allowing offline dictionary attacks. SPAKE2+ adds verifier
99+ storage security so the server doesn't store password-equivalent data."""
1010+maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
1111+authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
1212+license: "MIT"
1313+homepage: "https://github.com/samoht/ocaml-spake2"
1414+bug-reports: "https://github.com/samoht/ocaml-spake2/issues"
1515+depends: [
1616+ "dune" {>= "3.0"}
1717+ "ocaml" {>= "4.08"}
1818+ "zarith" {>= "1.12"}
1919+ "digestif" {>= "1.2.0"}
2020+ "kdf" {>= "0.1"}
2121+ "pbkdf2" {>= "0.1"}
2222+ "crypto-rng" {>= "1.0.0"}
2323+ "crypto-ec" {>= "1.0.0"}
2424+ "logs" {>= "0.7.0"}
2525+ "alcotest" {with-test}
2626+ "crowbar" {with-test}
2727+ "odoc" {with-doc}
2828+]
2929+build: [
3030+ ["dune" "subst"] {dev}
3131+ [
3232+ "dune"
3333+ "build"
3434+ "-p"
3535+ name
3636+ "-j"
3737+ jobs
3838+ "@install"
3939+ "@runtest" {with-test}
4040+ "@doc" {with-doc}
4141+ ]
4242+]