PASETO tokens for OCaml - v3.local (AES-256-CTR) and v4.local (XChaCha20)
0
fork

Configure Feed

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

Squashed 'ocaml-paseto/' content from commit 8c884d09 git-subtree-split: 8c884d090db410192030bd339e24cfdc8184ce59

+1150
+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.
+27
dune-project
··· 1 + (lang dune 3.0) 2 + (name paseto) 3 + 4 + (generate_opam_files true) 5 + 6 + (source (github samoht/ocaml-paseto)) 7 + (license MIT) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (package 12 + (name paseto) 13 + (synopsis "PASETO (Platform-Agnostic Security Tokens) implementation") 14 + (description "Type-safe PASETO tokens (RFC draft) for OCaml. Supports v3.local (AES-256-CTR + HMAC-SHA384) and v4.local (XChaCha20-Poly1305) for symmetric authenticated encryption of JSON payloads.") 15 + (depends 16 + (ocaml (>= 5.1)) 17 + (dune (>= 3.0)) 18 + (crypto (>= 1.0)) 19 + (crypto-rng (>= 1.0)) 20 + (digestif (>= 1.0)) 21 + (eqaf (>= 0.9)) 22 + (base64 (>= 3.0)) 23 + (jsont (>= 0.1.0)) 24 + (alcotest :with-test) 25 + (crypto-rng (and :with-test (>= 0.11.0))) 26 + (crowbar :with-test) 27 + (odoc :with-doc)))
+11
fuzz/dune
··· 1 + ; Fuzz test - run with: dune build @fuzz 2 + 3 + (executable 4 + (name fuzz_paseto) 5 + (libraries paseto crowbar crypto-rng.unix)) 6 + 7 + (rule 8 + (alias fuzz) 9 + (deps fuzz_paseto.exe) 10 + (action 11 + (run %{exe:fuzz_paseto.exe})))
+140
fuzz/fuzz_paseto.ml
··· 1 + (** Fuzz tests for PASETO module *) 2 + 3 + open Crowbar 4 + 5 + let () = Crypto_rng_unix.use_default () 6 + 7 + (* Generate a valid 32-byte key from arbitrary bytes *) 8 + let make_key bytes = 9 + let len = String.length bytes in 10 + if len < 32 then 11 + (* Pad short inputs *) 12 + bytes ^ String.make (32 - len) '\x00' 13 + else String.sub bytes 0 32 14 + 15 + let test_v3_local_roundtrip key_bytes payload footer = 16 + let key = make_key key_bytes in 17 + match Paseto.v3_local_encrypt ~key ~footer payload with 18 + | Error _ -> () 19 + | Ok token -> ( 20 + match Paseto.v3_local_decrypt ~key ~footer token with 21 + | Error _ -> fail "roundtrip failed" 22 + | Ok decrypted -> check_eq ~pp:Format.pp_print_string payload decrypted) 23 + 24 + let test_v3_local_wrong_key key1_bytes key2_bytes payload = 25 + let key1 = make_key key1_bytes in 26 + let key2 = make_key key2_bytes in 27 + if key1 = key2 then bad_test () 28 + else 29 + match Paseto.v3_local_encrypt ~key:key1 payload with 30 + | Error _ -> () 31 + | Ok token -> ( 32 + match Paseto.v3_local_decrypt ~key:key2 token with 33 + | Error _ -> () 34 + | Ok _ -> fail "wrong key should reject") 35 + 36 + let test_v3_local_wrong_footer key_bytes payload footer1 footer2 = 37 + if footer1 = footer2 then bad_test () 38 + else 39 + let key = make_key key_bytes in 40 + match Paseto.v3_local_encrypt ~key ~footer:footer1 payload with 41 + | Error _ -> () 42 + | Ok token -> ( 43 + match Paseto.v3_local_decrypt ~key ~footer:footer2 token with 44 + | Error _ -> () 45 + | Ok _ -> fail "wrong footer should reject") 46 + 47 + let test_malformed_input key_bytes input = 48 + let key = make_key key_bytes in 49 + let _ = Paseto.v3_local_decrypt ~key input in 50 + () 51 + 52 + (* Convert arbitrary bytes to a valid JSON string by hex-encoding *) 53 + let to_json_safe s = 54 + let buf = Buffer.create (String.length s * 2) in 55 + String.iter 56 + (fun c -> 57 + let code = Char.code c in 58 + if code >= 0x20 && code < 0x7F && c <> '"' && c <> '\\' then 59 + Buffer.add_char buf c 60 + else Printf.bprintf buf "\\u%04x" code) 61 + s; 62 + Buffer.contents buf 63 + 64 + let test_claims_roundtrip key_bytes sub iss exp = 65 + let key = make_key key_bytes in 66 + (* Convert to JSON-safe strings *) 67 + let sub = to_json_safe sub in 68 + let iss = to_json_safe iss in 69 + let exp = to_json_safe exp in 70 + let claims = 71 + { 72 + Paseto.empty_claims with 73 + sub = (if sub = "" then None else Some sub); 74 + iss = (if iss = "" then None else Some iss); 75 + exp = (if exp = "" then None else Some exp); 76 + } 77 + in 78 + match Paseto.v3_encrypt ~key claims with 79 + | Error _ -> () (* Skip if encoding fails *) 80 + | Ok token -> ( 81 + match Paseto.v3_decrypt ~key token with 82 + | Error _ -> fail "claims roundtrip failed" 83 + | Ok parsed -> 84 + check_eq 85 + ~pp:(Format.pp_print_option Format.pp_print_string) 86 + claims.sub parsed.sub; 87 + check_eq 88 + ~pp:(Format.pp_print_option Format.pp_print_string) 89 + claims.iss parsed.iss; 90 + check_eq 91 + ~pp:(Format.pp_print_option Format.pp_print_string) 92 + claims.exp parsed.exp) 93 + 94 + (* v4.local fuzz tests *) 95 + 96 + let test_v4_local_roundtrip key_bytes payload footer = 97 + let key = make_key key_bytes in 98 + match Paseto.v4_local_encrypt ~key ~footer payload with 99 + | Error _ -> () 100 + | Ok token -> ( 101 + match Paseto.v4_local_decrypt ~key ~footer token with 102 + | Error _ -> fail "v4 roundtrip failed" 103 + | Ok decrypted -> check_eq ~pp:Format.pp_print_string payload decrypted) 104 + 105 + let test_v4_local_wrong_key key1_bytes key2_bytes payload = 106 + let key1 = make_key key1_bytes in 107 + let key2 = make_key key2_bytes in 108 + if key1 = key2 then bad_test () 109 + else 110 + match Paseto.v4_local_encrypt ~key:key1 payload with 111 + | Error _ -> () 112 + | Ok token -> ( 113 + match Paseto.v4_local_decrypt ~key:key2 token with 114 + | Error _ -> () 115 + | Ok _ -> fail "v4 wrong key should reject") 116 + 117 + let test_v4_malformed_input key_bytes input = 118 + let key = make_key key_bytes in 119 + let _ = Paseto.v4_local_decrypt ~key input in 120 + () 121 + 122 + let () = 123 + add_test ~name:"v3.local roundtrip" [ bytes; bytes; bytes ] 124 + test_v3_local_roundtrip; 125 + add_test ~name:"v3.local wrong key rejects" [ bytes; bytes; bytes ] 126 + test_v3_local_wrong_key; 127 + add_test ~name:"v3.local wrong footer rejects" 128 + [ bytes; bytes; bytes; bytes ] 129 + test_v3_local_wrong_footer; 130 + add_test ~name:"v3 malformed input doesn't crash" [ bytes; bytes ] 131 + test_malformed_input; 132 + add_test ~name:"v3 claims roundtrip" 133 + [ bytes; bytes; bytes; bytes ] 134 + test_claims_roundtrip; 135 + add_test ~name:"v4.local roundtrip" [ bytes; bytes; bytes ] 136 + test_v4_local_roundtrip; 137 + add_test ~name:"v4.local wrong key rejects" [ bytes; bytes; bytes ] 138 + test_v4_local_wrong_key; 139 + add_test ~name:"v4 malformed input doesn't crash" [ bytes; bytes ] 140 + test_v4_malformed_input
+4
lib/dune
··· 1 + (library 2 + (name paseto) 3 + (public_name paseto) 4 + (libraries crypto crypto-rng digestif eqaf base64 jsont jsont.bytesrw))
+519
lib/paseto.ml
··· 1 + (** PASETO (Platform-Agnostic Security Tokens) implementation. 2 + 3 + This module implements PASETO v3.local and v4.local tokens: 4 + - v3.local: AES-256-CTR encryption with HMAC-SHA384 authentication 5 + - v4.local: XChaCha20-Poly1305 authenticated encryption 6 + 7 + See {{:https://github.com/paseto-standard/paseto-spec} PASETO Specification} 8 + *) 9 + 10 + let ( let* ) = Result.bind 11 + 12 + (** {1 Types} *) 13 + 14 + type version = V3 | V4 15 + type purpose = Local | Public 16 + 17 + type error = 18 + | Invalid_token_format 19 + | Invalid_base64 20 + | Invalid_header 21 + | Authentication_failed 22 + | Decryption_failed 23 + | Encryption_failed 24 + | Invalid_payload 25 + 26 + let pp_error ppf = function 27 + | Invalid_token_format -> Format.fprintf ppf "Invalid token format" 28 + | Invalid_base64 -> Format.fprintf ppf "Invalid base64 encoding" 29 + | Invalid_header -> Format.fprintf ppf "Invalid PASETO header" 30 + | Authentication_failed -> Format.fprintf ppf "Authentication failed" 31 + | Decryption_failed -> Format.fprintf ppf "Decryption failed" 32 + | Encryption_failed -> Format.fprintf ppf "Encryption failed" 33 + | Invalid_payload -> Format.fprintf ppf "Invalid payload" 34 + 35 + (** {1 Constants} *) 36 + 37 + let v3_local_header = "v3.local." 38 + let v3_nonce_size = 32 39 + let v3_mac_size = 48 (* SHA-384 output *) 40 + let v3_key_size = 32 41 + let v4_local_header = "v4.local." 42 + let v4_nonce_size = 32 43 + let v4_key_size = 32 44 + let v4_tag_size = 32 (* BLAKE2b-MAC tag *) 45 + 46 + (** {1 Base64url encoding} *) 47 + 48 + let base64url_encode s = 49 + Base64.encode_string ~pad:false ~alphabet:Base64.uri_safe_alphabet s 50 + 51 + let base64url_decode s = 52 + Base64.decode ~pad:false ~alphabet:Base64.uri_safe_alphabet s 53 + 54 + (** {1 Key derivation for v3.local} 55 + 56 + PASETO v3.local derives encryption and authentication keys from the 57 + symmetric key using HKDF-SHA384. *) 58 + 59 + let hkdf_sha384_expand ~prk ~info ~length = 60 + let hash_len = 48 in 61 + (* SHA-384 output length *) 62 + let n = (length + hash_len - 1) / hash_len in 63 + let rec loop t prev i = 64 + if i > n then t 65 + else 66 + let input = prev ^ info ^ String.make 1 (Char.chr i) in 67 + let next = 68 + Digestif.SHA384.(hmac_string ~key:prk input |> to_raw_string) 69 + in 70 + loop (t ^ next) next (i + 1) 71 + in 72 + String.sub (loop "" "" 1) 0 length 73 + 74 + let derive_keys ~key ~nonce = 75 + (* Derive encryption key (Ek) and authentication key (Ak) *) 76 + let tmp = 77 + hkdf_sha384_expand ~prk:key 78 + ~info:("paseto-encryption-key" ^ nonce) 79 + ~length:48 80 + in 81 + let ek = String.sub tmp 0 32 in 82 + let n2 = String.sub tmp 32 16 in 83 + let ak = 84 + hkdf_sha384_expand ~prk:key 85 + ~info:("paseto-auth-key-for-aead" ^ nonce) 86 + ~length:48 87 + in 88 + (ek, ak, n2) 89 + 90 + (** {1 Pre-authentication encoding (PAE)} 91 + 92 + PAE is used to create an unambiguous encoding of multiple pieces of data for 93 + authentication. *) 94 + 95 + let le64 n = 96 + let buf = Bytes.create 8 in 97 + for i = 0 to 7 do 98 + Bytes.set buf i 99 + (Char.chr 100 + (Int64.to_int 101 + (Int64.logand (Int64.shift_right_logical n (8 * i)) 0xFFL))) 102 + done; 103 + Bytes.to_string buf 104 + 105 + let pae pieces = 106 + let count = List.length pieces in 107 + let header = le64 (Int64.of_int count) in 108 + let encode_piece acc p = acc ^ le64 (Int64.of_int (String.length p)) ^ p in 109 + List.fold_left encode_piece header pieces 110 + 111 + (** {1 AES-256-CTR encryption} *) 112 + 113 + let aes_ctr_encrypt ~key ~iv plaintext = 114 + let aes_key = Crypto.AES.CTR.of_secret key in 115 + let ctr = Crypto.AES.CTR.ctr_of_octets iv in 116 + Crypto.AES.CTR.encrypt ~key:aes_key ~ctr plaintext 117 + 118 + let aes_ctr_decrypt ~key ~iv ciphertext = 119 + (* CTR mode encryption and decryption are the same operation *) 120 + aes_ctr_encrypt ~key ~iv ciphertext 121 + 122 + (** {1 HChaCha20 and XChaCha20-Poly1305} 123 + 124 + HChaCha20 is the core function used to derive a subkey for XChaCha20. It 125 + takes a 32-byte key and 16-byte input, and outputs a 32-byte subkey. *) 126 + 127 + (* ChaCha20 quarter round *) 128 + let quarter_round a b c d = 129 + let ( +% ) = Int32.add in 130 + let ( lxor ) = Int32.logxor in 131 + let rotl x n = 132 + Int32.(logor (shift_left x n) (shift_right_logical x (32 - n))) 133 + in 134 + let a = a +% b in 135 + let d = rotl (d lxor a) 16 in 136 + let c = c +% d in 137 + let b = rotl (b lxor c) 12 in 138 + let a = a +% b in 139 + let d = rotl (d lxor a) 8 in 140 + let c = c +% d in 141 + let b = rotl (b lxor c) 7 in 142 + (a, b, c, d) 143 + 144 + (* HChaCha20: ChaCha20 core without final addition *) 145 + let hchacha20 ~key ~nonce = 146 + if String.length key <> 32 then invalid_arg "HChaCha20: key must be 32 bytes"; 147 + if String.length nonce <> 16 then 148 + invalid_arg "HChaCha20: nonce must be 16 bytes"; 149 + (* Initialize state with constants, key, and nonce *) 150 + let get32 s i = 151 + let b0 = Int32.of_int (Char.code s.[i]) in 152 + let b1 = Int32.of_int (Char.code s.[i + 1]) in 153 + let b2 = Int32.of_int (Char.code s.[i + 2]) in 154 + let b3 = Int32.of_int (Char.code s.[i + 3]) in 155 + Int32.( 156 + logor 157 + (logor b0 (shift_left b1 8)) 158 + (logor (shift_left b2 16) (shift_left b3 24))) 159 + in 160 + let put32 buf i v = 161 + Bytes.set buf i (Char.chr (Int32.to_int (Int32.logand v 0xFFl))); 162 + Bytes.set buf (i + 1) 163 + (Char.chr 164 + (Int32.to_int (Int32.logand (Int32.shift_right_logical v 8) 0xFFl))); 165 + Bytes.set buf (i + 2) 166 + (Char.chr 167 + (Int32.to_int (Int32.logand (Int32.shift_right_logical v 16) 0xFFl))); 168 + Bytes.set buf (i + 3) 169 + (Char.chr 170 + (Int32.to_int (Int32.logand (Int32.shift_right_logical v 24) 0xFFl))) 171 + in 172 + (* "expand 32-byte k" *) 173 + let s = Array.make 16 0l in 174 + s.(0) <- 0x61707865l; 175 + s.(1) <- 0x3320646el; 176 + s.(2) <- 0x79622d32l; 177 + s.(3) <- 0x6b206574l; 178 + for i = 0 to 7 do 179 + s.(4 + i) <- get32 key (i * 4) 180 + done; 181 + for i = 0 to 3 do 182 + s.(12 + i) <- get32 nonce (i * 4) 183 + done; 184 + (* 20 rounds (10 double rounds) *) 185 + for _ = 1 to 10 do 186 + (* Column rounds *) 187 + let a, b, c, d = quarter_round s.(0) s.(4) s.(8) s.(12) in 188 + s.(0) <- a; 189 + s.(4) <- b; 190 + s.(8) <- c; 191 + s.(12) <- d; 192 + let a, b, c, d = quarter_round s.(1) s.(5) s.(9) s.(13) in 193 + s.(1) <- a; 194 + s.(5) <- b; 195 + s.(9) <- c; 196 + s.(13) <- d; 197 + let a, b, c, d = quarter_round s.(2) s.(6) s.(10) s.(14) in 198 + s.(2) <- a; 199 + s.(6) <- b; 200 + s.(10) <- c; 201 + s.(14) <- d; 202 + let a, b, c, d = quarter_round s.(3) s.(7) s.(11) s.(15) in 203 + s.(3) <- a; 204 + s.(7) <- b; 205 + s.(11) <- c; 206 + s.(15) <- d; 207 + (* Diagonal rounds *) 208 + let a, b, c, d = quarter_round s.(0) s.(5) s.(10) s.(15) in 209 + s.(0) <- a; 210 + s.(5) <- b; 211 + s.(10) <- c; 212 + s.(15) <- d; 213 + let a, b, c, d = quarter_round s.(1) s.(6) s.(11) s.(12) in 214 + s.(1) <- a; 215 + s.(6) <- b; 216 + s.(11) <- c; 217 + s.(12) <- d; 218 + let a, b, c, d = quarter_round s.(2) s.(7) s.(8) s.(13) in 219 + s.(2) <- a; 220 + s.(7) <- b; 221 + s.(8) <- c; 222 + s.(13) <- d; 223 + let a, b, c, d = quarter_round s.(3) s.(4) s.(9) s.(14) in 224 + s.(3) <- a; 225 + s.(4) <- b; 226 + s.(9) <- c; 227 + s.(14) <- d 228 + done; 229 + (* Extract first 4 and last 4 words (without adding initial state) *) 230 + let out = Bytes.create 32 in 231 + put32 out 0 s.(0); 232 + put32 out 4 s.(1); 233 + put32 out 8 s.(2); 234 + put32 out 12 s.(3); 235 + put32 out 16 s.(12); 236 + put32 out 20 s.(13); 237 + put32 out 24 s.(14); 238 + put32 out 28 s.(15); 239 + Bytes.to_string out 240 + 241 + (** {1 v3.local implementation} *) 242 + 243 + let v3_local_encrypt ~key ?(footer = "") payload = 244 + if String.length key <> v3_key_size then Error Encryption_failed 245 + else 246 + try 247 + (* 1. Generate random nonce *) 248 + let nonce = Crypto_rng.generate v3_nonce_size in 249 + 250 + (* 2. Derive keys *) 251 + let ek, ak, n2 = derive_keys ~key ~nonce in 252 + 253 + (* 3. Encrypt with AES-256-CTR *) 254 + let ciphertext = aes_ctr_encrypt ~key:ek ~iv:n2 payload in 255 + 256 + (* 4. Compute authentication tag *) 257 + let pre_auth = pae [ v3_local_header; nonce; ciphertext; footer ] in 258 + let tag = 259 + Digestif.SHA384.(hmac_string ~key:ak pre_auth |> to_raw_string) 260 + in 261 + 262 + (* 5. Assemble token *) 263 + let body = base64url_encode (nonce ^ ciphertext ^ tag) in 264 + let token = 265 + if footer = "" then v3_local_header ^ body 266 + else v3_local_header ^ body ^ "." ^ base64url_encode footer 267 + in 268 + Ok token 269 + with _ -> Error Encryption_failed 270 + 271 + let v3_local_decrypt ~key ?(footer = "") token = 272 + if String.length key <> v3_key_size then Error Decryption_failed 273 + else 274 + try 275 + (* 1. Parse header *) 276 + if not (String.starts_with ~prefix:v3_local_header token) then 277 + Error Invalid_header 278 + else 279 + let rest = 280 + String.sub token 281 + (String.length v3_local_header) 282 + (String.length token - String.length v3_local_header) 283 + in 284 + 285 + (* 2. Split body and footer *) 286 + let body, token_footer = 287 + match String.index_opt rest '.' with 288 + | None -> (rest, "") 289 + | Some i -> 290 + let b = String.sub rest 0 i in 291 + let f = String.sub rest (i + 1) (String.length rest - i - 1) in 292 + (b, base64url_decode f |> Result.value ~default:"") 293 + in 294 + 295 + (* 3. Verify footer matches *) 296 + if token_footer <> footer then Error Authentication_failed 297 + else 298 + (* 4. Decode body *) 299 + match base64url_decode body with 300 + | Error _ -> Error Invalid_base64 301 + | Ok decoded -> 302 + let min_len = v3_nonce_size + v3_mac_size in 303 + if String.length decoded < min_len then Error Invalid_token_format 304 + else 305 + let nonce = String.sub decoded 0 v3_nonce_size in 306 + let ciphertext_len = 307 + String.length decoded - v3_nonce_size - v3_mac_size 308 + in 309 + let ciphertext = 310 + String.sub decoded v3_nonce_size ciphertext_len 311 + in 312 + let tag = 313 + String.sub decoded 314 + (v3_nonce_size + ciphertext_len) 315 + v3_mac_size 316 + in 317 + 318 + (* 5. Derive keys *) 319 + let ek, ak, n2 = derive_keys ~key ~nonce in 320 + 321 + (* 6. Verify authentication tag *) 322 + let pre_auth = 323 + pae [ v3_local_header; nonce; ciphertext; footer ] 324 + in 325 + let expected_tag = 326 + Digestif.SHA384.( 327 + hmac_string ~key:ak pre_auth |> to_raw_string) 328 + in 329 + if not (Eqaf.equal tag expected_tag) then 330 + Error Authentication_failed 331 + else 332 + (* 7. Decrypt *) 333 + let plaintext = aes_ctr_decrypt ~key:ek ~iv:n2 ciphertext in 334 + Ok plaintext 335 + with _ -> Error Decryption_failed 336 + 337 + (** {1 v4.local implementation} 338 + 339 + PASETO v4.local uses XChaCha20 for encryption and BLAKE2b-MAC for 340 + authentication. Key derivation uses BLAKE2b keyed hashing. *) 341 + 342 + (* BLAKE2b modules for different output sizes *) 343 + module BLAKE2B_56 = Digestif.Make_BLAKE2B (struct 344 + let digest_size = 56 345 + end) 346 + 347 + module BLAKE2B_32 = Digestif.Make_BLAKE2B (struct 348 + let digest_size = 32 349 + end) 350 + 351 + (* BLAKE2b keyed hash - PASETO uses the keyed hash variant *) 352 + let blake2b_56 ~key data = BLAKE2B_56.(hmac_string ~key data |> to_raw_string) 353 + let blake2b_32 ~key data = BLAKE2B_32.(hmac_string ~key data |> to_raw_string) 354 + 355 + (* XChaCha20 stream cipher (not AEAD) *) 356 + let xchacha20_crypt ~key ~nonce data = 357 + if String.length key <> 32 then invalid_arg "XChaCha20: key must be 32 bytes"; 358 + if String.length nonce <> 24 then 359 + invalid_arg "XChaCha20: nonce must be 24 bytes"; 360 + (* Derive subkey using HChaCha20 with first 16 bytes of nonce *) 361 + let subkey = hchacha20 ~key ~nonce:(String.sub nonce 0 16) in 362 + let subkey = Crypto.Chacha20.of_secret subkey in 363 + (* Use last 8 bytes of nonce, prefixed with 4 zero bytes for 12-byte IETF nonce *) 364 + let chacha_nonce = "\x00\x00\x00\x00" ^ String.sub nonce 16 8 in 365 + Crypto.Chacha20.crypt ~key:subkey ~nonce:chacha_nonce data 366 + 367 + let derive_v4_keys ~key ~nonce = 368 + (* Derive encryption key (Ek), nonce (n2), and authentication key (Ak) *) 369 + let tmp = blake2b_56 ~key ("paseto-encryption-key" ^ nonce) in 370 + let ek = String.sub tmp 0 32 in 371 + let n2 = String.sub tmp 32 24 in 372 + let ak = blake2b_32 ~key ("paseto-auth-key-for-aead" ^ nonce) in 373 + (ek, n2, ak) 374 + 375 + let v4_local_encrypt ~key ?(footer = "") payload = 376 + if String.length key <> v4_key_size then Error Encryption_failed 377 + else 378 + try 379 + (* 1. Generate random nonce *) 380 + let nonce = Crypto_rng.generate v4_nonce_size in 381 + 382 + (* 2. Derive keys *) 383 + let ek, n2, ak = derive_v4_keys ~key ~nonce in 384 + 385 + (* 3. Encrypt with XChaCha20 *) 386 + let ciphertext = xchacha20_crypt ~key:ek ~nonce:n2 payload in 387 + 388 + (* 4. Compute BLAKE2b-MAC authentication tag *) 389 + let pre_auth = pae [ v4_local_header; nonce; ciphertext; footer ] in 390 + let tag = blake2b_32 ~key:ak pre_auth in 391 + 392 + (* 5. Assemble token *) 393 + let body = base64url_encode (nonce ^ ciphertext ^ tag) in 394 + let token = 395 + if footer = "" then v4_local_header ^ body 396 + else v4_local_header ^ body ^ "." ^ base64url_encode footer 397 + in 398 + Ok token 399 + with _ -> Error Encryption_failed 400 + 401 + let v4_local_decrypt ~key ?(footer = "") token = 402 + if String.length key <> v4_key_size then Error Decryption_failed 403 + else 404 + try 405 + (* 1. Parse header *) 406 + if not (String.starts_with ~prefix:v4_local_header token) then 407 + Error Invalid_header 408 + else 409 + let rest = 410 + String.sub token 411 + (String.length v4_local_header) 412 + (String.length token - String.length v4_local_header) 413 + in 414 + 415 + (* 2. Split body and footer *) 416 + let body, token_footer = 417 + match String.index_opt rest '.' with 418 + | None -> (rest, "") 419 + | Some i -> 420 + let b = String.sub rest 0 i in 421 + let f = String.sub rest (i + 1) (String.length rest - i - 1) in 422 + (b, base64url_decode f |> Result.value ~default:"") 423 + in 424 + 425 + (* 3. Verify footer matches *) 426 + if token_footer <> footer then Error Authentication_failed 427 + else 428 + (* 4. Decode body *) 429 + match base64url_decode body with 430 + | Error _ -> Error Invalid_base64 431 + | Ok decoded -> 432 + let min_len = v4_nonce_size + v4_tag_size in 433 + if String.length decoded < min_len then Error Invalid_token_format 434 + else 435 + let nonce = String.sub decoded 0 v4_nonce_size in 436 + let ciphertext_len = 437 + String.length decoded - v4_nonce_size - v4_tag_size 438 + in 439 + let ciphertext = 440 + String.sub decoded v4_nonce_size ciphertext_len 441 + in 442 + let tag = 443 + String.sub decoded 444 + (v4_nonce_size + ciphertext_len) 445 + v4_tag_size 446 + in 447 + 448 + (* 5. Derive keys *) 449 + let ek, n2, ak = derive_v4_keys ~key ~nonce in 450 + 451 + (* 6. Verify authentication tag *) 452 + let pre_auth = 453 + pae [ v4_local_header; nonce; ciphertext; footer ] 454 + in 455 + let expected_tag = blake2b_32 ~key:ak pre_auth in 456 + if not (Eqaf.equal tag expected_tag) then 457 + Error Authentication_failed 458 + else 459 + (* 7. Decrypt *) 460 + let plaintext = 461 + xchacha20_crypt ~key:ek ~nonce:n2 ciphertext 462 + in 463 + Ok plaintext 464 + with _ -> Error Decryption_failed 465 + 466 + (** {1 High-level JSON API} *) 467 + 468 + type claims = { 469 + iss : string option; (** Issuer *) 470 + sub : string option; (** Subject *) 471 + aud : string option; (** Audience *) 472 + exp : string option; (** Expiration (ISO 8601 datetime) *) 473 + nbf : string option; (** Not Before (ISO 8601 datetime) *) 474 + iat : string option; (** Issued At (ISO 8601 datetime) *) 475 + jti : string option; (** Token ID *) 476 + custom : (string * Jsont.json) list; (** Custom claims *) 477 + } 478 + (** Standard PASETO claims *) 479 + 480 + let empty_claims = 481 + { 482 + iss = None; 483 + sub = None; 484 + aud = None; 485 + exp = None; 486 + nbf = None; 487 + iat = None; 488 + jti = None; 489 + custom = []; 490 + } 491 + 492 + (** JSON codec for claims *) 493 + let claims_jsont = 494 + Jsont.Object.map ~kind:"paseto_claims" (fun iss sub aud exp nbf iat jti -> 495 + { iss; sub; aud; exp; nbf; iat; jti; custom = [] }) 496 + |> Jsont.Object.opt_mem "iss" Jsont.string ~enc:(fun c -> c.iss) 497 + |> Jsont.Object.opt_mem "sub" Jsont.string ~enc:(fun c -> c.sub) 498 + |> Jsont.Object.opt_mem "aud" Jsont.string ~enc:(fun c -> c.aud) 499 + |> Jsont.Object.opt_mem "exp" Jsont.string ~enc:(fun c -> c.exp) 500 + |> Jsont.Object.opt_mem "nbf" Jsont.string ~enc:(fun c -> c.nbf) 501 + |> Jsont.Object.opt_mem "iat" Jsont.string ~enc:(fun c -> c.iat) 502 + |> Jsont.Object.opt_mem "jti" Jsont.string ~enc:(fun c -> c.jti) 503 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 504 + 505 + let encode_claims claims = Jsont_bytesrw.encode_string claims_jsont claims 506 + let decode_claims s = Jsont_bytesrw.decode_string claims_jsont s 507 + 508 + (** {1 Convenience functions} *) 509 + 510 + let v3_encrypt ~key ?footer claims = 511 + match encode_claims claims with 512 + | Error _ -> Error Invalid_payload 513 + | Ok payload -> v3_local_encrypt ~key ?footer payload 514 + 515 + let v3_decrypt ~key ?footer token = 516 + let* payload = v3_local_decrypt ~key ?footer token in 517 + match decode_claims payload with 518 + | Error _ -> Error Invalid_payload 519 + | Ok claims -> Ok claims
+153
lib/paseto.mli
··· 1 + (** PASETO (Platform-Agnostic Security Tokens) implementation. 2 + 3 + PASETO is a specification for secure stateless tokens. This module 4 + implements v3.local and v4.local tokens: 5 + - v3.local: AES-256-CTR encryption with HMAC-SHA384 authentication 6 + - v4.local: XChaCha20 encryption with BLAKE2b-MAC authentication 7 + 8 + {2 Token Format} 9 + 10 + A PASETO token has the format: [version.purpose.payload.footer] 11 + 12 + - [v3.local.] - Version 3, local (symmetric) encryption 13 + - [v4.local.] - Version 4, local (symmetric) encryption 14 + - Payload is base64url-encoded 15 + - Footer is optional, also base64url-encoded 16 + 17 + {2 Example} 18 + 19 + {[ 20 + (* Generate a 32-byte key *) 21 + let key = Crypto_rng.generate 32 22 + 23 + (* Create claims *) 24 + let claims = { 25 + Paseto.empty_claims with 26 + sub = Some "user123"; 27 + exp = Some "2024-12-31T23:59:59Z"; 28 + } 29 + 30 + (* Encrypt *) 31 + match Paseto.v3_encrypt ~key claims with 32 + | Ok token -> print_endline token 33 + | Error e -> Paseto.pp_error Format.err_formatter e 34 + 35 + (* Decrypt *) 36 + match Paseto.v3_decrypt ~key token with 37 + | Ok claims -> (* use claims *) 38 + | Error e -> (* handle error *) 39 + ]} 40 + 41 + {2 References} 42 + 43 + - {{:https://github.com/paseto-standard/paseto-spec} PASETO Specification} 44 + - {{:https://github.com/paseto-standard/paseto-spec/blob/master/docs/01-Protocol-Versions/Version3.md} 45 + Version 3 Specification} *) 46 + 47 + (** {1 Types} *) 48 + 49 + type version = 50 + | V3 51 + | V4 (** PASETO version. Currently V3 is fully supported. *) 52 + 53 + type purpose = 54 + | Local 55 + | Public 56 + (** Token purpose. Local = symmetric encryption, Public = asymmetric 57 + signing. *) 58 + 59 + type error = 60 + | Invalid_token_format 61 + | Invalid_base64 62 + | Invalid_header 63 + | Authentication_failed 64 + | Decryption_failed 65 + | Encryption_failed 66 + | Invalid_payload 67 + 68 + val pp_error : Format.formatter -> error -> unit 69 + (** Pretty-print an error. *) 70 + 71 + (** {1 Standard Claims} *) 72 + 73 + type claims = { 74 + iss : string option; (** Issuer *) 75 + sub : string option; (** Subject *) 76 + aud : string option; (** Audience *) 77 + exp : string option; (** Expiration (ISO 8601 datetime) *) 78 + nbf : string option; (** Not Before (ISO 8601 datetime) *) 79 + iat : string option; (** Issued At (ISO 8601 datetime) *) 80 + jti : string option; (** Token ID *) 81 + custom : (string * Jsont.json) list; (** Custom claims *) 82 + } 83 + (** Standard PASETO claims following the specification. *) 84 + 85 + val empty_claims : claims 86 + (** Empty claims with all fields set to None. *) 87 + 88 + (** {1 Version 3 Local (Symmetric Encryption)} 89 + 90 + v3.local uses AES-256-CTR for encryption and HMAC-SHA384 for authentication. 91 + Keys must be exactly 32 bytes. *) 92 + 93 + val v3_local_encrypt : 94 + key:string -> ?footer:string -> string -> (string, error) result 95 + (** [v3_local_encrypt ~key ?footer payload] encrypts [payload] using PASETO 96 + v3.local. 97 + 98 + @param key A 32-byte symmetric key 99 + @param footer Optional footer data (authenticated but not encrypted) 100 + @param payload The plaintext to encrypt (typically JSON) 101 + @return A PASETO token string on success *) 102 + 103 + val v3_local_decrypt : 104 + key:string -> ?footer:string -> string -> (string, error) result 105 + (** [v3_local_decrypt ~key ?footer token] decrypts a PASETO v3.local token. 106 + 107 + @param key The same 32-byte key used for encryption 108 + @param footer The expected footer (must match token's footer) 109 + @param token The PASETO token string 110 + @return The decrypted payload on success *) 111 + 112 + (** {1 Version 4 Local (Symmetric Encryption)} 113 + 114 + v4.local uses XChaCha20 for encryption and BLAKE2b-MAC for authentication. 115 + Keys must be exactly 32 bytes. *) 116 + 117 + val v4_local_encrypt : 118 + key:string -> ?footer:string -> string -> (string, error) result 119 + (** [v4_local_encrypt ~key ?footer payload] encrypts [payload] using PASETO 120 + v4.local. 121 + 122 + @param key A 32-byte symmetric key 123 + @param footer Optional footer data (authenticated but not encrypted) 124 + @param payload The plaintext to encrypt (typically JSON) 125 + @return A PASETO token string on success *) 126 + 127 + val v4_local_decrypt : 128 + key:string -> ?footer:string -> string -> (string, error) result 129 + (** [v4_local_decrypt ~key ?footer token] decrypts a PASETO v4.local token. 130 + 131 + @param key The same 32-byte key used for encryption 132 + @param footer The expected footer (must match token's footer) 133 + @param token The PASETO token string 134 + @return The decrypted payload on success *) 135 + 136 + (** {1 High-level JSON API} *) 137 + 138 + val v3_encrypt : 139 + key:string -> ?footer:string -> claims -> (string, error) result 140 + (** [v3_encrypt ~key ?footer claims] serializes claims to JSON and encrypts. 141 + 142 + This is a convenience function that handles JSON encoding. *) 143 + 144 + val v3_decrypt : 145 + key:string -> ?footer:string -> string -> (claims, error) result 146 + (** [v3_decrypt ~key ?footer token] decrypts and parses claims from JSON. 147 + 148 + This is a convenience function that handles JSON decoding. *) 149 + 150 + (** {1 JSON Codec} *) 151 + 152 + val claims_jsont : claims Jsont.t 153 + (** JSON codec for standard claims. Custom claims are not preserved. *)
+39
paseto.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "PASETO (Platform-Agnostic Security Tokens) implementation" 4 + description: 5 + "Type-safe PASETO tokens (RFC draft) for OCaml. Supports v3.local (AES-256-CTR + HMAC-SHA384) and v4.local (XChaCha20-Poly1305) for symmetric authenticated encryption of JSON payloads." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "MIT" 9 + homepage: "https://github.com/samoht/ocaml-paseto" 10 + bug-reports: "https://github.com/samoht/ocaml-paseto/issues" 11 + depends: [ 12 + "ocaml" {>= "5.1"} 13 + "dune" {>= "3.0" & >= "3.0"} 14 + "crypto" {>= "1.0"} 15 + "crypto-rng" {>= "1.0"} 16 + "digestif" {>= "1.0"} 17 + "eqaf" {>= "0.9"} 18 + "base64" {>= "3.0"} 19 + "jsont" {>= "0.1.0"} 20 + "alcotest" {with-test} 21 + "crypto-rng" {with-test & >= "0.11.0"} 22 + "crowbar" {with-test} 23 + "odoc" {with-doc} 24 + ] 25 + build: [ 26 + ["dune" "subst"] {dev} 27 + [ 28 + "dune" 29 + "build" 30 + "-p" 31 + name 32 + "-j" 33 + jobs 34 + "@install" 35 + "@runtest" {with-test} 36 + "@doc" {with-doc} 37 + ] 38 + ] 39 + dev-repo: "git+https://github.com/samoht/ocaml-paseto.git"
+3
test/dune
··· 1 + (test 2 + (name test_paseto) 3 + (libraries paseto alcotest crypto-rng.unix))
+228
test/test_paseto.ml
··· 1 + (** Tests for PASETO module *) 2 + 3 + let () = Crypto_rng_unix.use_default () 4 + 5 + (* Generate a valid 32-byte key *) 6 + let key = Crypto_rng.generate 32 7 + 8 + let test_v3_local_roundtrip () = 9 + let payload = {|{"sub":"user123","exp":"2099-12-31T23:59:59Z"}|} in 10 + match Paseto.v3_local_encrypt ~key payload with 11 + | Error e -> Alcotest.failf "Encryption failed: %a" Paseto.pp_error e 12 + | Ok token -> ( 13 + (* Check header *) 14 + Alcotest.(check bool) 15 + "has v3.local header" true 16 + (String.starts_with ~prefix:"v3.local." token); 17 + (* Decrypt *) 18 + match Paseto.v3_local_decrypt ~key token with 19 + | Error e -> Alcotest.failf "Decryption failed: %a" Paseto.pp_error e 20 + | Ok decrypted -> 21 + Alcotest.(check string) "payload roundtrip" payload decrypted) 22 + 23 + let test_v3_local_with_footer () = 24 + let payload = "test payload" in 25 + let footer = "footer data" in 26 + match Paseto.v3_local_encrypt ~key ~footer payload with 27 + | Error e -> Alcotest.failf "Encryption failed: %a" Paseto.pp_error e 28 + | Ok token -> ( 29 + (* Token should have two dots after header *) 30 + let parts = String.split_on_char '.' token in 31 + Alcotest.(check int) "token has 4 parts" 4 (List.length parts); 32 + (* Decrypt with correct footer *) 33 + match Paseto.v3_local_decrypt ~key ~footer token with 34 + | Error e -> Alcotest.failf "Decryption failed: %a" Paseto.pp_error e 35 + | Ok decrypted -> 36 + Alcotest.(check string) "payload roundtrip" payload decrypted) 37 + 38 + let test_v3_local_wrong_key () = 39 + let payload = "secret data" in 40 + match Paseto.v3_local_encrypt ~key payload with 41 + | Error _ -> Alcotest.fail "Encryption should succeed" 42 + | Ok token -> ( 43 + let wrong_key = Crypto_rng.generate 32 in 44 + match Paseto.v3_local_decrypt ~key:wrong_key token with 45 + | Error Paseto.Authentication_failed -> () 46 + | Error e -> 47 + Alcotest.failf "Expected Authentication_failed, got: %a" 48 + Paseto.pp_error e 49 + | Ok _ -> Alcotest.fail "Should reject wrong key") 50 + 51 + let test_v3_local_tampered_token () = 52 + let payload = "secret data" in 53 + match Paseto.v3_local_encrypt ~key payload with 54 + | Error _ -> Alcotest.fail "Encryption should succeed" 55 + | Ok token -> ( 56 + (* Tamper with the token *) 57 + let len = String.length token in 58 + let tampered = 59 + if len > 15 then String.sub token 0 (len - 4) ^ "XXXX" else token 60 + in 61 + match Paseto.v3_local_decrypt ~key tampered with 62 + | Error _ -> () (* Any error is acceptable *) 63 + | Ok _ -> Alcotest.fail "Should reject tampered token") 64 + 65 + let test_v3_local_wrong_footer () = 66 + let payload = "test" in 67 + let footer = "correct footer" in 68 + match Paseto.v3_local_encrypt ~key ~footer payload with 69 + | Error _ -> Alcotest.fail "Encryption should succeed" 70 + | Ok token -> ( 71 + match Paseto.v3_local_decrypt ~key ~footer:"wrong footer" token with 72 + | Error Paseto.Authentication_failed -> () 73 + | Error e -> 74 + Alcotest.failf "Expected Authentication_failed, got: %a" 75 + Paseto.pp_error e 76 + | Ok _ -> Alcotest.fail "Should reject wrong footer") 77 + 78 + let test_v3_claims_roundtrip () = 79 + let claims = 80 + { 81 + Paseto.empty_claims with 82 + sub = Some "user123"; 83 + iss = Some "test-issuer"; 84 + exp = Some "2099-12-31T23:59:59Z"; 85 + } 86 + in 87 + match Paseto.v3_encrypt ~key claims with 88 + | Error e -> Alcotest.failf "Encryption failed: %a" Paseto.pp_error e 89 + | Ok token -> ( 90 + match Paseto.v3_decrypt ~key token with 91 + | Error e -> Alcotest.failf "Decryption failed: %a" Paseto.pp_error e 92 + | Ok parsed -> 93 + Alcotest.(check (option string)) "sub" claims.sub parsed.sub; 94 + Alcotest.(check (option string)) "iss" claims.iss parsed.iss; 95 + Alcotest.(check (option string)) "exp" claims.exp parsed.exp) 96 + 97 + let test_invalid_key_size () = 98 + let short_key = "too short" in 99 + let payload = "test" in 100 + match Paseto.v3_local_encrypt ~key:short_key payload with 101 + | Error Paseto.Encryption_failed -> () 102 + | Error e -> 103 + Alcotest.failf "Expected Encryption_failed, got: %a" Paseto.pp_error e 104 + | Ok _ -> Alcotest.fail "Should reject invalid key size" 105 + 106 + let test_invalid_token_header () = 107 + match Paseto.v3_local_decrypt ~key "v2.local.invalid" with 108 + | Error Paseto.Invalid_header -> () 109 + | Error e -> 110 + Alcotest.failf "Expected Invalid_header, got: %a" Paseto.pp_error e 111 + | Ok _ -> Alcotest.fail "Should reject invalid header" 112 + 113 + (* v4.local tests *) 114 + 115 + let test_v4_local_roundtrip () = 116 + let payload = {|{"sub":"user123","exp":"2099-12-31T23:59:59Z"}|} in 117 + match Paseto.v4_local_encrypt ~key payload with 118 + | Error e -> Alcotest.failf "Encryption failed: %a" Paseto.pp_error e 119 + | Ok token -> ( 120 + (* Check header *) 121 + Alcotest.(check bool) 122 + "has v4.local header" true 123 + (String.starts_with ~prefix:"v4.local." token); 124 + (* Decrypt *) 125 + match Paseto.v4_local_decrypt ~key token with 126 + | Error e -> Alcotest.failf "Decryption failed: %a" Paseto.pp_error e 127 + | Ok decrypted -> 128 + Alcotest.(check string) "payload roundtrip" payload decrypted) 129 + 130 + let test_v4_local_with_footer () = 131 + let payload = "test payload" in 132 + let footer = "footer data" in 133 + match Paseto.v4_local_encrypt ~key ~footer payload with 134 + | Error e -> Alcotest.failf "Encryption failed: %a" Paseto.pp_error e 135 + | Ok token -> ( 136 + (* Token should have two dots after header *) 137 + let parts = String.split_on_char '.' token in 138 + Alcotest.(check int) "token has 4 parts" 4 (List.length parts); 139 + (* Decrypt with correct footer *) 140 + match Paseto.v4_local_decrypt ~key ~footer token with 141 + | Error e -> Alcotest.failf "Decryption failed: %a" Paseto.pp_error e 142 + | Ok decrypted -> 143 + Alcotest.(check string) "payload roundtrip" payload decrypted) 144 + 145 + let test_v4_local_wrong_key () = 146 + let payload = "secret data" in 147 + match Paseto.v4_local_encrypt ~key payload with 148 + | Error _ -> Alcotest.fail "Encryption should succeed" 149 + | Ok token -> ( 150 + let wrong_key = Crypto_rng.generate 32 in 151 + match Paseto.v4_local_decrypt ~key:wrong_key token with 152 + | Error Paseto.Authentication_failed -> () 153 + | Error e -> 154 + Alcotest.failf "Expected Authentication_failed, got: %a" 155 + Paseto.pp_error e 156 + | Ok _ -> Alcotest.fail "Should reject wrong key") 157 + 158 + let test_v4_local_tampered_token () = 159 + let payload = "secret data" in 160 + match Paseto.v4_local_encrypt ~key payload with 161 + | Error _ -> Alcotest.fail "Encryption should succeed" 162 + | Ok token -> ( 163 + (* Tamper with the token *) 164 + let len = String.length token in 165 + let tampered = 166 + if len > 15 then String.sub token 0 (len - 4) ^ "XXXX" else token 167 + in 168 + match Paseto.v4_local_decrypt ~key tampered with 169 + | Error _ -> () (* Any error is acceptable *) 170 + | Ok _ -> Alcotest.fail "Should reject tampered token") 171 + 172 + let test_v4_local_wrong_footer () = 173 + let payload = "test" in 174 + let footer = "correct footer" in 175 + match Paseto.v4_local_encrypt ~key ~footer payload with 176 + | Error _ -> Alcotest.fail "Encryption should succeed" 177 + | Ok token -> ( 178 + match Paseto.v4_local_decrypt ~key ~footer:"wrong footer" token with 179 + | Error Paseto.Authentication_failed -> () 180 + | Error e -> 181 + Alcotest.failf "Expected Authentication_failed, got: %a" 182 + Paseto.pp_error e 183 + | Ok _ -> Alcotest.fail "Should reject wrong footer") 184 + 185 + let test_v4_invalid_key_size () = 186 + let short_key = "too short" in 187 + let payload = "test" in 188 + match Paseto.v4_local_encrypt ~key:short_key payload with 189 + | Error Paseto.Encryption_failed -> () 190 + | Error e -> 191 + Alcotest.failf "Expected Encryption_failed, got: %a" Paseto.pp_error e 192 + | Ok _ -> Alcotest.fail "Should reject invalid key size" 193 + 194 + let test_v4_invalid_token_header () = 195 + match Paseto.v4_local_decrypt ~key "v3.local.invalid" with 196 + | Error Paseto.Invalid_header -> () 197 + | Error e -> 198 + Alcotest.failf "Expected Invalid_header, got: %a" Paseto.pp_error e 199 + | Ok _ -> Alcotest.fail "Should reject invalid header" 200 + 201 + let () = 202 + Alcotest.run "paseto" 203 + [ 204 + ( "v3.local", 205 + [ 206 + Alcotest.test_case "roundtrip" `Quick test_v3_local_roundtrip; 207 + Alcotest.test_case "with footer" `Quick test_v3_local_with_footer; 208 + Alcotest.test_case "wrong key" `Quick test_v3_local_wrong_key; 209 + Alcotest.test_case "tampered token" `Quick 210 + test_v3_local_tampered_token; 211 + Alcotest.test_case "wrong footer" `Quick test_v3_local_wrong_footer; 212 + Alcotest.test_case "claims roundtrip" `Quick test_v3_claims_roundtrip; 213 + Alcotest.test_case "invalid key size" `Quick test_invalid_key_size; 214 + Alcotest.test_case "invalid header" `Quick test_invalid_token_header; 215 + ] ); 216 + ( "v4.local", 217 + [ 218 + Alcotest.test_case "roundtrip" `Quick test_v4_local_roundtrip; 219 + Alcotest.test_case "with footer" `Quick test_v4_local_with_footer; 220 + Alcotest.test_case "wrong key" `Quick test_v4_local_wrong_key; 221 + Alcotest.test_case "tampered token" `Quick 222 + test_v4_local_tampered_token; 223 + Alcotest.test_case "wrong footer" `Quick test_v4_local_wrong_footer; 224 + Alcotest.test_case "invalid key size" `Quick test_v4_invalid_key_size; 225 + Alcotest.test_case "invalid header" `Quick 226 + test_v4_invalid_token_header; 227 + ] ); 228 + ]