Skip to content

Commit 0604f91

Browse files
committed
Patch soft_webauthn module bit flags with a dirty hack!
- i only need to patch 2 characters in the original source, so this has less overhead than overriding both methods. - create tests directory - tests patch applied by looking at compiled code objects - new tests dir for more than one tests module
1 parent ac73b13 commit 0604f91

4 files changed

Lines changed: 54 additions & 121 deletions

File tree

soft_webauthn_patched.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Patches the source of soft_webauthn module so that user verification (UV) bits
3+
are always set.
4+
5+
"How to modify imported source code on-the-fly?": https://stackoverflow.com/a/41863728/1660046
6+
"""
7+
8+
import sys
9+
from importlib import util
10+
11+
12+
def modify_and_import(module_name, package, modification_func):
13+
spec = util.find_spec(module_name, package)
14+
source = spec.loader.get_source(module_name)
15+
new_source = modification_func(source)
16+
module = util.module_from_spec(spec)
17+
codeobj = compile(new_source, module.__spec__.origin, "exec")
18+
exec(codeobj, module.__dict__)
19+
sys.modules[module_name] = module
20+
return module
21+
22+
23+
def patch_flags(source: str):
24+
"""Enables User Verification (UV) bit in create() and get() methods' bit flags.
25+
26+
Bit flag info: https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Authenticator_data
27+
"""
28+
source = (
29+
source
30+
# patch bit flags in .create()
31+
.replace(r"flags = b'\x41'", r"flags = b'\x45'")
32+
# and .get()
33+
.replace(r"flags = b'\x01'", r"flags = b'\x05'")
34+
)
35+
return source
36+
37+
38+
soft_webauthn = modify_and_import("soft_webauthn", "soft_webauthn", patch_flags)
39+
SoftWebauthnDevice = soft_webauthn.SoftWebauthnDevice
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from soft_webauthn_patched import SoftWebauthnDevice
2+
3+
4+
def test_patch():
5+
"""Asserts bit flags are patched as expected."""
6+
assert b"\x45" in SoftWebauthnDevice.create.__code__.co_consts
7+
assert b"\x05" in SoftWebauthnDevice.get.__code__.co_consts

test_ubank.py renamed to tests/test_ubank.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22
from datetime import date
33

44
import pytest
5-
from cryptography.hazmat.primitives import serialization
65
from httpx import HTTPStatusError
76

87
from ubank import (
98
Api,
109
Passkey,
10+
SoftWebauthnDevice,
1111
TransactionsSearchBody,
12-
UserVerifiedDevice,
1312
__version__,
1413
add_passkey,
1514
int8array_to_bytes,
@@ -144,7 +143,7 @@ def test_passkey_serialization(tmp_path):
144143
passkey.dump(f, password="")
145144
with (tmp_path / "passkey.txt").open("rb") as f:
146145
deserialized_passkey = Passkey.load(f, password="")
147-
assert type(deserialized_passkey.soft_webauthn_device) is UserVerifiedDevice
146+
assert type(deserialized_passkey.soft_webauthn_device) is SoftWebauthnDevice
148147

149148

150149
def test_add_passkey_bad_username():

ubank.py

Lines changed: 6 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from typing import IO, AnyStr, Optional
2222

2323
import httpx
24-
import soft_webauthn
2524
from cryptography.fernet import Fernet
2625
from cryptography.hazmat.backends import default_backend
2726
from cryptography.hazmat.primitives import hashes, serialization
@@ -30,7 +29,8 @@
3029
from meatie import endpoint
3130
from meatie_httpx import Client as MeatieClient
3231
from pydantic import BaseModel, Field
33-
from soft_webauthn import SoftWebauthnDevice
32+
33+
from soft_webauthn_patched import SoftWebauthnDevice
3434

3535
__version__ = "2.1.0"
3636

@@ -258,7 +258,7 @@ def __init__(self, name: str):
258258
self.device_id = device_id
259259
self.device_meta = device_meta
260260
self.username = username
261-
self.soft_webauthn_device = UserVerifiedDevice()
261+
self.soft_webauthn_device = SoftWebauthnDevice()
262262

263263
def dump(self, file: IO[bytes], password: str = ""):
264264
"""Serializes passkey to file, encrypted with a password.
@@ -306,118 +306,6 @@ def load(cls, file: IO[AnyStr], password: str = "") -> Passkey:
306306
return passkey
307307

308308

309-
class UserVerifiedDevice(SoftWebauthnDevice):
310-
"""Software webauthn device with UV bit flag ALWAYS set."""
311-
312-
def create(self, options, origin):
313-
"""IDENTICAL to super().create(), except User Verification flag set."""
314-
if {"alg": -7, "type": "public-key"} not in options["publicKey"][
315-
"pubKeyCredParams"
316-
]:
317-
raise ValueError(
318-
"Requested pubKeyCredParams does not contain supported type"
319-
)
320-
321-
if ("attestation" in options["publicKey"]) and (
322-
options["publicKey"]["attestation"] not in [None, "none"]
323-
):
324-
raise ValueError("Only none attestation supported")
325-
326-
# prepare new key
327-
self.cred_init(
328-
options["publicKey"]["rp"]["id"], options["publicKey"]["user"]["id"]
329-
)
330-
331-
# generate credential response
332-
client_data = {
333-
"type": "webauthn.create",
334-
"challenge": soft_webauthn.urlsafe_b64encode(
335-
options["publicKey"]["challenge"]
336-
)
337-
.decode("ascii")
338-
.rstrip("="),
339-
"origin": origin,
340-
}
341-
342-
rp_id_hash = soft_webauthn.sha256(self.rp_id.encode("ascii"))
343-
# Set Bit 2, User Verification (UV) also.
344-
# https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Authenticator_data
345-
flags = b"\x45" # attested_data + user_present + user_verified
346-
sign_count = soft_webauthn.pack(">I", self.sign_count)
347-
credential_id_length = soft_webauthn.pack(">H", len(self.credential_id))
348-
cose_key = soft_webauthn.cbor.encode(
349-
soft_webauthn.ES256.from_cryptography_key(self.private_key.public_key())
350-
)
351-
attestation_object = {
352-
"authData": rp_id_hash
353-
+ flags
354-
+ sign_count
355-
+ self.aaguid
356-
+ credential_id_length
357-
+ self.credential_id
358-
+ cose_key,
359-
"fmt": "none",
360-
"attStmt": {},
361-
}
362-
363-
return {
364-
"id": soft_webauthn.urlsafe_b64encode(self.credential_id),
365-
"rawId": self.credential_id,
366-
"response": {
367-
"clientDataJSON": json.dumps(client_data).encode("utf-8"),
368-
"attestationObject": soft_webauthn.cbor.encode(attestation_object),
369-
},
370-
"type": "public-key",
371-
}
372-
373-
def get(self, options, origin):
374-
"""IDENTICAL to super().create(), except User Verification flag set."""
375-
376-
if self.rp_id != options["publicKey"]["rpId"]:
377-
raise ValueError("Requested rpID does not match current credential")
378-
379-
self.sign_count += 1
380-
381-
# prepare signature
382-
client_data = json.dumps(
383-
{
384-
"type": "webauthn.get",
385-
"challenge": soft_webauthn.urlsafe_b64encode(
386-
options["publicKey"]["challenge"]
387-
)
388-
.decode("ascii")
389-
.rstrip("="),
390-
"origin": origin,
391-
}
392-
).encode("utf-8")
393-
client_data_hash = soft_webauthn.sha256(client_data)
394-
395-
rp_id_hash = soft_webauthn.sha256(self.rp_id.encode("ascii"))
396-
# Set Bit 2, User Verification (UV) also.
397-
# https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Authenticator_data
398-
flags = b"\x05"
399-
sign_count = soft_webauthn.pack(">I", self.sign_count)
400-
authenticator_data = rp_id_hash + flags + sign_count
401-
402-
signature = self.private_key.sign(
403-
authenticator_data + client_data_hash,
404-
soft_webauthn.ec.ECDSA(soft_webauthn.hashes.SHA256()),
405-
)
406-
407-
# generate assertion
408-
return {
409-
"id": soft_webauthn.urlsafe_b64encode(self.credential_id),
410-
"rawId": self.credential_id,
411-
"response": {
412-
"authenticatorData": authenticator_data,
413-
"clientDataJSON": client_data,
414-
"signature": signature,
415-
"userHandle": self.user_handle,
416-
},
417-
"type": "public-key",
418-
}
419-
420-
421309
class Api(MeatieClient):
422310
"""A ubank API client.
423311
@@ -853,7 +741,7 @@ def add_passkey(username: str, password: str, passkey_name: str) -> Passkey:
853741
return passkey
854742

855743

856-
def to_dict(device: UserVerifiedDevice) -> dict:
744+
def to_dict(device: SoftWebauthnDevice) -> dict:
857745
"""Converts SoftWebauthnDevice instance to dict with serialized private key."""
858746
serialized_private_key = (
859747
device.private_key.private_bytes(
@@ -874,9 +762,9 @@ def to_dict(device: UserVerifiedDevice) -> dict:
874762
}
875763

876764

877-
def from_dict(device_dict: dict) -> UserVerifiedDevice:
765+
def from_dict(device_dict: dict) -> SoftWebauthnDevice:
878766
"""Returns device instantiated from dict."""
879-
device = UserVerifiedDevice()
767+
device = SoftWebauthnDevice()
880768
device.credential_id = device_dict["credential_id"]
881769
device.private_key = serialization.load_pem_private_key(
882770
device_dict["serialized_private_key"],

0 commit comments

Comments
 (0)