Compare commits

..

No commits in common. "gh-pages" and "main" have entirely different histories.

22 changed files with 1496 additions and 807 deletions

8
CHANGES.md Normal file
View file

@ -0,0 +1,8 @@
## v0.2.0 (2024-09-13)
* Update to `mirage-crypto*>=1.0.0` and get rid of cstruct dependency
* Demo application is updated to dream.1.0.0~alpha7
## v0.1.0 (2021-11-18)
* Initial release, sponsored by skolem.tech

23
LICENSE.md Normal file
View file

@ -0,0 +1,23 @@
Copyright (c) 2021, Reynir Björnsson and Hannes Mehnert
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

0
README
View file

31
README.md Normal file
View file

@ -0,0 +1,31 @@
## WebAuthn - authenticating users to services using public key cryptography
WebAuthn is a web standard published by the W3C. Its goal is to
standardize an interface for authenticating users to web-based
applications and services using public key cryptography. Modern web
browsers support WebAuthn functionality.
WebAuthn provides two functions: register and authenticate. Usually the
public-private keypair is stored on an external device, called a security key
(Yubikey, Trustkey etc.) or inside a platform(OS) authenticator. Platform
authenticators are available on all modern platforms, such as Windows, Mac,
Android and iOS. After the public key is registered, it can
be used to authenticate to the same service.
This module does not preserve a database of registered public keys, their
credential ID, usernames and pending challenges - instead this data must
be stored by a client of this API in a database or other persistent
storage.
[WebAuthn specification at W3C](https://w3c.github.io/webauthn/)
A basic demonstration server is provided (`bin/webauthn_demo`),
running at [webauthn-demo.robur.coop](https://webauthn-demo.robur.coop).
## Documentation
[API documentation](https://robur-coop.github.io/webauthn/doc) is available online.
## Installation
`opam install webauthn` will install this library.

114
bin/base64.js Normal file
View file

@ -0,0 +1,114 @@
var lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
;(function (exports) {
'use strict'
var Arr = (typeof Uint8Array !== 'undefined')
? Uint8Array
: Array
var PLUS = '+'.charCodeAt(0)
var SLASH = '/'.charCodeAt(0)
var NUMBER = '0'.charCodeAt(0)
var LOWER = 'a'.charCodeAt(0)
var UPPER = 'A'.charCodeAt(0)
var PLUS_URL_SAFE = '-'.charCodeAt(0)
var SLASH_URL_SAFE = '_'.charCodeAt(0)
function decode (elt) {
var code = elt.charCodeAt(0)
if (code === PLUS || code === PLUS_URL_SAFE) return 62 // '+'
if (code === SLASH || code === SLASH_URL_SAFE) return 63 // '/'
if (code < NUMBER) return -1 // no match
if (code < NUMBER + 10) return code - NUMBER + 26 + 26
if (code < UPPER + 26) return code - UPPER
if (code < LOWER + 26) return code - LOWER + 26
}
function b64ToByteArray (b64) {
var i, j, l, tmp, placeHolders, arr
// the number of equal signs (place holders)
// if there are two placeholders, than the two characters before it
// represent one byte
// if there is only one, then the three characters before it represent 2 bytes
// this is just a cheap hack to not do indexOf twice
var len = b64.length
placeHolders = b64.charAt(len - 2) === '=' ? 2 : b64.charAt(len - 1) === '=' ? 1 : 0
// base64 is 4/3 + up to two characters of the original data
arr = new Arr(b64.length * 3 / 4 - placeHolders)
// if there are placeholders, only get up to the last complete 4 chars
l = placeHolders > 0 ? b64.length - 4 : b64.length
var L = 0
function push (v) {
arr[L++] = v
}
for (i = 0, j = 0; i < l; i += 4, j += 3) {
tmp = (decode(b64.charAt(i)) << 18) | (decode(b64.charAt(i + 1)) << 12) | (decode(b64.charAt(i + 2)) << 6) | decode(b64.charAt(i + 3))
push((tmp & 0xFF0000) >> 16)
push((tmp & 0xFF00) >> 8)
push(tmp & 0xFF)
}
if (placeHolders === 2) {
tmp = (decode(b64.charAt(i)) << 2) | (decode(b64.charAt(i + 1)) >> 4)
push(tmp & 0xFF)
} else if (placeHolders === 1) {
tmp = (decode(b64.charAt(i)) << 10) | (decode(b64.charAt(i + 1)) << 4) | (decode(b64.charAt(i + 2)) >> 2)
push((tmp >> 8) & 0xFF)
push(tmp & 0xFF)
}
return arr
}
function uint8ToBase64 (uint8) {
var i
var extraBytes = uint8.length % 3 // if we have 1 byte left, pad 2 bytes
var output = ''
var temp, length
function encode (num) {
return lookup.charAt(num)
}
function tripletToBase64 (num) {
return encode(num >> 18 & 0x3F) + encode(num >> 12 & 0x3F) + encode(num >> 6 & 0x3F) + encode(num & 0x3F)
}
// go through the array every three bytes, we'll deal with trailing stuff later
for (i = 0, length = uint8.length - extraBytes; i < length; i += 3) {
temp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2])
output += tripletToBase64(temp)
}
// pad the end with zeros, but make sure to not forget the extra bytes
switch (extraBytes) {
case 1:
temp = uint8[uint8.length - 1]
output += encode(temp >> 2)
output += encode((temp << 4) & 0x3F)
output += '=='
break
case 2:
temp = (uint8[uint8.length - 2] << 8) + (uint8[uint8.length - 1])
output += encode(temp >> 10)
output += encode((temp >> 4) & 0x3F)
output += encode((temp << 2) & 0x3F)
output += '='
break
default:
break
}
return output
}
exports.toByteArray = b64ToByteArray
exports.fromByteArray = uint8ToBase64
}(typeof exports === 'undefined' ? (this.base64js = {}) : exports))

8
bin/dune Normal file
View file

@ -0,0 +1,8 @@
(executable
(public_name webauthn_demo)
(name webauthn_demo)
(modules webauthn_demo template)
(preprocessor_deps base64.js)
(preprocess (pps ppx_blob))
(libraries webauthn dream cmdliner logs.cli lwt flash_message)
(optional))

189
bin/template.ml Normal file
View file

@ -0,0 +1,189 @@
let page s b =
Printf.sprintf {|
<html>
<head>
<title>WebAuthn Demo</title>
<script type="text/javascript" src="/static/base64.js"></script>
<script>
function bufferEncode(value) {
return base64js.fromByteArray(value)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function bufferDecode(value) {
return base64js.toByteArray(value);
}
</script>
<script>%s</script>
</head><body>%s</body></html>|} s b
let overview notes authenticated_as users =
let authenticated_as =
match authenticated_as with
| None -> "<h2>Not authenticated</h2>"
| Some user -> Printf.sprintf {|<h2>Authenticated as %s</h2>
<form action="/logout" method="post"><input type="submit" value="Log out"/></form>
|} user
and links =
{|<h2>Register</h2><ul>
<li><a href="/register">register</a></li>
</ul>
|}
and users =
String.concat ""
("<h2>Users</h2><ul>" ::
Hashtbl.fold (fun id (name, keys) acc ->
let credentials = List.map (fun (_, cid, _) ->
Base64.encode_string ~pad:false ~alphabet:Base64.uri_safe_alphabet cid)
keys
in
(Printf.sprintf "<li>%s [<a href=/authenticate/%s>authenticate</a>] (%s)</li>" name id (String.concat ", " credentials)) :: acc)
users [] @ [ "</ul>" ])
in
page "" (String.concat "" (notes @ [authenticated_as;links;users]))
let register_view origin user =
let script = Printf.sprintf {|
function makePublicKey(challengeData, attestation) {
let challenge = bufferDecode(challengeData.challenge);
let user_id = bufferDecode(challengeData.user.id);
return {
challenge: challenge,
rp: {
id: "%s",
name: "WebAuthn Demo from robur.coop"
},
user: {
id: user_id,
displayName: challengeData.user.displayName,
name: challengeData.user.name
},
pubKeyCredParams: [
{
type: "public-key",
alg: -7
}
],
attestation: attestation,
excludeCredentials: challengeData.excludeCredentials.map(id => ({ type: "public-key",
id: bufferDecode(id)}))
};
}
function do_register(username, attestation) {
fetch("/registration-challenge/"+username)
.then(response => response.json())
.then(function (challengeData) {
let publicKey = makePublicKey(challengeData, attestation);
navigator.credentials.create({ publicKey })
.then(function (credential) {
let response = credential.response;
let attestationObject = new Uint8Array(response.attestationObject);
let clientDataJSON = new Uint8Array(response.clientDataJSON);
let body =
JSON.stringify({
attestationObject: bufferEncode(attestationObject),
clientDataJSON: bufferEncode(clientDataJSON),
});
let headers = {'Content-type': "application/json; charset=utf-8"};
let request = new Request('/register_finish/'+challengeData.user.id, { method: 'POST', body: body, headers: headers } );
fetch(request)
.then(function (response) {
if (!response.ok && response.status != 403) {
alert("bad response: " + response.status);
return
};
response.json().then(function (success) {
alert(success ? "Successfully registered!" : "Failed to register :(");
window.location = "/";
});
});
}).catch(function (err) {
// XXX: only if the exception came from navigator.credentials.create()
if (err.name === "InvalidStateError") {
alert("authenticator already registered");
} else {
alert("exception: " + err);
}
window.location = "/";
});
});
}
function doit() {
let username = document.getElementById("username").value;
let attestation = document.getElementById("attestation").value;
return do_register(username, attestation);
}
|} origin
and body =
Printf.sprintf {|
<p>Welcome.</p>
<form method="post" id="form" onsubmit="return false;">
<label for="username" >Desired username</label><input name="username" id="username" value="%s"/>
<label for="attestation">Attestation type</label><select name="attestation" id="attestation">
<option value="direct">direct</option>
<option value="indirect">indirect</option>
<option value="none">none</option>
</select>
<button id="button" type="button" onmousedown="doit()">Register</button>
</form>
|} user
in
page script body
let authenticate_view challenge credentials user =
let script =
Printf.sprintf {|
let request_options = {
challenge: bufferDecode("%s"),
allowCredentials: %s.map(x => { x.id = bufferDecode(x.id); return x }),
};
navigator.credentials.get({ publicKey: request_options })
.then(function (assertion) {
let response = assertion.response;
let authenticatorData = new Uint8Array(assertion.response.authenticatorData);
let clientDataJSON = new Uint8Array(assertion.response.clientDataJSON);
let signature = new Uint8Array(assertion.response.signature);
let userHandle = assertion.response.userHandle ? new Uint8Array(assertion.response.userHandle) : null;
let body =
JSON.stringify({
authenticatorData: bufferEncode(authenticatorData),
clientDataJSON: bufferEncode(clientDataJSON),
signature: bufferEncode(signature),
userHandle: userHandle ? bufferEncode(userHandle) : null,
});
let headers = {'Content-type': "application/json; charset=utf-8"};
let username = window.location.pathname.substring("/authenticate/".length);
let request = new Request('/authenticate_finish/'+assertion.id+'/'+username, { method: 'POST', body: body, headers: headers } );
fetch(request)
.then(function (response) {
if (!response.ok) {
alert("bad response: " + response.status);
window.location = "/";
return
};
response.json().then(function (success) {
alert(success ? "Successfully authenticated!" : "Failed to authenticate :(");
window.location = "/";
});
});
}).catch(function (err) {
alert("exception: " + err);
window.location = "/";
});
|} challenge
(Yojson.to_string (`List
(List.map (fun credential_id ->
(`Assoc ["id", `String credential_id; "type", `String "public-key"]))
credentials)))
and body =
Printf.sprintf {|
<p>Touch your token to authenticate as %S.</p>
|} user
in
page script body

313
bin/webauthn_demo.ml Normal file
View file

@ -0,0 +1,313 @@
open Lwt.Infix
let pp_cert =
let pp_extensions ppf (oid, data) =
let fido_u2f_transport_oid_name = "id-fido-u2f-ce-transports" in
if Asn.OID.equal oid Webauthn.fido_u2f_transport_oid then
match Webauthn.decode_transport data with
| Error `Msg _ ->
Fmt.pf ppf "%s invalid-data %a" fido_u2f_transport_oid_name (Ohex.pp_hexdump ()) data
| Ok transports ->
Fmt.pf ppf "%s %a" fido_u2f_transport_oid_name
Fmt.(list ~sep:(any ",") Webauthn.pp_transport) transports
else
Fmt.pf ppf "unsupported %a: %a" Asn.OID.pp oid (Ohex.pp_hexdump ()) data
in
X509.Certificate.pp' pp_extensions
let users : (string, string * (Mirage_crypto_ec.P256.Dsa.pub * string * X509.Certificate.t option) list) Hashtbl.t = Hashtbl.create 7
let find_username username =
Hashtbl.fold (fun id v r ->
if String.equal (fst v) username then Some (id, v) else r)
users None
module KhPubHashtbl = Hashtbl.Make(struct
type t = Webauthn.credential_id * Mirage_crypto_ec.P256.Dsa.pub
let string_of_pub = Mirage_crypto_ec.P256.Dsa.pub_to_octets
let equal (kh, pub) (kh', pub') =
String.equal kh kh' && String.equal (string_of_pub pub) (string_of_pub pub')
let hash (kh, pub) = Hashtbl.hash (kh, string_of_pub pub )
end)
let counters = KhPubHashtbl.create 7
let check_counter kh_pub counter =
let r =
match KhPubHashtbl.find_opt counters kh_pub with
| Some counter' -> Int32.unsigned_compare counter counter' > 0
| None -> true
in
if r
then KhPubHashtbl.replace counters kh_pub counter;
r
let registration_challenges : (string, string * Webauthn.challenge list) Hashtbl.t = Hashtbl.create 7
let remove_registration_challenge userid challenge =
match Hashtbl.find_opt registration_challenges userid with
| None -> ()
| Some (username, challenges) ->
let challenges = List.filter (fun c -> not (Webauthn.challenge_equal c challenge)) challenges in
if challenges = [] then
Hashtbl.remove registration_challenges userid
else
Hashtbl.replace registration_challenges userid (username, challenges)
let authentication_challenges : (string, Webauthn.challenge list) Hashtbl.t = Hashtbl.create 7
let remove_authentication_challenge userid challenge =
match Hashtbl.find_opt authentication_challenges userid with
| None -> ()
| Some challenges ->
let challenges = List.filter (fun c -> not (Webauthn.challenge_equal c challenge)) challenges in
if challenges = [] then
Hashtbl.remove authentication_challenges userid
else
Hashtbl.replace authentication_challenges userid challenges
let to_string err = Format.asprintf "%a" Webauthn.pp_error err
let gen_data ?(pad = false) ?alphabet length =
Base64.encode_string ~pad ?alphabet
(Mirage_crypto_rng.generate length)
let add_routes t =
let main req =
let authenticated_as = Dream.session_field req "authenticated_as" in
let flash = Flash_message.get_flash req |> List.map snd in
Dream.html (Template.overview flash authenticated_as users)
in
let register req =
let user =
match Dream.session_field req "authenticated_as" with
| None -> gen_data ~alphabet:Base64.uri_safe_alphabet 8
| Some username -> username
in
Dream.html (Template.register_view (Webauthn.rpid t) user)
in
let registration_challenge req =
let user = Dream.param req "user" in
let challenge, challenge_b64 = Webauthn.generate_challenge () in
let userid, credentials = match find_username user with
| None -> gen_data ~alphabet:Base64.uri_safe_alphabet 8, []
| Some (userid, (_, credentials)) -> userid, List.map (fun (_, cid, _) -> cid) credentials
in
let challenges =
Option.map snd (Hashtbl.find_opt registration_challenges userid) |>
Option.value ~default:[]
in
Hashtbl.replace registration_challenges userid (user, challenge :: challenges);
let json = `Assoc [
"challenge", `String challenge_b64 ;
"user", `Assoc [
"id", `String userid ;
"name", `String user ;
"displayName", `String user ;
] ;
"excludeCredentials", `List (List.map (fun s -> `String (Base64.encode_string s)) credentials) ;
]
in
Logs.info (fun m -> m "produced challenge for user %s: %s" user challenge_b64);
Dream.json (Yojson.Safe.to_string json)
in
let register_finish req =
let userid = Dream.param req "userid" in
Dream.body req >>= fun body ->
Logs.debug (fun m -> m "received body: %s" body);
match Hashtbl.find_opt registration_challenges userid with
| None ->
Logs.warn (fun m -> m "no challenge found");
Dream.respond ~status:`Bad_Request "Bad request."
| Some (username, challenges) ->
match Webauthn.register_response_of_string body with
| Error e ->
Logs.warn (fun m -> m "error %a" Webauthn.pp_error e);
let err = to_string e in
Flash_message.put_flash "" ("Registration failed " ^ err) req;
Dream.json "false"
| Ok response ->
match Webauthn.register t response with
| Error e ->
Logs.warn (fun m -> m "error %a" Webauthn.pp_error e);
let err = to_string e in
Flash_message.put_flash "" ("Registration failed " ^ err) req;
Dream.json "false"
| Ok (challenge, { user_present ; user_verified ; sign_count ; attested_credential_data ; certificate ; _ }) ->
let { Webauthn.credential_id ; public_key ; _ } = attested_credential_data in
if not (List.exists (Webauthn.challenge_equal challenge) challenges) then begin
Logs.warn (fun m -> m "challenge invalid");
Flash_message.put_flash "" "Registration failed: invalid challenge" req;
Dream.json "false"
end else begin
remove_registration_challenge userid challenge;
ignore (check_counter (credential_id, public_key) sign_count);
Logs.info (fun m -> m "register %S user present %B user verified %B"
username user_present user_verified);
let registered other_keys =
Logs.app (fun m -> m "registered %s: %S" username credential_id);
Hashtbl.replace users userid (username, ((public_key, credential_id, certificate) :: other_keys)) ;
Dream.invalidate_session req >>= fun () ->
let cert_pem, cert_string, transports =
Option.fold ~none:("No certificate", "No certificate", Ok [])
~some:(fun c ->
X509.Certificate.encode_pem c,
Fmt.to_to_string pp_cert c,
Webauthn.transports_of_cert c)
certificate
in
let transports = match transports with
| Error `Msg m -> "error " ^ m
| Ok ts -> Fmt.str "%a" Fmt.(list ~sep:(any ", ") Webauthn.pp_transport) ts
in
Flash_message.put_flash ""
(Printf.sprintf "Successfully registered as %s! <a href=\"/authenticate/%s\">[authenticate]</a><br/>Certificate transports: %s<br/>Certificate: %s<br/>PEM Certificate:<br/><pre>%s</pre>" username userid transports cert_string cert_pem)
req;
Dream.json "true"
in
match Dream.session_field req "authenticated_as", Hashtbl.find_opt users userid with
| _, None -> registered []
| Some session_user, Some (username', keys) ->
if String.equal username session_user && String.equal username username' then begin
registered keys
end else
(Logs.info (fun m -> m "session_user %s, user %s (user in users table %s)" session_user username username');
Dream.json ~status:`Forbidden "false")
| None, Some _keys ->
Logs.app (fun m -> m "no session user");
Dream.json ~status:`Forbidden "false"
end
in
let authenticate req =
let userid = Dream.param req "userid" in
match Hashtbl.find_opt users userid with
| None ->
Logs.warn (fun m -> m "no user found");
Dream.respond ~status:`Bad_Request "Bad request."
| Some (username, keys) ->
let credentials = List.map (fun (_, c, _) -> Base64.encode_string c) keys in
let challenge, challenge_b64 = Webauthn.generate_challenge () in
let challenges = Option.value ~default:[] (Hashtbl.find_opt authentication_challenges userid) in
Hashtbl.replace authentication_challenges userid (challenge :: challenges);
Dream.html (Template.authenticate_view challenge_b64 credentials username)
in
let authenticate_finish req =
let userid = Dream.param req "userid"
and b64_credential_id = Dream.param req "credential_id"
in
match Base64.decode ~alphabet:Base64.uri_safe_alphabet ~pad:false b64_credential_id with
| Error `Msg err ->
Logs.err (fun m -> m "credential id (%S) is not base64 uri safe: %s" b64_credential_id err);
Dream.json ~status:`Bad_Request "credential ID decoding error"
| Ok credential_id ->
Dream.body req >>= fun body ->
Logs.debug (fun m -> m "received body: %s" body);
match Hashtbl.find_opt authentication_challenges userid, Hashtbl.find_opt users userid with
| None, _ -> Dream.respond ~status:`Internal_Server_Error "Internal server error."
| _, None ->
Logs.warn (fun m -> m "no user found with id %s" userid);
Dream.respond ~status:`Bad_Request "Bad request."
| Some challenges, Some (username, keys) ->
match List.find_opt (fun (_, cid, _) -> String.equal cid credential_id) keys with
| None ->
Logs.warn (fun m -> m "no key found with credential id %s" b64_credential_id);
Dream.respond ~status:`Bad_Request "Bad request."
| Some (pubkey, _, _) ->
match Webauthn.authenticate_response_of_string body with
| Error e ->
Logs.warn (fun m -> m "error %a" Webauthn.pp_error e);
let err = to_string e in
Flash_message.put_flash "" ("Authentication failure: " ^ err) req;
Dream.json "false"
| Ok authenticate_response ->
match Webauthn.authenticate t pubkey authenticate_response with
| Ok (challenge, { user_present ; user_verified ; sign_count ; _ }) ->
Logs.info (fun m -> m "authenticate %S user present %B user verified %B"
username user_present user_verified);
if not (List.exists (Webauthn.challenge_equal challenge) challenges) then begin
Logs.warn (fun m -> m "invalid challenge");
Flash_message.put_flash "" "Authentication failure: invalid challenge" req;
Dream.json "false"
end else begin
remove_authentication_challenge userid challenge;
if check_counter (credential_id, pubkey) sign_count
then begin
Flash_message.put_flash "" "Successfully authenticated" req;
Dream.set_session_field req "authenticated_as" username >>= fun () ->
Dream.json "true"
end else begin
Logs.warn (fun m -> m "credential %S for user %S: counter not strictly increasing! \
Got %ld, expected >%ld. webauthn device compromised?"
b64_credential_id username sign_count (KhPubHashtbl.find counters (credential_id, pubkey)));
Flash_message.put_flash "" "Authentication failure: key compromised?" req;
Dream.json "false"
end
end
| Error e ->
Logs.warn (fun m -> m "error %a" Webauthn.pp_error e);
let err = to_string e in
Flash_message.put_flash "" ("Authentication failure: " ^ err) req;
Dream.json "false"
in
let logout req =
Dream.invalidate_session req >>= fun () ->
Dream.redirect req "/"
in
let base64 _req =
Dream.respond ~headers:[("Content-type", "application/javascript")]
[%blob "base64.js"]
in
Dream.router [
Dream.get "/" main;
Dream.get "/register" register;
Dream.get "/registration-challenge/:user" registration_challenge;
Dream.post "/register_finish/:userid" register_finish;
Dream.get "/authenticate/:userid" authenticate;
Dream.post "/authenticate_finish/:credential_id/:userid" authenticate_finish;
Dream.post "/logout" logout;
Dream.get "/static/base64.js" base64;
]
let setup_app level port host origin tls =
let level = match level with None -> None | Some Logs.Debug -> Some `Debug | Some Info -> Some `Info | Some Warning -> Some `Warning | Some Error -> Some `Error | Some App -> None in
Dream.initialize_log ?level ();
match Webauthn.create origin with
| Error e -> Logs.err (fun m -> m "failed to create webauthn: %s" e); exit 1
| Ok webauthn ->
Dream.run ~port ~interface:host ~tls
@@ Dream.logger
@@ Dream.memory_sessions
@@ Flash_message.flash_messages
@@ add_routes webauthn
open Cmdliner
let port =
let doc = "port" in
Arg.(value & opt int 5000 & info [ "p"; "port" ] ~doc)
let host =
let doc = "host" in
Arg.(value & opt string "0.0.0.0" & info [ "h"; "host" ] ~doc)
let origin =
let doc = "the webauthn relying party origin - usually protocol://host" in
Arg.(value & opt string "https://webauthn-demo.robur.coop" & info [ "origin" ] ~doc)
let tls =
let doc = "tls" in
Arg.(value & flag & info [ "tls" ] ~doc)
let () =
let term = Term.(const setup_app $ Logs_cli.level () $ port $ host $ origin $ tls) in
let info = Cmd.info "Webauthn app" ~doc:"Webauthn app" ~man:[] in
exit (Cmd.eval (Cmd.v info term))

File diff suppressed because one or more lines are too long

View file

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>index</title>
<link rel="stylesheet" href="./odoc.css"/>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
</head>
<body>
<main class="content">
<div class="by-name">
<h2>OCaml package documentation</h2>
<ol>
<li><a href="webauthn/index.html">webauthn</a> <span class="version">0.1.0</span></li>
</ol>
</div>
</main>
</body>
</html>

View file

@ -1,782 +0,0 @@
@charset "UTF-8";
/* Copyright (c) 2016 The odoc contributors. All rights reserved.
Distributed under the ISC license, see terms at the end of the file.
odoc 2.0.0 */
/* Fonts */
@import url('https://fonts.googleapis.com/css?family=Fira+Mono:400,500');
@import url('https://fonts.googleapis.com/css?family=Noticia+Text:400,400i,700');
@import url('https://fonts.googleapis.com/css?family=Fira+Sans:400,400i,500,500i,600,600i,700,700i');
:root,
.light:root {
--main-background: #FFFFFF;
--color: #333333;
--link-color: #2C94BD;
--anchor-hover: #555;
--anchor-color: #d5d5d5;
--xref-shadow: #cc6666;
--header-shadow: #ddd;
--by-name-version-color: #aaa;
--by-name-nav-link-color: #222;
--target-background: rgba(187, 239, 253, 0.3);
--target-shadow: rgba(187, 239, 253, 0.8);
--pre-border-color: #eee;
--code-background: #f6f8fa;
--li-code-background: #f6f8fa;
--li-code-color: #0d2b3e;
--toc-color: #1F2D3D;
--toc-before-color: #777;
--toc-background: #f6f8fa;
--toc-list-border: #ccc;
--spec-summary-border-color: #5c9cf5;
--spec-summary-background: var(--code-background);
--spec-summary-hover-background: #ebeff2;
--spec-details-after-background: rgba(0, 4, 15, 0.05);
--spec-details-after-shadow: rgba(204, 204, 204, 0.53);
}
.dark:root {
--main-background: #202020;
--code-background: #222;
--line-numbers-background: rgba(0, 0, 0, 0.125);
--navbar-background: #202020;
--color: #bebebe;
--dirname-color: #666;
--underline-color: #444;
--visited-color: #002800;
--visited-number-color: #252;
--unvisited-color: #380000;
--unvisited-number-color: #622;
--somevisited-color: #303000;
--highlight-color: #303e3f;
--line-number-color: rgba(230, 230, 230, 0.3);
--unvisited-margin-color: #622;
--border: #333;
--navbar-border: #333;
--code-color: #ccc;
--li-code-background: #373737;
--li-code-color: #999;
--toc-color: #777;
--toc-background: #252525;
--hljs-link: #999;
--hljs-keyword: #cda869;
--hljs-regexp: #f9ee98;
--hljs-title: #dcdcaa;
--hljs-type: #ac885b;
--hljs-meta: #82aaff;
--hljs-variable: #cf6a4c;
}
@media (prefers-color-scheme: dark) {
:root {
--main-background: #202020;
--code-background: #333;
--line-numbers-background: rgba(0, 0, 0, 0.125);
--navbar-background: #202020;
--meter-unvisited-color: #622;
--meter-visited-color: #252;
--meter-separator-color: black;
--color: #bebebe;
--dirname-color: #666;
--underline-color: #444;
--visited-color: #002800;
--visited-number-color: #252;
--unvisited-color: #380000;
--unvisited-number-color: #622;
--somevisited-color: #303000;
--highlight-color: #303e3f;
--line-number-color: rgba(230, 230, 230, 0.3);
--unvisited-margin-color: #622;
--border: #333;
--navbar-border: #333;
--code-color: #ccc;
--by-name-nav-link-color: var(--color);
--li-code-background: #373737;
--li-code-color: #999;
--toc-color: #777;
--toc-before-color: #777;
--toc-background: #252525;
--toc-list-border: #ccc;
--spec-summary-hover-background: #ebeff2;
--spec-details-after-background: rgba(0, 4, 15, 0.05);
--spec-details-after-shadow: rgba(204, 204, 204, 0.53);
--hljs-link: #999;
--hljs-keyword: #cda869;
--hljs-regexp: #f9ee98;
--hljs-title: #dcdcaa;
--hljs-type: #ac885b;
--hljs-meta: #82aaff;
--hljs-variable: #cf6a4c;
}
}
/* Reset a few things. */
html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font: inherit;
vertical-align: baseline;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
*, *:before, *:after {
box-sizing: border-box;
}
html {
font-size: 15px;
}
body {
text-align: left;
background: #FFFFFF;
color: var(--color);
background-color: var(--main-background);
}
body {
max-width: 90ex;
margin-left: calc(10vw + 20ex);
margin-right: 4ex;
margin-top: 20px;
margin-bottom: 50px;
font-family: "Noticia Text", Georgia, serif;
line-height: 1.5;
}
header {
margin-bottom: 30px;
}
nav {
font-family: "Fira Sans", Helvetica, Arial, sans-serif;
}
/* Basic markup elements */
b, strong {
font-weight: bold;
}
i {
font-style: italic;
}
em, i em.odd{
font-style: italic;
}
em.odd, i em {
font-style: normal;
}
sup {
vertical-align: super;
}
sub {
vertical-align: sub;
}
sup, sub {
font-size: 12px;
line-height: 0;
margin-left: 0.2ex;
}
pre {
margin-top: 0.8em;
margin-bottom: 1.2em;
}
p, ul, ol {
margin-top: 0.5em;
margin-bottom: 1em;
}
ul, ol {
list-style-position: outside
}
ul>li {
margin-left: 22px;
}
ol>li {
margin-left: 27.2px;
}
li>*:first-child {
margin-top: 0
}
/* Text alignements, this should be forbidden. */
.left {
text-align: left;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
/* Links and anchors */
a {
text-decoration: none;
color: var(--link-color);
}
a:hover {
box-shadow: 0 1px 0 0 var(--link-color);
}
/* Linked highlight */
*:target {
background-color: var(--target-background) !important;
box-shadow: 0 0px 0 1px var(--target-shadow) !important;
border-radius: 1px;
}
*:hover > a.anchor {
visibility: visible;
}
a.anchor:before {
content: "#";
}
a.anchor:hover {
box-shadow: none;
text-decoration: none;
color: var(--anchor-hover);
}
a.anchor {
visibility: hidden;
position: absolute;
/* top: 0px; */
/* margin-left: -3ex; */
margin-left: -1.3em;
font-weight: normal;
font-style: normal;
padding-right: 0.4em;
padding-left: 0.4em;
/* To remain selectable */
color: var(--anchor-color);
}
.spec > a.anchor {
margin-left: -2.3em;
padding-right: 0.9em;
}
.xref-unresolved {
color: #2C94BD;
}
.xref-unresolved:hover {
box-shadow: 0 1px 0 0 var(--xref-shadow);
}
/* Section and document divisions.
Until at least 4.03 many of the modules of the stdlib start at .h7,
we restart the sequence there like h2 */
h1, h2, h3, h4, h5, h6, .h7, .h8, .h9, .h10 {
font-family: "Fira Sans", Helvetica, Arial, sans-serif;
font-weight: 400;
margin: 0.5em 0 0.5em 0;
padding-top: 0.1em;
line-height: 1.2;
overflow-wrap: break-word;
}
h1 {
font-weight: 500;
font-size: 2.441em;
margin-top: 1.214em;
}
h1 {
font-weight: 500;
font-size: 1.953em;
box-shadow: 0 1px 0 0 var(--header-shadow);
}
h2 {
font-size: 1.563em;
}
h3 {
font-size: 1.25em;
}
small, .font_small {
font-size: 0.8em;
}
h1 code, h1 tt {
font-size: inherit;
font-weight: inherit;
}
h2 code, h2 tt {
font-size: inherit;
font-weight: inherit;
}
h3 code, h3 tt {
font-size: inherit;
font-weight: inherit;
}
h3 code, h3 tt {
font-size: inherit;
font-weight: inherit;
}
h4 {
font-size: 1.12em;
}
/* Comment delimiters, hidden but accessible to screen readers and
selected for copy/pasting */
/* Taken from bootstrap */
/* See also https://stackoverflow.com/a/27769435/4220738 */
.comment-delim {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Preformatted and code */
tt, code, pre {
font-family: "Fira Mono", courier;
font-weight: 400;
}
pre {
padding: 0.1em;
border: 1px solid var(--pre-border-color);
border-radius: 5px;
overflow-x: auto;
}
p code,
li code {
background-color: var(--li-code-background);
color: var(--li-code-color);
border-radius: 3px;
padding: 0 0.3ex;
}
p a > code {
color: var(--link-color);
}
/* Code blocks (e.g. Examples) */
pre code {
font-size: 0.893rem;
}
/* Code lexemes */
.keyword {
font-weight: 500;
}
.arrow { white-space: nowrap }
/* Module member specification */
.spec {
background-color: var(--spec-summary-background);
border-radius: 3px;
border-left: 4px solid var(--spec-summary-border-color);
border-right: 5px solid transparent;
padding: 0.35em 0.5em;
}
div.spec, .def-doc {
margin-bottom: 20px;
}
.spec.type .variant {
margin-left: 2ch;
}
.spec.type .variant p {
margin: 0;
font-style: italic;
}
.spec.type .record {
margin-left: 2ch;
}
.spec.type .record p {
margin: 0;
font-style: italic;
}
div.def {
margin-top: 0;
text-indent: -2ex;
padding-left: 2ex;
}
div.def+div.def-doc {
margin-left: 1ex;
margin-top: 2.5px
}
div.def-doc>*:first-child {
margin-top: 0;
}
/* Collapsible inlined include and module */
.odoc-include details {
position: relative;
}
.odoc-include details:after {
z-index: -100;
display: block;
content: " ";
position: absolute;
border-radius: 0 1ex 1ex 0;
right: -20px;
top: 1px;
bottom: 1px;
width: 15px;
background: var(--spec-details-after-background, rgba(0, 4, 15, 0.05));
box-shadow: 0 0px 0 1px var(--spec-details-after-shadow, rgba(204, 204, 204, 0.53));
}
.odoc-include summary {
position: relative;
margin-bottom: 20px;
cursor: pointer;
outline: none;
}
.odoc-include summary:hover {
background-color: var(--spec-summary-hover-background);
}
/* FIXME: Does not work in Firefox. */
.odoc-include summary::-webkit-details-marker {
color: #888;
transform: scaleX(-1);
position: absolute;
top: calc(50% - 5px);
height: 11px;
right: -29px;
}
/* Records and variants FIXME */
div.def table {
text-indent: 0em;
padding: 0;
margin-left: -2ex;
}
td.def {
padding-left: 2ex;
}
td.def-doc *:first-child {
margin-top: 0em;
}
/* Lists of @tags */
.at-tags { list-style-type: none; margin-left: -3ex; }
.at-tags li { padding-left: 3ex; text-indent: -3ex; }
.at-tags .at-tag { text-transform: capitalize }
/* Lists of modules */
.modules { list-style-type: none; margin-left: -3ex; }
.modules li { padding-left: 3ex; text-indent: -3ex; margin-top: 5px }
.modules .synopsis { padding-left: 1ch; }
/* Odig package index */
.packages { list-style-type: none; margin-left: -3ex; }
.packages li { padding-left: 3ex; text-indent: -3ex }
.packages li a.anchor { padding-right: 0.5ch; padding-left: 3ch; }
.packages .version { font-size: 10px; color: var(--by-name-version-color); }
.packages .synopsis { padding-left: 1ch }
.by-name nav a {
text-transform: uppercase;
font-size: 18px;
margin-right: 1ex;
color: var(--by-name-nav-link-color,);
display: inline-block;
}
.by-tag nav a {
margin-right: 1ex;
color: var(--by-name-nav-link-color);
display: inline-block;
}
.by-tag ol { list-style-type: none; }
.by-tag ol.tags li { margin-left: 1ch; display: inline-block }
.by-tag td:first-child { text-transform: uppercase; }
/* Odig package page */
.package nav {
display: inline;
font-size: 14px;
font-weight: normal;
}
.package .version {
font-size: 14px;
}
.package.info {
margin: 0;
}
.package.info td:first-child {
font-style: italic;
padding-right: 2ex;
}
.package.info ul {
list-style-type: none;
display: inline;
margin: 0;
}
.package.info li {
display: inline-block;
margin: 0;
margin-right: 1ex;
}
#info-authors li, #info-maintainers li {
display: block;
}
/* Sidebar and TOC */
.odoc-toc:before {
display: block;
content: "Contents";
text-transform: uppercase;
font-size: 1em;
margin: 1.414em 0 0.5em;
font-weight: 500;
color: var(--toc-before-color);
line-height: 1.2;
}
.odoc-toc {
position: fixed;
top: 0px;
bottom: 0px;
left: 0px;
max-width: 30ex;
min-width: 26ex;
width: 20%;
background: var(--toc-background);
overflow: auto;
color: var(--toc-color);
padding-left: 2ex;
padding-right: 2ex;
}
.odoc-toc ul li a {
font-family: "Fira Sans", sans-serif;
font-size: 0.95em;
color: var(--color);
font-weight: 400;
line-height: 1.6em;
display: block;
}
.odoc-toc ul li a:hover {
box-shadow: none;
text-decoration: underline;
}
/* First level titles */
.odoc-toc>ul>li>a {
font-weight: 500;
}
.odoc-toc li ul {
margin: 0px;
}
.odoc-toc ul {
list-style-type: none;
}
.odoc-toc ul li {
margin: 0;
}
.odoc-toc>ul>li {
margin-bottom: 0.3em;
}
.odoc-toc ul li li {
border-left: 1px solid var(--toc-list-border);
margin-left: 5px;
padding-left: 12px;
}
/* Mobile adjustements. */
@media only screen and (max-width: 95ex) {
.odoc-content {
margin: auto;
padding: 2em;
}
.odoc-toc {
position: static;
width: auto;
min-width: unset;
max-width: unset;
border: none;
padding: 0.2em 1em;
border-radius: 5px;
}
}
/* Print adjustements. */
@media print {
body {
color: black;
background: white;
}
body nav:first-child {
visibility: hidden;
}
}
/* Syntax highlighting (based on github-gist) */
.hljs {
display: block;
background: var(--code-background);
padding: 0.5em;
color: var(--color);
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag {
color: #a71d5d;
}
.hljs-type,
.hljs-class .hljs-title {
color: #458;
font-weight: 500;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}
/*---------------------------------------------------------------------------
Copyright (c) 2016 The odoc contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---------------------------------------------------------------------------*/

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>index (webauthn.index)</title><link rel="stylesheet" href="../odoc.css"/><meta charset="utf-8"/><meta name="generator" content="odoc 2.0.0"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/><script src="../highlight.pack.js"></script><script>hljs.initHighlightingOnLoad();</script></head><body class="odoc"><nav class="odoc-nav"><a href="../index.html">Up</a> webauthn</nav><header class="odoc-preamble"><h1 id="webauthn-index"><a href="#webauthn-index" class="anchor"></a>webauthn index</h1></header><nav class="odoc-toc"><ul><li><a href="#library-webauthn">Library webauthn</a></li></ul></nav><div class="odoc-content"><h2 id="library-webauthn"><a href="#library-webauthn" class="anchor"></a>Library webauthn</h2><p>The entry point of this library is the module: <a href="Webauthn/index.html"><code>Webauthn</code></a>.</p></div></body></html>

3
dune-project Normal file
View file

@ -0,0 +1,3 @@
(lang dune 2.7)
(name webauthn)
(formatting disabled)

3
flash_message/dune Normal file
View file

@ -0,0 +1,3 @@
(library
(name flash_message)
(libraries dream))

View file

@ -0,0 +1,52 @@
open Lwt.Syntax
let five_minutes = 5. *. 60.
let storage = Dream.new_field ~name:"dream.flash_message" ()
let flash_cookie = "dream.flash_message"
let flash_messages inner_handler request =
let outbox = ref [] in
Dream.set_field request storage outbox;
let* response = inner_handler request in
Lwt.return(
let entries = List.rev !outbox in
let content = List.fold_right (fun (x,y) a -> `String x :: `String y :: a) entries [] in
let value = `List content |> Yojson.Basic.to_string in
Dream.set_cookie response request flash_cookie value ~max_age:five_minutes;
response
)
let (|>?) =
Option.bind
let get_flash request =
let rec group x = match x with
| x1::x2::rest -> (x1, x2) :: group rest
| _ -> []
in
let unpack u = match u with
| `String x -> x
| _ -> failwith "Bad flash message content" in
let x = Dream.cookie request flash_cookie
|>? fun value ->
match Yojson.Basic.from_string value with
| `List y -> Some (group @@ List.map unpack y)
| _ -> None
in Option.value x ~default:[]
let put_flash category message request =
let outbox = match Dream.field request storage with
| Some outbox -> outbox
| None ->
let message = "Missing flash message middleware" in
Logs.err (fun log -> log "%s" message);
failwith message in
outbox := (category, message) :: !outbox

23
misc/rc.d/webauthn_demo Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
. /etc/rc.subr
name="webauthn_demo"
title="webauthn-demo"
rcvar="${name}_enable"
pidfile="/var/run/${name}.pid"
# Change this if you place the demo binary elsewhere
exec_path="/home/builder/webauthn/${name}.exe"
load_rc_config "$name"
: ${webauthn_demo_enable:="NO"}
# We can't use $webauthn_demo_user as otherwise daemon(8) will run unprivileged
# and can't create the pidfile and drop privileges
: ${webauthn_demo_runas:="builder"}
command="/usr/sbin/daemon"
command_args="-r -S -t ${title} -P ${pidfile} -u ${webauthn_demo_runas} ${exec_path}"
run_rc_command "$1"

6
src/dune Normal file
View file

@ -0,0 +1,6 @@
(library
(name webauthn)
(public_name webauthn)
(preprocess
(pps ppx_deriving_yojson))
(libraries mirage-crypto-rng yojson mirage-crypto-ec x509 base64 cbor))

481
src/webauthn.ml Normal file
View file

@ -0,0 +1,481 @@
type credential_id = string
type json_decoding_error = [ `Json_decoding of string * string * string ]
type decoding_error = [
json_decoding_error
| `Base64_decoding of string * string * string
| `CBOR_decoding of string * string * string
| `Unexpected_CBOR of string * string * CBOR.Simple.t
| `Binary_decoding of string * string * string
| `Attestation_object_decoding of string * string * string
]
type error = [
decoding_error
| `Unsupported_key_type of int
| `Unsupported_algorithm of int
| `Unsupported_elliptic_curve of int
| `Unsupported_attestation_format of string
| `Invalid_public_key of string
| `Client_data_type_mismatch of string
| `Origin_mismatch of string * string
| `Rpid_hash_mismatch of string * string
| `Missing_credential_data
| `Signature_verification of string
]
let pp_error ppf = function
| `Json_decoding (ctx, msg, json) ->
Fmt.pf ppf "json decoding error in %s: %s (json: %s)" ctx msg json
| `Base64_decoding (ctx, msg, data) ->
Fmt.pf ppf "base64 decoding error in %s: %s (data: %s)" ctx msg data
| `CBOR_decoding (ctx, msg, data) ->
Fmt.pf ppf "cbor decoding error in %s: %s (data: %s)" ctx msg data
| `Unexpected_CBOR (ctx, msg, data) ->
Fmt.pf ppf "unexpected cbor in %s: %s (data: %s)" ctx msg (CBOR.Simple.to_diagnostic data)
| `Binary_decoding (ctx, msg, data) ->
Fmt.pf ppf "binary decoding error in %s: %s (data: %a)" ctx msg (Ohex.pp_hexdump ()) data
| `Attestation_object_decoding (ctx, msg, data) ->
Fmt.pf ppf "attestation object decoding error in %s: %s (data: %s)" ctx msg data
| `Unsupported_key_type i ->
Fmt.pf ppf "unsupported cose key type %d" i
| `Unsupported_algorithm i ->
Fmt.pf ppf "unsupported cose algorithm %d" i
| `Unsupported_elliptic_curve i ->
Fmt.pf ppf "unsupported cose elliptic curve %d" i
| `Unsupported_attestation_format fmt ->
Fmt.pf ppf "unsupported attestation format %s" fmt
| `Invalid_public_key msg ->
Fmt.pf ppf "invalid public key %s" msg
| `Client_data_type_mismatch is ->
Fmt.pf ppf "client data type mismatch: received %s" is
| `Origin_mismatch (should, is) ->
Fmt.pf ppf "origin mismatch: expected %s, received %s" should is
| `Rpid_hash_mismatch (should, is) ->
Fmt.pf ppf "rpid hash mismatch: expected %s received %s"
(Base64.encode_string should) (Base64.encode_string is)
| `Missing_credential_data -> Fmt.string ppf "missing credential data"
| `Signature_verification msg -> Fmt.pf ppf "signature verification failed %s" msg
type t = {
origin : string;
rpid : [`host] Domain_name.t;
}
type challenge = string
let generate_challenge ?(size = 32) () =
if size < 16 then invalid_arg "size must be at least 16 bytes";
let ch = Mirage_crypto_rng.generate size in
ch, Base64.encode_string ch
let challenge_to_string c = c
let challenge_of_string s = Some s
let challenge_equal = String.equal
let b64_dec thing s =
Result.map_error
(function `Msg m -> `Base64_decoding (thing, m, s))
Base64.(decode ~pad:false ~alphabet:uri_safe_alphabet s)
let guard p e = if p then Ok () else Error e
let (>>=) v f = match v with Ok v -> f v | Error _ as e -> e
type base64url_string = string
let base64url_string_of_yojson = function
| `String b64 ->
Base64.(decode ~pad:false ~alphabet:uri_safe_alphabet b64)
|> Result.map_error (function `Msg m -> m)
| _ -> Error "base64url_string"
let extract_k_i ctx map k =
Option.to_result ~none:(`Unexpected_CBOR (ctx, "integer key not present: " ^ string_of_int k, `Map map))
(Option.map snd
(List.find_opt (fun (l, _) -> match l with `Int i -> i = k | _ -> false) map))
let extract_k_str ctx map k =
Option.to_result ~none:(`Unexpected_CBOR (ctx, "string key not present: " ^ k, `Map map))
(Option.map snd
(List.find_opt (fun (l, _) -> match l with `Text s -> s = k | _ -> false) map))
let extract_int ctx = function
| `Int i -> Ok i
| c -> Error (`Unexpected_CBOR (ctx, "not an integer", c))
let extract_bytes ctx = function
| `Bytes b -> Ok b
| c -> Error (`Unexpected_CBOR (ctx, "not bytes", c))
let extract_map ctx = function
| `Map b -> Ok b
| c -> Error (`Unexpected_CBOR (ctx, "not a map", c))
let extract_array ctx = function
| `Array b -> Ok b
| c -> Error (`Unexpected_CBOR (ctx, "not an array", c))
let extract_text ctx = function
| `Text s -> Ok s
| c -> Error (`Unexpected_CBOR (ctx, "not a text", c))
let cose_pubkey cbor_data =
extract_map "cose pubkey" cbor_data >>= fun kv ->
extract_k_i "cose pubkey kty" kv 1 >>= extract_int "cose pubkey kty" >>= fun kty ->
guard (kty = 2) (`Unsupported_key_type kty) >>= fun () ->
extract_k_i "cose pubkey alg" kv 3 >>= extract_int "cose pubkey alg" >>= fun alg ->
guard (alg = -7) (`Unsupported_algorithm alg) >>= fun () ->
extract_k_i "cose pubkey crv" kv (-1) >>= extract_int "cose pubkey crv" >>= fun crv ->
guard (crv = 1) (`Unsupported_elliptic_curve crv) >>= fun () ->
extract_k_i "cose pubkey x" kv (-2) >>= extract_bytes "cose pubkey x" >>= fun x ->
extract_k_i "cose pubkey y" kv (-3) >>= extract_bytes "cose pubkey y" >>= fun y ->
let str = String.concat "" [ "\004" ; x ; y ] in
Result.map_error
(fun e -> `Invalid_public_key (Fmt.to_to_string Mirage_crypto_ec.pp_error e))
(Mirage_crypto_ec.P256.Dsa.pub_of_octets str)
let decode_partial_cbor ctx data =
try Ok (CBOR.Simple.decode_partial data)
with CBOR.Error m -> Error (`CBOR_decoding (ctx, "failed to decode CBOR " ^ m, data))
let decode_cbor ctx data =
try Ok (CBOR.Simple.decode data)
with CBOR.Error m -> Error (`CBOR_decoding (ctx, "failed to decode CBOR " ^ m, data))
let guard_length ctx len str =
guard (String.length str >= len)
(`Binary_decoding (ctx, "too short (< " ^ string_of_int len ^ ")", str))
let parse_attested_credential_data data =
guard_length "attested credential data" 18 data >>= fun () ->
let aaguid = String.sub data 0 16 in
let cid_len = String.get_uint16_be data 16 in
let rest = String.sub data 18 (String.length data - 18) in
guard_length "attested credential data" cid_len rest >>= fun () ->
let cid, pubkey =
String.sub rest 0 cid_len,
String.sub rest cid_len (String.length rest - cid_len)
in
decode_partial_cbor "public key" pubkey >>= fun (pubkey, rest) ->
cose_pubkey pubkey >>= fun pubkey ->
Ok ((aaguid, cid, pubkey), rest)
let string_keys ctx kv =
List.fold_right (fun (k, v) acc ->
match acc, k with
| Error _ as e, _ -> e
| Ok xs, `Text t -> Ok ((t, v) :: xs)
| _, _ -> Error (`Unexpected_CBOR (ctx, "Map does contain non-text keys", `Map kv)))
kv (Ok [])
let parse_extension_data data =
decode_partial_cbor "extension data" data >>= fun (data, rest) ->
extract_map "extension data" data >>= fun kv ->
string_keys "extension data" kv >>= fun kv ->
Ok (kv, rest)
type auth_data = {
rpid_hash : string ;
user_present : bool ;
user_verified : bool ;
sign_count : Int32.t ;
attested_credential_data : (string * string * Mirage_crypto_ec.P256.Dsa.pub) option ;
extension_data : (string * CBOR.Simple.t) list option ;
}
let flags byte =
let b i = byte land (1 lsl i) <> 0 in
b 0, b 2, b 6, b 7
let parse_auth_data data =
guard_length "authenticator data" 37 data >>= fun () ->
let rpid_hash = String.sub data 0 32 in
let user_present, user_verified, attested_data_included, extension_data_included =
flags (String.get_uint8 data 32)
in
let sign_count = String.get_int32_be data 33 in
let rest = String.sub data 37 (String.length data - 37) in
(if attested_data_included then
Result.map (fun (d, r) -> Some d, r) (parse_attested_credential_data rest)
else Ok (None, rest)) >>= fun (attested_credential_data, rest) ->
(if extension_data_included then
Result.map (fun (d, r) -> Some d, r) (parse_extension_data rest)
else Ok (None, rest)) >>= fun (extension_data, rest) ->
guard (String.length rest = 0) (`Binary_decoding ("authenticator data", "leftover", rest)) >>= fun () ->
Ok { rpid_hash ; user_present ; user_verified ; sign_count ; attested_credential_data ; extension_data }
let parse_attestation_statement fmt data =
match fmt with
| "none" when data = [] -> Ok None
| "none" -> Error (`Unexpected_CBOR ("attestion statement", "format is none, map must be empty", `Map data))
| "fido-u2f" ->
extract_k_str "attestation statement" data "x5c" >>= extract_array "attestation statement x5c" >>= fun cert ->
extract_k_str "attestation statement" data "sig" >>= extract_bytes "attestation statemnt sig" >>= fun signature ->
begin match cert with
| [ c ] ->
extract_bytes "attestation statement x5c" c >>= fun c ->
Result.map_error
(function `Msg m -> `Attestation_object_decoding ("attestation statement x5c", m, String.escaped c))
(X509.Certificate.decode_der c)
| cs -> Error (`Attestation_object_decoding ("attestation statement x5c", "expected single certificate", String.concat "," (List.map CBOR.Simple.to_diagnostic cs)))
end >>= fun cert ->
Ok (Some (cert, signature))
| x -> Error (`Unsupported_attestation_format x)
let parse_attestation_object data =
decode_cbor "attestation object" data >>= extract_map "attestation object" >>= fun kv ->
extract_k_str "attestation object" kv "fmt" >>= extract_text "attestation object fmt" >>= fun fmt ->
extract_k_str "attestation object" kv "authData" >>= extract_bytes "attestation object authData" >>= fun auth_data ->
extract_k_str "attestation object" kv "attStmt" >>= extract_map "attestation object attStmt" >>= fun attestation_statement ->
parse_auth_data auth_data >>= fun auth_data ->
parse_attestation_statement fmt attestation_statement >>= fun attestation_statement ->
Ok (auth_data, attestation_statement)
let of_json_or_err thing p json =
Result.map_error
(fun msg -> `Json_decoding (thing, msg, Yojson.Safe.to_string json))
(p json)
let of_json thing p s =
(try Ok (Yojson.Safe.from_string s)
with Yojson.Json_error msg ->
Error (`Json_decoding (thing, msg, s))) >>=
of_json_or_err thing p
let json_get member = function
| `Assoc kv as json ->
List.assoc_opt member kv
|> Option.to_result ~none:(`Json_decoding (member, "missing key", Yojson.Safe.to_string json))
| json -> Error (`Json_decoding (member, "non-object", Yojson.Safe.to_string json))
let json_string thing : Yojson.Safe.t -> (string, _) result = function
| `String s -> Ok s
| json -> Error (`Json_decoding (thing, "non-string", Yojson.Safe.to_string json))
let json_assoc thing : Yojson.Safe.t -> ((string * Yojson.Safe.t) list, _) result = function
| `Assoc s -> Ok s
| json -> Error (`Json_decoding (thing, "non-assoc", Yojson.Safe.to_string json))
let create origin =
match String.split_on_char '/' origin with
| [ "https:" ; "" ; host_port ] ->
let host_ok h =
match Domain_name.of_string h with
| Error (`Msg m) -> Error ("origin is not a domain name " ^ m ^ "(data: " ^ h ^ ")")
| Ok d -> match Domain_name.host d with
| Error (`Msg m) -> Error ("origin is not a host name " ^ m ^ "(data: " ^ h ^ ")")
| Ok host -> Ok host
in
begin
match
match String.split_on_char ':' host_port with
| [ host ] -> host_ok host
| [ host ; port ] ->
(match host_ok host with
| Error _ as e -> e
| Ok h -> (try ignore(int_of_string port); Ok h
with Failure _ -> Error ("invalid port " ^ port)))
| _ -> Error ("invalid origin host and port " ^ host_port)
with
| Ok host -> Ok { origin ; rpid = host }
| Error _ as e -> e
end
| _ -> Error ("invalid origin " ^ origin)
let rpid t = Domain_name.to_string t.rpid
type credential_data = {
aaguid : string ;
credential_id : credential_id ;
public_key : Mirage_crypto_ec.P256.Dsa.pub ;
}
type registration = {
user_present : bool ;
user_verified : bool ;
sign_count : Int32.t ;
attested_credential_data : credential_data ;
authenticator_extensions : (string * CBOR.Simple.t) list option ;
client_extensions : (string * Yojson.Safe.t) list option ;
certificate : X509.Certificate.t option ;
}
type register_response = {
attestation_object : base64url_string [@key "attestationObject"];
client_data_json : base64url_string [@key "clientDataJSON"];
} [@@deriving of_yojson]
let register_response_of_string =
of_json "register response" register_response_of_yojson
let register t response =
(* XXX: credential.getClientExtensionResults() *)
let client_data_hash =
Digestif.SHA256.(to_raw_string (digest_string response.client_data_json))
in
begin try Ok (Yojson.Safe.from_string response.client_data_json)
with Yojson.Json_error msg ->
Error (`Json_decoding ("clientDataJSON", msg, response.client_data_json))
end >>= fun client_data ->
json_get "type" client_data >>= json_string "type" >>=
(function
| "webauthn.create" -> Ok ()
| wrong_typ -> Error (`Client_data_type_mismatch wrong_typ)) >>= fun () ->
json_get "challenge" client_data >>= json_string "challenge" >>= fun challenge ->
b64_dec "response.ClientDataJSON.challenge" challenge >>= fun challenge ->
json_get "origin" client_data >>= json_string "origin" >>= fun origin ->
guard (String.equal t.origin origin)
(`Origin_mismatch (t.origin, origin)) >>= fun () ->
let client_extensions = Result.to_option (json_get "clientExtensions" client_data) in
begin match client_extensions with
| Some client_extensions ->
json_assoc "clientExtensions" client_extensions >>= fun client_extensions ->
Ok (Some client_extensions)
| None ->
Ok None
end >>= fun client_extensions ->
parse_attestation_object response.attestation_object >>= fun (auth_data, attestation_statement) ->
let rpid_hash =
Digestif.SHA256.(to_raw_string (digest_string (rpid t))) in
guard (String.equal auth_data.rpid_hash rpid_hash)
(`Rpid_hash_mismatch (rpid_hash, auth_data.rpid_hash)) >>= fun () ->
(* verify user present, user verified flags in auth_data.flags *)
Option.to_result ~none:`Missing_credential_data
auth_data.attested_credential_data >>= fun (aaguid, credential_id, public_key) ->
begin match attestation_statement with
| None -> Ok None
| Some (cert, signature) ->
let pub_cs = Mirage_crypto_ec.P256.Dsa.pub_to_octets public_key in
let sigdata = String.concat "" [
"\000" ; rpid_hash ; client_data_hash ; credential_id ; pub_cs
] in
let pk = X509.Certificate.public_key cert in
Result.map_error (function `Msg m -> `Signature_verification m)
(X509.Public_key.verify `SHA256 ~signature pk (`Message sigdata)) >>= fun () ->
Ok (Some cert)
end >>= fun certificate ->
(* check attestation cert, maybe *)
(* check auth_data.attested_credential_data.credential_id is not registered ? *)
let registration =
let attested_credential_data = {
aaguid ;
credential_id ;
public_key
} in
{
user_present = auth_data.user_present ;
user_verified = auth_data.user_verified ;
sign_count = auth_data.sign_count ;
attested_credential_data ;
authenticator_extensions = auth_data.extension_data ;
client_extensions ;
certificate ;
}
in
Ok (challenge, registration)
type authentication = {
user_present : bool ;
user_verified : bool ;
sign_count : Int32.t ;
authenticator_extensions : (string * CBOR.Simple.t) list option ;
client_extensions : (string * Yojson.Safe.t) list option ;
}
type authenticate_response = {
authenticator_data : base64url_string [@key "authenticatorData"];
client_data_json : base64url_string [@key "clientDataJSON"];
signature : base64url_string ;
userHandle : base64url_string option ;
} [@@deriving of_yojson]
let authenticate_response_of_string =
of_json "authenticate response" authenticate_response_of_yojson
let authenticate t public_key response =
let client_data_hash =
Digestif.SHA256.(to_raw_string (digest_string response.client_data_json))
in
begin try Ok (Yojson.Safe.from_string response.client_data_json)
with Yojson.Json_error msg ->
Error (`Json_decoding ("clientDataJSON", msg, response.client_data_json))
end >>= fun client_data ->
json_get "type" client_data >>= json_string "type" >>=
(function
| "webauthn.get" -> Ok ()
| wrong_typ -> Error (`Client_data_type_mismatch wrong_typ)) >>= fun () ->
json_get "challenge" client_data >>= json_string "challenge" >>= fun challenge ->
b64_dec "response.ClientDataJSON.challenge" challenge >>= fun challenge ->
json_get "origin" client_data >>= json_string "origin" >>= fun origin ->
guard (String.equal t.origin origin)
(`Origin_mismatch (t.origin, origin)) >>= fun () ->
let client_extensions = Result.to_option (json_get "clientExtensions" client_data) in
begin match client_extensions with
| Some client_extensions ->
json_assoc "clientExtensions" client_extensions >>= fun client_extensions ->
Ok (Some client_extensions)
| None ->
Ok None
end >>= fun client_extensions ->
parse_auth_data response.authenticator_data >>= fun auth_data ->
let rpid_hash = Digestif.SHA256.(to_raw_string (digest_string (rpid t))) in
guard (String.equal auth_data.rpid_hash rpid_hash)
(`Rpid_hash_mismatch (rpid_hash, auth_data.rpid_hash)) >>= fun () ->
let sigdata = response.authenticator_data ^ client_data_hash
and signature = response.signature in
Result.map_error (function `Msg m -> `Signature_verification m)
(X509.Public_key.verify `SHA256 ~signature (`P256 public_key) (`Message sigdata)) >>= fun () ->
let authentication = {
user_present = auth_data.user_present ;
user_verified = auth_data.user_verified ;
sign_count = auth_data.sign_count ;
authenticator_extensions = auth_data.extension_data ;
client_extensions ;
} in
Ok (challenge, authentication)
let fido_u2f_transport_oid =
Asn.OID.(base 1 3 <| 6 <| 1 <| 4 <| 1 <| 45724 <| 2 <| 1 <| 1)
type transport = [
| `Bluetooth_classic
| `Bluetooth_low_energy
| `Usb
| `Nfc
| `Usb_internal
]
let pp_transport ppf = function
| `Bluetooth_classic -> Fmt.string ppf "BluetoothClassic"
| `Bluetooth_low_energy -> Fmt.string ppf "BluetoothLowEnergy"
| `Usb -> Fmt.string ppf "USB"
| `Nfc -> Fmt.string ppf "NFC"
| `Usb_internal -> Fmt.string ppf "USBInternal"
let transports =
let opts = [
(0, `Bluetooth_classic);
(1, `Bluetooth_low_energy);
(2, `Usb);
(3, `Nfc);
(4, `Usb_internal);
] in
Asn.S.bit_string_flags opts
let decode_strict codec cs =
match Asn.decode codec cs with
| Ok (a, cs) ->
guard (String.length cs = 0) (`Msg "trailing bytes") >>= fun () ->
Ok a
| Error (`Parse msg) -> Error (`Msg msg)
let decode_transport =
decode_strict (Asn.codec Asn.der transports)
let transports_of_cert c =
Result.bind
(Option.to_result ~none:(`Msg "extension not present")
(X509.Extension.(find (Unsupported fido_u2f_transport_oid) (X509.Certificate.extensions c))))
(fun (_, data) -> decode_transport data)

183
src/webauthn.mli Normal file
View file

@ -0,0 +1,183 @@
(** WebAuthn - authenticating users to services using public key cryptography
WebAuthn is a web standard published by the W3C. Its goal is to
standardize an interfacefor authenticating users to web-based
applications and services using public key cryptography. Modern web
browsers support WebAuthn functionality.
WebAuthn provides two funcitons: register and authenticate. Usually the
public and private keypair is stored on an external token (Yuikey etc.)
or part of the platform (TPM). After the public key is registered, it can
be used to authenticate to the same service.
This module implements at the moment only "fido-u2f" and "none"
attestations with P256 keys.
A common use of this module is that on startup a {!t} is created (using
{!create}). A public key can then be registered ({!register}) with a server
generated {!challenge}. When this is successfull, the client can be
authenticated {!authenticate}.
This module does not preserve a database of registered public keys, their
credential ID, usernames and pending challenges - instead this data must
be stored by a client of this API in a database or other persistent
storage.
{{:https://w3c.github.io/webauthn/}WebAuthn specification at W3C.}
*)
(** The type of a webauthn state, containing the [origin]. *)
type t
(** [create origin] is a webauthn state, or an error if the origin does not
meet the specification (schema must be https, the host must be a valid
hostname. An optional port is supported: https://example.com:4444 *)
val create : string -> (t, string) result
(** [rpid t] is the relying party ID. Specifically, it is the effective domain
of the origin. Using registrable domain suffix as the relying party ID is
currently unsupported. *)
val rpid : t -> string
(** The type os json decoding errors: context, message, and data. *)
type json_decoding_error = [ `Json_decoding of string * string * string ]
(** The variant of decoding errors with the various encoding formats. *)
type decoding_error = [
json_decoding_error
| `Base64_decoding of string * string * string
| `CBOR_decoding of string * string * string
| `Unexpected_CBOR of string * string * CBOR.Simple.t
| `Binary_decoding of string * string * string
| `Attestation_object_decoding of string * string * string
]
(** The variant of errors. *)
type error = [
decoding_error
| `Unsupported_key_type of int
| `Unsupported_algorithm of int
| `Unsupported_elliptic_curve of int
| `Unsupported_attestation_format of string
| `Invalid_public_key of string
| `Client_data_type_mismatch of string
| `Origin_mismatch of string * string
| `Rpid_hash_mismatch of string * string
| `Missing_credential_data
| `Signature_verification of string
]
(** [pp_error ppf e] pretty-prints the error [e] on [ppf]. *)
val pp_error : Format.formatter -> [< error ] -> unit
(** The abstract type of challenges. *)
type challenge
(** [generate_challenge ~size ()] generates a new challenge, and returns a pair
of the challenge and its Base64 URI safe encoding.
@raise Invalid_argument if size is smaller than 16. *)
val generate_challenge : ?size:int -> unit -> challenge * string
(** [challenge_to_string c] is a string representing this challenge. *)
val challenge_to_string : challenge -> string
(** [challenge_of_string s] decodes [s] as a challenge. *)
val challenge_of_string : string -> challenge option
(** [challenge_equal a b] is [true] if [a] and [b] are the same challenge. *)
val challenge_equal : challenge -> challenge -> bool
(** The type of credential identifiers. *)
type credential_id = string
(** The type for credential data. *)
type credential_data = {
aaguid : string ;
credential_id : credential_id ;
public_key : Mirage_crypto_ec.P256.Dsa.pub ;
}
(** The type for a registration. *)
type registration = {
user_present : bool ;
user_verified : bool ;
sign_count : Int32.t ;
attested_credential_data : credential_data ;
authenticator_extensions : (string * CBOR.Simple.t) list option ;
client_extensions : (string * Yojson.Safe.t) list option ;
certificate : X509.Certificate.t option ;
}
(** The type for a register_response. *)
type register_response
(** [register_response_of_string s] decodes the json encoded response
(consisting of a JSON dictionary with an attestationObject and
clientDataJSON - both Base64 URI safe encoded). The result is a
register_response or a decoding error. *)
val register_response_of_string : string ->
(register_response, json_decoding_error) result
(** [register t response] registers the response, and returns the used
challenge and a registration. The challenge needs to be verified to be
valid by the caller. If a direct attestation is used, the certificate
is returned -- and the signature is validated to establish the trust
chain between certificate and public key. The certificate should be
validated by the caller. *)
val register : t -> register_response ->
(challenge * registration, error) result
(** The type for an authentication. *)
type authentication = {
user_present : bool ;
user_verified : bool ;
sign_count : Int32.t ;
authenticator_extensions : (string * CBOR.Simple.t) list option ;
client_extensions : (string * Yojson.Safe.t) list option ;
}
(** The type for an authentication response. *)
type authenticate_response
(** [authentication_response_of_string s] decodes the response (a JSON
dictionary of Base64 URI-safe encoded values: authenticatorData,
clientDataJSON, signature, userHandle). If decoding fails, an
error is reported. *)
val authenticate_response_of_string : string ->
(authenticate_response, json_decoding_error) result
(** [authenticate t public_key response] authenticates [response], by checking
the signature with the [public_key]. If it is valid, the used [challenge]
is returned together with the authentication. The challenge needs to be
validated by the caller, and then caller is responsible for looking up the
public key corresponding to the credential id returned by the client web
browser. *)
val authenticate : t -> Mirage_crypto_ec.P256.Dsa.pub -> authenticate_response ->
(challenge * authentication, error) result
(** The type of FIDO U2F transports. *)
type transport = [
| `Bluetooth_classic
| `Bluetooth_low_energy
| `Usb
| `Nfc
| `Usb_internal
]
(** [pp_transport ppf tranport] pretty-prints the [transport] on [ppf]. *)
val pp_transport : Format.formatter -> transport -> unit
(** [fido_u2f_transport_oid] is the OID 1.3.6.1.4.1.45724.2.1.1 for
certificate authenticator transports extensions. *)
val fido_u2f_transport_oid : Asn.oid
(** [decode_transport data] decodes the [fido_u2f_transport_oid] certificate
extension data. *)
val decode_transport : string -> (transport list, [> `Msg of string ]) result
(** [transports_of_cert certficate] attempts to extract the FIDO U2F
authenticator transports extension (OID 1.3.6.1.4.1.45724.2.1.1) from the
[certificate]. *)
val transports_of_cert : X509.Certificate.t ->
(transport list, [`Msg of string]) result

59
webauthn.opam Normal file
View file

@ -0,0 +1,59 @@
opam-version: "2.0"
homepage: "https://github.com/robur-coop/webauthn"
dev-repo: "git+https://github.com/robur-coop/webauthn.git"
bug-reports: "https://github.com/robur-coop/webauthn/issues"
doc: "https://robur-coop.github.io/webauthn/doc"
maintainer: [ "team@robur.coop" ]
authors: [ "Reynir Björnsson <reynir@reynir.dk>" "Hannes Mehnert <hannes@mehnert.org>" ]
license: "BSD-2-Clause"
build: [
["dune" "subst"] {dev}
["dune" "build" "-p" name "-j" jobs]
["dune" "runtest" "-p" name "-j" jobs] {with-test}
]
depends: [
"ocaml" {>= "4.08.0"}
"dune" {>= "2.7"}
"dream" {dev & >= "1.0.0~alpha7"}
"ppx_blob" {dev & >= "0.9.0"}
"cmdliner" {dev & >= "1.1.0"}
"logs" {dev}
"lwt" {dev}
"yojson"
"ppx_deriving_yojson"
"digestif"
"mirage-crypto-ec" {>= "1.1.0"}
"mirage-crypto-rng" {>= "1.1.0"}
"ocplib-endian"
"x509" {>= "1.0.4"}
"base64" {>= "3.1.0"}
"cbor" {>= "0.5"}
"ohex" {>= "0.2.0"}
]
conflicts: [
"result" {< "1.5"}
]
synopsis: "WebAuthn - authenticating users to services using public key cryptography"
description: """
WebAuthn is a web standard published by the W3C. Its goal is to
standardize an interfacefor authenticating users to web-based
applications and services using public key cryptography. Modern web
browsers support WebAuthn functionality.
WebAuthn provides two funcitons: register and authenticate. Usually the
public and private keypair is stored on an external token (Yuikey etc.)
or part of the platform (TPM). After the public key is registered, it can
be used to authenticate to the same service.
This module does not preserve a database of registered public keys, their
credential ID, usernames and pending challenges - instead this data must
be stored by a client of this API in a database or other persistent
storage.
[Demo server](https://webauthn-demo.robur.coop)
[WebAuthn specification at W3C](https://w3c.github.io/webauthn/)
"""