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