···27272828let verify_2fa_code ~(actor : Data_store.Types.actor) ~code db =
2929 let did = actor.did in
3030- let%lwt totp_valid = Totp.verify_login_code ~did ~code db in
3131- if totp_valid then Lwt.return_ok ()
3030+ let%lwt sk_valid = Security_key.verify_login ~did ~code db in
3131+ if sk_valid then Lwt.return_ok ()
3232 else
3333- let%lwt backup_valid = Totp.Backup_codes.verify_and_consume ~did ~code db in
3434- if backup_valid then Lwt.return_ok ()
3333+ let%lwt totp_valid = Totp.verify_login_code ~did ~code db in
3434+ if totp_valid then Lwt.return_ok ()
3535 else
3636- match%lwt Two_factor.verify_email_code_by_did ~did ~code db with
3737- | Ok _ ->
3838- Lwt.return_ok ()
3939- | Error e ->
4040- Lwt.return_error e
3636+ let%lwt backup_valid =
3737+ Totp.Backup_codes.verify_and_consume ~did ~code db
3838+ in
3939+ if backup_valid then Lwt.return_ok ()
4040+ else
4141+ match%lwt Two_factor.verify_email_code_by_did ~did ~code db with
4242+ | Ok _ ->
4343+ Lwt.return_ok ()
4444+ | Error e ->
4545+ Lwt.return_error e
41464247let handler =
4348 Xrpc.handler (fun {req; db; _} ->
···5964 Lwt_result.catch @@ fun () -> Data_store.try_login ~id ~password db
6065 with
6166 | Ok (Some actor) -> (
6262- let is_2fa_enabled =
6363- actor.email_2fa_enabled = 1 || actor.totp_verified_at <> None
6767+ let%lwt is_2fa_enabled =
6868+ Two_factor.is_2fa_enabled ~did:actor.did db
6469 in
6570 if not is_2fa_enabled then complete_login actor
6671 else
···7883 in
7984 (* only send code to email if email is the only method *)
8085 let%lwt () =
8181- if methods.email && not methods.totp then
8686+ if
8787+ methods.email && (not methods.totp)
8888+ && not methods.security_key
8989+ then
8290 let%lwt session_token =
8391 Two_factor.create_pending_session ~did:actor.did db
8492 in
···11+CREATE TABLE IF NOT EXISTS security_keys (
22+ id INTEGER PRIMARY KEY,
33+ did TEXT NOT NULL,
44+ name TEXT NOT NULL DEFAULT 'Security Key',
55+ secret BLOB NOT NULL,
66+ counter INTEGER NOT NULL DEFAULT 0,
77+ created_at INTEGER NOT NULL,
88+ last_used_at INTEGER,
99+ verified_at INTEGER,
1010+ FOREIGN KEY (did) REFERENCES actors(did) ON DELETE CASCADE
1111+);
1212+1313+CREATE INDEX IF NOT EXISTS security_keys_did_idx ON security_keys(did);
+280
pegasus/lib/security_key.ml
···11+open Util.Rapper
22+33+let max_security_keys_per_user = 5
44+55+let look_ahead_window = 100
66+77+let resync_window = 1000
88+99+let code_digits = 6
1010+1111+let secret_length = 20 (* 160 bits for HMAC-SHA1 *)
1212+1313+module Types = struct
1414+ type security_key =
1515+ { id: int
1616+ ; did: string
1717+ ; name: string
1818+ ; secret: bytes
1919+ ; counter: int
2020+ ; created_at: int
2121+ ; last_used_at: int option
2222+ ; verified_at: int option }
2323+end
2424+2525+open Types
2626+2727+module Queries = struct
2828+ let insert_security_key =
2929+ [%rapper
3030+ execute
3131+ {sql| INSERT INTO security_keys (did, name, secret, counter, created_at)
3232+ VALUES (%string{did}, %string{name}, %Blob{secret}, %int{counter}, %int{created_at})
3333+ |sql}]
3434+3535+ let get_last_insert_id =
3636+ [%rapper get_one {sql| SELECT last_insert_rowid() AS @int{id} |sql}]
3737+3838+ let get_security_keys_by_did =
3939+ [%rapper
4040+ get_many
4141+ {sql| SELECT @int{id}, @string{did}, @string{name}, @Blob{secret},
4242+ @int{counter}, @int{created_at}, @int?{last_used_at}, @int?{verified_at}
4343+ FROM security_keys WHERE did = %string{did}
4444+ ORDER BY created_at DESC
4545+ |sql}
4646+ record_out]
4747+4848+ let get_verified_security_keys_by_did =
4949+ [%rapper
5050+ get_many
5151+ {sql| SELECT @int{id}, @string{did}, @string{name}, @Blob{secret},
5252+ @int{counter}, @int{created_at}, @int?{last_used_at}, @int?{verified_at}
5353+ FROM security_keys WHERE did = %string{did} AND verified_at IS NOT NULL
5454+ ORDER BY created_at DESC
5555+ |sql}
5656+ record_out]
5757+5858+ let get_security_key_by_id id did =
5959+ [%rapper
6060+ get_opt
6161+ {sql| SELECT @int{id}, @string{did}, @string{name}, @Blob{secret},
6262+ @int{counter}, @int{created_at}, @int?{last_used_at}, @int?{verified_at}
6363+ FROM security_keys WHERE id = %int{id} AND did = %string{did}
6464+ |sql}
6565+ record_out]
6666+ ~id ~did
6767+6868+ let update_counter_and_last_used =
6969+ [%rapper
7070+ execute
7171+ {sql| UPDATE security_keys SET counter = %int{counter}, last_used_at = %int{last_used_at}
7272+ WHERE id = %int{id}
7373+ |sql}]
7474+7575+ let update_counter =
7676+ [%rapper
7777+ execute
7878+ {sql| UPDATE security_keys SET counter = %int{counter}
7979+ WHERE id = %int{id}
8080+ |sql}]
8181+8282+ let verify_security_key =
8383+ [%rapper
8484+ execute
8585+ {sql| UPDATE security_keys SET verified_at = %int{verified_at}, counter = %int{counter}
8686+ WHERE id = %int{id} AND did = %string{did}
8787+ |sql}]
8888+8989+ let delete_security_key =
9090+ [%rapper
9191+ execute
9292+ {sql| DELETE FROM security_keys WHERE id = %int{id} AND did = %string{did}
9393+ |sql}]
9494+9595+ let count_security_keys =
9696+ [%rapper
9797+ get_one
9898+ {sql| SELECT COUNT(*) AS @int{count} FROM security_keys WHERE did = %string{did}
9999+ |sql}]
100100+101101+ let count_verified_security_keys =
102102+ [%rapper
103103+ get_one
104104+ {sql| SELECT COUNT(*) AS @int{count} FROM security_keys
105105+ WHERE did = %string{did} AND verified_at IS NOT NULL
106106+ |sql}]
107107+108108+ let has_security_keys =
109109+ [%rapper
110110+ get_opt
111111+ {sql| SELECT 1 AS @int{has_sk} FROM security_keys
112112+ WHERE did = %string{did} AND verified_at IS NOT NULL LIMIT 1
113113+ |sql}]
114114+end
115115+116116+(* RFC 4226 *)
117117+let hotp ~(secret : bytes) ~(counter : int64) : string =
118118+ (* convert counter to 8-byte big-endian *)
119119+ let counter_bytes = Bytes.create 8 in
120120+ let c = ref counter in
121121+ for i = 7 downto 0 do
122122+ Bytes.set counter_bytes i (Char.chr (Int64.to_int (Int64.logand !c 0xffL))) ;
123123+ c := Int64.shift_right_logical !c 8
124124+ done ;
125125+ let hmac =
126126+ Digestif.SHA1.(
127127+ hmac_bytes ~key:(Bytes.to_string secret) counter_bytes |> to_raw_string )
128128+ in
129129+ (* dynamic truncation *)
130130+ let offset = Char.code hmac.[19] land 0xf in
131131+ let code =
132132+ ((Char.code hmac.[offset] land 0x7f) lsl 24)
133133+ lor ((Char.code hmac.[offset + 1] land 0xff) lsl 16)
134134+ lor ((Char.code hmac.[offset + 2] land 0xff) lsl 8)
135135+ lor (Char.code hmac.[offset + 3] land 0xff)
136136+ in
137137+ let modulo = int_of_float (10. ** float_of_int code_digits) in
138138+ Printf.sprintf "%0*d" code_digits (code mod modulo)
139139+140140+let generate_secret () =
141141+ let () = Mirage_crypto_rng_unix.use_default () in
142142+ Bytes.of_string (Mirage_crypto_rng_unix.getrandom secret_length)
143143+144144+let make_provisioning_uri ~secret ~account ~issuer =
145145+ let secret_b32 =
146146+ Multibase.Base32.encode_exn ~pad:false (Bytes.to_string secret)
147147+ in
148148+ let encoded_account = Uri.pct_encode account in
149149+ let encoded_issuer = Uri.pct_encode issuer in
150150+ Printf.sprintf
151151+ "otpauth://hotp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&counter=0"
152152+ encoded_issuer encoded_account secret_b32 encoded_issuer code_digits
153153+154154+let verify_code ~secret ~stored_counter ~code =
155155+ let rec check_window offset =
156156+ if offset > look_ahead_window then Error "Code not valid (may need resync)"
157157+ else
158158+ let counter = Int64.of_int (stored_counter + offset) in
159159+ if hotp ~secret ~counter = code then Ok (stored_counter + offset + 1)
160160+ (* update counter past this one *)
161161+ else check_window (offset + 1)
162162+ in
163163+ check_window 0
164164+165165+(* resync requires two consecutive valid codes *)
166166+let resync ~secret ~stored_counter ~code1 ~code2 =
167167+ let rec find_first offset =
168168+ if offset > resync_window then None
169169+ else
170170+ let counter = Int64.of_int (stored_counter + offset) in
171171+ if hotp ~secret ~counter = code1 then Some (stored_counter + offset)
172172+ else find_first (offset + 1)
173173+ in
174174+ match find_first 0 with
175175+ | None ->
176176+ Error "First code not found in resync window"
177177+ | Some counter1 ->
178178+ let counter2 = Int64.of_int (counter1 + 1) in
179179+ if hotp ~secret ~counter:counter2 = code2 then Ok (counter1 + 2)
180180+ (* resync to after both codes *)
181181+ else Error "Second code must immediately follow the first"
182182+183183+let setup_security_key ~did ~name db =
184184+ let secret = generate_secret () in
185185+ let now = Util.now_ms () in
186186+ let%lwt () =
187187+ Util.use_pool db
188188+ @@ Queries.insert_security_key ~did ~name ~secret ~counter:0 ~created_at:now
189189+ in
190190+ let%lwt id = Util.use_pool db @@ Queries.get_last_insert_id () in
191191+ let issuer = "Pegasus PDS (" ^ Env.hostname ^ ")" in
192192+ let uri = make_provisioning_uri ~secret ~account:did ~issuer in
193193+ let secret_b32 =
194194+ Multibase.Base32.encode_exn ~pad:false (Bytes.to_string secret)
195195+ in
196196+ Lwt.return (id, secret_b32, uri)
197197+198198+let verify_setup ~id ~did ~code db =
199199+ match%lwt Util.use_pool db @@ Queries.get_security_key_by_id id did with
200200+ | None ->
201201+ Lwt.return_error "Security key not found"
202202+ | Some sk -> (
203203+ if Option.is_some sk.verified_at then
204204+ Lwt.return_error "Security key already verified"
205205+ else
206206+ match
207207+ verify_code ~secret:sk.secret ~stored_counter:sk.counter ~code
208208+ with
209209+ | Error msg ->
210210+ Lwt.return_error msg
211211+ | Ok new_counter ->
212212+ let now = Util.now_ms () in
213213+ let%lwt () =
214214+ Util.use_pool db
215215+ @@ Queries.verify_security_key ~id ~did ~verified_at:now
216216+ ~counter:new_counter
217217+ in
218218+ Lwt.return_ok () )
219219+220220+let verify_login ~did ~code db =
221221+ let%lwt keys =
222222+ Util.use_pool db @@ Queries.get_verified_security_keys_by_did ~did
223223+ in
224224+ let rec try_keys = function
225225+ | [] ->
226226+ Lwt.return_false
227227+ | sk :: rest -> (
228228+ match verify_code ~secret:sk.secret ~stored_counter:sk.counter ~code with
229229+ | Error _ ->
230230+ try_keys rest
231231+ | Ok new_counter ->
232232+ let now = Util.now_ms () in
233233+ let%lwt () =
234234+ Util.use_pool db
235235+ @@ Queries.update_counter_and_last_used ~id:sk.id
236236+ ~counter:new_counter ~last_used_at:now
237237+ in
238238+ Lwt.return_true )
239239+ in
240240+ try_keys keys
241241+242242+let resync_key ~id ~did ~code1 ~code2 db =
243243+ match%lwt Util.use_pool db @@ Queries.get_security_key_by_id id did with
244244+ | None ->
245245+ Lwt.return_error "Security key not found"
246246+ | Some sk -> (
247247+ if Option.is_none sk.verified_at then
248248+ Lwt.return_error "Security key not verified yet"
249249+ else
250250+ match
251251+ resync ~secret:sk.secret ~stored_counter:sk.counter ~code1 ~code2
252252+ with
253253+ | Error msg ->
254254+ Lwt.return_error msg
255255+ | Ok new_counter ->
256256+ let%lwt () =
257257+ Util.use_pool db
258258+ @@ Queries.update_counter ~id:sk.id ~counter:new_counter
259259+ in
260260+ Lwt.return_ok () )
261261+262262+let get_keys_for_user ~did db =
263263+ Util.use_pool db @@ Queries.get_security_keys_by_did ~did
264264+265265+let delete_key ~id ~did db =
266266+ let%lwt () = Util.use_pool db @@ Queries.delete_security_key ~id ~did in
267267+ Lwt.return_true
268268+269269+let has_security_keys ~did db =
270270+ match%lwt Util.use_pool db @@ Queries.has_security_keys ~did with
271271+ | Some _ ->
272272+ Lwt.return_true
273273+ | None ->
274274+ Lwt.return_false
275275+276276+let count_security_keys ~did db =
277277+ Util.use_pool db @@ Queries.count_security_keys ~did
278278+279279+let count_verified_security_keys ~did db =
280280+ Util.use_pool db @@ Queries.count_verified_security_keys ~did
+21-6
pegasus/lib/two_factor.ml
···33let email_code_expiry_ms = 10 * 60 * 1000
4455module Types = struct
66- type two_factor_method = TOTP | Email | BackupCode
66+ type two_factor_method = TOTP | Email | BackupCode | SecurityKey
7788 type two_factor_status =
99- {totp_enabled: bool; email_2fa_enabled: bool; backup_codes_remaining: int}
99+ { totp_enabled: bool
1010+ ; email_2fa_enabled: bool
1111+ ; backup_codes_remaining: int
1212+ ; security_keys_count: int }
1013 [@@deriving yojson {strict= false}]
11141215 type pending_2fa =
···2023 ; created_at: int }
21242225 type available_methods = Frontend.LoginPage.two_fa_methods =
2323- {totp: bool; email: bool; backup_code: bool}
2626+ {totp: bool; email: bool; backup_code: bool; security_key: bool}
2427 [@@deriving yojson {strict= false}]
2528end
2629···8588 [%rapper
8689 get_opt
8790 {sql| SELECT CASE
8888- WHEN totp_verified_at IS NOT NULL OR email_2fa_enabled = 1 THEN 1
9191+ WHEN totp_verified_at IS NOT NULL
9292+ OR email_2fa_enabled = 1
9393+ OR EXISTS(SELECT 1 FROM security_keys WHERE security_keys.did = actors.did AND security_keys.verified_at IS NOT NULL)
9494+ THEN 1
8995 ELSE 0
9096 END AS @int{result}
9197 FROM actors
···114120 Lwt.return_false
115121 in
116122 let%lwt backup_count = Totp.Backup_codes.get_remaining_count ~did db in
123123+ let%lwt security_keys_count =
124124+ Security_key.count_verified_security_keys ~did db
125125+ in
117126 Lwt.return
118127 { totp_enabled
119128 ; email_2fa_enabled= email_2fa
120120- ; backup_codes_remaining= backup_count }
129129+ ; backup_codes_remaining= backup_count
130130+ ; security_keys_count }
121131122132let get_available_methods ~did db =
123133 let%lwt totp_enabled = Totp.is_enabled ~did db in
···129139 Lwt.return_false
130140 in
131141 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}
142142+ let%lwt has_security_key = Security_key.has_security_keys ~did db in
143143+ Lwt.return
144144+ { totp= totp_enabled
145145+ ; email= email_2fa
146146+ ; backup_code= has_backup
147147+ ; security_key= has_security_key }
133148134149(* create a pending 2FA session after password verification *)
135150let create_pending_session ~did db =