(* $Id$ *)
(* Unit tests: tests/netstring/bench/test_netmech.ml (SASL only) *)
(* The core of digest authentication *)
(* What is implemented in the client (when H is the name of the hash function):
- HTTP: RFC-2069 mode
- HTTP: RFC-2617 mode: qop="auth", both H and H-sess
- HTTP: charset is iso-8859-1
- HTTP: user name hashing
- SASL mode: qop="auth", H-sess, charset=utf-8
What is implemented in the server:
- HTTP: NO RFC-2069 mode
- HTTP: RFC-2617 mode: qop="auth", both H and H-sess
(selected by ss.snosess)
- HTTP: NO user name hashing
- HTTP: charset can be iso-8859-1 or utf-8
- SASL mode: qop="auth", H-sess, charset=utf-8
So far: H=MD5. We are prepared for other hash functions, though.
*)
open Printf
module StrMap = Map.Make(String)
module StrSet = Set.Make(String)
type ptype = [ `SASL | `HTTP ]
type profile =
{ ptype : ptype;
hash_functions : Netsys_digests.iana_hash_fn list;
(* The server will only use the first one. The client will accept
any of these *)
mutual : bool;
(* Only for clients: whether it is required that the server includes
(for HTTP) or includes the right rspauth header. *)
}
type response_params =
{ r_ptype : ptype;
r_hash : Netsys_digests.iana_hash_fn;
r_no_sess : bool; (* simple scheme w/o -sess. Only HTTP *)
r_rfc2069 : bool;
r_user : string; (* UTF-8 or ISO-8859-1 *)
r_authz : string option;
r_realm : string; (* UTF-8 or ISO-8859-1 *)
r_nonce : string;
r_cnonce : string;
r_nc : int;
r_method : string;
r_digest_uri : string;
r_utf8 : bool;
r_opaque : string option; (* only HTTP *)
r_domain : string list; (* only HTTP *)
r_userhash : bool; (* only HTTP *)
}
type credentials =
(string * string * (string * string) list) list
type server_session =
{ sstate : Netsys_sasl_types.server_state;
sresponse : (response_params * string * string) option;
snextnc : int;
sstale : bool;
snonce : string;
srealm : string option; (* always UTF-8 *)
sprofile : profile;
sutf8 : bool; (* whether to use UTF-8 on the wire *)
snosess : bool;
lookup : string -> string -> credentials option;
}
let create_nonce() =
let nonce_data = Bytes.create 16 in
Netsys_rng.fill_random nonce_data;
Netencoding.to_hex (Bytes.to_string nonce_data)
let hash iana_name =
if iana_name = `MD5 then
Digest.string
else
Netsys_digests.digest_string (Netsys_digests.iana_find iana_name)
let hash_available iana_name =
iana_name = `MD5 ||
( try ignore(Netsys_digests.iana_find iana_name); true
with Not_found -> false
)
(* Quotes strings: *)
let qstring =
Nethttp.qstring_of_value
let hex s =
Netencoding.to_hex ~lc:true s
let compute_response (p:response_params) password a2_prefix =
(* a2_prefix: either "AUTHENTICATE:" or ":" *)
let nc = sprintf "%08x" p.r_nc in
(*
eprintf "compute_response user=%s authz=%s realm=%s password=%s nonce=%s cnonce=%s digest-uri=%s nc=%s a2_prefix=%s\n"
p.r_user (match p.r_authz with None -> "n/a" | Some a -> a)
p.r_realm password p.r_nonce p.r_cnonce p.r_digest_uri nc a2_prefix;
*)
(* Note that RFC-2617 has an error here (it would calculate
a1_a = hex (h ...)), and this made it into the standard. So
DIGEST-MD5 as SASL is incompatible with Digest Authentication for HTTP.
*)
let h = hash p.r_hash in
let a1 =
if p.r_no_sess then
p.r_user ^ ":" ^ p.r_realm ^ ":" ^ password
else
let a1_a =
h (p.r_user ^ ":" ^ p.r_realm ^ ":" ^ password) in
let a1_a =
match p.r_ptype with
| `HTTP -> hex a1_a (* see comment above *)
| `SASL -> a1_a in
let a1_b =
a1_a ^ ":" ^ p.r_nonce ^ ":" ^ p.r_cnonce in
match p.r_authz with
| None -> a1_b
| Some authz -> a1_b ^ ":" ^ authz in
let a2 = a2_prefix ^ p.r_digest_uri in
let auth_body =
if p.r_rfc2069 then (* RFC 2069 mode *)
[ hex (h a1); p.r_nonce; hex (h a2) ]
else
[ hex (h a1); p.r_nonce; nc; p.r_cnonce; "auth"; hex (h a2) ] in
hex (h (String.concat ":" auth_body))
let verify_utf8 s =
try
Netconversion.verify `Enc_utf8 s
with _ -> failwith "UTF-8 mismatch"
let to_utf8 is_utf8 s =
(* Convert from client encoding to UTF-8 *)
if is_utf8 then (
verify_utf8 s;
s
)
else
(* it is ISO-8859-1 *)
Netconversion.convert
~in_enc:`Enc_iso88591
~out_enc:`Enc_utf8
s
let to_client is_utf8 s =
(* Convert from UTF-8 to client encoding *)
if is_utf8 then (
verify_utf8 s;
s (* client uses utf-8, too *)
)
else
try
Netconversion.convert
~in_enc:`Enc_utf8
~out_enc:`Enc_iso88591
s
with
| Netconversion.Malformed_code ->
failwith "cannot convert to ISO-8859-1"
let to_strmap l =
(* will raise Not_found if a key appears twice *)
fst
(List.fold_left
(fun (m,s) (name,value) ->
if StrSet.mem name s then raise Not_found;
(StrMap.add name value m, StrSet.add name s)
)
(StrMap.empty, StrSet.empty)
l
)
let space_re = Netstring_str.regexp "[ \t]+"
let space_split = Netstring_str.split space_re
let nc_re =
let hex = "[0-9a-f]" in
Netstring_str.regexp (hex ^ hex ^ hex ^ hex ^ hex ^ hex ^ hex ^ hex ^ "$")
let get_nc s =
match Netstring_str.string_match nc_re s 0 with
| None ->
raise Not_found
| Some _ ->
( try int_of_string ("0x" ^ s)
with Failure _ -> raise Not_found
)
let server_emit_initial_challenge_kv ?(quote=false) ss =
(* for HTTP: "domain" is not returned *)
let q s = if quote then qstring s else s in
let h = List.hd ss.sprofile.hash_functions in
let h_name = List.assoc h Netsys_digests.iana_rev_alist in
let l =
( match ss.srealm with
| None -> []
| Some realm -> [ "realm", q (to_utf8 ss.sutf8 realm) ]
) @
[ "nonce", q ss.snonce;
"qpop", "auth"
] @
( if ss.sstale then [ "stale", "true" ] else [] ) @
( if ss.sutf8 then [ "charset", "utf-8" ] else [] ) @
[ "algorithm", STRING_UPPERCASE h_name ^
(if ss.snosess then "" else "-sess") ] in
( { ss with
sstate = `Wait;
sstale = false;
},
l
)
let server_emit_final_challenge_kv ?(quote=false) ss =
let q s = if quote then qstring s else s in
match ss.sresponse with
| None -> assert false
| Some(rp,_,srv_resp) ->
( { ss with sstate = `OK; },
[ "rspauth", q srv_resp ] @
( match ss.sprofile.ptype with
| `SASL -> []
| `HTTP ->
[ "qop", "auth";
"cnonce", q rp.r_cnonce;
"nc", sprintf "%08x" rp.r_nc
]
)
)
let iana_sess_alist =
List.map
(fun (name,code) -> (name ^ "-sess", code))
Netsys_digests.iana_alist
let decode_response ptype msg_params method_name =
let m = to_strmap msg_params in
let user = StrMap.find "username" m in
let realm = try StrMap.find "realm" m with Not_found -> "" in
let nonce = StrMap.find "nonce" m in
(* We only support qop="auth" in server mode, so there is always
a cnonce and nc.
*)
let qop = StrMap.find "qop" m in
if qop <>"auth" then failwith "bad qop";
let cnonce = StrMap.find "cnonce" m in
let nc_str = StrMap.find "nc" m in
let nc = get_nc nc_str in
let digest_uri_name =
match ptype with
| `HTTP -> "uri"
| `SASL -> "digest-uri" in
let digest_uri = StrMap.find digest_uri_name m in
let response = StrMap.find "response" m in
let utf8 =
if StrMap.mem "charset" m then (
let v = StrMap.find "charset" m in
if v <> "utf-8" then failwith "bad charset";
true
)
else
false in
let opaque =
try Some(StrMap.find "opaque" m) with Not_found -> None in
let authz0 =
try Some(StrMap.find "authzid" m) with Not_found -> None in
let authz =
if authz0 = Some "" then None else authz0 in
let userhash =
try StrMap.find "userhash" m = "true" with Not_found -> false in
let alg_lc =
try StrMap.find "algorithm" m with Not_found -> "" in
let hash, no_sess =
try (List.assoc alg_lc Netsys_digests.iana_alist, true)
with Not_found ->
try (List.assoc alg_lc iana_sess_alist, false)
with Not_found ->
match ptype with
| `SASL -> (`MD5, false)
| `HTTP -> raise Not_found in
let r =
{ r_ptype = ptype;
r_hash = hash;
r_no_sess = no_sess;
r_user = user;
r_authz = authz;
r_realm = realm;
r_nonce = nonce;
r_cnonce = cnonce;
r_nc = nc;
r_method = method_name;
r_digest_uri = digest_uri;
r_utf8 = utf8;
r_rfc2069 = false; (* not in the server *)
r_opaque = opaque;
r_domain = []; (* not repeated in response *)
r_userhash = userhash;
} in
(r, response)
let validate_response ss r response =
let realm_utf8 = to_utf8 r.r_utf8 r.r_realm in
( match ss.srealm with
| None -> ()
| Some expected_realm ->
if expected_realm <> realm_utf8 then failwith "bad realm";
);
if r.r_hash <> List.hd ss.sprofile.hash_functions then
failwith "unexpected hash function";
if r.r_no_sess <> ss.snosess then
failwith "parameter mismatch";
if r.r_userhash then
failwith "user name hashing not supported";
(* not supported on server side *)
let user_utf8 = to_utf8 r.r_utf8 r.r_user in
let authz =
match r.r_authz with
| None -> ""
| Some authz -> verify_utf8 authz; authz in
let password_utf8 =
match ss.lookup user_utf8 authz with
| None ->
failwith "bad user"
| Some creds ->
Netsys_sasl_util.extract_password creds in
let password = to_client r.r_utf8 password_utf8 in
let expected_response = compute_response r password (r.r_method ^ ":") in
if response <> expected_response then failwith "bad password";
password
exception Restart of string
let server_process_response_kv ss msg_params method_name =
try
let (r, response) =
decode_response ss.sprofile.ptype msg_params method_name in
if r.r_nc > 1 then raise(Restart r.r_nonce);
if ss.sstate <> `Wait then raise Not_found;
let password = validate_response ss r response in
(* success: *)
let srv_response = compute_response r password ":" in
{ ss with
snextnc = r.r_nc + 1;
sresponse = Some(r, response, srv_response);
sstate = `Emit;
}
with
| Failure msg ->
{ ss with sstate = `Auth_error msg }
| Not_found ->
{ ss with sstate = `Auth_error "unspecified" }
| Restart id ->
{ ss with sstate = `Restart id }
let server_process_response_restart_kv ss msg_params set_stale method_name =
try
let old_r =
match ss.sresponse with
| None -> assert false
| Some (r, _, _) -> r in
let (new_r, response) =
decode_response ss.sprofile.ptype msg_params method_name in
if old_r.r_hash <> new_r.r_hash
|| old_r.r_no_sess <> new_r.r_no_sess
|| old_r.r_user <> new_r.r_user
|| old_r.r_authz <> new_r.r_authz
|| old_r.r_realm <> new_r.r_realm
|| old_r.r_nonce <> new_r.r_nonce
|| old_r.r_cnonce <> new_r.r_cnonce
|| old_r.r_nc + 1 <> new_r.r_nc
(* || old_r.r_digest_uri <> new_r.r_digest_uri *) (* CHECK *)
|| old_r.r_utf8 <> new_r.r_utf8 then raise Not_found;
let password = validate_response ss new_r response in
(* success *)
if set_stale then raise Exit;
let srv_response = compute_response new_r password ":" in
( { ss with
snextnc = new_r.r_nc + 1;
sresponse = Some(new_r, response, srv_response);
sstate = `Emit;
},
true
)
with
| Failure _ -> (* from validate_response *)
( { ss with
snonce = create_nonce();
snextnc = 1;
sresponse = None;
sstate = `Emit;
},
false
)
| Not_found ->
( { ss with
snonce = create_nonce();
snextnc = 1;
sresponse = None;
sstate = `Emit;
},
false
)
| Exit ->
( { ss with
snonce = create_nonce();
snextnc = 1;
sresponse = None;
sstate = `Emit;
sstale = true
},
false
)
let server_stash_session_i ss =
let tuple =
(ss.sprofile, ss.sstate, ss.sresponse, ss.snextnc, ss.sstale, ss.srealm,
ss.snonce, ss.sutf8, ss.snosess) in
"server,t=DIGEST;" ^
Marshal.to_string tuple []
let ss_re =
Netstring_str.regexp "server,t=DIGEST;"
let server_resume_session_i ~lookup s =
match Netstring_str.string_match ss_re s 0 with
| None ->
failwith "Netmech_digest.server_resume_session"
| Some m ->
let p = Netstring_str.match_end m in
let data = String.sub s p (String.length s - p) in
let (sprofile,sstate, sresponse, snextnc, sstale, srealm, snonce,
sutf8, snosess) =
Marshal.from_string data 0 in
{ sprofile;
sstate;
sresponse;
snextnc;
sstale;
srealm;
snonce;
sutf8;
snosess;
lookup
}
let server_prop_i ss key =
match key with
| "nonce" -> ss.snonce
| _ ->
( match ss.sresponse with
| None -> raise Not_found
| Some(rp,_,_) ->
match key with
| "digest-uri" | "uri" -> rp.r_digest_uri
| "cnonce" -> rp.r_cnonce
| "nc" -> string_of_int rp.r_nc
| "realm" ->
(* may be in ISO-8859-1 *)
to_utf8 rp.r_utf8 rp.r_realm
| _ -> raise Not_found
)
type client_session =
{ cstate : Netsys_sasl_types.client_state;
cresp : response_params option;
cdigest_uri : string;
cmethod : string;
cprofile : profile;
crealm : string option; (* always UTF-8 *)
cuser : string; (* always UTF-8 *)
cauthz : string; (* always UTF-8 *)
cpasswd : string; (* always UTF-8 *)
cnonce : string;
}
let client_restart_i cs =
match cs.cresp with
| None -> assert false
| Some rp ->
let rp_next = { rp with r_nc = rp.r_nc+1 } in
{ cs with
cresp = Some rp_next;
cstate = `Emit
}
let client_process_final_challenge_kv cs msg_params =
try
if cs.cstate <> `Wait then raise Not_found;
if cs.cprofile.mutual then (
let m = to_strmap msg_params in
let rspauth = StrMap.find "rspauth" m in
match cs.cresp with
| None -> raise Not_found
| Some rp ->
let pw = to_client rp.r_utf8 cs.cpasswd in
let resp = compute_response rp pw ":" in
if resp <> rspauth then raise Not_found;
{ cs with cstate = `OK }
) else
{ cs with cstate = `OK }
with
| Failure msg ->
{ cs with cstate = `Auth_error msg }
| Not_found ->
{ cs with cstate = `Auth_error "cannot authenticate server" }
let client_process_initial_challenge_kv cs msg_params =
try
if cs.cstate <> `Wait then raise Not_found;
let m = to_strmap msg_params in
let utf8 =
try StrMap.find "charset" m = "utf-8" with Not_found -> false in
(* UTF-8: we encode our message in UTF-8 when the server sets the utf-8
attribute
*)
let realm =
try StrMap.find "realm" m
with Not_found ->
match cs.crealm with
| Some r -> to_client utf8 r
| None -> "" in
let nonce = StrMap.find "nonce" m in
let qop_s, rfc2069 =
try (StrMap.find "qop" m, false) with Not_found -> ("auth", true) in
let qop_l = space_split qop_s in
if not (List.mem "auth" qop_l) then failwith "bad qop";
let stale =
try StrMap.find "stale" m = "true" with Not_found -> false in
if stale && cs.cresp = None then raise Not_found;
if cs.cprofile.ptype = `SASL && not utf8 then failwith "missing utf-8";
let opaque =
try Some(StrMap.find "opaque" m) with Not_found -> None in
let domain =
try space_split (StrMap.find "domain" m) with Not_found -> [] in
let alg_lc =
try STRING_LOWERCASE (StrMap.find "algorithm" m)
with Not_found when cs.cprofile.ptype = `HTTP -> "md5" in
let hash, no_sess =
try (List.assoc alg_lc Netsys_digests.iana_alist, true)
with Not_found ->
(List.assoc alg_lc iana_sess_alist, false) in
let userhash =
try StrMap.find "userhash" m = "true" with Not_found -> false in
if cs.cprofile.ptype = `SASL && no_sess then raise Not_found;
if not (List.mem hash cs.cprofile.hash_functions) then
failwith "unsupported hash function";
(* If this is an initial challenge after we tried to resume the
old session, we need a new conce *)
let cnonce =
match cs.cresp with
| None -> cs.cnonce
| Some _ -> create_nonce() in
let rp =
{ r_ptype = cs.cprofile.ptype;
r_hash = hash;
r_no_sess = no_sess;
r_user = to_client utf8 cs.cuser;
r_authz = if cs.cauthz="" then None else Some(to_client utf8 cs.cauthz);
r_realm = realm;
r_nonce = nonce;
r_cnonce = cnonce;
r_nc = 1;
r_method = cs.cmethod;
r_digest_uri = cs.cdigest_uri;
r_utf8 = utf8;
r_rfc2069 = cs.cprofile.ptype=`HTTP && rfc2069;
r_opaque = opaque;
r_domain = domain;
r_userhash = userhash;
} in
{ cs with
cresp = Some rp;
cstate = if stale then `Stale else `Emit;
cnonce = cnonce;
}
with
| Failure msg ->
{ cs with cstate = `Auth_error msg }
| Not_found ->
{ cs with cstate = `Auth_error "unspecified" }
let client_modify ?mod_method ?mod_uri cs =
match cs.cresp with
| None ->
invalid_arg "Netmech_digest.client_modify"
| Some rp ->
let rp1 =
{ rp with
r_method = (match mod_method with
| None -> rp.r_method
| Some m -> m
);
r_digest_uri = (match mod_uri with
| None -> rp.r_digest_uri
| Some u -> u
)
} in
{ cs with cresp = Some rp1 }
let client_emit_response_kv ?(quote=false) cs =
(* SASL: method_name="AUTHENTICATE" *)
let q s = if quote then qstring s else s in
match cs.cresp with
| None ->
assert false
| Some rp ->
let pw = to_client rp.r_utf8 cs.cpasswd in
let resp = compute_response rp pw (rp.r_method ^ ":") in
let digest_uri_name =
match cs.cprofile.ptype with
| `SASL -> "digest-uri"
| `HTTP -> "uri" in
let username =
if rp.r_userhash then
let h = hash rp.r_hash in
h (rp.r_user ^ ":" ^ rp.r_realm)
else
rp.r_user in
let l =
[ "username", q username;
"realm", q rp.r_realm;
"nonce", q rp.r_nonce;
digest_uri_name, q rp.r_digest_uri;
"response", q resp;
] @
( if rp.r_rfc2069 then
[]
else
[ "cnonce", q rp.r_cnonce;
"nc", sprintf "%08x" rp.r_nc;
"qop", "auth";
]
) @
( if rp.r_utf8 then [ "charset", "utf-8" ] else [] ) @
( match rp.r_authz with
| None -> []
| Some authz -> [ "authzid", q authz ]
) @
( match rp.r_opaque with
| None -> []
| Some s -> [ "opaque", q s ]
) @
( if rp.r_ptype = `SASL && rp.r_hash = `MD5 then
[]
else
let alg =
STRING_UPPERCASE
(List.assoc
rp.r_hash Netsys_digests.iana_rev_alist) in
let suffix =
if rp.r_no_sess then "" else "-sess" in
[ "algorithm", alg ^ suffix ]
) in
( { cs with cstate = (if cs.cprofile.mutual then `Wait else `OK) },
l
)
let client_stash_session_i cs =
"client,t=DIGEST;" ^
Marshal.to_string cs []
let cs_re =
Netstring_str.regexp "client,t=DIGEST;"
let client_resume_session_i s =
match Netstring_str.string_match cs_re s 0 with
| None ->
failwith "Netmech_digest.client_resume_session"
| Some m ->
let p = Netstring_str.match_end m in
let data = String.sub s p (String.length s - p) in
let cs = Marshal.from_string data 0 in
(cs : client_session)
let client_prop_i cs key =
match key with
| "cnonce" -> cs.cnonce
| "digest-uri" | "uri" -> cs.cdigest_uri
| _ ->
(match cs.cresp with
| None -> raise Not_found
| Some rp ->
match key with
| "realm" -> rp.r_realm
| "nonce" -> rp.r_nonce
| "nc" -> string_of_int rp.r_nc
| _ -> raise Not_found
)