···11+ALTER TABLE actors ADD COLUMN totp_secret BLOB;
22+ALTER TABLE actors ADD COLUMN totp_verified_at INTEGER;
33+44+ALTER TABLE actors ADD COLUMN email_2fa_enabled INTEGER DEFAULT 0;
55+66+CREATE TABLE IF NOT EXISTS backup_codes (
77+ id INTEGER PRIMARY KEY,
88+ did TEXT NOT NULL,
99+ code_hash TEXT NOT NULL,
1010+ used_at INTEGER,
1111+ created_at INTEGER NOT NULL,
1212+ FOREIGN KEY (did) REFERENCES actors(did) ON DELETE CASCADE
1313+);
1414+1515+CREATE INDEX IF NOT EXISTS backup_codes_did_idx ON backup_codes(did);
1616+1717+CREATE TABLE IF NOT EXISTS pending_2fa (
1818+ id INTEGER PRIMARY KEY,
1919+ session_token TEXT NOT NULL UNIQUE,
2020+ did TEXT NOT NULL,
2121+ password_verified_at INTEGER NOT NULL,
2222+ expires_at INTEGER NOT NULL,
2323+ email_code TEXT,
2424+ email_code_expires_at INTEGER,
2525+ created_at INTEGER NOT NULL
2626+);
2727+2828+CREATE INDEX IF NOT EXISTS pending_2fa_session_idx ON pending_2fa(session_token);
2929+CREATE INDEX IF NOT EXISTS pending_2fa_expires_idx ON pending_2fa(expires_at);
3030+3131+CREATE TRIGGER IF NOT EXISTS cleanup_expired_pending_2fa
3232+AFTER INSERT ON pending_2fa
3333+BEGIN
3434+ DELETE FROM pending_2fa WHERE expires_at < unixepoch() * 1000;
3535+END;
+7
pegasus/lib/session.ml
···113113114114open Raw
115115116116+let get_current_did_exn req =
117117+ match%lwt get_current_did req with
118118+ | None ->
119119+ Errors.auth_required "not authorized"
120120+ | Some did ->
121121+ Lwt.return did
122122+116123let log_in_did req did =
117124 match%lwt get_session req with
118125 | Some {logged_in_dids; session_id; admin_authenticated; _} ->
+285
pegasus/lib/totp.ml
···11+open Util.Rapper
22+33+let secret_length = 20 (* 160 bits for HMAC-SHA1 *)
44+55+let time_step = 30 (* 30 second intervals *)
66+77+let code_digits = 6
88+99+let window_size = 1
1010+1111+module Backup_codes = struct
1212+ let code_count = 10
1313+1414+ let code_length = 8
1515+1616+ module Types = struct
1717+ type backup_code =
1818+ { id: int
1919+ ; did: string
2020+ ; code_hash: string
2121+ ; used_at: int option
2222+ ; created_at: int }
2323+ end
2424+2525+ open Types
2626+2727+ module Queries = struct
2828+ let insert_backup_code =
2929+ [%rapper
3030+ execute
3131+ {sql| INSERT INTO backup_codes (did, code_hash, created_at)
3232+ VALUES (%string{did}, %string{code_hash}, %int{created_at})
3333+ |sql}]
3434+3535+ let get_backup_codes_by_did did =
3636+ [%rapper
3737+ get_many
3838+ {sql| SELECT @int{id}, @string{did}, @string{code_hash}, @int?{used_at}, @int{created_at}
3939+ FROM backup_codes WHERE did = %string{did}
4040+ ORDER BY created_at ASC
4141+ |sql}
4242+ record_out]
4343+ did
4444+4545+ let get_unused_backup_codes_by_did did =
4646+ [%rapper
4747+ get_many
4848+ {sql| SELECT @int{id}, @string{did}, @string{code_hash}, @int?{used_at}, @int{created_at}
4949+ FROM backup_codes WHERE did = %string{did} AND used_at IS NULL
5050+ ORDER BY created_at ASC
5151+ |sql}
5252+ record_out]
5353+ did
5454+5555+ let mark_code_used =
5656+ [%rapper
5757+ execute
5858+ {sql| UPDATE backup_codes SET used_at = %int{used_at}
5959+ WHERE id = %int{id} AND did = %string{did}
6060+ |sql}]
6161+6262+ let delete_backup_codes_by_did =
6363+ [%rapper
6464+ execute {sql| DELETE FROM backup_codes WHERE did = %string{did} |sql}]
6565+6666+ let count_unused_backup_codes did =
6767+ [%rapper
6868+ get_one
6969+ {sql| SELECT COUNT(*) AS @int{count}
7070+ FROM backup_codes WHERE did = %string{did} AND used_at IS NULL
7171+ |sql}]
7272+ did
7373+ end
7474+7575+ let generate_code () =
7676+ let () = Mirage_crypto_rng_unix.use_default () in
7777+ Multibase.Base32.encode_string ~pad:false
7878+ (* 5 bytes = 8 base32 chars *)
7979+ @@ Mirage_crypto_rng_unix.getrandom 5
8080+8181+ let format_code code =
8282+ if String.length code = 8 then
8383+ String.sub code 0 4 ^ "-" ^ String.sub code 4 4
8484+ else code
8585+8686+ let normalize_code code =
8787+ String.concat "" (String.split_on_char '-' code) |> String.uppercase_ascii
8888+8989+ let generate_codes () = List.init code_count (fun _ -> generate_code ())
9090+9191+ let hash_code code = Bcrypt.hash code |> Bcrypt.string_of_hash
9292+9393+ let verify_code_hash code hash =
9494+ try
9595+ let hash_obj = Bcrypt.hash_of_string hash in
9696+ Bcrypt.verify code hash_obj
9797+ with _ -> false
9898+9999+ let store_codes ~did ~codes db =
100100+ let now = Util.now_ms () in
101101+ Lwt_list.iter_s
102102+ (fun code ->
103103+ let code_hash = hash_code code in
104104+ Util.use_pool db
105105+ @@ Queries.insert_backup_code ~did ~code_hash ~created_at:now )
106106+ codes
107107+108108+ let regenerate ~did db =
109109+ let%lwt () = Util.use_pool db @@ Queries.delete_backup_codes_by_did ~did in
110110+ let codes = generate_codes () in
111111+ let%lwt () = store_codes ~did ~codes db in
112112+ Lwt.return (List.map format_code codes)
113113+114114+ let verify_and_consume ~did ~code db =
115115+ let normalized_code = normalize_code code in
116116+ let%lwt codes =
117117+ Util.use_pool db @@ Queries.get_unused_backup_codes_by_did ~did
118118+ in
119119+ let rec check = function
120120+ | [] ->
121121+ Lwt.return_false
122122+ | c :: rest ->
123123+ if verify_code_hash normalized_code c.code_hash then
124124+ let now = Util.now_ms () in
125125+ let%lwt () =
126126+ Util.use_pool db
127127+ @@ Queries.mark_code_used ~id:c.id ~did ~used_at:now
128128+ in
129129+ Lwt.return_true
130130+ else check rest
131131+ in
132132+ check codes
133133+134134+ let get_remaining_count ~did db =
135135+ Util.use_pool db @@ Queries.count_unused_backup_codes ~did
136136+137137+ let has_backup_codes ~did db =
138138+ let%lwt count = get_remaining_count ~did db in
139139+ Lwt.return (count > 0)
140140+141141+ let ensure_codes_exist ~did db =
142142+ let%lwt count = get_remaining_count ~did db in
143143+ if count > 0 then Lwt.return_none
144144+ else
145145+ let%lwt codes = regenerate ~did db in
146146+ Lwt.return_some codes
147147+end
148148+149149+module Queries = struct
150150+ let set_totp_secret =
151151+ [%rapper
152152+ execute
153153+ {sql| UPDATE actors SET totp_secret = %Blob{secret}, totp_verified_at = NULL
154154+ WHERE did = %string{did}
155155+ |sql}]
156156+157157+ let get_totp_secret did =
158158+ [%rapper
159159+ get_opt
160160+ {sql| SELECT @Blob?{totp_secret}, @int?{totp_verified_at}
161161+ FROM actors WHERE did = %string{did}
162162+ |sql}]
163163+ did
164164+165165+ let verify_totp_secret =
166166+ [%rapper
167167+ execute
168168+ {sql| UPDATE actors SET totp_verified_at = %int{verified_at}
169169+ WHERE did = %string{did}
170170+ |sql}]
171171+172172+ let clear_totp_secret =
173173+ [%rapper
174174+ execute
175175+ {sql| UPDATE actors SET totp_secret = NULL, totp_verified_at = NULL
176176+ WHERE did = %string{did}
177177+ |sql}]
178178+179179+ let is_totp_enabled did =
180180+ [%rapper
181181+ get_opt
182182+ {sql| SELECT 1 AS @int{enabled}
183183+ FROM actors WHERE did = %string{did} AND totp_verified_at IS NOT NULL
184184+ |sql}]
185185+ did
186186+end
187187+188188+let generate_secret () =
189189+ let () = Mirage_crypto_rng_unix.use_default () in
190190+ Bytes.of_string (Mirage_crypto_rng_unix.getrandom secret_length)
191191+192192+let make_provisioning_uri ~secret ~email ~issuer =
193193+ let secret_b32 =
194194+ Multibase.Base32.encode_exn ~pad:false (Bytes.to_string secret)
195195+ in
196196+ let encoded_email = Uri.pct_encode email in
197197+ let encoded_issuer = Uri.pct_encode issuer in
198198+ Printf.sprintf
199199+ "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d"
200200+ encoded_issuer encoded_email secret_b32 encoded_issuer code_digits time_step
201201+202202+let hotp ~(secret : bytes) ~(counter : int64) : string =
203203+ (* convert counter to 8-byte big-endian *)
204204+ let counter_bytes = Bytes.create 8 in
205205+ let c = ref counter in
206206+ for i = 7 downto 0 do
207207+ Bytes.set counter_bytes i (Char.chr (Int64.to_int (Int64.logand !c 0xffL))) ;
208208+ c := Int64.shift_right_logical !c 8
209209+ done ;
210210+ let hmac =
211211+ Digestif.SHA1.(
212212+ hmac_bytes ~key:(Bytes.to_string secret) counter_bytes |> to_raw_string )
213213+ in
214214+ (* dynamic truncation *)
215215+ let offset = Char.code hmac.[19] land 0xf in
216216+ let code =
217217+ ((Char.code hmac.[offset] land 0x7f) lsl 24)
218218+ lor ((Char.code hmac.[offset + 1] land 0xff) lsl 16)
219219+ lor ((Char.code hmac.[offset + 2] land 0xff) lsl 8)
220220+ lor (Char.code hmac.[offset + 3] land 0xff)
221221+ in
222222+ let modulo = int_of_float (10. ** float_of_int code_digits) in
223223+ Printf.sprintf "%0*d" code_digits (code mod modulo)
224224+225225+let generate_code ~secret =
226226+ let counter =
227227+ Int64.div (Int64.of_float (Unix.gettimeofday ())) (Int64.of_int time_step)
228228+ in
229229+ hotp ~secret ~counter
230230+231231+let verify_code ~secret ~code =
232232+ let current_counter =
233233+ Int64.div (Int64.of_float (Unix.gettimeofday ())) (Int64.of_int time_step)
234234+ in
235235+ let rec check offset =
236236+ if offset > window_size then false
237237+ else
238238+ let counter_plus = Int64.add current_counter (Int64.of_int offset) in
239239+ let counter_minus = Int64.sub current_counter (Int64.of_int offset) in
240240+ if hotp ~secret ~counter:counter_plus = code then true
241241+ else if offset > 0 && hotp ~secret ~counter:counter_minus = code then true
242242+ else check (offset + 1)
243243+ in
244244+ check 0
245245+246246+let create_secret ~did ~secret db =
247247+ Util.use_pool db @@ Queries.set_totp_secret ~did ~secret
248248+249249+let get_secret ~did db =
250250+ match%lwt Util.use_pool db @@ Queries.get_totp_secret ~did with
251251+ | Some (Some secret, verified_at) ->
252252+ Lwt.return_some (secret, verified_at)
253253+ | _ ->
254254+ Lwt.return_none
255255+256256+let verify_and_enable ~did ~code db =
257257+ match%lwt get_secret ~did db with
258258+ | None ->
259259+ Lwt.return_error "No TOTP setup in progress"
260260+ | Some (_, Some _) ->
261261+ Lwt.return_error "TOTP is already enabled"
262262+ | Some (secret, None) ->
263263+ if verify_code ~secret ~code then
264264+ let now = Util.now_ms () in
265265+ let%lwt () =
266266+ Util.use_pool db @@ Queries.verify_totp_secret ~did ~verified_at:now
267267+ in
268268+ Lwt.return_ok ()
269269+ else Lwt.return_error "Invalid verification code"
270270+271271+let disable ~did db = Util.use_pool db @@ Queries.clear_totp_secret ~did
272272+273273+let is_enabled ~did db =
274274+ match%lwt Util.use_pool db @@ Queries.is_totp_enabled ~did with
275275+ | Some _ ->
276276+ Lwt.return_true
277277+ | None ->
278278+ Lwt.return_false
279279+280280+let verify_login_code ~did ~code db =
281281+ match%lwt get_secret ~did db with
282282+ | Some (secret, Some _) ->
283283+ Lwt.return (verify_code ~secret ~code)
284284+ | _ ->
285285+ Lwt.return_false
+229
pegasus/lib/two_factor.ml
···11+let pending_session_expiry_ms = 5 * 60 * 1000
22+33+let email_code_expiry_ms = 10 * 60 * 1000
44+55+module Types = struct
66+ type two_factor_method = TOTP | Email | BackupCode
77+88+ type two_factor_status =
99+ {totp_enabled: bool; email_2fa_enabled: bool; backup_codes_remaining: int}
1010+ [@@deriving yojson {strict= false}]
1111+1212+ type pending_2fa =
1313+ { id: int
1414+ ; session_token: string
1515+ ; did: string
1616+ ; password_verified_at: int
1717+ ; expires_at: int
1818+ ; email_code: string option
1919+ ; email_code_expires_at: int option
2020+ ; created_at: int }
2121+2222+ type available_methods = Frontend.LoginPage.two_fa_methods =
2323+ {totp: bool; email: bool; backup_code: bool}
2424+ [@@deriving yojson {strict= false}]
2525+end
2626+2727+open Types
2828+2929+module Queries = struct
3030+ let insert_pending_2fa =
3131+ [%rapper
3232+ execute
3333+ {sql| INSERT INTO pending_2fa (session_token, did, password_verified_at, expires_at, created_at)
3434+ VALUES (%string{session_token}, %string{did}, %int{password_verified_at}, %int{expires_at}, %int{created_at})
3535+ |sql}]
3636+3737+ let get_pending_2fa session_token now =
3838+ [%rapper
3939+ get_opt
4040+ {sql| SELECT @int{id}, @string{session_token}, @string{did}, @int{password_verified_at},
4141+ @int{expires_at}, @string?{email_code}, @int?{email_code_expires_at}, @int{created_at}
4242+ FROM pending_2fa WHERE session_token = %string{session_token} AND expires_at > %int{now}
4343+ |sql}
4444+ record_out]
4545+ ~session_token ~now
4646+4747+ let get_pending_2fa_for_did did now =
4848+ [%rapper
4949+ get_opt
5050+ {sql| SELECT @int{id}, @string{session_token}, @string{did}, @int{password_verified_at},
5151+ @int{expires_at}, @string?{email_code}, @int?{email_code_expires_at}, @int{created_at}
5252+ FROM pending_2fa WHERE did = %string{did} AND expires_at > %int{now}
5353+ |sql}
5454+ record_out]
5555+ ~did ~now
5656+5757+ let update_email_code =
5858+ [%rapper
5959+ execute
6060+ {sql| UPDATE pending_2fa SET email_code = %string{email_code}, email_code_expires_at = %int{email_code_expires_at}
6161+ WHERE session_token = %string{session_token}
6262+ |sql}]
6363+6464+ let delete_pending_2fa =
6565+ [%rapper
6666+ execute
6767+ {sql| DELETE FROM pending_2fa WHERE session_token = %string{session_token}
6868+ |sql}]
6969+7070+ let get_email_2fa_enabled did =
7171+ [%rapper
7272+ get_opt
7373+ {sql| SELECT @int{email_2fa_enabled} FROM actors WHERE did = %string{did}
7474+ |sql}]
7575+ did
7676+7777+ let set_email_2fa_enabled =
7878+ [%rapper
7979+ execute
8080+ {sql| UPDATE actors SET email_2fa_enabled = %int{enabled}
8181+ WHERE did = %string{did}
8282+ |sql}]
8383+8484+ let is_2fa_enabled =
8585+ [%rapper
8686+ get_opt
8787+ {sql| SELECT CASE
8888+ WHEN totp_verified_at IS NOT NULL OR email_2fa_enabled = 1 THEN 1
8989+ ELSE 0
9090+ END AS @int{result}
9191+ FROM actors
9292+ WHERE did = %string{did} |sql}]
9393+end
9494+9595+let generate_session_token () =
9696+ let () = Mirage_crypto_rng_unix.use_default () in
9797+ let token = Mirage_crypto_rng_unix.getrandom 32 in
9898+ Base64.(encode_string ~alphabet:uri_safe_alphabet ~pad:false token)
9999+100100+let is_2fa_enabled ~did db =
101101+ match%lwt Util.use_pool db @@ Queries.is_2fa_enabled ~did with
102102+ | Some 1 ->
103103+ Lwt.return_true
104104+ | _ ->
105105+ Lwt.return_false
106106+107107+let get_status ~did db =
108108+ let%lwt totp_enabled = Totp.is_enabled ~did db in
109109+ let%lwt email_2fa =
110110+ match%lwt Util.use_pool db @@ Queries.get_email_2fa_enabled ~did with
111111+ | Some 1 ->
112112+ Lwt.return_true
113113+ | _ ->
114114+ Lwt.return_false
115115+ in
116116+ let%lwt backup_count = Totp.Backup_codes.get_remaining_count ~did db in
117117+ Lwt.return
118118+ { totp_enabled
119119+ ; email_2fa_enabled= email_2fa
120120+ ; backup_codes_remaining= backup_count }
121121+122122+let get_available_methods ~did db =
123123+ let%lwt totp_enabled = Totp.is_enabled ~did db in
124124+ let%lwt email_2fa =
125125+ match%lwt Util.use_pool db @@ Queries.get_email_2fa_enabled ~did with
126126+ | Some 1 ->
127127+ Lwt.return_true
128128+ | _ ->
129129+ Lwt.return_false
130130+ in
131131+ let%lwt has_backup = Totp.Backup_codes.has_backup_codes ~did db in
132132+ Lwt.return {totp= totp_enabled; email= email_2fa; backup_code= has_backup}
133133+134134+(* create a pending 2FA session after password verification *)
135135+let create_pending_session ~did db =
136136+ let session_token = generate_session_token () in
137137+ let now = Util.now_ms () in
138138+ let expires_at = now + pending_session_expiry_ms in
139139+ let%lwt () =
140140+ Util.use_pool db
141141+ @@ Queries.insert_pending_2fa ~session_token ~did ~password_verified_at:now
142142+ ~expires_at ~created_at:now
143143+ in
144144+ Lwt.return session_token
145145+146146+let get_pending_session ~session_token db =
147147+ let now = Util.now_ms () in
148148+ Util.use_pool db @@ Queries.get_pending_2fa session_token now
149149+150150+let get_pending_session_for_did ~did db =
151151+ let now = Util.now_ms () in
152152+ Util.use_pool db @@ Queries.get_pending_2fa_for_did did now
153153+154154+let delete_pending_session ~session_token db =
155155+ Util.use_pool db @@ Queries.delete_pending_2fa ~session_token
156156+157157+let send_email_code ~session_token ~actor db =
158158+ let code = Util.make_code () in
159159+ let now = Util.now_ms () in
160160+ let expires_at = now + email_code_expiry_ms in
161161+ let%lwt () =
162162+ Util.use_pool db
163163+ @@ Queries.update_email_code ~session_token ~email_code:code
164164+ ~email_code_expires_at:expires_at
165165+ in
166166+ let subject = "Your login verification code" in
167167+ let body =
168168+ Emails.TwoFactorAuth.make ~handle:actor.Data_store.Types.handle ~code
169169+ in
170170+ let recipients = [Letters.To actor.email] in
171171+ let%lwt () = Util.send_email_or_log ~recipients ~subject ~body in
172172+ Lwt.return_unit
173173+174174+let _verify_email_code ~code ~session =
175175+ match (session.email_code, session.email_code_expires_at) with
176176+ | Some stored_code, Some expires_at ->
177177+ let now = Util.now_ms () in
178178+ if now > expires_at then Lwt.return_error "Email code expired"
179179+ else if stored_code = code then Lwt.return_ok session.did
180180+ else Lwt.return_error "Invalid code"
181181+ | _ ->
182182+ Lwt.return_error "No email code sent for this session"
183183+184184+let verify_email_code_by_token ~session_token ~code db =
185185+ match%lwt get_pending_session ~session_token db with
186186+ | None ->
187187+ Lwt.return_error "Invalid or expired session"
188188+ | Some pending ->
189189+ _verify_email_code ~code ~session:pending
190190+191191+let verify_email_code_by_did ~did ~code db =
192192+ match%lwt get_pending_session_for_did ~did db with
193193+ | None ->
194194+ Lwt.return_error "Invalid or expired session"
195195+ | Some pending ->
196196+ _verify_email_code ~code ~session:pending
197197+198198+let verify_totp_code ~session_token ~code db =
199199+ match%lwt get_pending_session ~session_token db with
200200+ | None ->
201201+ Lwt.return_error "Invalid or expired session"
202202+ | Some pending ->
203203+ let%lwt valid = Totp.verify_login_code ~did:pending.did ~code db in
204204+ if valid then Lwt.return_ok pending.did
205205+ else Lwt.return_error "Invalid TOTP code"
206206+207207+let verify_backup_code ~session_token ~code db =
208208+ match%lwt get_pending_session ~session_token db with
209209+ | None ->
210210+ Lwt.return_error "Invalid or expired session"
211211+ | Some pending ->
212212+ let%lwt valid =
213213+ Totp.Backup_codes.verify_and_consume ~did:pending.did ~code db
214214+ in
215215+ if valid then Lwt.return_ok pending.did
216216+ else Lwt.return_error "Invalid backup code"
217217+218218+let enable_email_2fa ~did db =
219219+ Util.use_pool db @@ Queries.set_email_2fa_enabled ~did ~enabled:1
220220+221221+let disable_email_2fa ~did db =
222222+ Util.use_pool db @@ Queries.set_email_2fa_enabled ~did ~enabled:0
223223+224224+let is_email_2fa_enabled ~did db =
225225+ match%lwt Util.use_pool db @@ Queries.get_email_2fa_enabled ~did with
226226+ | Some 1 ->
227227+ Lwt.return_true
228228+ | _ ->
229229+ Lwt.return_false