[OCaml] High Intensity Training Online
refactor Move password hashing behind a port
Trainee no longer names a hashing library. Add a Password_hash.S port in the use-case layer and a Bcrypt_hash adapter over safepass in hito.persistence. Service.Make now takes the hasher as a functor argument (R)(H), and the four hash/verify call sites route through it. Handlers.Make forwards the hasher so the web tier still never names an algorithm. The credential stays opaque; only credential_of_hash/credential_to_hash remain on Trainee. hito.app now depends on hito.core and lwt only. Add Bcrypt_hash unit tests, including a malformed-hash case that verify now rejects instead of raising.
Changed files
- ARCHITECTURE.org
- bin/main.ml
- lib/app/dune
- lib/app/password_hash.ml
- lib/app/password_hash.mli
- lib/app/service.ml
- lib/app/service.mli
- lib/app/trainee.ml
- lib/app/trainee.mli
- lib/persistence/bcrypt_hash.ml
- lib/persistence/bcrypt_hash.mli
- lib/persistence/dune
- lib/web/handlers.ml
- lib/web/handlers.mli
- test/dune
- test/test_bcrypt_hash.ml
- test/test_hito.ml
- test/test_service.ml
- test/test_sqlite_repo.ml
- test/test_web.ml
ARCHITECTURE.org
@@ -309,9 +309,12 @@
309
309
** Accounts and sessions
310
310
311
311
=Trainee= normalizes a username — trimmed, lowercased, 4 to 20 characters — and
312
Removed:
holds a =credential= that carries a salted hash and never the password. Hashing
313
Removed:
and constant-time verification happen inside that module through =safepass=, so
314
Removed:
no other layer holds a plaintext password beyond a request.
312
Added:
holds a =credential= that carries a salted hash and never the password. The
313
Added:
=credential= is opaque and =Trainee= names no hashing library: the algorithm
314
Added:
lives behind the =Password_hash.S= port, implemented by =Bcrypt_hash= over
315
Added:
=safepass= in =hito.persistence=. =Service.Make= takes the hasher as a functor
316
Added:
argument, so no layer above holds a plaintext password beyond a request, and the
317
Added:
algorithm can change by swapping the adapter alone.
315
318
316
319
Passwords carry no length or content policy, so they never refuse registration.
317
320
Public sign-up is closed by default: =Handlers.make ?registration_open=
bin/main.ml
@@ -20,8 +20,15 @@
20
20
the password from [HITO_SEED_PASSWORD]. Both are temporary until public
21
21
registration returns. *)
22
22
23
Removed:
module Handlers = Hito_web.Handlers.Make (Hito_persistence.Sqlite_repo)
24
Removed:
module Service = Hito_app.Service.Make (Hito_persistence.Sqlite_repo)
23
Added:
module Handlers =
24
Added:
Hito_web.Handlers.Make
25
Added:
(Hito_persistence.Sqlite_repo)
26
Added:
(Hito_persistence.Bcrypt_hash)
27
Added:
28
Added:
module Service =
29
Added:
Hito_app.Service.Make
30
Added:
(Hito_persistence.Sqlite_repo)
31
Added:
(Hito_persistence.Bcrypt_hash)
25
32
26
33
let getenv name default =
27
34
match Sys.getenv_opt name with Some v when v <> "" -> v | _ -> default
lib/app/dune
@@ -1,9 +1,9 @@
1
Removed:
; The use-case layer: entities' identity, the Repository port, the Service, and
2
Removed:
; the shared Catalog. It depends inward on hito.core only — no database, no web
3
Removed:
; framework — so the compiler forbids the Service from reaching an adapter.
4
Removed:
; safepass stays only until password hashing moves behind a port.
1
Added:
; The use-case layer: entities' identity, the Repository port, the Password_hash
2
Added:
; port, the Service, and the shared Catalog. It depends inward on hito.core only
3
Added:
; — no database, no web framework, no hashing library — so the compiler forbids
4
Added:
; the Service from reaching an adapter.
5
5
6
6
(library
7
7
(name hito_app)
8
8
(public_name hito.app)
9
Removed:
(libraries hito.core lwt unix safepass))
9
Added:
(libraries hito.core lwt))
lib/app/password_hash.ml
@@ -0,0 +1,4 @@
1
Added:
module type S = sig
2
Added:
val hash : string -> string
3
Added:
val verify : hash:string -> string -> bool
4
Added:
end
lib/app/password_hash.mli
@@ -0,0 +1,11 @@
1
Added:
(** The password-hashing port. An adapter supplies a concrete algorithm, so the
2
Added:
use case and the {!Trainee} entity never name one. The stored form is an
3
Added:
opaque verifier string, wrapped by {!Trainee.credential_of_hash}. *)
4
Added:
5
Added:
module type S = sig
6
Added:
val hash : string -> string
7
Added:
(** Salt and hash a new password into a stored verifier. *)
8
Added:
9
Added:
val verify : hash:string -> string -> bool
10
Added:
(** Constant-time check of a candidate password against a stored verifier. *)
11
Added:
end
lib/app/service.ml
@@ -1,4 +1,4 @@
1
Removed:
module Make (R : Repository.S) = struct
1
Added:
module Make (R : Repository.S) (H : Password_hash.S) = struct
2
2
open Lwt.Infix
3
3
4
4
type t = { repo : R.t }
@@ -64,7 +64,7 @@
64
64
Lwt.return
65
65
(Result.map_error (fun e -> `Username e) (Trainee.username username))
66
66
in
67
Removed:
let credential = Trainee.hash_password password in
67
Added:
let credential = Trainee.credential_of_hash (H.hash password) in
68
68
Lwt_result.map_error
69
69
(fun `Username_taken -> `Username_taken)
70
70
(R.create_trainee t.repo ~username ~credential)
@@ -75,7 +75,9 @@
75
75
| Ok username -> (
76
76
R.find_trainee_by_username t.repo username >|= function
77
77
| Some trainee
78
Removed:
when Trainee.verify_password trainee.Trainee.credential password ->
78
Added:
when H.verify
79
Added:
~hash:(Trainee.credential_to_hash trainee.Trainee.credential)
80
Added:
password ->
79
81
Some trainee
80
82
| _ -> None)
81
83
@@ -100,10 +102,15 @@
100
102
R.find_trainee t.repo id >>= function
101
103
| None -> Lwt.return (Error `Unknown)
102
104
| Some trainee ->
103
Removed:
if not (Trainee.verify_password trainee.Trainee.credential current)
105
Added:
if
106
Added:
not
107
Added:
(H.verify
108
Added:
~hash:(Trainee.credential_to_hash trainee.Trainee.credential)
109
Added:
current)
104
110
then Lwt.return (Error `Incorrect_password)
105
111
else
106
Removed:
R.update_credential t.repo id (Trainee.hash_password next)
112
Added:
R.update_credential t.repo id
113
Added:
(Trainee.credential_of_hash (H.hash next))
107
114
>|= fun updated -> Option.to_result ~none:`Unknown updated
108
115
end
109
116
lib/app/service.mli
@@ -12,7 +12,7 @@
12
12
here means a native client cannot quietly adopt looser rules than the web
13
13
one. *)
14
14
15
Removed:
module Make (R : Repository.S) : sig
15
Added:
module Make (R : Repository.S) (_ : Password_hash.S) : sig
16
16
type t
17
17
18
18
val make : repo:R.t -> t
lib/app/trainee.ml
@@ -26,15 +26,9 @@
26
26
27
27
let username_to_string u = u
28
28
29
Removed:
type credential = string (* the bcrypt hash string *)
29
Added:
type credential = string (* an opaque password verifier string *)
30
30
31
31
let credential_of_hash h = h
32
32
let credential_to_hash h = h
33
Removed:
let hash_password password = Bcrypt.string_of_hash (Bcrypt.hash password)
34
Removed:
35
Removed:
let verify_password credential password =
36
Removed:
match Bcrypt.hash_of_string credential with
37
Removed:
| hash -> Bcrypt.verify password hash
38
Removed:
| exception _ -> false
39
33
40
34
type t = { id : id; username : username; credential : credential }
lib/app/trainee.mli
@@ -2,9 +2,9 @@
2
2
logbook. Identity lives in {!Hito_app}, not the core, because the core needs
3
3
none.
4
4
5
Removed:
A {!credential} is a password verifier, never the password. The hash is
6
Removed:
computed and checked here so that no other layer sees a plaintext password
7
Removed:
for longer than one request. *)
5
Added:
A {!credential} is a password verifier, never the password. The hashing
6
Added:
algorithm lives behind the {!Password_hash.S} port, so this entity binds to
7
Added:
no hashing library. *)
8
8
9
9
type id = private string
10
10
(** An opaque account identity, assigned by an adapter. *)
@@ -31,20 +31,15 @@
31
31
val pp_username_error : Format.formatter -> username_error -> unit
32
32
33
33
type credential
34
Removed:
(** A password verifier. Carries a salted hash, never the password. *)
34
Added:
(** A password verifier. Carries a salted hash, never the password. The hashing
35
Added:
algorithm is a {!Password_hash.S} adapter detail; this type only wraps the
36
Added:
stored verifier so no other layer sees a plaintext password. *)
35
37
36
38
val credential_of_hash : string -> credential
37
39
(** Wraps a stored hash read back from persistence. *)
38
40
39
41
val credential_to_hash : credential -> string
40
42
(** The stored hash, for persistence. *)
41
Removed:
42
Removed:
val hash_password : string -> credential
43
Removed:
(** Salts and hashes a new password. Accepts any string; there is no length or
44
Removed:
content policy on passwords. *)
45
Removed:
46
Removed:
val verify_password : credential -> string -> bool
47
Removed:
(** Constant-time verification of a candidate password against the verifier. *)
48
43
49
44
type t = { id : id; username : username; credential : credential }
50
45
(** A stored account. *)
lib/persistence/bcrypt_hash.ml
@@ -0,0 +1,7 @@
1
Added:
(** Bcrypt implementation of the {!Hito_app.Password_hash.S} port, over
2
Added:
safepass. The one place that names a hashing algorithm. *)
3
Added:
4
Added:
let hash password = Bcrypt.string_of_hash (Bcrypt.hash password)
5
Added:
6
Added:
let verify ~hash password =
7
Added:
try Bcrypt.verify password (Bcrypt.hash_of_string hash) with _ -> false
lib/persistence/bcrypt_hash.mli
@@ -0,0 +1,3 @@
1
Added:
(** Bcrypt implementation of the password-hashing port. *)
2
Added:
3
Added:
include Hito_app.Password_hash.S
lib/persistence/dune
@@ -13,4 +13,5 @@
13
13
caqti-lwt
14
14
caqti-lwt.unix
15
15
caqti-driver-sqlite3
16
Removed:
uri))
16
Added:
uri
17
Added:
safepass))
lib/web/handlers.ml
@@ -1,8 +1,8 @@
1
1
open Lwt.Infix
2
2
open Hito_app
3
3
4
Removed:
module Make (R : Repository.S) = struct
5
Removed:
module Service = Service.Make (R)
4
Added:
module Make (R : Repository.S) (H : Password_hash.S) = struct
5
Added:
module Service = Service.Make (R) (H)
6
6
7
7
type t = {
8
8
service : Service.t;
lib/web/handlers.mli
@@ -2,7 +2,7 @@
2
2
the repository so tests drive it with an in-memory store while production
3
3
uses SQLite. *)
4
4
5
Removed:
module Make (R : Hito_app.Repository.S) : sig
5
Added:
module Make (R : Hito_app.Repository.S) (_ : Hito_app.Password_hash.S) : sig
6
6
type t
7
7
8
8
val make :
test/dune
@@ -9,6 +9,7 @@
9
9
test_progression
10
10
test_service
11
11
test_codec
12
Added:
test_bcrypt_hash
12
13
test_sqlite_repo
13
14
test_decode
14
15
test_web)
test/test_bcrypt_hash.ml
@@ -0,0 +1,40 @@
1
Added:
(** Tests for {!Hito_persistence.Bcrypt_hash} — the hashing adapter behind the
2
Added:
{!Hito_app.Password_hash.S} port. *)
3
Added:
4
Added:
open Hito_persistence
5
Added:
6
Added:
let hash_tests =
7
Added:
[
8
Added:
( "a hashed password verifies against itself",
9
Added:
`Quick,
10
Added:
fun () ->
11
Added:
let hash = Bcrypt_hash.hash "correct horse battery staple" in
12
Added:
Alcotest.(check bool)
13
Added:
"verifies" true
14
Added:
(Bcrypt_hash.verify ~hash "correct horse battery staple") );
15
Added:
( "a wrong password does not verify",
16
Added:
`Quick,
17
Added:
fun () ->
18
Added:
let hash = Bcrypt_hash.hash "correct horse battery staple" in
19
Added:
Alcotest.(check bool)
20
Added:
"rejects" false
21
Added:
(Bcrypt_hash.verify ~hash "Tr0ub4dor&3") );
22
Added:
( "hashing is salted: the same password hashes differently",
23
Added:
`Quick,
24
Added:
fun () ->
25
Added:
let a = Bcrypt_hash.hash "same password" in
26
Added:
let b = Bcrypt_hash.hash "same password" in
27
Added:
Alcotest.(check bool) "distinct hashes" false (String.equal a b);
28
Added:
Alcotest.(check bool)
29
Added:
"both still verify" true
30
Added:
(Bcrypt_hash.verify ~hash:a "same password"
31
Added:
&& Bcrypt_hash.verify ~hash:b "same password") );
32
Added:
( "a malformed stored hash never verifies",
33
Added:
`Quick,
34
Added:
fun () ->
35
Added:
Alcotest.(check bool)
36
Added:
"rejects garbage" false
37
Added:
(Bcrypt_hash.verify ~hash:"not a bcrypt hash" "anything") );
38
Added:
]
39
Added:
40
Added:
let suite = [ ("bcrypt_hash", hash_tests) ]
test/test_hito.ml
@@ -9,5 +9,5 @@
9
9
Alcotest.run "hito"
10
10
(Test_exercise.suite @ Test_recovery.suite @ Test_prescription.suite
11
11
@ Test_evidence.suite @ Test_progression.suite @ Test_service.suite
12
Removed:
@ Test_codec.suite @ Test_sqlite_repo.suite @ Test_decode.suite
13
Removed:
@ Test_web.suite)
12
Added:
@ Test_codec.suite @ Test_bcrypt_hash.suite @ Test_sqlite_repo.suite
13
Added:
@ Test_decode.suite @ Test_web.suite)
test/test_service.ml
@@ -3,7 +3,7 @@
3
3
4
4
open Hito_app
5
5
open Hito_persistence
6
Removed:
module S = Service.Make (Memory_repo)
6
Added:
module S = Service.Make (Memory_repo) (Bcrypt_hash)
7
7
module Stimulus = Evidence.Stimulus
8
8
module Workout = Evidence.Workout
9
9
test/test_sqlite_repo.ml
@@ -3,7 +3,7 @@
3
3
4
4
open Hito_app
5
5
open Hito_persistence
6
Removed:
module S = Service.Make (Sqlite_repo)
6
Added:
module S = Service.Make (Sqlite_repo) (Bcrypt_hash)
7
7
module Stimulus = Evidence.Stimulus
8
8
module Workout = Evidence.Workout
9
9
test/test_web.ml
@@ -4,7 +4,7 @@
4
4
stands in for SQLite; session storage is Dream's in-memory back end. *)
5
5
6
6
open Hito_persistence
7
Removed:
module Handlers = Hito_web.Handlers.Make (Memory_repo)
7
Added:
module Handlers = Hito_web.Handlers.Make (Memory_repo) (Bcrypt_hash)
8
8
9
9
(* A fresh, fully wired application: secret, in-memory sessions, routes.
10
10
Registration is opened here so the auth-flow tests can create trainees via