mylofi / local-data-lock

Protect local-first app data with encryption/decryption key secured in Webauthn (biometric) passkey
https://mylofi.github.io/local-data-lock/
MIT License
111 stars 5 forks source link

Thoughts & Notes on `rawId` vs `userHandle` as shared secret/seed #7

Closed coolaj86 closed 1 week ago

coolaj86 commented 2 weeks ago

Depending on how the credential is created, the userHandle shows up in macOS Keychain Access as Account without any additional authentication. However, id is kept invisible.

To get the effect of being able to have multiple keys you could use the index as salt to WebCrypto PBKDF2, and then pass that into the box keypair function for the seed.

When stored in macOS Passwords and Passkeys the User Name is public, but to view the info at all requires authentication. I don't know what is visible when the User Name is blank, but my guess would be Display Name. I'll find out later.

id will also be the same for the given passkey with every use.

Some U2F devices (in the vein of FIDO2 YubiKey) may not support userHandle, but they will support id.

publicKey is only available on registration, and never again

signature is nonced by ES256 and EDDSA, so the same key and payload yield different sigs

getify commented 2 weeks ago

without any additional authentication

What does that mean?

However, id is kept invisible

That may or may not be true from a UI perspective, but it does not match what the webauthn spec says nor how the APIs will behave. The credential ID is not protected or secret (nor are the username or displayname), but the userHandle is. They took extra care not to return the userHandle in cases where UV isn't used, to prevent leakage of that "secret" info. That same protection isn't afforded any of the other fields in question.

So we won't be switching to use any of those other approaches.


All that said, using userHandle is intentionally a bit of a hack while waiting to switch to PRF. That's the more appropriate way to do this, but the support is not quite widespread enough yet.

getify commented 2 weeks ago

From @coolaj86 (originally posted here):

I don't know if this will live and grow here for a long time, but this is my own demo: https://beyondcodebootcamp.github.io/vanilla-webauthn/

I found that it's actually waaaaaaaaay simpler than almost everyone is explaining it, if you know a few critical details about how the system works.

It's definitely made to give Big Tech the advantage and to make it as close to as impossible as can be for Self-Sovereign Identity, but I've found a few loopholes that I think I can exploit for local encryption, and a few shortcuts to really simplify the DX based on how it works in practice rather than the 10,000 permutations that are spec'd, but that I can verify don't work.

Early next week I'll be having more people test on their phones and computers to make sure I've got it all right and that my implementation doesn't conflict with future variations of the standard, but I feel pretty confident that I could explain and demo it simply enough to, for example, make conference slides for it (too bad I didn't run into this months ago to submit to UtahJS).

Right now I believe that the best thing that Credential IDs can be used for is local encryption. As far as I can tell, the UX between "Register Passkey" and "Login with Passkey" is so constrained, that even if you sync the IDs through servers across devices, there's a lot of caveats that maybe we're probably better off just not trying to work around. Instead:

Much of the spec itself, and certainly what Big Tech has chosen to implement seems a lot like some sort of "malicious compliance" - like it's not really about the awesome marketing message about "the users" and "safety", but rather just putting Big Tech more in control. The implementation we have to work with certainly does not build a strong case for a true goal being to make user or developer experience better.

At least on Desktop I feel safe about Brave storing my keys, but apparently Apple won't let Brave sync the keys on Mobile, and Apple also won't share the keys to Brave on Mobile - so I hit the immediate wall of frustration that I can't have a seamless experience - pretty much matches the password manager story on Apple - full Safari or bust, exile the webviews (other browsers) to the sandbox. I imagine that Android is better and worse in that it probably allows more between phones and browsers, but probably doesn't work well with Microsoft accounts. 🤷‍♂️

getify commented 2 weeks ago

There are numerous concerns I uncovered when I initially considered using the credential-ID as the seed for encryption/decryption keys. I ultimately abandoned that design choice, in favor of sticking the seed in the userHandle. I would urge you to consider these before proceeding with your own implementation as you discussed.

In any case, as it relates to Local Data Lock, the design you propose has already been ruled out, so we don't need to continue this line of debate here. I'm only including this information for posterity sake (and your benefit!).


  1. The webauthn spec indicates that authenticators generate the credential ID randomly (good!), but it might be as weak as 100 bits (16 bytes). I don't think that's secure enough. That's even weaker than SHA-1, which was abandoned for cryptographic purposes years ago. The encryption I'm using, and believe is a minimum-acceptable strength, requires 256 bits (32 bytes) of entropy.

    It should be obvious, but I'll state it for clarity: if you base an encryption scheme off of weak entropy, the whole system crumbles when attacked. Brute-force cracking a 100-bit key versus a 256-bit key is an entirely different (lower!) level of effort. I consider this a deal breaker.

  2. The worst problem with using credential ID is that you typically -- at least as it relates to the local-first, zero-server use cases being supported by my libraries -- store this value directly in the client, plain-text.

    Here's the major problem: IF YOU STORE THE ENCRYPTION SEED persistently in the client, alongside the data you are encrypting, you are almost completely defeating the purpose of the encryption. I understand use-cases where you might temporarily store the encryption-key next to the data -- for example, in sessionStorage -- to provide more convenience to users from not needing to re-login in on each page load of a single session.

    But, in the proposed design of using credential ID for the encryption key, you would have to be storing the credential ID persistently, forever, alongside that data. Which in my way of seeing things, means you should just not do any encryption at all. There's no point.

    Why do you need to store the credential ID persistently forever? Because:

    • you need to correlate the original public-key you get back only at registration time to whichever passkey is used in subsequent authentications, so that you can perform a verification of the challenge-signature -- ensuring that an authenticator hasn't been compromised by a MitM. There's no other identifier that can correlate a credential used in auth/login back to its original public-key.

      So if you don't store the credential ID, then you have to abandon doing any verification of auth/login. That's a weaker design, and I considered it a deal breaker.

    • you either need to tell WebAuthn to use a specific credential, by passing in the credential ID, when doing subsequent auths (logins), OR you have to rely on discoverable (aka "resident") credentials -- where the user picks from a list of passkeys on the authenticator, and if they pick the right one, then you're able to "login".

      The problem with resident keys is that not all authenticators support them, which means you'd potentially be more severely limiting which users can use the passkey auth system. If your webapp is "traditional" -- it uses servers, and uses username/password or some other auth -- this limitation is not too big of a deal.

      But for my local-first, zero-server use-cases, this a much more serious constraint. I find it a deal-breaker, in fact.

  3. The spec process for WebAuthn went to great lengths to ensure that the design wasn't a privacy leak to malicious client code.

    One such "hack" they worried about was calling a create() or get() call "silently" -- where you pass in a 0 timeout, or just use a abort-signal that its canceled almost immediately, such that the WebAuthn system might "disclose" some information to the malicious client code even though the system modal prompts were either never shown, or flashed up and then went away so quickly. They were concerned that things like credentialID might leak during such hacks, so they called these issues out in the spec.

    I'm not personally so worried about these leaks, but it is a small factor in my decision, alongside the other points I'm raising here.

    One specific issue I did consider relevant: there was an earlier spec design where the userHandle would be returned on every authentication request, both affirmative user-verified requests AND silent background requests the user doesn't know about at all. But this was raised as a concern, that this userHandle could be leaked, unaware to the user. So they changed the spec design such that userHandle is only returned for user-verified authentications, which strengthens the protection SPECIFICALLY of userHandle.

    None of the other exposed passkey fields -- username, displayName, and credential ID -- have this protection. Therefore, I believed userHandle was the better option.

  4. The credential ID has an over-rigidity to it for the functionality, and UX, of these contemplated local-first, zero-server data protection purposes: you can never create a second passkey that has the same credential ID.

    You might think "of course not!", but there are a few reasons you might indeed want to do so:

    • First, I think some users (and maybe most!?) would prefer to register multiple passkeys for the same protected storage... for example, both thumbs, or a finger and a face. This way, if there's a bandaid on one finger, or their camera is unavailable due to poor lighting, they can still properly authenticate with one of the alternate passkeys and unlock their data.

      If you're restricted to only having one passkey per encryption-key seed (via its unique credential ID), you lose the robustness of having multiple passkeys to let you access your data. I consider that a soft deal-breaker.

    • Second, you might want to let a user update their credential to change either their username or displayName. The API allows you to effectively "re-register" a credential, which replaces it (deletes the old one) with a new passkey with the updated username/displayName. This re-registered passkey credential can have the same userHandle, but it will absolutely not have the same credential ID.

      So you must churn the encryption key just for updating username/displayName. To do so, you have to temporarily hold (in memory) the unencrypted data (from the previous credential ID encryption seed) while a user registers the new passkey (and then you get back a new encryption seed), before you then re-encrypt the data and store it back.

      This is a more brittle design, because the client could crash -- or the user could just accidentally navigate away from the page! -- while data is in this limbo state between its old encryption and its new encryption. This could cause total data loss, or it could cause data to remain encrypted but have a loss of the encryption seed. This all can be done safely, but it's a more risky/vulnerable process and takes more intricate and careful coding. This isn't necessarily a deal-breaker for me, but it's a detraction from the design, coupled with all the other issues I've raised.


So, for all the reasons/concerns above, I decided that using credential ID was inappropriate/insufficient, and I abandoned that idea. The userHandle approach currently being used is not perfect, but... it's much better than credential ID in my opinion. And as I previously mentioned PRF, that's actually the even better way to derive encryption keys from passkeys. Once that's more widely supported, I plan to move Local Data Lock in that direction.

coolaj86 commented 2 weeks ago

Thanks. You've got me convinced on userHandle.

Also, I think it will work with FIDO2 devices. I'm still getting confused with all the specs and the note I saw about U2F on the new devices seems to be about backwards compatibility, not current limitation.

(will read more and possibly respond more tomorrow)

coolaj86 commented 1 week ago

How do you solve this problem:

  1. In order to use excludeCredentials, you must store the userHandle
  2. If you don't use excludeCredentials, the user could accidentally DELETE their encryption key
  3. When the user IS NOT logged in, they could easily get confused between the UI of "Add Passkey" vs "Use Passkey"

even weaker than SHA-1, which was abandoned for cryptographic purposes years ago

Yes, but it's not what you might think:

SHA-1 is vulnerable to birthday attacks from INPUT data (i.e. the keyspace isn't perfectly evenly distributed 128-bit)

So while you might not want to use SHA-1 as a key expansion algorithm to create psuedorandom 128-bits, that's very different from a 128 bit key being able to be broken.

128 bits is just as secure for 256 bits for all real-world purposes:

And for new applications I suggest that people don’t use AES-256. AES-128 provides more than enough security margin for the forseeable future. But if you’re already using AES-256, there’s no reason to change. - https://www.schneier.com/blog/archives/2009/07/another_new_aes.html

if there's a bandaid on one finger

I imagine that Android and Windows implement this the same way that macOS does: The fingers (and sunglasses, etc) are abstracted at the OS level. When you create your Touch ID / Face ID on macOS you can add as many fingers as you want - because the Touch ID is for the USER, not for the key. The key is pairwise to applications and usernames, but it's all the same biometrics regardless.

Furthermore:

they worried about was calling a create() or get() call "silently"

I don't believe that the silent option will ever be implemented by any OS or browser. I don't believe it's been implemented on any to date.

...

In general, I think they tried waaaaay to hard to make WebAuthn a standard for every possible biometric scenario that may ever exist in the universe, but I don't believe most of it will ever be implemented. It's too complicated.

coolaj86 commented 1 week ago

while a user registers the new passkey (and then you get back a new encryption seed), before you then re-encrypt the data and store it back

I think I just answered this concern as well as my own:

macOS & Brave (Chromium)

Screenshot 2024-09-06 at 3 49 36 PM

YubiKey (FIDO2)

Screenshot 2024-09-06 at 3 55 00 PM

Huzzah!

It appears that they're all pairwise between the Authenticator and Relying Party, and not the user.id.

So I can't create a key for two different users under the same OS account or the same Device.

This is actually really great. That means that as long as user.id is NOT THE SAME, neither the credential id nor user id / handle / data will be overwritten by accident.

So it seems like not only can you have high-entropy user.id, it's probably what you SHOULD do because it seems to be the only way to let the user know "hey, you already have an account, and we probably don't want to delete the data associated with it".

It is a little sad that you never get name back though. If you lose that, it's just gone.

Edit: I may have not hit refresh hard enough when testing. Now it seems that now I can create alternate credentials using the same device with different user ids.

coolaj86 commented 1 week ago

Okay, I got confused by the documentation at https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions.

An Array of objects describing existing credentials that are already mapped to this user account (as identified by user.id). This is provided by the relying party, and checked by the user agent to avoid creating a new public key credential on an authenticator that already has a credential mapped to the specified user account. for an existing user who already has some. Each item should be of the form:

id

  • An ArrayBuffer, TypedArray, or DataView representing the existing credential ID.

Regardless of what the spec says, it seems that the actual behavior is that when I pass in an existing PublicKeyCredential.id with a NEW random user.id, it will user user.name to block a duplicate identity from being created. Passing in user.id as documented on MDN doesn't seem to do anything.

If I don't do that then macOS will REPLACE the existing account, but Brave (Chromium, Chrome?) will create a DUPLICATE identity.

getify commented 1 week ago

My experience has been that as long as the user.id is different, it doesn't matter whether the username or display-name are unique or not.

This is why my approach is to generate a new user.id for each passkey... the way I have multiple passkeys map to the same encryption key seed is that the user.id is actually composed of the 32 bytes of the key seed, plus an additional 2 bytes at the end that are a big-endian encoded counter (0-65535). IOW, in my scheme, I can support up to 65,535 passkeys for the same "user" (as defined by their encryption key), on the same authenticator. I figured this was at least a couple of orders of magnitude higher than it could possibly need to be, so it's basically infinite.

getify commented 1 week ago

As for excludeCredentials, I think it's absolutely the case that you pass in credential IDs, not userIDs.

Also, I think that name is terrible, because what that feature is actually doing is excluding an authenticator if that authenticator has a credential matching that ID. It really should be called excludeAuthenticator. See: https://x.com/getifyX/status/1818529224803623306

getify commented 1 week ago

I don't believe that the silent option will ever be implemented by any OS or browser. I don't believe it's been implemented on any to date.

There's actually silent and there's "silent". The latter is technically possibly right now -- you can pass in an already-aborted AbortSignal (or one that's times to abort in 0ms), with the create() or get() wrapped in a try..catch that swallows it... and the way (and timing) that the rejection comes back, can disclose information, for example paired with allowCredentials or excludeCredentials. This very well might actually cause the system dialog to never appear (or on some systems, it's even more subtle, like an icon in the address bar), or to appear and dismiss so quickly that the user doesn't even know what happened.

In general, I think they tried waaaaay to hard to make WebAuthn a standard for every possible biometric scenario that may ever exist in the universe, but I don't believe most of it will ever be implemented. It's too complicated.

Completely agree with that. That's a significant reason why I created WALC.

getify commented 1 week ago

As this has become a valuable store of information for posterity, rather than actionable, moving to a discussion.