···11+MIT License
22+33+Copyright (c) 2026 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.
+36
csrf.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "CSRF protection using HMAC-signed state tokens"
44+description:
55+ "CSRF protection using HMAC-signed state tokens with HKDF key derivation (RFC 5869) and constant-time signature verification. Provides sign_state and verify_state functions for secure OAuth state parameters."
66+maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
77+authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
88+license: "MIT"
99+homepage: "https://github.com/samoht/ocaml-csrf"
1010+bug-reports: "https://github.com/samoht/ocaml-csrf/issues"
1111+depends: [
1212+ "ocaml" {>= "4.08"}
1313+ "dune" {>= "3.0" & >= "3.0"}
1414+ "kdf" {>= "0.1"}
1515+ "digestif" {>= "1.2"}
1616+ "eqaf" {>= "0.9"}
1717+ "alcotest" {with-test}
1818+ "crowbar" {with-test}
1919+ "crypto-rng" {with-test & >= "0.11.0"}
2020+ "odoc" {with-doc}
2121+]
2222+build: [
2323+ ["dune" "subst"] {dev}
2424+ [
2525+ "dune"
2626+ "build"
2727+ "-p"
2828+ name
2929+ "-j"
3030+ jobs
3131+ "@install"
3232+ "@runtest" {with-test}
3333+ "@doc" {with-doc}
3434+ ]
3535+]
3636+dev-repo: "git+https://github.com/samoht/ocaml-csrf.git"
···11+(** Fuzz tests for CSRF module *)
22+33+open Crowbar
44+55+let () = Crypto_rng_unix.use_default ()
66+77+(** Roundtrip: sign then verify should return original state *)
88+let test_roundtrip secret state =
99+ if String.length secret = 0 then bad_test ()
1010+ else
1111+ let signed = Csrf.sign_state ~secret state in
1212+ match Csrf.verify_state ~secret signed with
1313+ | Some recovered -> check_eq ~pp:Format.pp_print_string state recovered
1414+ | None -> fail "verification failed for valid signed state"
1515+1616+(** Wrong secret should never verify *)
1717+let test_wrong_secret secret1 secret2 state =
1818+ if String.length secret1 = 0 || String.length secret2 = 0 || secret1 = secret2
1919+ then bad_test ()
2020+ else
2121+ let signed = Csrf.sign_state ~secret:secret1 state in
2222+ match Csrf.verify_state ~secret:secret2 signed with
2323+ | None -> ()
2424+ | Some _ -> fail "wrong secret should not verify"
2525+2626+(** Tampered signature should never verify *)
2727+let test_tampered_signature secret state =
2828+ if String.length secret = 0 then bad_test ()
2929+ else
3030+ let signed = Csrf.sign_state ~secret state in
3131+ let tampered =
3232+ let len = String.length signed in
3333+ if len > 0 then (
3434+ let b = Bytes.of_string signed in
3535+ let last = Char.code (Bytes.get b (len - 1)) in
3636+ Bytes.set b (len - 1) (Char.chr (last lxor 1));
3737+ Bytes.to_string b)
3838+ else signed
3939+ in
4040+ match Csrf.verify_state ~secret tampered with
4141+ | None -> ()
4242+ | Some _ -> fail "tampered signature should not verify"
4343+4444+(** Malformed inputs should never crash *)
4545+let test_malformed secret input =
4646+ if String.length secret = 0 then bad_test ()
4747+ else
4848+ let _ = Csrf.verify_state ~secret input in
4949+ ()
5050+5151+let () =
5252+ add_test ~name:"sign/verify roundtrip" [ bytes; bytes ] test_roundtrip;
5353+ add_test ~name:"wrong secret rejects" [ bytes; bytes; bytes ]
5454+ test_wrong_secret;
5555+ add_test ~name:"tampered signature rejects" [ bytes; bytes ]
5656+ test_tampered_signature;
5757+ add_test ~name:"malformed input doesn't crash" [ bytes; bytes ] test_malformed
+40
lib/csrf.ml
···11+(** CSRF protection using HMAC-signed state tokens.
22+33+ See {!Csrf} for usage documentation and security features. *)
44+55+let max_signed_state_length = 256
66+77+(** Derive HMAC key from secret using HKDF (RFC 5869). Uses HKDF-SHA256 with
88+ context separation to ensure the same secret can be used for multiple
99+ purposes (e.g., encryption and HMAC) without compromising security. *)
1010+let derive_hmac_key secret =
1111+ let prk = Hkdf.extract ~hash:`SHA256 ~salt:"" secret in
1212+ Hkdf.expand ~hash:`SHA256 ~prk ~info:"csrf-hmac-v1" 32
1313+1414+let sign_state ~secret state =
1515+ let hmac_key = derive_hmac_key secret in
1616+ let signature =
1717+ Digestif.SHA256.hmac_string ~key:hmac_key state |> Digestif.SHA256.to_hex
1818+ in
1919+ state ^ "." ^ signature
2020+2121+let verify_state ~secret signed_state =
2222+ (* Validate length before processing (DoS prevention) *)
2323+ if String.length signed_state > max_signed_state_length then None
2424+ else
2525+ (* Find last dot - signature is always after it (state may contain dots) *)
2626+ match String.rindex_opt signed_state '.' with
2727+ | None -> None
2828+ | Some dot_pos ->
2929+ let state = String.sub signed_state 0 dot_pos in
3030+ let signature =
3131+ String.sub signed_state (dot_pos + 1)
3232+ (String.length signed_state - dot_pos - 1)
3333+ in
3434+ let hmac_key = derive_hmac_key secret in
3535+ let expected_signature =
3636+ Digestif.SHA256.hmac_string ~key:hmac_key state
3737+ |> Digestif.SHA256.to_hex
3838+ in
3939+ (* Constant-time comparison using eqaf library *)
4040+ if Eqaf.equal signature expected_signature then Some state else None
+59
lib/csrf.mli
···11+(** CSRF protection using HMAC-signed state tokens.
22+33+ This module provides CSRF (Cross-Site Request Forgery) protection using
44+ HMAC-signed state tokens. It is designed for use with OAuth flows where a
55+ state parameter must be verified to prevent CSRF attacks.
66+77+ {2 Security Features}
88+99+ - HKDF (RFC 5869) key derivation for HMAC key with context separation
1010+ - HMAC-SHA256 signatures prevent state manipulation
1111+ - Constant-time comparison prevents timing attacks
1212+ - Length validation prevents DoS attacks
1313+1414+ {2 Usage Example}
1515+1616+ {[
1717+ (* Generate and sign state for OAuth request *)
1818+ let state = generate_random_state () in
1919+ let signed = Csrf.sign_state ~secret:"my-secret" state in
2020+ (* signed = "abc123...fed.deadbeef..." *)
2121+2222+ (* Later, verify state from OAuth callback *)
2323+ match Csrf.verify_state ~secret:"my-secret" signed_state with
2424+ | Some state -> (* Valid - proceed with OAuth flow *)
2525+ | None -> (* Invalid - reject request *)
2626+ ]}
2727+2828+ {2 References}
2929+3030+ - {{:https://datatracker.ietf.org/doc/html/rfc5869} RFC 5869 - HKDF}
3131+ - {{:https://datatracker.ietf.org/doc/html/rfc2104} RFC 2104 - HMAC} *)
3232+3333+val sign_state : secret:string -> string -> string
3434+(** [sign_state ~secret state] signs a state value with HMAC-SHA256.
3535+3636+ The HMAC key is derived from [secret] using HKDF with context separation,
3737+ ensuring the same secret can be used for multiple purposes without
3838+ compromising security.
3939+4040+ @return
4141+ A signed state in the format ["state.signature"] where signature is the
4242+ hex-encoded HMAC-SHA256 of the state. *)
4343+4444+val verify_state : secret:string -> string -> string option
4545+(** [verify_state ~secret signed_state] verifies and extracts a signed state.
4646+4747+ Uses constant-time comparison to prevent timing attacks. Also validates
4848+ input length to prevent DoS attacks (max 256 characters).
4949+5050+ @return
5151+ [Some state] if the signature is valid, [None] if the signature is
5252+ invalid, the input is malformed, or the input exceeds maximum length. *)
5353+5454+val max_signed_state_length : int
5555+(** Maximum allowed length for signed state tokens (256 characters).
5656+5757+ This limit prevents DoS attacks from processing excessively long inputs. The
5858+ limit accommodates: 128-char state + 1 dot + 64-char hex signature with a
5959+ safety margin. *)
···11+(** Tests for CSRF module *)
22+33+(** Generate a random hex state for testing *)
44+let generate_state () =
55+ let bytes = Crypto_rng.generate 16 in
66+ let hex = Bytes.create 32 in
77+ for i = 0 to 15 do
88+ let b = Char.code bytes.[i] in
99+ let hi = b lsr 4 and lo = b land 0xf in
1010+ let hex_char n = Char.chr (if n < 10 then n + 48 else n + 87) in
1111+ Bytes.set hex (i * 2) (hex_char hi);
1212+ Bytes.set hex ((i * 2) + 1) (hex_char lo)
1313+ done;
1414+ Bytes.to_string hex
1515+1616+let test_hkdf_key_derivation () =
1717+ let secret = "test-secret-key-12345-must-be-long-enough" in
1818+1919+ (* Derive HMAC key using HKDF *)
2020+ let prk = Hkdf.extract ~hash:`SHA256 ~salt:"" secret in
2121+ let hmac_key = Hkdf.expand ~hash:`SHA256 ~prk ~info:"csrf-hmac-v1" 32 in
2222+2323+ (* Derive a different key with different context *)
2424+ let other_key = Hkdf.expand ~hash:`SHA256 ~prk ~info:"other-context-v1" 32 in
2525+2626+ (* Verify key has correct length (32 bytes for SHA-256 HMAC) *)
2727+ Alcotest.(check int) "HMAC key length" 32 (String.length hmac_key);
2828+2929+ (* Verify keys are different (context separation works) *)
3030+ Alcotest.(check bool) "keys are distinct" true (hmac_key <> other_key);
3131+3232+ (* Verify keys are deterministic (same input = same output) *)
3333+ let hmac_key2 = Hkdf.expand ~hash:`SHA256 ~prk ~info:"csrf-hmac-v1" 32 in
3434+ Alcotest.(check bool) "HMAC key is deterministic" true (hmac_key = hmac_key2);
3535+3636+ (* Verify different secrets produce different keys *)
3737+ let secret2 = "different-secret-key-12345-must-be-long" in
3838+ let prk2 = Hkdf.extract ~hash:`SHA256 ~salt:"" secret2 in
3939+ let hmac_key3 = Hkdf.expand ~hash:`SHA256 ~prk:prk2 ~info:"csrf-hmac-v1" 32 in
4040+ Alcotest.(check bool)
4141+ "different secrets = different keys" true (hmac_key <> hmac_key3)
4242+4343+let test_csrf_signing () =
4444+ let secret = "test-secret-key-12345-must-be-long-enough" in
4545+ let state = generate_state () in
4646+4747+ (* Test signing and verification *)
4848+ let signed_state = Csrf.sign_state ~secret state in
4949+ Alcotest.(check bool)
5050+ "signed state contains dot separator" true
5151+ (String.contains signed_state '.');
5252+5353+ (* Test valid verification *)
5454+ let verified = Csrf.verify_state ~secret signed_state in
5555+ Alcotest.(check (option string))
5656+ "verify valid signed state" (Some state) verified;
5757+5858+ (* Test invalid signature *)
5959+ let tampered_signed =
6060+ state ^ ".deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678"
6161+ in
6262+ let tampered_verified = Csrf.verify_state ~secret tampered_signed in
6363+ Alcotest.(check (option string))
6464+ "reject tampered signature" None tampered_verified;
6565+6666+ (* Test wrong secret *)
6767+ let wrong_secret_verified =
6868+ Csrf.verify_state ~secret:"wrong-secret" signed_state
6969+ in
7070+ Alcotest.(check (option string))
7171+ "reject wrong secret" None wrong_secret_verified;
7272+7373+ (* Test malformed input (no dot) *)
7474+ let malformed_verified = Csrf.verify_state ~secret "nodothere" in
7575+ Alcotest.(check (option string))
7676+ "reject malformed (no dot)" None malformed_verified;
7777+7878+ (* Test state containing dots (should work - signature is after last dot) *)
7979+ let dotted_state = "foo.bar.baz" in
8080+ let dotted_signed = Csrf.sign_state ~secret dotted_state in
8181+ let dotted_verified = Csrf.verify_state ~secret dotted_signed in
8282+ Alcotest.(check (option string))
8383+ "verify state with dots" (Some dotted_state) dotted_verified;
8484+8585+ (* Test empty state *)
8686+ let empty_signed = Csrf.sign_state ~secret "" in
8787+ let empty_verified = Csrf.verify_state ~secret empty_signed in
8888+ Alcotest.(check (option string)) "verify empty state" (Some "") empty_verified
8989+9090+let test_length_limit () =
9191+ let secret = "test-secret" in
9292+9393+ (* Create a state that would exceed the limit when signed *)
9494+ let long_state = String.make 200 'x' in
9595+ let signed = Csrf.sign_state ~secret long_state in
9696+9797+ (* Should be rejected due to length *)
9898+ Alcotest.(check bool)
9999+ "signed long state exceeds limit" true
100100+ (String.length signed > Csrf.max_signed_state_length);
101101+102102+ let verified = Csrf.verify_state ~secret signed in
103103+ Alcotest.(check (option string)) "reject oversized signed state" None verified;
104104+105105+ (* Test state that fits within limit *)
106106+ let short_state = String.make 100 'y' in
107107+ let short_signed = Csrf.sign_state ~secret short_state in
108108+ let short_verified = Csrf.verify_state ~secret short_signed in
109109+ Alcotest.(check (option string))
110110+ "accept reasonably sized state" (Some short_state) short_verified
111111+112112+let () =
113113+ Crypto_rng_unix.use_default ();
114114+ Alcotest.run "csrf"
115115+ [
116116+ ( "CSRF",
117117+ [
118118+ Alcotest.test_case "state signing and verification" `Quick
119119+ test_csrf_signing;
120120+ Alcotest.test_case "HKDF key derivation" `Quick
121121+ test_hkdf_key_derivation;
122122+ Alcotest.test_case "length limit protection" `Quick test_length_limit;
123123+ ] );
124124+ ]