···11+CREATE TABLE IF NOT EXISTS passkeys (
22+ id INTEGER PRIMARY KEY,
33+ did TEXT NOT NULL,
44+ credential_id TEXT NOT NULL UNIQUE,
55+ public_key BLOB NOT NULL,
66+ sign_count INTEGER NOT NULL DEFAULT 0,
77+ name TEXT NOT NULL DEFAULT 'Passkey',
88+ created_at INTEGER NOT NULL,
99+ last_used_at INTEGER,
1010+ FOREIGN KEY (did) REFERENCES actors(did) ON DELETE CASCADE
1111+);
1212+1313+CREATE INDEX IF NOT EXISTS passkeys_did_idx ON passkeys(did);
1414+CREATE INDEX IF NOT EXISTS passkeys_credential_id_idx ON passkeys(credential_id);
1515+1616+CREATE TABLE IF NOT EXISTS passkey_challenges (
1717+ challenge TEXT PRIMARY KEY,
1818+ did TEXT,
1919+ challenge_type TEXT NOT NULL,
2020+ expires_at INTEGER NOT NULL,
2121+ created_at INTEGER NOT NULL
2222+);
2323+2424+CREATE INDEX IF NOT EXISTS passkey_challenges_expires_idx ON passkey_challenges(expires_at);
2525+2626+CREATE TRIGGER IF NOT EXISTS cleanup_expired_passkey_challenges
2727+AFTER INSERT ON passkey_challenges
2828+BEGIN
2929+ DELETE FROM passkey_challenges WHERE expires_at < unixepoch() * 1000;
3030+END;
+368
pegasus/lib/passkey.ml
···11+open Util.Rapper
22+33+let challenge_expiry_ms = 5 * 60 * 1000
44+55+module Types = struct
66+ type passkey =
77+ { id: int
88+ ; did: string
99+ ; credential_id: string
1010+ ; public_key: bytes
1111+ ; sign_count: int
1212+ ; name: string
1313+ ; created_at: int
1414+ ; last_used_at: int option }
1515+1616+ type challenge =
1717+ { challenge: string
1818+ ; did: string option
1919+ ; challenge_type: string
2020+ ; expires_at: int
2121+ ; created_at: int }
2222+2323+ type passkey_display =
2424+ { id: int
2525+ ; name: string
2626+ ; created_at: int
2727+ ; last_used_at: int option [@default None] }
2828+ [@@deriving yojson {strict= false}]
2929+end
3030+3131+open Types
3232+3333+module Queries = struct
3434+ let insert_passkey =
3535+ [%rapper
3636+ execute
3737+ {sql| INSERT INTO passkeys (did, credential_id, public_key, sign_count, name, created_at)
3838+ VALUES (%string{did}, %string{credential_id}, %Blob{public_key}, %int{sign_count}, %string{name}, %int{created_at})
3939+ |sql}]
4040+4141+ let get_passkeys_by_did did =
4242+ [%rapper
4343+ get_many
4444+ {sql| SELECT @int{id}, @string{did}, @string{credential_id}, @Blob{public_key},
4545+ @int{sign_count}, @string{name}, @int{created_at}, @int?{last_used_at}
4646+ FROM passkeys WHERE did = %string{did}
4747+ ORDER BY created_at DESC
4848+ |sql}
4949+ record_out]
5050+ did
5151+5252+ let get_passkey_by_credential_id credential_id =
5353+ [%rapper
5454+ get_opt
5555+ {sql| SELECT @int{id}, @string{did}, @string{credential_id}, @Blob{public_key},
5656+ @int{sign_count}, @string{name}, @int{created_at}, @int?{last_used_at}
5757+ FROM passkeys WHERE credential_id = %string{credential_id}
5858+ |sql}
5959+ record_out]
6060+ credential_id
6161+6262+ let update_passkey_sign_count =
6363+ [%rapper
6464+ execute
6565+ {sql| UPDATE passkeys SET sign_count = %int{sign_count}, last_used_at = %int{last_used_at}
6666+ WHERE credential_id = %string{credential_id}
6767+ |sql}]
6868+6969+ let delete_passkey =
7070+ [%rapper
7171+ execute
7272+ {sql| DELETE FROM passkeys WHERE id = %int{id} AND did = %string{did}
7373+ |sql}]
7474+7575+ let rename_passkey =
7676+ [%rapper
7777+ execute
7878+ {sql| UPDATE passkeys SET name = %string{name} WHERE id = %int{id} AND did = %string{did}
7979+ |sql}]
8080+8181+ let insert_challenge =
8282+ [%rapper
8383+ execute
8484+ {sql| INSERT INTO passkey_challenges (challenge, did, challenge_type, expires_at, created_at)
8585+ VALUES (%string{challenge}, %string?{did}, %string{challenge_type}, %int{expires_at}, %int{created_at})
8686+ |sql}]
8787+8888+ let get_challenge challenge now =
8989+ [%rapper
9090+ get_opt
9191+ {sql| SELECT @string{challenge}, @string?{did}, @string{challenge_type},
9292+ @int{expires_at}, @int{created_at}
9393+ FROM passkey_challenges
9494+ WHERE challenge = %string{challenge} AND expires_at > %int{now}
9595+ |sql}
9696+ record_out]
9797+ ~challenge ~now
9898+9999+ let delete_challenge =
100100+ [%rapper
101101+ execute
102102+ {sql| DELETE FROM passkey_challenges WHERE challenge = %string{challenge}
103103+ |sql}]
104104+end
105105+106106+let webauthn_instance : Webauthn.t option ref = ref None
107107+108108+let webauthn () =
109109+ match !webauthn_instance with
110110+ | Some t ->
111111+ t
112112+ | None -> (
113113+ match Webauthn.create Env.host_endpoint with
114114+ | Ok t ->
115115+ webauthn_instance := Some t ;
116116+ t
117117+ | Error msg ->
118118+ failwith ("Failed to initialize WebAuthn: " ^ msg) )
119119+120120+let serialize_pubkey (pk : Mirage_crypto_ec.P256.Dsa.pub) : bytes =
121121+ Bytes.of_string (Mirage_crypto_ec.P256.Dsa.pub_to_octets pk)
122122+123123+let deserialize_pubkey (b : bytes) : Mirage_crypto_ec.P256.Dsa.pub option =
124124+ Mirage_crypto_ec.P256.Dsa.pub_of_octets (Bytes.to_string b)
125125+ |> Result.to_option
126126+127127+let create_challenge ?did ~challenge_type db =
128128+ let _challenge_obj, challenge_b64 = Webauthn.generate_challenge () in
129129+ let now = Util.now_ms () in
130130+ let expires_at = now + challenge_expiry_ms in
131131+ let challenge_type_str =
132132+ match challenge_type with
133133+ | `Register ->
134134+ "register"
135135+ | `Authenticate ->
136136+ "authenticate"
137137+ in
138138+ let%lwt () =
139139+ Util.use_pool db
140140+ @@ Queries.insert_challenge ~challenge:challenge_b64 ~did
141141+ ~challenge_type:challenge_type_str ~expires_at ~created_at:now
142142+ in
143143+ Lwt.return challenge_b64
144144+145145+let verify_challenge ~challenge ~challenge_type db =
146146+ let now = Util.now_ms () in
147147+ let expected_type =
148148+ match challenge_type with
149149+ | `Register ->
150150+ "register"
151151+ | `Authenticate ->
152152+ "authenticate"
153153+ in
154154+ match%lwt Util.use_pool db @@ Queries.get_challenge challenge now with
155155+ | Some c when c.challenge_type = expected_type ->
156156+ Lwt.return_some c
157157+ | _ ->
158158+ Lwt.return_none
159159+160160+let delete_challenge ~challenge db =
161161+ Util.use_pool db @@ Queries.delete_challenge ~challenge
162162+163163+let store_credential ~did ~credential_id ~public_key ~name db =
164164+ let now = Util.now_ms () in
165165+ Util.use_pool db
166166+ @@ Queries.insert_passkey ~did ~credential_id ~public_key ~sign_count:0 ~name
167167+ ~created_at:now
168168+169169+let get_credentials_for_user ~did db =
170170+ Util.use_pool db @@ Queries.get_passkeys_by_did ~did
171171+172172+let get_credential_by_id ~credential_id db =
173173+ Util.use_pool db @@ Queries.get_passkey_by_credential_id ~credential_id
174174+175175+let update_sign_count ~credential_id ~sign_count db =
176176+ let now = Util.now_ms () in
177177+ Util.use_pool db
178178+ @@ Queries.update_passkey_sign_count ~credential_id ~sign_count
179179+ ~last_used_at:now
180180+181181+let delete_credential ~id ~did db =
182182+ let%lwt () = Util.use_pool db @@ Queries.delete_passkey ~id ~did in
183183+ Lwt.return_true
184184+185185+let rename_credential ~id ~did ~name db =
186186+ let%lwt () = Util.use_pool db @@ Queries.rename_passkey ~id ~did ~name in
187187+ Lwt.return_true
188188+189189+let generate_registration_options ~did ~email ~existing_credentials db =
190190+ let%lwt challenge = create_challenge ~did ~challenge_type:`Register db in
191191+ let exclude_credentials =
192192+ List.map
193193+ (fun (pk : passkey) ->
194194+ `Assoc
195195+ [ ("id", `String pk.credential_id)
196196+ ; ("type", `String "public-key")
197197+ ; ("transports", `List [`String "internal"; `String "hybrid"]) ] )
198198+ existing_credentials
199199+ in
200200+ let user_id =
201201+ Base64.(encode_string ~alphabet:uri_safe_alphabet ~pad:false did)
202202+ in
203203+ Lwt.return
204204+ @@ `Assoc
205205+ [ ("challenge", `String challenge)
206206+ ; ( "rp"
207207+ , `Assoc [("name", `String "Pegasus PDS"); ("id", `String Env.hostname)]
208208+ )
209209+ ; ( "user"
210210+ , `Assoc
211211+ [ ("id", `String user_id)
212212+ ; ("name", `String email)
213213+ ; ("displayName", `String email) ] )
214214+ ; ( "pubKeyCredParams"
215215+ , `List [`Assoc [("alg", `Int (-7)); ("type", `String "public-key")]]
216216+ )
217217+ ; ("timeout", `Int 300000)
218218+ ; ("attestation", `String "none")
219219+ ; ("excludeCredentials", `List exclude_credentials)
220220+ ; ( "authenticatorSelection"
221221+ , `Assoc
222222+ [ ("residentKey", `String "preferred")
223223+ ; ("userVerification", `String "preferred") ] ) ]
224224+225225+let verify_registration ~challenge ~response db =
226226+ match%lwt verify_challenge ~challenge ~challenge_type:`Register db with
227227+ | None ->
228228+ Lwt.return_error "Invalid or expired challenge"
229229+ | Some _ -> (
230230+ let%lwt () = delete_challenge ~challenge db in
231231+ let credential_json = Yojson.Safe.from_string response in
232232+ let credential_id =
233233+ match credential_json with
234234+ | `Assoc fields -> (
235235+ match List.assoc_opt "id" fields with
236236+ | Some (`String id) ->
237237+ id
238238+ | _ ->
239239+ "" )
240240+ | _ ->
241241+ ""
242242+ in
243243+ let inner_response =
244244+ match credential_json with
245245+ | `Assoc fields -> (
246246+ match List.assoc_opt "response" fields with
247247+ | Some (`Assoc r) -> (
248248+ match
249249+ (* need to extract these fields only, extra fields will cause
250250+ register_response_of_string to error *)
251251+ ( List.assoc_opt "attestationObject" r
252252+ , List.assoc_opt "clientDataJSON" r )
253253+ with
254254+ | Some ao, Some cd ->
255255+ Yojson.Safe.to_string
256256+ (`Assoc [("attestationObject", ao); ("clientDataJSON", cd)])
257257+ | _ ->
258258+ "" )
259259+ | _ ->
260260+ "" )
261261+ | _ ->
262262+ ""
263263+ in
264264+ if String.length credential_id = 0 || String.length inner_response = 0
265265+ then Lwt.return_error "invalid credential format"
266266+ else
267267+ match Webauthn.register_response_of_string inner_response with
268268+ | Error e ->
269269+ let err = Format.asprintf "%a" Webauthn.pp_error e in
270270+ Lwt.return_error ("invalid registration response: " ^ err)
271271+ | Ok reg_response -> (
272272+ match Webauthn.register (webauthn ()) reg_response with
273273+ | Error e ->
274274+ let err = Format.asprintf "%a" Webauthn.pp_error e in
275275+ Lwt.return_error ("registration verification failed: " ^ err)
276276+ | Ok (_returned_challenge, registration) ->
277277+ let public_key =
278278+ serialize_pubkey
279279+ registration.attested_credential_data.public_key
280280+ in
281281+ Lwt.return_ok (credential_id, public_key) ) )
282282+283283+let generate_authentication_options ?did:_did db =
284284+ let%lwt challenge = create_challenge ~challenge_type:`Authenticate db in
285285+ (* for conditional UI, we use empty allowCredentials *)
286286+ Lwt.return
287287+ @@ `Assoc
288288+ [ ("challenge", `String challenge)
289289+ ; ("timeout", `Int 300000)
290290+ ; ("rpId", `String Env.hostname)
291291+ ; ("userVerification", `String "preferred")
292292+ ; ("allowCredentials", `List []) ]
293293+294294+let verify_authentication ~challenge ~response db =
295295+ match%lwt verify_challenge ~challenge ~challenge_type:`Authenticate db with
296296+ | None ->
297297+ Lwt.return_error "invalid or expired challenge"
298298+ | Some _ -> (
299299+ let%lwt () = delete_challenge ~challenge db in
300300+ let credential_json = Yojson.Safe.from_string response in
301301+ let credential_id =
302302+ match credential_json with
303303+ | `Assoc fields -> (
304304+ match List.assoc_opt "id" fields with
305305+ | Some (`String id) ->
306306+ id
307307+ | _ ->
308308+ "" )
309309+ | _ ->
310310+ ""
311311+ in
312312+ let inner_response =
313313+ match credential_json with
314314+ | `Assoc fields -> (
315315+ match List.assoc_opt "response" fields with
316316+ | Some (`Assoc r) -> (
317317+ match
318318+ (* need to extract these fields only, extra fields will cause
319319+ register_response_of_string to error *)
320320+ ( List.assoc_opt "authenticatorData" r
321321+ , List.assoc_opt "clientDataJSON" r
322322+ , List.assoc_opt "signature" r
323323+ , List.assoc_opt "userHandle" r )
324324+ with
325325+ | Some ad, Some cd, Some sgn, uh ->
326326+ Yojson.Safe.to_string
327327+ (`Assoc
328328+ ( ( match uh with
329329+ | Some uh ->
330330+ [("userHandle", uh)]
331331+ | None ->
332332+ [] )
333333+ @ [ ("authenticatorData", ad)
334334+ ; ("clientDataJSON", cd)
335335+ ; ("signature", sgn) ] ) )
336336+ | _ ->
337337+ "" )
338338+ | _ ->
339339+ "" )
340340+ | _ ->
341341+ ""
342342+ in
343343+ if String.length credential_id = 0 || String.length inner_response = 0
344344+ then Lwt.return_error "invalid credential format"
345345+ else
346346+ match Webauthn.authenticate_response_of_string inner_response with
347347+ | Error _ ->
348348+ Lwt.return_error "invalid authentication response"
349349+ | Ok auth_response -> (
350350+ match%lwt get_credential_by_id ~credential_id db with
351351+ | None ->
352352+ Lwt.return_error "unknown credential"
353353+ | Some passkey -> (
354354+ match deserialize_pubkey passkey.public_key with
355355+ | None ->
356356+ Lwt.return_error "invalid stored public key"
357357+ | Some pubkey -> (
358358+ match
359359+ Webauthn.authenticate (webauthn ()) pubkey auth_response
360360+ with
361361+ | Error _ ->
362362+ Lwt.return_error "authentication verification failed"
363363+ | Ok (_returned_challenge, auth) ->
364364+ let sign_count = Int32.to_int auth.sign_count in
365365+ let%lwt () =
366366+ update_sign_count ~credential_id ~sign_count db
367367+ in
368368+ Lwt.return_ok passkey.did ) ) ) )