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 %}