CSRF protection using HMAC-signed state tokens (RFC 5869, RFC 2104)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Squashed 'ocaml-csrf/' content from commit 72ee72c git-subtree-split: 72ee72c626aafbc9e3a30fc8de7cb240900eabae

+384
+4
.gitignore
··· 1 + _build/ 2 + _opam/ 3 + *.install 4 + .merlin
+1
.ocamlformat
··· 1 + version = 0.28.1
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Thomas Gazagnaire 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+36
csrf.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "CSRF protection using HMAC-signed state tokens" 4 + description: 5 + "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." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "MIT" 9 + homepage: "https://github.com/samoht/ocaml-csrf" 10 + bug-reports: "https://github.com/samoht/ocaml-csrf/issues" 11 + depends: [ 12 + "ocaml" {>= "4.08"} 13 + "dune" {>= "3.0" & >= "3.0"} 14 + "kdf" {>= "0.1"} 15 + "digestif" {>= "1.2"} 16 + "eqaf" {>= "0.9"} 17 + "alcotest" {with-test} 18 + "crowbar" {with-test} 19 + "crypto-rng" {with-test & >= "0.11.0"} 20 + "odoc" {with-doc} 21 + ] 22 + build: [ 23 + ["dune" "subst"] {dev} 24 + [ 25 + "dune" 26 + "build" 27 + "-p" 28 + name 29 + "-j" 30 + jobs 31 + "@install" 32 + "@runtest" {with-test} 33 + "@doc" {with-doc} 34 + ] 35 + ] 36 + dev-repo: "git+https://github.com/samoht/ocaml-csrf.git"
+24
dune-project
··· 1 + (lang dune 3.0) 2 + (name csrf) 3 + 4 + (generate_opam_files true) 5 + 6 + (source (github samoht/ocaml-csrf)) 7 + (license MIT) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (package 12 + (name csrf) 13 + (synopsis "CSRF protection using HMAC-signed state tokens") 14 + (description "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.") 15 + (depends 16 + (ocaml (>= 4.08)) 17 + (dune (>= 3.0)) 18 + (kdf (>= 0.1)) 19 + (digestif (>= 1.2)) 20 + (eqaf (>= 0.9)) 21 + (alcotest :with-test) 22 + (crowbar :with-test) 23 + (crypto-rng (and :with-test (>= 0.11.0))) 24 + (odoc :with-doc)))
+11
fuzz/dune
··· 1 + ; Fuzz test - run with: dune build @fuzz 2 + 3 + (executable 4 + (name fuzz_csrf) 5 + (libraries csrf crowbar crypto-rng.unix)) 6 + 7 + (rule 8 + (alias fuzz) 9 + (deps fuzz_csrf.exe) 10 + (action 11 + (run %{exe:fuzz_csrf.exe})))
+57
fuzz/fuzz_csrf.ml
··· 1 + (** Fuzz tests for CSRF module *) 2 + 3 + open Crowbar 4 + 5 + let () = Crypto_rng_unix.use_default () 6 + 7 + (** Roundtrip: sign then verify should return original state *) 8 + let test_roundtrip secret state = 9 + if String.length secret = 0 then bad_test () 10 + else 11 + let signed = Csrf.sign_state ~secret state in 12 + match Csrf.verify_state ~secret signed with 13 + | Some recovered -> check_eq ~pp:Format.pp_print_string state recovered 14 + | None -> fail "verification failed for valid signed state" 15 + 16 + (** Wrong secret should never verify *) 17 + let test_wrong_secret secret1 secret2 state = 18 + if String.length secret1 = 0 || String.length secret2 = 0 || secret1 = secret2 19 + then bad_test () 20 + else 21 + let signed = Csrf.sign_state ~secret:secret1 state in 22 + match Csrf.verify_state ~secret:secret2 signed with 23 + | None -> () 24 + | Some _ -> fail "wrong secret should not verify" 25 + 26 + (** Tampered signature should never verify *) 27 + let test_tampered_signature secret state = 28 + if String.length secret = 0 then bad_test () 29 + else 30 + let signed = Csrf.sign_state ~secret state in 31 + let tampered = 32 + let len = String.length signed in 33 + if len > 0 then ( 34 + let b = Bytes.of_string signed in 35 + let last = Char.code (Bytes.get b (len - 1)) in 36 + Bytes.set b (len - 1) (Char.chr (last lxor 1)); 37 + Bytes.to_string b) 38 + else signed 39 + in 40 + match Csrf.verify_state ~secret tampered with 41 + | None -> () 42 + | Some _ -> fail "tampered signature should not verify" 43 + 44 + (** Malformed inputs should never crash *) 45 + let test_malformed secret input = 46 + if String.length secret = 0 then bad_test () 47 + else 48 + let _ = Csrf.verify_state ~secret input in 49 + () 50 + 51 + let () = 52 + add_test ~name:"sign/verify roundtrip" [ bytes; bytes ] test_roundtrip; 53 + add_test ~name:"wrong secret rejects" [ bytes; bytes; bytes ] 54 + test_wrong_secret; 55 + add_test ~name:"tampered signature rejects" [ bytes; bytes ] 56 + test_tampered_signature; 57 + add_test ~name:"malformed input doesn't crash" [ bytes; bytes ] test_malformed
+40
lib/csrf.ml
··· 1 + (** CSRF protection using HMAC-signed state tokens. 2 + 3 + See {!Csrf} for usage documentation and security features. *) 4 + 5 + let max_signed_state_length = 256 6 + 7 + (** Derive HMAC key from secret using HKDF (RFC 5869). Uses HKDF-SHA256 with 8 + context separation to ensure the same secret can be used for multiple 9 + purposes (e.g., encryption and HMAC) without compromising security. *) 10 + let derive_hmac_key secret = 11 + let prk = Hkdf.extract ~hash:`SHA256 ~salt:"" secret in 12 + Hkdf.expand ~hash:`SHA256 ~prk ~info:"csrf-hmac-v1" 32 13 + 14 + let sign_state ~secret state = 15 + let hmac_key = derive_hmac_key secret in 16 + let signature = 17 + Digestif.SHA256.hmac_string ~key:hmac_key state |> Digestif.SHA256.to_hex 18 + in 19 + state ^ "." ^ signature 20 + 21 + let verify_state ~secret signed_state = 22 + (* Validate length before processing (DoS prevention) *) 23 + if String.length signed_state > max_signed_state_length then None 24 + else 25 + (* Find last dot - signature is always after it (state may contain dots) *) 26 + match String.rindex_opt signed_state '.' with 27 + | None -> None 28 + | Some dot_pos -> 29 + let state = String.sub signed_state 0 dot_pos in 30 + let signature = 31 + String.sub signed_state (dot_pos + 1) 32 + (String.length signed_state - dot_pos - 1) 33 + in 34 + let hmac_key = derive_hmac_key secret in 35 + let expected_signature = 36 + Digestif.SHA256.hmac_string ~key:hmac_key state 37 + |> Digestif.SHA256.to_hex 38 + in 39 + (* Constant-time comparison using eqaf library *) 40 + if Eqaf.equal signature expected_signature then Some state else None
+59
lib/csrf.mli
··· 1 + (** CSRF protection using HMAC-signed state tokens. 2 + 3 + This module provides CSRF (Cross-Site Request Forgery) protection using 4 + HMAC-signed state tokens. It is designed for use with OAuth flows where a 5 + state parameter must be verified to prevent CSRF attacks. 6 + 7 + {2 Security Features} 8 + 9 + - HKDF (RFC 5869) key derivation for HMAC key with context separation 10 + - HMAC-SHA256 signatures prevent state manipulation 11 + - Constant-time comparison prevents timing attacks 12 + - Length validation prevents DoS attacks 13 + 14 + {2 Usage Example} 15 + 16 + {[ 17 + (* Generate and sign state for OAuth request *) 18 + let state = generate_random_state () in 19 + let signed = Csrf.sign_state ~secret:"my-secret" state in 20 + (* signed = "abc123...fed.deadbeef..." *) 21 + 22 + (* Later, verify state from OAuth callback *) 23 + match Csrf.verify_state ~secret:"my-secret" signed_state with 24 + | Some state -> (* Valid - proceed with OAuth flow *) 25 + | None -> (* Invalid - reject request *) 26 + ]} 27 + 28 + {2 References} 29 + 30 + - {{:https://datatracker.ietf.org/doc/html/rfc5869} RFC 5869 - HKDF} 31 + - {{:https://datatracker.ietf.org/doc/html/rfc2104} RFC 2104 - HMAC} *) 32 + 33 + val sign_state : secret:string -> string -> string 34 + (** [sign_state ~secret state] signs a state value with HMAC-SHA256. 35 + 36 + The HMAC key is derived from [secret] using HKDF with context separation, 37 + ensuring the same secret can be used for multiple purposes without 38 + compromising security. 39 + 40 + @return 41 + A signed state in the format ["state.signature"] where signature is the 42 + hex-encoded HMAC-SHA256 of the state. *) 43 + 44 + val verify_state : secret:string -> string -> string option 45 + (** [verify_state ~secret signed_state] verifies and extracts a signed state. 46 + 47 + Uses constant-time comparison to prevent timing attacks. Also validates 48 + input length to prevent DoS attacks (max 256 characters). 49 + 50 + @return 51 + [Some state] if the signature is valid, [None] if the signature is 52 + invalid, the input is malformed, or the input exceeds maximum length. *) 53 + 54 + val max_signed_state_length : int 55 + (** Maximum allowed length for signed state tokens (256 characters). 56 + 57 + This limit prevents DoS attacks from processing excessively long inputs. The 58 + limit accommodates: 128-char state + 1 dot + 64-char hex signature with a 59 + safety margin. *)
+4
lib/dune
··· 1 + (library 2 + (name csrf) 3 + (public_name csrf) 4 + (libraries kdf.hkdf digestif eqaf))
+3
test/dune
··· 1 + (test 2 + (name test_csrf) 3 + (libraries csrf kdf.hkdf alcotest crypto-rng.unix))
+124
test/test_csrf.ml
··· 1 + (** Tests for CSRF module *) 2 + 3 + (** Generate a random hex state for testing *) 4 + let generate_state () = 5 + let bytes = Crypto_rng.generate 16 in 6 + let hex = Bytes.create 32 in 7 + for i = 0 to 15 do 8 + let b = Char.code bytes.[i] in 9 + let hi = b lsr 4 and lo = b land 0xf in 10 + let hex_char n = Char.chr (if n < 10 then n + 48 else n + 87) in 11 + Bytes.set hex (i * 2) (hex_char hi); 12 + Bytes.set hex ((i * 2) + 1) (hex_char lo) 13 + done; 14 + Bytes.to_string hex 15 + 16 + let test_hkdf_key_derivation () = 17 + let secret = "test-secret-key-12345-must-be-long-enough" in 18 + 19 + (* Derive HMAC key using HKDF *) 20 + let prk = Hkdf.extract ~hash:`SHA256 ~salt:"" secret in 21 + let hmac_key = Hkdf.expand ~hash:`SHA256 ~prk ~info:"csrf-hmac-v1" 32 in 22 + 23 + (* Derive a different key with different context *) 24 + let other_key = Hkdf.expand ~hash:`SHA256 ~prk ~info:"other-context-v1" 32 in 25 + 26 + (* Verify key has correct length (32 bytes for SHA-256 HMAC) *) 27 + Alcotest.(check int) "HMAC key length" 32 (String.length hmac_key); 28 + 29 + (* Verify keys are different (context separation works) *) 30 + Alcotest.(check bool) "keys are distinct" true (hmac_key <> other_key); 31 + 32 + (* Verify keys are deterministic (same input = same output) *) 33 + let hmac_key2 = Hkdf.expand ~hash:`SHA256 ~prk ~info:"csrf-hmac-v1" 32 in 34 + Alcotest.(check bool) "HMAC key is deterministic" true (hmac_key = hmac_key2); 35 + 36 + (* Verify different secrets produce different keys *) 37 + let secret2 = "different-secret-key-12345-must-be-long" in 38 + let prk2 = Hkdf.extract ~hash:`SHA256 ~salt:"" secret2 in 39 + let hmac_key3 = Hkdf.expand ~hash:`SHA256 ~prk:prk2 ~info:"csrf-hmac-v1" 32 in 40 + Alcotest.(check bool) 41 + "different secrets = different keys" true (hmac_key <> hmac_key3) 42 + 43 + let test_csrf_signing () = 44 + let secret = "test-secret-key-12345-must-be-long-enough" in 45 + let state = generate_state () in 46 + 47 + (* Test signing and verification *) 48 + let signed_state = Csrf.sign_state ~secret state in 49 + Alcotest.(check bool) 50 + "signed state contains dot separator" true 51 + (String.contains signed_state '.'); 52 + 53 + (* Test valid verification *) 54 + let verified = Csrf.verify_state ~secret signed_state in 55 + Alcotest.(check (option string)) 56 + "verify valid signed state" (Some state) verified; 57 + 58 + (* Test invalid signature *) 59 + let tampered_signed = 60 + state ^ ".deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678" 61 + in 62 + let tampered_verified = Csrf.verify_state ~secret tampered_signed in 63 + Alcotest.(check (option string)) 64 + "reject tampered signature" None tampered_verified; 65 + 66 + (* Test wrong secret *) 67 + let wrong_secret_verified = 68 + Csrf.verify_state ~secret:"wrong-secret" signed_state 69 + in 70 + Alcotest.(check (option string)) 71 + "reject wrong secret" None wrong_secret_verified; 72 + 73 + (* Test malformed input (no dot) *) 74 + let malformed_verified = Csrf.verify_state ~secret "nodothere" in 75 + Alcotest.(check (option string)) 76 + "reject malformed (no dot)" None malformed_verified; 77 + 78 + (* Test state containing dots (should work - signature is after last dot) *) 79 + let dotted_state = "foo.bar.baz" in 80 + let dotted_signed = Csrf.sign_state ~secret dotted_state in 81 + let dotted_verified = Csrf.verify_state ~secret dotted_signed in 82 + Alcotest.(check (option string)) 83 + "verify state with dots" (Some dotted_state) dotted_verified; 84 + 85 + (* Test empty state *) 86 + let empty_signed = Csrf.sign_state ~secret "" in 87 + let empty_verified = Csrf.verify_state ~secret empty_signed in 88 + Alcotest.(check (option string)) "verify empty state" (Some "") empty_verified 89 + 90 + let test_length_limit () = 91 + let secret = "test-secret" in 92 + 93 + (* Create a state that would exceed the limit when signed *) 94 + let long_state = String.make 200 'x' in 95 + let signed = Csrf.sign_state ~secret long_state in 96 + 97 + (* Should be rejected due to length *) 98 + Alcotest.(check bool) 99 + "signed long state exceeds limit" true 100 + (String.length signed > Csrf.max_signed_state_length); 101 + 102 + let verified = Csrf.verify_state ~secret signed in 103 + Alcotest.(check (option string)) "reject oversized signed state" None verified; 104 + 105 + (* Test state that fits within limit *) 106 + let short_state = String.make 100 'y' in 107 + let short_signed = Csrf.sign_state ~secret short_state in 108 + let short_verified = Csrf.verify_state ~secret short_signed in 109 + Alcotest.(check (option string)) 110 + "accept reasonably sized state" (Some short_state) short_verified 111 + 112 + let () = 113 + Crypto_rng_unix.use_default (); 114 + Alcotest.run "csrf" 115 + [ 116 + ( "CSRF", 117 + [ 118 + Alcotest.test_case "state signing and verification" `Quick 119 + test_csrf_signing; 120 + Alcotest.test_case "HKDF key derivation" `Quick 121 + test_hkdf_key_derivation; 122 + Alcotest.test_case "length limit protection" `Quick test_length_limit; 123 + ] ); 124 + ]