duo-labs / py_webauthn

Pythonic WebAuthn 🐍
https://duo-labs.github.io/py_webauthn
BSD 3-Clause "New" or "Revised" License
865 stars 172 forks source link

Need help at the verify_authentication_response() function #208

Closed Satistile closed 1 month ago

Satistile commented 7 months ago

I currently try to implement the py_webauthn library in a little test project, but currently the verify_authentication_response() function seems to not work correctly.

Here's my code (I currently work with flask):

def verify_authentication_response_func(request):
    content_type = request.headers.get('Content-Type')
    if content_type == 'application/json':
        request_json = request.json

        # get user id from client json and prepare payload for processing
        logged_in_user_id = request_json['id']
        request_payload_object = json.loads(base64url_to_string(request_json['payload']))

        # get current challenge and public key from user
        current_challenge = base64url_to_bytes(db_calls.get_challenge(logged_in_user_id))
        pub_key = base64url_to_bytes(db_calls.get_key(logged_in_user_id))

        print(pub_key)

        try:
            credential = request_payload_object
            verification = verify_authentication_response(
                credential=credential,
                expected_challenge=current_challenge,
                expected_rp_id="localhost",
                expected_origin="http://localhost:63343",
                credential_public_key=pub_key,
                credential_current_sign_count=0,
                require_user_verification=True,
            )
        except Exception as err:
            db_calls.delete_challenge(logged_in_user_id)
            return {"verified": False, "msg": str(err), "status": 400}

        db_calls.delete_challenge(logged_in_user_id)

        return {"verified": True}
    else:
        return 'Content-Type not supported!'

And here's the replied JSON from it: {"msg":"'int' object is not subscriptable","status":400,"verified":false}

The error seems to occour while the processing of the public key. And this is the point, where I don't get what's wrong. After the registration process is completed, I save the public key directly in my database. The public key is send directly from the client, where the @simplewebauthn/browser framework had processed the options and replied the public key as a base64url string.

I currently still don't know if this is my error or an error in the library. Thanks in advance for helping!

Edit: Here's the complete traceback:

[2024-03-20 21:47:24,264] ERROR in app: Exception on /verify-authentication-response [POST]                                                  
Traceback (most recent call last):                                                                                                           
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\flask\app.py", line 1463, in wsgi_app                  
    response = self.full_dispatch_request()                                                                                                  
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                  
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\flask\app.py", line 872, in full_dispatch_request      
    rv = self.handle_user_exception(e)                                                                                                       
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                       
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\flask_cors\extension.py", line 176, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
                                                ^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\flask\app.py", line 870, in full_dispatch_request
    rv = self.dispatch_request()
         ^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\flask\app.py", line 855, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)  # type: ignore[no-any-return]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\flask_cors\decorator.py", line 130, in wrapped_function
    resp = make_response(f(*args, **kwargs))
                         ^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\app.py", line 45, in verify_authentication_response_route
    return verify_authentication_response_func(request)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\processes\authentication_functions.py", line 65, in verify_authentication_response_func
    verification = verify_authentication_response(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\webauthn\authentication\verify_authentication_response.py", line 164, in verify_authentication_response
    decoded_public_key = decode_credential_public_key(credential_public_key)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\jetbrains-projekte\passkey test py\passkey test py\.venv\Lib\site-packages\webauthn\helpers\decode_credential_public_key.py", line 62, in decode_credential_public_key
    kty = decoded_key[COSEKey.KTY]
          ~~~~~~~~~~~^^^^^^^^^^^^^
TypeError: 'int' object is not subscriptable
127.0.0.1 - - [20/Mar/2024 21:47:24] "POST /verify-authentication-response HTTP/1.1" 500 -
MasterKale commented 7 months ago

Hello @Satistile, what's the base64url value of pub_key coming out of db_calls.get_key(logged_in_user_id)? Sharing that might help troubleshoot what's going on here.

Satistile commented 7 months ago

The pub_key variable corresponds to the response.publicKey variable in the JSON object returned by the @simplewebauthn/browser library when calling the startRegistration() function. This value is already encoded in base64url by the library and theoraticly I just have to decode this in my python backend. As far as I got with debugging, I can say for sure that my encoding and decoding functions for base64url are working as intended.

MasterKale commented 7 months ago

Without a sample value for pub_key I can't assist in troubleshooting any further. Please feel free to reopen this issue if you are able to provide such a value.

Satistile commented 7 months ago

A sample value for the pub_key would be MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8APYzNW_OkIzf8cizuNiwqMJhk8IqIxCiVdjlxUulYH0fpTdr0W46fI89XooslN8r1V4Vu1DrUcdc1vHGnHB0g

MasterKale commented 7 months ago

This is bizarre, that pub_key is the following hex:

3059301306072a8648ce3d020106082a8648ce3d03010703420004f003d8ccd5bf3a42337fc722cee362c2a309864f08a88c4289576397152e9581f47e94ddaf45b8e9f23cf57a28b2537caf557856ed43ad471d735bc71a71c1d2

When I drop that hex into https://cbor.me the site says the bytes are "the value -17", with "90 unused bytes after the end of the data item":

Screenshot 2024-03-28 at 12 34 42 PM

So this is telling me that there's something potentially misbehaving with the browser and/or authenticator, before @simplewebauthn/browser base64url-encodes the output from WebAuthn's response.getPublicKey()...

@Satistile What OS, browser, their versions, and authenticator are you attempting to support here? Might as well let me know what version of @simplewebauthn/browser you're using too (I doubt it's the culprit but just to be safe.)

Satistile commented 7 months ago

I don't actually know the version of my @simplewebauthn/browser, but I use it in my website with the recommended way without typescript (<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>). The site currently runs on Windows 10 pro 22h2 and I use it with Microsoft Edge V. 123.0.2420.65. I've used the Windows Hello authenticator and the authenticator build in into IOS 17.4.1. The error occured while using both authenticators. But on other websites like google.com and icloud.com aren't any problems.

I hope these informations are helpful.

Satistile commented 7 months ago

Maybe it's also helpful to know, that the registration process don't throws any errors and runs without any problems.

Satistile commented 6 months ago

I've thought a bit about the issue, and came to the conclusion that the error might have happened while the storing process of the public key.

Corresponding code:

def verify_registration_response_func(request):
    content_type = request.headers.get('Content-Type')
    if content_type == 'application/json':
        request_json = request.json

        # get user id from client json and prepare payload for processing
        logged_in_user_id = request_json['id']
        request_payload_object = json.loads(base64url_to_string(request_json['payload']))

        # get current challenge from user
        current_challenge = base64url_to_bytes(db_calls.get_challenge(logged_in_user_id))

        # verify challenge
        try:
            credential = request_payload_object
            verification = verify_registration_response(
                credential=credential,
                expected_challenge=current_challenge,
                expected_rp_id="localhost",
                expected_origin="http://localhost:63342",
                require_user_verification=True,
            )
        except Exception as err:
            db_calls.delete_challenge(logged_in_user_id)
            return {"verified": False, "msg": str(err), "status": 400}

        # remove challenge from database and add public key to db
        db_calls.delete_challenge(logged_in_user_id)
        db_calls.add_key(logged_in_user_id, credential["response"]["publicKey"])

        return {"verified": True}
    else:
        return 'Content-Type not supported!'

I hope that this might help a bit

Satistile commented 6 months ago

Oh this was a mistake, sorry

MasterKale commented 4 months ago

Hello @Satistile, is this still an issue for you?

# registration
db_calls.add_key(logged_in_user_id, credential["response"]["publicKey"])
# authentication
pub_key = base64url_to_bytes(db_calls.get_key(logged_in_user_id))

The only thing I can suggest is that credential["response"]["publicKey"] and pub_key are the same value whether as bytes or base64url string. If pub_key isn't the same value then I'd say something is going wrong while storing the public key bytes...

Satistile commented 3 months ago

I've checked lately if that's the case, but in the storing process seems to be no error. The values are exactly the same. My guess at this point is, that the public key that I'm storing is wrong. But the verification process with the signed challenge in the registration process seems to work flawless. Currently, as shown in the code example I already provided (the one with the verify_registration_response_func in it), I assumed that the public key value provided in the returned json from @simplewebauthn/browser is the public key I need later for the verification. If that's not the case it could be that there is a error in one of the librarys, or more likely, I've made a dumb mistake anywhere else in the process. I hope, that's somewhat helpful

Densaugeo commented 3 months ago

I'm having the same issue. Using a public key that comes from calling .response.getPublicKey() on the result of creating a new key by calling await navigator.credentials.create({ ... }). I've tried storing it in two different formats:

new Uint8Array(credential.response.getPublicKey()) // [48, 89, 48, 19, 6, 7, 42, 134, 72, 206, 61, 2, 1, 6, 8, 42, 134, 72, 206, 61, 3, 1, 7, 3, 66, 0, 4, 213, 234, 207, 60, 174, 205, 100, 74, 105, 96, 227, 16, 254, 247, 215, 131, 195, 28, 29, 245, 38, 9, 61, 170, 223, 102, 211, 100, 71, 50, 33, 95, 206, 89, 85, 124, 248, 12, 138, 196, 77, 188, 60, 177, 222, 51, 232, 71, 203, 41, 203, 225, 231, 5, 185, 35, 175, 179, 142, 206, 200, 11, 86, 189]
buffer_to_b64(credential.response.getPublicKey()) // 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1erPPK7NZEppYOMQ/vfXg8McHfUmCT2q32bTZEcyIV/OWVV8+AyKxE28PLHeM+hHyynL4ecFuSOvs47OyAtWvQ=='

But calling verify_authentication_response() always fails with the same error caused by cbor parsing returning -17:

---- snip ----
  File "/home/den-antares/projects/tir-na-nog/venv-python3.12/lib64/python3.12/site-packages/webauthn/authentication/verify_authentication_response.py", line 164, in verify_authentication_response
    decoded_public_key = decode_credential_public_key(credential_public_key)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/den-antares/projects/tir-na-nog/venv-python3.12/lib64/python3.12/site-packages/webauthn/helpers/decode_credential_public_key.py", line 63, in decode_credential_public_key
    kty = decoded_key[COSEKey.KTY]
          ~~~~~~~~~~~^^^^^^^^^^^^^
TypeError: 'int' object is not subscriptable

It looks like cbor parsing relies on a C library underneath, so the -17 is probably some kind of return code, but I can't find any documentation about what it means. I've tried making a new key a few times, but I always get the same result.

Are there any working examples of creating a passkey with navigator.credentials.create() and getting it to work with pywebauthn? The example I found just validated hard-coded keys.

Densaugeo commented 3 months ago

I eventually figured out that pywebauthn is using a different public key format from the built in browser functions - you needs to pass the result of navigator.credentials.create() to the server, run it through pywebauthn's verify_registration_response() and use the public key from the result.

It took me quite a while to figure this out, and there don't seem to be any working examples of using a browser client with pywebauthn. The closest I found was https://gist.github.com/samuelcolvin/3ff019aa738aa558a185c4fb002b5751 , and that one is a few years old and doesn't work with the current version.

MasterKale commented 3 months ago

Hello @Densaugeo and @Satistile, I'm finally understanding what's going on now. The output of .getPublicKey() is DER-encoded bytes comprising a SubjectPublicKeyInfo data structure. Inside this structure is the same public key bytes that come out of verify_registration_response() as credential_public_key, but an RP has to know this or else they'll run into the issues you're reporting here.

I hadn't paid attention to the output of AuthenticatorAttestationResponse.getPublicKey() till now because py_webauthn was written assuming credential_public_key will get stored out of verify_registration_response() to subsequently be fed into verify_authentication_response() as its credential_public_key argument.

I poked around right now to see if I could handle this all within py_webauthn without any changes to verify_authentication_response(). Unfortunately there's a limitation with getPublicKey() called out in the WebAuthn spec:

A SubjectPublicKeyInfo does not include information about the signing algorithm (for example, which hash function to use) that is included in the COSE public key. To provide this, getPublicKeyAlgorithm() returns the COSEAlgorithmIdentifier for the credential public key.

The algorithm is present in credential_public_key returned from verify_registration_response(), though...I can't see a way to silently detect and adapt to the output of getPublicKey() when it's passed into verify_authentication_response() as credential_public_key without potentially making verify_authentication_response() more confusing to use by adding a new "public_key_algorithm" argument that is only to be used when an RP wants to feed in the result of getPublicKey() as credential_public_key.

@Densaugeo and @Satistile: is there a reason why you reached for the output from getPublicKey() instead of storing credential_public_key out of verify_registration_response()? There's a solution here that involves you refactoring your code to persist and use credential_public_key and forget about using getPublicKey(), but I'd like to understand your use case more before I close this ticket out as WONTFIX.

Satistile commented 3 months ago

I've did some refactoring and it works now. There wasn't any special use case for taking the public key straight from getPublicKey(). I just had no clue, that verify_registration_response() has any more values, including the public key. Thus, it's not nessecary for me to change something in the library. Thank you very much for your help!

Densaugeo commented 3 months ago

I eventually got it working with verify_registration_response(). If calling getPublicKey() on the client side doesn't give all of the necessary information, it's probably best to stick with verify_registration_response().

I tried to use getPublicKey() because I had trouble figuring out how to set things up - it seems like pywebauthn is designed to use the output of navigator.credentials.create(), stringified to JSON and fed into verify_registration_response(). This works fine in Firefox, but in Chrome passing the credential object from navigator.credentials.create() to JSON.stringify() returns {}. Eventually I figured out all the different fields that have to be copied over for verify_registration_response() to work:

const credential = await navigator.credentials.create({
  publicKey: {
    challenge: b64_to_u8_array(prelogin.challenge),
    rp: { id: "localhost", name: "Localhost" },
    user: {
      id: new TextEncoder().encode('den_antares'),
      name: "den_antares",
      displayName: "Den Antares"
    },
    pubKeyCredParams: [
      // Chrome logs a warning unless both of these algorithms are specified
      { type: "public-key", alg: -7 },
      { type: "public-key", alg: -257 },
    ],
  }
})

// Works in Firefox, fails in Chrome because JSON.stringify(credential) returns '{}'
let res = await fetch('/register-key', { method: 'POST', body: JSON.stringify(credential) })

// Works in both
let res = await fetch('/register-key', { method: 'POST', body: JSON.stringify({
  id: credential.id,
  rawId: buffer_to_b64(credential.rawId),
  response: {
    attestationObject: buffer_to_b64(credential.response.attestationObject),
    clientDataJSON: buffer_to_b64(credential.response.clientDataJSON),
  },
  type: credential.type,
}) })

My suggestion is to add an example of how to capture credentials on the client side. I wasn't able to find any current examples for how to do that, and took a lot of wrong turns trying to figure it out.