481 lines
19 KiB
OCaml
481 lines
19 KiB
OCaml
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 "Bluetooth classic"
|
|
| `Bluetooth_low_energy -> Fmt.string ppf "Bluetooth low energy"
|
|
| `Usb -> Fmt.string ppf "USB"
|
|
| `Nfc -> Fmt.string ppf "NFC"
|
|
| `Usb_internal -> Fmt.string ppf "USB internal"
|
|
|
|
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)
|