A batteries included HTTP/1.1 client in OCaml
at main 496 lines 18 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6let src = Logs.Src.create "requests.auth" ~doc:"HTTP Authentication" 7 8module Log = (val Logs.src_log src : Logs.LOG) 9 10type t = 11 | No_auth 12 | Basic of { username : string; password : string } 13 | Bearer of { token : string } 14 | Bearer_form of { token : string } 15 (** RFC 6750 Section 2.2: Bearer token in form-encoded body *) 16 | Digest of { username : string; password : string } 17 | Signature of Signature.config (** RFC 9421: HTTP Message Signatures *) 18 | Custom of (Headers.t -> Headers.t) 19 20type digest_challenge = { 21 realm : string; 22 nonce : string; 23 qop : string option; 24 algorithm : string; (** MD5, SHA-256, etc. *) 25 opaque : string option; 26 stale : bool; 27 userhash : bool; (** RFC 7616: If true, hash the username *) 28} 29(** Digest authentication challenge parsed from WWW-Authenticate header *) 30 31let none = No_auth 32let basic ~username ~password = Basic { username; password } 33let bearer ~token = Bearer { token } 34let digest ~username ~password = Digest { username; password } 35let signature config = Signature config 36let custom f = Custom f 37 38(** Check if a URL uses HTTPS scheme *) 39let is_https url = 40 let uri = Uri.of_string url in 41 match Uri.scheme uri with Some "https" -> true | _ -> false 42 43(** Get the authentication type name for error messages *) 44let type_name = function 45 | No_auth -> "None" 46 | Basic _ -> "Basic" 47 | Bearer _ -> "Bearer" 48 | Bearer_form _ -> "Bearer (form)" 49 | Digest _ -> "Digest" 50 | Signature _ -> "Signature" 51 | Custom _ -> "Custom" 52 53(** Check if auth type requires HTTPS (per RFC 7617/6750). Basic, Bearer, and 54 Digest send credentials that can be intercepted. Signature does not strictly 55 require HTTPS as it provides its own integrity. *) 56let requires_https = function 57 | Basic _ | Bearer _ | Bearer_form _ | Digest _ -> true 58 | No_auth | Signature _ | Custom _ -> false 59 60(** Validate that sensitive authentication is used over HTTPS. Per RFC 7617 61 Section 4 (Basic) and RFC 6750 Section 5.1 (Bearer): These authentication 62 methods MUST be used over TLS to prevent credential leakage. 63 64 @param allow_insecure_auth 65 If true, skip the check (for testing environments) 66 @param url The request URL 67 @param auth The authentication configuration 68 @raise Error.Insecure_auth if auth requires HTTPS but URL is HTTP *) 69let validate_secure_transport ?(allow_insecure_auth = false) ~url auth = 70 if allow_insecure_auth then 71 Log.warn (fun m -> 72 m "allow_insecure_auth=true: skipping HTTPS check for %s auth" 73 (type_name auth)) 74 else if requires_https auth && not (is_https url) then begin 75 Log.err (fun m -> 76 m 77 "%s authentication rejected over HTTP (use HTTPS or \ 78 allow_insecure_auth=true)" 79 (type_name auth)); 80 raise (Error.err (Error.Insecure_auth { url; auth_type = type_name auth })) 81 end 82 83let apply auth headers = 84 match auth with 85 | No_auth -> headers 86 | Basic { username; password } -> 87 Log.debug (fun m -> 88 m "Applying basic authentication for user: %s" username); 89 Headers.basic ~username ~password headers 90 | Bearer { token } -> 91 Log.debug (fun m -> m "Applying bearer token authentication"); 92 Headers.bearer token headers 93 | Bearer_form { token = _ } -> 94 Log.debug (fun m -> 95 m "Bearer form auth - token goes in body, not headers"); 96 (* Bearer form auth puts token in body, not headers. 97 Use bearer_form_body to get the body content. *) 98 headers 99 | Digest { username; password = _ } -> 100 Log.debug (fun m -> 101 m "Digest auth configured for user: %s (requires server challenge)" 102 username); 103 (* Digest auth requires server challenge first, handled elsewhere *) 104 headers 105 | Signature _ -> 106 Log.debug (fun m -> 107 m "Signature auth configured (requires request context)"); 108 (* Signature auth requires request context (method, URI) to compute. 109 Handled separately in request flow via apply_signature. *) 110 headers 111 | Custom f -> 112 Log.debug (fun m -> m "Applying custom authentication handler"); 113 f headers 114 115(** Apply authentication with HTTPS validation. This is the secure version that 116 checks transport security before applying auth. 117 118 @param allow_insecure_auth If true, allow auth over HTTP (not recommended) 119 @param url The request URL (used for security check) 120 @param auth The authentication to apply 121 @param headers The headers to modify *) 122let apply_secure ?(allow_insecure_auth = false) ~url auth headers = 123 validate_secure_transport ~allow_insecure_auth ~url auth; 124 apply auth headers 125 126(** {1 Digest Authentication Implementation} *) 127 128(** Parse comma-separated key=value or key="value" pairs. *) 129let rec parse_digest_kv_pairs acc str = 130 let str = String.trim str in 131 if str = "" then List.rev acc 132 else 133 match String.index_opt str '=' with 134 | None -> List.rev acc 135 | Some eq_idx -> 136 let key = String.trim (String.sub str 0 eq_idx) in 137 let rest = 138 String.sub str (eq_idx + 1) (String.length str - eq_idx - 1) 139 in 140 let rest = String.trim rest in 141 let value, remaining = 142 if String.length rest > 0 && rest.[0] = '"' then 143 match String.index_from_opt rest 1 '"' with 144 | Some end_quote -> 145 let v = String.sub rest 1 (end_quote - 1) in 146 let rem = 147 String.sub rest (end_quote + 1) 148 (String.length rest - end_quote - 1) 149 in 150 let rem = String.trim rem in 151 let rem = 152 if String.length rem > 0 && rem.[0] = ',' then 153 String.sub rem 1 (String.length rem - 1) 154 else rem 155 in 156 (v, rem) 157 | None -> (rest, "") 158 else 159 match String.index_opt rest ',' with 160 | Some comma -> 161 let v = String.trim (String.sub rest 0 comma) in 162 let rem = 163 String.sub rest (comma + 1) (String.length rest - comma - 1) 164 in 165 (v, rem) 166 | None -> (String.trim rest, "") 167 in 168 parse_digest_kv_pairs 169 ((String.lowercase_ascii key, value) :: acc) 170 remaining 171 172(** Parse WWW-Authenticate header for Digest challenge *) 173let parse_www_authenticate header = 174 Log.debug (fun m -> m "Parsing WWW-Authenticate: %s" header); 175 let header = String.trim header in 176 if 177 not 178 (String.length header >= 7 179 && String.lowercase_ascii (String.sub header 0 7) = "digest ") 180 then begin 181 Log.debug (fun m -> m "Not a Digest challenge"); 182 None 183 end 184 else 185 let params = String.sub header 7 (String.length header - 7) in 186 let pairs = parse_digest_kv_pairs [] params in 187 match (List.assoc_opt "realm" pairs, List.assoc_opt "nonce" pairs) with 188 | Some realm, Some nonce -> 189 let challenge = 190 { 191 realm; 192 nonce; 193 qop = List.assoc_opt "qop" pairs; 194 algorithm = 195 List.assoc_opt "algorithm" pairs |> Option.value ~default:"MD5"; 196 opaque = List.assoc_opt "opaque" pairs; 197 stale = List.assoc_opt "stale" pairs = Some "true"; 198 userhash = List.assoc_opt "userhash" pairs = Some "true"; 199 } 200 in 201 Log.debug (fun m -> 202 m 203 "Parsed Digest challenge: realm=%s nonce=%s algorithm=%s \ 204 userhash=%b" 205 challenge.realm challenge.nonce challenge.algorithm 206 challenge.userhash); 207 Option.some challenge 208 | _ -> 209 Log.warn (fun m -> 210 m "Digest challenge missing required fields (realm/nonce)"); 211 Option.none 212 213(** Hash function based on algorithm. Supports MD5 (default), SHA-256, and 214 SHA-512 per RFC 7616. 215 @raise Error.Authentication_failed if an unsupported algorithm is requested 216*) 217let hash_string ~algorithm s = 218 match String.uppercase_ascii algorithm with 219 | "MD5" | "MD5-SESS" -> Digestif.MD5.(to_hex (digest_string s)) 220 | "SHA-256" | "SHA256" | "SHA-256-SESS" -> 221 Digestif.SHA256.(to_hex (digest_string s)) 222 | "SHA-512" | "SHA512" -> Digestif.SHA512.(to_hex (digest_string s)) 223 | "SHA-512-256" | "SHA512-256" -> 224 (* SHA-512/256 requires specific initialization vectors that differ from 225 standard SHA-512. Truncating SHA-512 output is cryptographically incorrect. 226 This algorithm is rarely used; recommend SHA-256 instead. *) 227 Log.err (fun m -> 228 m "SHA-512-256 algorithm not supported (requires special IVs)"); 229 raise 230 (Error.err 231 (Error.Authentication_failed 232 { 233 url = ""; 234 reason = 235 "Digest algorithm SHA-512-256 is not supported. Server \ 236 should offer SHA-256 or MD5."; 237 })) 238 | other -> 239 (* RFC 7616: Unknown algorithms should be rejected to prevent security downgrades. 240 Silent fallback to MD5 could mask server misconfigurations. *) 241 Log.err (fun m -> m "Unknown digest algorithm '%s'" other); 242 raise 243 (Error.err 244 (Error.Authentication_failed 245 { 246 url = ""; 247 reason = 248 Fmt.str 249 "Unknown digest algorithm '%s'. Supported: MD5, SHA-256, \ 250 SHA-512." 251 other; 252 })) 253 254(** Generate a random client nonce *) 255let generate_cnonce () = 256 let bytes = Crypto_rng.generate 8 in 257 (* Convert bytes to hex string *) 258 let hex_of_char c = 259 let n = Char.code c in 260 Fmt.str "%02x" n 261 in 262 String.concat "" 263 (List.init (String.length bytes) (fun i -> hex_of_char bytes.[i])) 264 265(** Check if algorithm is a -sess variant *) 266let is_sess_algorithm algorithm = 267 let alg = String.uppercase_ascii algorithm in 268 String.ends_with ~suffix:"-SESS" alg 269 270(** Compute digest response according to RFC 7616. 271 272 @param body 273 Optional request body for auth-int qop (body hash included in HA2) *) 274let compute_digest_response ~username ~password ~method_ ~uri ~challenge ~nc 275 ~cnonce ?body () = 276 let algorithm = challenge.algorithm in 277 (* HA1 calculation differs for -sess algorithms (RFC 7616 Section 3.4.2) *) 278 let ha1_base = 279 hash_string ~algorithm 280 (Fmt.str "%s:%s:%s" username challenge.realm password) 281 in 282 let ha1 = 283 if is_sess_algorithm algorithm then 284 (* For -sess: HA1 = hash(hash(username:realm:password):nonce:cnonce) *) 285 hash_string ~algorithm 286 (Fmt.str "%s:%s:%s" ha1_base challenge.nonce cnonce) 287 else ha1_base 288 in 289 (* Determine which qop to use *) 290 let selected_qop = 291 match challenge.qop with 292 | Some qop -> 293 let qop_parts = String.split_on_char ',' qop |> List.map String.trim in 294 (* Prefer auth-int if body is provided and available, else auth *) 295 if List.mem "auth-int" qop_parts && Option.is_some body then 296 Some "auth-int" 297 else if List.mem "auth" qop_parts then Some "auth" 298 else if qop_parts <> [] then Some (List.hd qop_parts) 299 else None 300 | None -> None 301 in 302 (* HA2 depends on qop *) 303 let ha2 = 304 match (selected_qop, body) with 305 | Some "auth-int", Some body_content -> 306 (* HA2 = hash(method:uri:hash(body)) for auth-int *) 307 let body_hash = hash_string ~algorithm body_content in 308 hash_string ~algorithm (Fmt.str "%s:%s:%s" method_ uri body_hash) 309 | _ -> 310 (* HA2 = hash(method:uri) for auth or no qop *) 311 hash_string ~algorithm (Fmt.str "%s:%s" method_ uri) 312 in 313 (* Response depends on qop *) 314 let response, actual_qop = 315 match selected_qop with 316 | Some qop -> 317 (* qop present: hash(HA1:nonce:nc:cnonce:qop:HA2) *) 318 let resp = 319 hash_string ~algorithm 320 (Fmt.str "%s:%s:%s:%s:%s:%s" ha1 challenge.nonce nc cnonce qop ha2) 321 in 322 (resp, Some qop) 323 | None -> 324 (* No qop: hash(HA1:nonce:HA2) *) 325 let resp = 326 hash_string ~algorithm (Fmt.str "%s:%s:%s" ha1 challenge.nonce ha2) 327 in 328 (resp, None) 329 in 330 Log.debug (fun m -> 331 m "Computed digest response for user %s (qop=%s)" username 332 (Option.value ~default:"none" actual_qop)); 333 (response, actual_qop) 334 335(** Build the Authorization header value for Digest auth. 336 @param actual_qop The qop that was actually used (auth or auth-int) *) 337let build_digest_header ~username ~uri ~challenge ~nc ~cnonce ~response 338 ~actual_qop = 339 (* RFC 7616 Section 3.4.4: userhash support *) 340 let username_value, userhash_param = 341 if challenge.userhash then 342 let hashed = 343 hash_string ~algorithm:challenge.algorithm 344 (Fmt.str "%s:%s" username challenge.realm) 345 in 346 (hashed, Some "userhash=true") 347 else (username, None) 348 in 349 let parts = 350 [ 351 Fmt.str "username=\"%s\"" username_value; 352 Fmt.str "realm=\"%s\"" challenge.realm; 353 Fmt.str "nonce=\"%s\"" challenge.nonce; 354 Fmt.str "uri=\"%s\"" uri; 355 Fmt.str "algorithm=%s" challenge.algorithm; 356 Fmt.str "response=\"%s\"" response; 357 ] 358 in 359 let parts = 360 match userhash_param with Some p -> parts @ [ p ] | None -> parts 361 in 362 let parts = 363 match actual_qop with 364 | Some qop -> 365 parts 366 @ [ 367 Fmt.str "qop=%s" qop; 368 Fmt.str "nc=%s" nc; 369 Fmt.str "cnonce=\"%s\"" cnonce; 370 ] 371 | None -> parts 372 in 373 let parts = 374 match challenge.opaque with 375 | Some o -> parts @ [ Fmt.str "opaque=\"%s\"" o ] 376 | None -> parts 377 in 378 "Digest " ^ String.concat ", " parts 379 380(** {1 Nonce Count Tracking} 381 382 Per RFC 7616, the nonce count (nc) must be incremented for each request 383 using the same server nonce to prevent replay attacks. *) 384 385module Nonce_counter = struct 386 type t = (string, int) Hashtbl.t 387 (** Mutable nonce count tracker, keyed by server nonce *) 388 389 let create () : t = Hashtbl.create 16 390 391 (** Get and increment the nonce count for a given server nonce. Returns the 392 count formatted as 8 hex digits (e.g., "00000001"). *) 393 let next (t : t) ~nonce = 394 let count = 395 match Hashtbl.find_opt t nonce with Some c -> c + 1 | None -> 1 396 in 397 Hashtbl.replace t nonce count; 398 Fmt.str "%08x" count 399 400 (** Clear all tracked nonces (e.g., on session reset) *) 401 let clear (t : t) = Hashtbl.clear t 402end 403 404(** Apply Digest authentication given a challenge. 405 @param nonce_counter 406 Optional nonce counter for replay protection. If provided, the nonce count 407 is tracked and incremented per-nonce. If not provided, defaults to 408 "00000001" (single-request mode). 409 @param body Optional request body for auth-int qop support. *) 410let apply_digest ?nonce_counter ?body ~username ~password ~method_ ~uri 411 ~challenge headers = 412 let nc = 413 match nonce_counter with 414 | Some counter -> Nonce_counter.next counter ~nonce:challenge.nonce 415 | None -> "00000001" 416 in 417 let cnonce = generate_cnonce () in 418 let response, actual_qop = 419 compute_digest_response ~username ~password ~method_ ~uri ~challenge ~nc 420 ~cnonce ?body () 421 in 422 let auth_header = 423 build_digest_header ~username ~uri ~challenge ~nc ~cnonce ~response 424 ~actual_qop 425 in 426 Log.debug (fun m -> 427 m "Applied Digest authentication for user %s (nc=%s qop=%s)" username nc 428 (Option.value ~default:"none" actual_qop)); 429 Headers.set `Authorization auth_header headers 430 431(** Check if auth type is Digest *) 432let is_digest = function Digest _ -> true | _ -> false 433 434(** Get Digest credentials if configured *) 435let digest_credentials = function 436 | Digest { username; password } -> Some (username, password) 437 | _ -> None 438 439(** {1 Bearer Form Authentication} 440 441 Per RFC 6750 Section 2.2: Bearer token can be sent as a form-encoded body 442 parameter "access_token". This is less preferred than the Authorization 443 header but may be required by some APIs. *) 444 445let bearer_form ~token = Bearer_form { token } 446let is_bearer_form = function Bearer_form _ -> true | _ -> false 447 448let bearer_form_body = function 449 | Bearer_form { token } -> Some (Fmt.str "access_token=%s" token) 450 | _ -> None 451 452(** Check if stale=true in digest challenge, indicating password is still valid. 453 Per RFC 7616: If stale=true, the client should retry with same credentials 454 using the new nonce. If stale=false or not present, credentials are wrong. 455*) 456let digest_is_stale challenge = challenge.stale 457 458(** {1 HTTP Message Signatures (RFC 9421)} *) 459 460let is_signature = function Signature _ -> true | _ -> false 461let signature_config = function Signature config -> Some config | _ -> None 462 463(** Apply HTTP Message Signature to headers given request context. This computes 464 and adds the Signature-Input and Signature headers. 465 466 @param clock Eio clock for timestamp generation 467 @param method_ The HTTP method 468 @param uri The request URI 469 @param headers The headers to sign (and add signature to) 470 @param auth The authentication configuration (must be [Signature]) 471 @return 472 Updated headers with signature, or original headers if not Signature auth 473*) 474let apply_signature ~clock ~method_ ~uri ~headers auth = 475 match auth with 476 | Signature config -> ( 477 let context = Signature.Context.request ~method_ ~uri ~headers in 478 match Signature.sign ~clock ~config ~context ~headers with 479 | Ok signed_headers -> 480 Log.debug (fun m -> m "Applied HTTP message signature"); 481 signed_headers 482 | Error e -> 483 Log.err (fun m -> 484 m "Failed to apply HTTP message signature: %s" 485 (Signature.sign_error_to_string e)); 486 headers) 487 | _ -> headers 488 489let pp fmt = function 490 | No_auth -> Format.pp_print_string fmt "No_auth" 491 | Basic { username; _ } -> Fmt.pf fmt "Basic(%s)" username 492 | Bearer _ -> Format.pp_print_string fmt "Bearer(<token>)" 493 | Bearer_form _ -> Format.pp_print_string fmt "Bearer_form(<token>)" 494 | Digest { username; _ } -> Fmt.pf fmt "Digest(%s)" username 495 | Signature _ -> Format.pp_print_string fmt "Signature(<config>)" 496 | Custom _ -> Format.pp_print_string fmt "Custom(<fn>)"