|
| 1 | +(* |
| 2 | + * Copyright (c) Cloud Software Group, Inc |
| 3 | + * |
| 4 | + * This program is free software; you can redistribute it and/or modify |
| 5 | + * it under the terms of the GNU Lesser General Public License as published |
| 6 | + * by the Free Software Foundation; version 2.1 only. with the special |
| 7 | + * exception on linking described in file LICENSE. |
| 8 | + * |
| 9 | + * This program is distributed in the hope that it will be useful, |
| 10 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | + * GNU Lesser General Public License for more details. |
| 13 | + *) |
| 14 | + |
| 15 | +module Pam = struct |
| 16 | + let unsafe_crypt_r = Pam.unsafe_crypt_r [@@alert "-unsafe"] |
| 17 | + (* Suppress the alert the purpose of testing. *) |
| 18 | +end |
| 19 | + |
| 20 | +let valid_salts = |
| 21 | + [ |
| 22 | + "salt" (* Don't need to specify algorithm, will default to something. *) |
| 23 | + ; "$5$salt$" (* 5 = SHA-256 should work. *) |
| 24 | + ; "$6$salt$" (* 6 = SHA-512 should work. *) |
| 25 | + ] |
| 26 | + |
| 27 | +let invalid_salts = |
| 28 | + [ |
| 29 | + "" (* Salt cannot be empty. *) |
| 30 | + ; "$" (* Salt cannot be $. *) |
| 31 | + ; "$9$salt$" (* Salt must specify valid algorithm constant. *) |
| 32 | + ; "$6,rounds=1000$salt$" (* Salt cannot specify iteration count. *) |
| 33 | + ; "£6£salt£" (* Only American currency is legal tender. *) |
| 34 | + ] |
| 35 | + |
| 36 | +let test_salts ~msg ~succeeds salts = |
| 37 | + let test salt = |
| 38 | + let actual = |
| 39 | + Option.is_some (Pam.unsafe_crypt_r ~key:"password" ~setting:salt) |
| 40 | + in |
| 41 | + Alcotest.((check' bool) ~msg ~expected:succeeds ~actual) |
| 42 | + in |
| 43 | + List.iter test salts |
| 44 | + |
| 45 | +let test_valid_salts () = |
| 46 | + test_salts ~msg:"Hash can be computed from valid salt" ~succeeds:true |
| 47 | + valid_salts |
| 48 | + |
| 49 | +let test_invalid_salts () = |
| 50 | + test_salts ~msg:"Hash cannot be computed from invalid salt" ~succeeds:false |
| 51 | + invalid_salts |
| 52 | + |
| 53 | +let test_salt_truncation () = |
| 54 | + let salt_max_length = 16 in |
| 55 | + let salt = "a_salt_that_is_longer_than_is_actually_accepted" in |
| 56 | + assert (String.length salt > salt_max_length) ; |
| 57 | + let test prefix_length = |
| 58 | + (* The C API accepts at most 16 chars for the salt, optionally |
| 59 | + enclosed within $k$salt$ - anything else is ignored (implicitly |
| 60 | + truncated). *) |
| 61 | + let truncated_salt = String.sub salt 0 prefix_length in |
| 62 | + let sha512 = Printf.sprintf "$6$%s$" in |
| 63 | + let key = "password" in |
| 64 | + let h = Pam.unsafe_crypt_r ~key ~setting:(sha512 salt) in |
| 65 | + let h' = Pam.unsafe_crypt_r ~key ~setting:(sha512 truncated_salt) in |
| 66 | + if Option.(is_none h || is_none h') then |
| 67 | + failwith (Printf.sprintf "Failed to compute hash in %s" __FUNCTION__) |
| 68 | + else |
| 69 | + Option.equal ( = ) h h' |
| 70 | + in |
| 71 | + let msg = |
| 72 | + Printf.sprintf |
| 73 | + "Hash computed with implicitly truncated salt is the same as explicitly \ |
| 74 | + truncated (len = %d)\n\ |
| 75 | + ." |
| 76 | + in |
| 77 | + let expectation len = |
| 78 | + (* We expect all lengths greater than max salt length to succeed, |
| 79 | + as they are implicitly truncated. Any length < salt_max_length |
| 80 | + should fail. *) |
| 81 | + len >= salt_max_length |
| 82 | + in |
| 83 | + for len = 0 to String.length salt do |
| 84 | + let msg = msg len in |
| 85 | + let actual = test len in |
| 86 | + let expected = expectation len in |
| 87 | + Alcotest.(check' bool) ~msg ~expected ~actual |
| 88 | + done |
| 89 | + |
| 90 | +(* Invalidate the following tests if any hash fails to be computed. *) |
| 91 | +let unsafe_crypt_r ~key ~setting = |
| 92 | + match Pam.unsafe_crypt_r ~key ~setting with |
| 93 | + | Some hash -> |
| 94 | + hash |
| 95 | + | _ -> |
| 96 | + failwith "Invalid input provided to crypt_r" |
| 97 | + |
| 98 | +let test_crypt_r_many_threads () = |
| 99 | + Printexc.record_backtrace true ; |
| 100 | + let settings = ["$6$salt$"; "$5$salt123$"; "$6$foobar$"; "salt"] in |
| 101 | + (* Each test case is a 3-tuple (key, setting, hash). A thread is |
| 102 | + spawned for each test case. The hash component stores the expected |
| 103 | + result of hashing key with setting. These hashes are computed prior |
| 104 | + to spawning the threads so they are guaranteed to have been computed |
| 105 | + sequentially. *) |
| 106 | + let test_cases = |
| 107 | + let create_case setting = |
| 108 | + let key = "password" in |
| 109 | + let hash = unsafe_crypt_r ~key ~setting in |
| 110 | + (key, setting, hash) |
| 111 | + in |
| 112 | + List.map create_case settings |
| 113 | + in |
| 114 | + let num_cases = List.length test_cases in |
| 115 | + let thread_count = Atomic.make 0 in |
| 116 | + let ready () = Atomic.get thread_count >= num_cases in |
| 117 | + let m = Mutex.create () in |
| 118 | + let c = Condition.create () in |
| 119 | + (* Each thread will populate an entry in the results array. *) |
| 120 | + let results : (unit, _) result array = Array.make num_cases (Ok ()) in |
| 121 | + let spawn i (key, setting, expectation) = |
| 122 | + let loop () = |
| 123 | + let now = Unix.gettimeofday in |
| 124 | + let start = now () in |
| 125 | + while now () -. start < 0.2 do |
| 126 | + let actual = unsafe_crypt_r ~key ~setting in |
| 127 | + Printf.printf "thread %d computed %s\n" i actual ; |
| 128 | + flush stdout ; |
| 129 | + if actual <> expectation then |
| 130 | + failwith (Printf.sprintf "%s <> %s" actual expectation) |
| 131 | + done |
| 132 | + in |
| 133 | + (* Record that this thread has been started, then wait for the |
| 134 | + main thread to broadcast that the others have also started. *) |
| 135 | + Atomic.incr thread_count ; |
| 136 | + Mutex.lock m ; |
| 137 | + while not (ready ()) do |
| 138 | + Condition.wait c m |
| 139 | + done ; |
| 140 | + Mutex.unlock m ; |
| 141 | + (* Run the test, capturing any exception as a result to the |
| 142 | + negative. *) |
| 143 | + results.(i) <- Rresult.R.trap_exn loop () |
| 144 | + in |
| 145 | + (* Spawn a thread per valid test case. *) |
| 146 | + let tids = List.mapi (fun i -> Thread.create (spawn i)) test_cases in |
| 147 | + (* Wait for all threads to identify themselves as having started |
| 148 | + before broadcasting that they should start hashing. *) |
| 149 | + while not (ready ()) do |
| 150 | + Unix.sleepf 0.1 |
| 151 | + done ; |
| 152 | + Mutex.lock m ; |
| 153 | + Condition.broadcast c ; |
| 154 | + Mutex.unlock m ; |
| 155 | + List.iter Thread.join tids ; |
| 156 | + (* Re-raise the first encountered trapped exception with its |
| 157 | + backtrace to ensure the test fails if any thread reported |
| 158 | + failure. *) |
| 159 | + let reraise = function |
| 160 | + | Error (`Exn_trap (exn, bt)) -> |
| 161 | + Printexc.raise_with_backtrace exn bt |
| 162 | + | _ -> |
| 163 | + () |
| 164 | + in |
| 165 | + Array.iter reraise results |
| 166 | + |
| 167 | +let () = test_crypt_r_many_threads () |
| 168 | + |
| 169 | +(* This test hashes strings of language 'a'+ over a small range of lengths to |
| 170 | + ensure no duplicates occur. A suitable cryptographic hash function should have |
| 171 | + no collisions doing this. So, if a collision occurs, it is more likely because |
| 172 | + the underlying algorithm has a maximum length key size (and is truncating our |
| 173 | + input). *) |
| 174 | +let test_increasing_length () = |
| 175 | + let min, max = (50, 140) in |
| 176 | + (* Records hash -> length, so colliding lengths can be reported. *) |
| 177 | + let tbl = Hashtbl.create 127 in |
| 178 | + let setting = "$6$salt$" in |
| 179 | + let go len = |
| 180 | + let key = String.make len 'a' in |
| 181 | + let hash = |
| 182 | + try unsafe_crypt_r ~key ~setting |
| 183 | + with _ -> |
| 184 | + failwith (Printf.sprintf "Failed to compute hash aa..a of length %d" len) |
| 185 | + in |
| 186 | + match Hashtbl.find_opt tbl hash with |
| 187 | + | Some len' -> |
| 188 | + failwith |
| 189 | + (Printf.sprintf "Hash value a.. (len = %d) matches a.. (len %d)" len |
| 190 | + len' |
| 191 | + ) |
| 192 | + | _ -> |
| 193 | + Hashtbl.add tbl hash len |
| 194 | + in |
| 195 | + for i = min to max do |
| 196 | + go i |
| 197 | + done |
| 198 | + |
| 199 | +(* This test demonstrates the behaviour that the C API will |
| 200 | + (expectedly) only read up to the null terminator character. OCaml |
| 201 | + strings are stored as an array of words, with the final byte |
| 202 | + specifying how many padding bytes precede it. Since the number of |
| 203 | + words and number of padding bytes is used to determine string length, |
| 204 | + there is no reliance on a C-style null terminator - so '\0' can appear |
| 205 | + anywhere in an OCaml string. *) |
| 206 | +let test_c_truncation () = |
| 207 | + let key = "password" in |
| 208 | + let key' = key ^ "\x00_arbitrary_data_here" in |
| 209 | + let setting = "$6$salt$" in |
| 210 | + let hash = unsafe_crypt_r ~key ~setting in |
| 211 | + let hash' = unsafe_crypt_r ~key:key' ~setting in |
| 212 | + if hash <> hash' then |
| 213 | + failwith "Expected truncation using C-style null termination failed" |
| 214 | + |
| 215 | +let tests () = |
| 216 | + [ |
| 217 | + ("Valid salts", `Quick, test_valid_salts) |
| 218 | + ; ("Invalid salts", `Quick, test_invalid_salts) |
| 219 | + ; ("Implicit salt truncation", `Quick, test_salt_truncation) |
| 220 | + ; ("Increasing string length", `Quick, test_increasing_length) |
| 221 | + ; ("C-style termination", `Quick, test_c_truncation) |
| 222 | + ; ("Multiple threads", `Quick, test_crypt_r_many_threads) |
| 223 | + ] |
0 commit comments