Skip to content

Commit 022f4fe

Browse files
author
Colin James
committed
CP-50518: Add quicktests for crypt_r
The following small tests are provided as a quicktest suite to run on hosts: - Valid salts: a few valid salts are confirmed to compute a hash. - Invalid salts: a few erronerous salts are confirmed to fail. - Implicit salt truncation: the behaviour that any salt longer than a specified maximum (at present, 16) does not cause hash computation to fail, but rather its value gets implicitly truncated by the algorithm. - Increasing string length: strings from language 'a'+ are tested in increasing length to ensure they compute to a set of hashes that are pairwise distinct. This test is really to ensure that the algorithm does not cap the maximum key length - which would be implicitly truncated on our behalf and cause lengths over a certain threshold to hash to equivalent values. This property is worth tracking, currently it would appear there is no limit to worry about. - C style termination: OCaml strings don't rely on a C-style null terminator character ('\0') to determine their length. Consequently, '\0' can appear anywhere in OCaml strings without impeding various computations on strings. This test exercises the property that the C API expectedly does stop reading after seeing '\0'. This property should not be relied upon and its behaviour is documented here as a test. - Multiple threads hashing simultaneously: this test exercises the property that crypt_r truly is re-entrant. The test spawns a few threads with a precomputed hash (and the inputs used to compute it) and then spends ~200ms iteratively attempting to compute the same hash. If any hash computed within this ~200ms window differs from the initial hash, or any hash fails to be computed, the test fails. This property must be tested because the C stub temporarily relinquishes the runtime lock when computing the hash, thus allowing the possibility that hashes are computed in parallel. It is not committed here but this test was shown to be well formed by using the non-reentrant "crypt" function in place of crypt_r, where the test reliably fails. Switching this test over crypt_r allows it to pass. It is important that the tests are actually executed on a host - as the implementation of crypt_r (from libcrypt.so) could differ across development and target machines, but also across updates to XenServer. Some of the tests are for regression purposes only, to ensure that certain expectations are retained from time of writing. Signed-off-by: Colin James <colin.barr@cloud.com>
1 parent cbb3e68 commit 022f4fe

File tree

3 files changed

+225
-0
lines changed

3 files changed

+225
-0
lines changed

ocaml/quicktest/dune

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
http_lib
1414
mtime
1515
mtime.clock.os
16+
pam
1617
qcheck-alcotest
1718
result
1819
rresult

ocaml/quicktest/quicktest.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ let () =
4242
; ("Quicktest_max_vdi_size", Quicktest_max_vdi_size.tests ())
4343
; ("Quicktest_static_vdis", Quicktest_static_vdis.tests ())
4444
; ("Quicktest_date", Quicktest_date.tests ())
45+
; ("Quicktest_crypt_r", Quicktest_crypt_r.tests ())
4546
]
4647
@
4748
if not !Quicktest_args.using_unix_domain_socket then

ocaml/quicktest/quicktest_crypt_r.ml

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)