From 57021e962c365701f7171bb67844fdc5ddc75a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com> Date: Sun, 19 Jun 2022 10:10:57 +0300 Subject: [PATCH] Refactor webauthn implementation, use webauthn-json --- hc/accounts/forms.py | 22 +- hc/accounts/views.py | 101 ++-------- hc/lib/webauthn.py | 86 ++++++++ static/js/add_credential.js | 14 +- static/js/login_tfa.js | 16 +- static/js/webauthn-json.browser-global.js | 232 ++++++++++++++++++++++ templates/accounts/add_credential.html | 9 +- templates/accounts/login_webauthn.html | 11 +- 8 files changed, 356 insertions(+), 135 deletions(-) create mode 100644 hc/lib/webauthn.py create mode 100644 static/js/webauthn-json.browser-global.js diff --git a/hc/accounts/forms.py b/hc/accounts/forms.py index f4e54674..f99dfa1a 100644 --- a/hc/accounts/forms.py +++ b/hc/accounts/forms.py @@ -1,9 +1,6 @@ -import base64 -import binascii from datetime import timedelta as td from django import forms -from django.core.exceptions import ValidationError from django.contrib.auth import authenticate from django.contrib.auth.models import User from hc.accounts.models import REPORT_CHOICES, Member @@ -17,17 +14,6 @@ class LowercaseEmailField(forms.EmailField): return value.lower() -class Base64Field(forms.CharField): - def to_python(self, value): - if value is None: - return None - - try: - return base64.b64decode(value.encode()) - except binascii.Error: - raise ValidationError(message="Cannot decode base64") - - class SignupForm(forms.Form): # Call it "identity" instead of "email" # to avoid some of the dumber bots @@ -153,15 +139,11 @@ class TransferForm(forms.Form): class AddWebAuthnForm(forms.Form): name = forms.CharField(max_length=100) - client_data_json = Base64Field() - attestation_object = Base64Field() + response = forms.CharField() class WebAuthnForm(forms.Form): - credential_id = Base64Field() - client_data_json = Base64Field() - authenticator_data = Base64Field() - signature = Base64Field() + response = forms.CharField() class TotpForm(forms.Form): diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 1924c20e..298de7ed 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -1,6 +1,5 @@ -import base64 from datetime import timedelta as td -from secrets import token_bytes, token_urlsafe +from secrets import token_urlsafe from urllib.parse import urlparse import time import uuid @@ -20,16 +19,12 @@ from django.utils.timezone import now from django.urls import resolve, reverse, Resolver404 from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from fido2.ctap2 import AttestationObject, AuthenticatorData -from fido2.client import ClientData -from fido2.server import Fido2Server -from fido2.webauthn import PublicKeyCredentialRpEntity -from fido2 import cbor from hc.accounts import forms from hc.accounts.decorators import require_sudo_mode from hc.accounts.models import Credential, Profile, Project, Member from hc.api.models import Channel, Check, TokenBucket from hc.payments.models import Subscription +from hc.lib.webauthn import Server import pyotp import segno @@ -46,7 +41,7 @@ POST_LOGIN_ROUTES = ( "hc-uncloak", ) -FIDO2_SERVER = Fido2Server(PublicKeyCredentialRpEntity(settings.RP_ID, "healthchecks")) +WEBAUTHN = Server(settings.RP_ID, "healthchecks") def _allow_redirect(redirect_url): @@ -667,27 +662,6 @@ def remove_project(request, code): return redirect("hc-index") -def _get_credential_data(request, form): - """Complete WebAuthn registration, return binary credential data. - - This function is an interface to the fido2 library. It is separated - out so that we don't need to mock ClientData, AttestationObject, - register_complete separately in tests. - - """ - - try: - auth_data = FIDO2_SERVER.register_complete( - request.session["state"], - ClientData(form.cleaned_data["client_data_json"]), - AttestationObject(form.cleaned_data["attestation_object"]), - ) - except ValueError: - return None - - return auth_data.credential_data - - @login_required @require_sudo_mode def add_webauthn(request): @@ -699,42 +673,25 @@ def add_webauthn(request): if not form.is_valid(): return HttpResponseBadRequest() - credential_data = _get_credential_data(request, form) - if not credential_data: + try: + auth_data = WEBAUTHN.register_complete( + request.session["state"], form.cleaned_data["response"] + ) + except ValueError as e: return HttpResponseBadRequest() c = Credential(user=request.user) c.name = form.cleaned_data["name"] - c.data = credential_data + c.data = auth_data.credential_data c.save() request.session["added_credential_name"] = c.name return redirect("hc-profile") credentials = [c.unpack() for c in request.user.credentials.all()] - # User handle is used in a username-less authentication, to map a credential - # received from browser with an user account in the database. - # Since we only use security keys as a second factor, - # the user handle is not of much use to us. - # - # The user handle: - # - must not be blank, - # - must not be a constant value, - # - must not contain personally identifiable information. - # So we use random bytes, and don't store them on our end: - options, state = FIDO2_SERVER.register_begin( - { - "id": token_bytes(16), - "name": request.user.email, - "displayName": request.user.email, - }, - credentials, - ) - + options, state = WEBAUTHN.register_begin(request.user.email, credentials) request.session["state"] = state - - ctx = {"options": base64.b64encode(cbor.encode(options)).decode()} - return render(request, "accounts/add_credential.html", ctx) + return render(request, "accounts/add_credential.html", {"options": options}) @login_required @@ -811,30 +768,6 @@ def remove_credential(request, code): return render(request, "accounts/remove_credential.html", ctx) -def _check_credential(request, form, credentials): - """Complete WebAuthn authentication, return True on success. - - This function is an interface to the fido2 library. It is separated - out so that we don't need to mock ClientData, AuthenticatorData, - authenticate_complete separately in tests. - - """ - - try: - FIDO2_SERVER.authenticate_complete( - request.session["state"], - credentials, - form.cleaned_data["credential_id"], - ClientData(form.cleaned_data["client_data_json"]), - AuthenticatorData(form.cleaned_data["authenticator_data"]), - form.cleaned_data["signature"], - ) - except ValueError: - return False - - return True - - def login_webauthn(request): # We require RP_ID. Fail predicably if it is not set: if not settings.RP_ID: @@ -863,7 +796,13 @@ def login_webauthn(request): if not form.is_valid(): return HttpResponseBadRequest() - if not _check_credential(request, form, credentials): + try: + WEBAUTHN.authenticate_complete( + request.session["state"], + credentials, + form.cleaned_data["response"], + ) + except ValueError as e: return HttpResponseBadRequest() request.session.pop("state") @@ -871,7 +810,7 @@ def login_webauthn(request): auth_login(request, user, "hc.accounts.backends.EmailBackend") return _redirect_after_login(request) - options, state = FIDO2_SERVER.authenticate_begin(credentials) + options, state = WEBAUTHN.authenticate_begin(credentials) request.session["state"] = state totp_url = None @@ -882,7 +821,7 @@ def login_webauthn(request): totp_url += "?next=%s" % redirect_url ctx = { - "options": base64.b64encode(cbor.encode(options)).decode(), + "options": options, "totp_url": totp_url, } return render(request, "accounts/login_webauthn.html", ctx) diff --git a/hc/lib/webauthn.py b/hc/lib/webauthn.py new file mode 100644 index 00000000..d3e46bb4 --- /dev/null +++ b/hc/lib/webauthn.py @@ -0,0 +1,86 @@ +import json +from secrets import token_bytes + +from fido2.client import ClientData +from fido2.ctap2 import AttestationObject, AuthenticatorData +from fido2.server import Fido2Server +from fido2.utils import websafe_encode, websafe_decode +from fido2.webauthn import PublicKeyCredentialRpEntity + + +def bytes_to_b64(obj): + if isinstance(obj, dict): + return {k: bytes_to_b64(v) for k, v in obj.items()} + + if isinstance(obj, list): + return [bytes_to_b64(v) for v in obj] + + if isinstance(obj, bytes): + return websafe_encode(obj) + + return obj + + +json_decode_map = { + "clientDataJSON": ClientData, + "attestationObject": AttestationObject, + "rawId": bytes, + "authenticatorData": AuthenticatorData, + "signature": bytes, +} + + +def json_decode_hook(d): + for key, cls in json_decode_map.items(): + if key in d: + as_bytes = websafe_decode(d[key]) + d[key] = cls(as_bytes) + + return d + + +class Server(object): + def __init__(self, id, name): + self.server = Fido2Server(PublicKeyCredentialRpEntity(id, name)) + + def register_begin(self, email, credentials): + # User handle is used in a username-less authentication, to map a credential + # received from browser with an user account in the database. + # Since we only use security keys as a second factor, + # the user handle is not of much use to us. + # + # The user handle: + # - must not be blank, + # - must not be a constant value, + # - must not contain personally identifiable information. + # So we use random bytes, and don't store them on our end: + user = { + "id": token_bytes(16), + "name": email, + "displayName": email, + } + options, state = self.server.register_begin(user, credentials) + return bytes_to_b64(options), state + + def register_complete(self, state, response_json): + doc = json.loads(response_json, object_hook=json_decode_hook) + return self.server.register_complete( + state, + doc["response"]["clientDataJSON"], + doc["response"]["attestationObject"], + ) + + def authenticate_begin(self, credentials): + options, state = self.server.authenticate_begin(credentials) + return bytes_to_b64(options), state + + def authenticate_complete(self, state, credentials, response_json): + doc = json.loads(response_json, object_hook=json_decode_hook) + return self.server.authenticate_complete( + state, + credentials, + doc["rawId"], + doc["response"]["clientDataJSON"], + doc["response"]["authenticatorData"], + doc["response"]["signature"], + ) diff --git a/static/js/add_credential.js b/static/js/add_credential.js index c9fe54c4..3ac1cad9 100644 --- a/static/js/add_credential.js +++ b/static/js/add_credential.js @@ -1,12 +1,5 @@ $(function() { var form = document.getElementById("add-credential-form"); - var optionsBytes = Uint8Array.from(atob(form.dataset.options), c => c.charCodeAt(0)); - // cbor.js expects ArrayBuffer as input when decoding - var options = CBOR.decode(optionsBytes.buffer); - - function b64(arraybuffer) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(arraybuffer))); - } function requestCredentials() { // Hide error & success messages, show the "waiting" message @@ -15,10 +8,9 @@ $(function() { $("#error").addClass("hide"); $("#success").addClass("hide"); - navigator.credentials.create(options).then(function(attestation) { - $("#attestation_object").val(b64(attestation.response.attestationObject)); - $("#client_data_json").val(b64(attestation.response.clientDataJSON)); - + var options = JSON.parse($("#options").text()); + webauthnJSON.create(options).then(function(response) { + $("#response").val(JSON.stringify(response)); // Show the success message and save button $("#waiting").addClass("hide"); $("#success").removeClass("hide"); diff --git a/static/js/login_tfa.js b/static/js/login_tfa.js index 57a1548a..731ae3cc 100644 --- a/static/js/login_tfa.js +++ b/static/js/login_tfa.js @@ -1,24 +1,14 @@ $(function() { var form = document.getElementById("login-tfa-form"); - var optionsBytes = Uint8Array.from(atob(form.dataset.options), c => c.charCodeAt(0)); - // cbor.js expects ArrayBuffer as input when decoding - var options = CBOR.decode(optionsBytes.buffer); - - function b64(arraybuffer) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(arraybuffer))); - } function authenticate() { $("#pick-method").addClass("hide"); $("#waiting").removeClass("hide"); $("#error").addClass("hide"); - navigator.credentials.get(options).then(function(assertion) { - $("#credential_id").val(b64(assertion.rawId)); - $("#authenticator_data").val(b64(assertion.response.authenticatorData)); - $("#client_data_json").val(b64(assertion.response.clientDataJSON)); - $("#signature").val(b64(assertion.response.signature)); - + var options = JSON.parse($("#options").text()); + webauthnJSON.get(options).then(function(response) { + $("#response").val(JSON.stringify(response)); // Show the success message and save button $("#waiting").addClass("hide"); $("#success").removeClass("hide"); diff --git a/static/js/webauthn-json.browser-global.js b/static/js/webauthn-json.browser-global.js new file mode 100644 index 00000000..69d2852f --- /dev/null +++ b/static/js/webauthn-json.browser-global.js @@ -0,0 +1,232 @@ +(() => { + var __defProp = Object.defineProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); + }; + + // src/webauthn-json/index.ts + var webauthn_json_exports = {}; + __export(webauthn_json_exports, { + create: () => create, + get: () => get, + schema: () => schema, + supported: () => supported + }); + + // src/webauthn-json/base64url.ts + function base64urlToBuffer(baseurl64String) { + const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4); + const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding; + const str = atob(base64String); + const buffer = new ArrayBuffer(str.length); + const byteView = new Uint8Array(buffer); + for (let i = 0; i < str.length; i++) { + byteView[i] = str.charCodeAt(i); + } + return buffer; + } + function bufferToBase64url(buffer) { + const byteView = new Uint8Array(buffer); + let str = ""; + for (const charCode of byteView) { + str += String.fromCharCode(charCode); + } + const base64String = btoa(str); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + return base64urlString; + } + + // src/webauthn-json/convert.ts + var copyValue = "copy"; + var convertValue = "convert"; + function convert(conversionFn, schema2, input) { + if (schema2 === copyValue) { + return input; + } + if (schema2 === convertValue) { + return conversionFn(input); + } + if (schema2 instanceof Array) { + return input.map((v) => convert(conversionFn, schema2[0], v)); + } + if (schema2 instanceof Object) { + const output = {}; + for (const [key, schemaField] of Object.entries(schema2)) { + if (schemaField.derive) { + const v = schemaField.derive(input); + if (v !== void 0) { + input[key] = v; + } + } + if (!(key in input)) { + if (schemaField.required) { + throw new Error(`Missing key: ${key}`); + } + continue; + } + if (input[key] == null) { + output[key] = null; + continue; + } + output[key] = convert(conversionFn, schemaField.schema, input[key]); + } + return output; + } + } + function derived(schema2, derive) { + return { + required: true, + schema: schema2, + derive + }; + } + function required(schema2) { + return { + required: true, + schema: schema2 + }; + } + function optional(schema2) { + return { + required: false, + schema: schema2 + }; + } + + // src/webauthn-json/basic/schema.ts + var publicKeyCredentialDescriptorSchema = { + type: required(copyValue), + id: required(convertValue), + transports: optional(copyValue) + }; + var simplifiedExtensionsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) + }; + var simplifiedClientExtensionResultsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) + }; + var credentialCreationOptions = { + publicKey: required({ + rp: required(copyValue), + user: required({ + id: required(convertValue), + name: required(copyValue), + displayName: required(copyValue) + }), + challenge: required(convertValue), + pubKeyCredParams: required(copyValue), + timeout: optional(copyValue), + excludeCredentials: optional([publicKeyCredentialDescriptorSchema]), + authenticatorSelection: optional(copyValue), + attestation: optional(copyValue), + extensions: optional(simplifiedExtensionsSchema) + }), + signal: optional(copyValue) + }; + var publicKeyCredentialWithAttestation = { + type: required(copyValue), + id: required(copyValue), + rawId: required(convertValue), + authenticatorAttachment: optional(copyValue), + response: required({ + clientDataJSON: required(convertValue), + attestationObject: required(convertValue), + transports: derived(copyValue, (response) => { + var _a; + return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || []; + }) + }), + clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults()) + }; + var credentialRequestOptions = { + mediation: optional(copyValue), + publicKey: required({ + challenge: required(convertValue), + timeout: optional(copyValue), + rpId: optional(copyValue), + allowCredentials: optional([publicKeyCredentialDescriptorSchema]), + userVerification: optional(copyValue), + extensions: optional(simplifiedExtensionsSchema) + }), + signal: optional(copyValue) + }; + var publicKeyCredentialWithAssertion = { + type: required(copyValue), + id: required(copyValue), + rawId: required(convertValue), + authenticatorAttachment: optional(copyValue), + response: required({ + clientDataJSON: required(convertValue), + authenticatorData: required(convertValue), + signature: required(convertValue), + userHandle: required(convertValue) + }), + clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults()) + }; + var schema = { + credentialCreationOptions, + publicKeyCredentialWithAttestation, + credentialRequestOptions, + publicKeyCredentialWithAssertion + }; + + // src/webauthn-json/basic/api.ts + function createRequestFromJSON(requestJSON) { + return convert(base64urlToBuffer, credentialCreationOptions, requestJSON); + } + function createResponseToJSON(credential) { + return convert(bufferToBase64url, publicKeyCredentialWithAttestation, credential); + } + function create(requestJSON) { + return __async(this, null, function* () { + const credential = yield navigator.credentials.create(createRequestFromJSON(requestJSON)); + return createResponseToJSON(credential); + }); + } + function getRequestFromJSON(requestJSON) { + return convert(base64urlToBuffer, credentialRequestOptions, requestJSON); + } + function getResponseToJSON(credential) { + return convert(bufferToBase64url, publicKeyCredentialWithAssertion, credential); + } + function get(requestJSON) { + return __async(this, null, function* () { + const credential = yield navigator.credentials.get(getRequestFromJSON(requestJSON)); + return getResponseToJSON(credential); + }); + } + + // src/webauthn-json/basic/supported.ts + function supported() { + return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential); + } + + // src/webauthn-json/browser-global.ts + globalThis.webauthnJSON = webauthn_json_exports; +})(); +//# sourceMappingURL=webauthn-json.browser-global.js.map diff --git a/templates/accounts/add_credential.html b/templates/accounts/add_credential.html index 7188d98b..5e2fd107 100644 --- a/templates/accounts/add_credential.html +++ b/templates/accounts/add_credential.html @@ -7,14 +7,12 @@ <form id="add-credential-form" class="col-sm-6 col-sm-offset-3" - data-options="{{ options }}" method="post" encrypt="multipart/form-data"> <h1>Add Security Key</h1> {% csrf_token %} - <input id="attestation_object" type="hidden" name="attestation_object"> - <input id="client_data_json" type="hidden" name="client_data_json"> + <input id="response" type="hidden" name="response"> <div class="form-group"> <label for="name">Name</label> @@ -81,13 +79,16 @@ </div> </form> </div> + +{{ options|json_script:"options" }} + {% endblock %} {% block scripts %} {% compress js %} <script src="{% static 'js/jquery-3.6.0.min.js' %}"></script> <script src="{% static 'js/bootstrap.min.js' %}"></script> -<script src="{% static 'js/cbor.js' %}"></script> +<script src="{% static 'js/webauthn-json.browser-global.js' %}"></script> <script src="{% static 'js/add_credential.js' %}"></script> {% endcompress %} {% endblock %} diff --git a/templates/accounts/login_webauthn.html b/templates/accounts/login_webauthn.html index b2a99813..b5188019 100644 --- a/templates/accounts/login_webauthn.html +++ b/templates/accounts/login_webauthn.html @@ -7,7 +7,6 @@ <form id="login-tfa-form" class="col-sm-6 col-sm-offset-3" - data-options="{{ options }}" method="post" encrypt="multipart/form-data"> <h1>Two-factor Authentication</h1> @@ -34,10 +33,7 @@ </div> {% csrf_token %} - <input id="credential_id" type="hidden" name="credential_id"> - <input id="authenticator_data" type="hidden" name="authenticator_data"> - <input id="client_data_json" type="hidden" name="client_data_json"> - <input id="signature" type="hidden" name="signature"> + <input id="response" type="hidden" name="response"> <div id="waiting" class="hide"> <h2>Waiting for security key</h2> @@ -73,13 +69,16 @@ </div> </form> </div> + +{{ options|json_script:"options" }} + {% endblock %} {% block scripts %} {% compress js %} <script src="{% static 'js/jquery-3.6.0.min.js' %}"></script> <script src="{% static 'js/bootstrap.min.js' %}"></script> -<script src="{% static 'js/cbor.js' %}"></script> +<script src="{% static 'js/webauthn-json.browser-global.js' %}"></script> <script src="{% static 'js/login_tfa.js' %}"></script> {% endcompress %} {% endblock %}