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
8
CHANGES.md
Normal 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
23
LICENSE.md
Normal 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
0
README
31
README.md
Normal file
31
README.md
Normal 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
114
bin/base64.js
Normal 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
8
bin/dune
Normal 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
189
bin/template.ml
Normal 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
313
bin/webauthn_demo.ml
Normal 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
|
@ -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>
|
782
doc/odoc.css
782
doc/odoc.css
|
@ -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
|
@ -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
3
dune-project
Normal file
|
@ -0,0 +1,3 @@
|
|||
(lang dune 2.7)
|
||||
(name webauthn)
|
||||
(formatting disabled)
|
3
flash_message/dune
Normal file
3
flash_message/dune
Normal file
|
@ -0,0 +1,3 @@
|
|||
(library
|
||||
(name flash_message)
|
||||
(libraries dream))
|
52
flash_message/flash_message.ml
Normal file
52
flash_message/flash_message.ml
Normal 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
23
misc/rc.d/webauthn_demo
Executable 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
6
src/dune
Normal 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
481
src/webauthn.ml
Normal 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
183
src/webauthn.mli
Normal 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
59
webauthn.opam
Normal 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/)
|
||||
"""
|
Loading…
Reference in a new issue