-
Notifications
You must be signed in to change notification settings - Fork 37
Passkeys (experimental) #4234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Passkeys (experimental) #4234
Conversation
When I'm running the frontend tests locally, they're only rendering error pages and I don't know why. Would appreciate some pointers as to what I'm missing. Either way, in the meanwhile @sandhose could we request for the designs? |
@tonkku107, I only grokked the code; but it appears you plan to be storing a 16-byte identifier for the challenges. I'm not aware of the memory requirements, but Footnotes
|
Some quick comments pertaining to
Longer comment: Origin validation is "complex" in that it's not particularly possible to support all the possible cases "out of the box". To fully empower downstream code,
A few examples that will fail origin validation when
Examples that will succeed:
In summary if this is too relaxed, too strict, or both for your needs; then you have no choice but to define your own type that parses |
Storing in memory won't be an option since multiple instances can be run at once.
I've added an Ulid to be consistent with the rest of the stuff in this project. There's actually only 10 bytes of entropy within the ID as Ulids reserve 6 bytes for a timestamp.
Yeah I know and I wasn't a big fan of that and ultimately moved it out to be more explicit about the weird type stuff going on.
I used JSON or strings wherever I could, but if database efficiency is a concern to the maintainers (which I doubt given my 60GB synapse DB full of JSON strings), that could be changed.
Can you even get any of those examples out of a browser? 😄 |
The code is probably OK. My comments were isolated to Since we are here, there is one more point I do think is important to make and that is related to the CBOR-parsing that happens internally. I strictly adhere to CTAP2 canonical CBOR encoding form. For the most part this is fine and is technically required by the spec; however there have been real-world cases where implementations violate it. For example, there was a time Firefox was not correctly encoding the I think most browsers correctly do it now though, so I haven't experienced issues in my tests. It's something you may want to be aware of though. Perhaps the bigger problem though is the CBOR-decoding that is done in |
|
I use
Did not notice that as I was looking for the serialization impls. The metadata is quite useless anyway. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pub struct UserPasskey { | ||
pub id: Ulid, | ||
pub user_id: Ulid, | ||
pub credential_id: String, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is supposed to be a 'buffersource', so raw bytes? Shouldn't that be a Vec<u8>
/ BYTEA
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whichever works, the webauthn api provides both a base64 string and an ArrayBuffer (though both get sent as base64 thanks to toJSON). credential_id needs to be queried so a string format probably works better and that reminds me that it probably needs an index as well...
pub user_id: Ulid, | ||
pub credential_id: String, | ||
pub name: String, | ||
pub transports: serde_json::Value, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I would rather have that the exact type, even if that means making mas-data-model depend on webauthn-rp? This way, potential errors comes at the repository level, and there is no need for post-processing those later
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Learned this the hard way: foreign keys don't create indexes!
So for each FK, please also do an explicit index, e.g.
CREATE INDEX user_passkeys_user_id_fk ON user_passkeys (user_id);
It's fine to create indexes non-concurrently on tables you've just created, but for FKs you're creating on existing tables, you need to create those with CREATE INDEX CONCURRENTLY
to avoid locking the table during migration.
The other caveat, is that CREATE INDEX CONCURRENTLY
must run outside of a transaction, and by default, migrations are run in transactions. You can opt-out of this by having -- no-transaction
at the top of the file… which also means you can have a single statement in the migration.
See #4385 for example migrations for FK indexes on existing tables
@@ -88,6 +88,7 @@ rand.workspace = true | |||
rand_chacha.workspace = true | |||
headers.workspace = true | |||
ulid.workspace = true | |||
webauthn_rp = { version = "0.3.0", features = ["bin", "serde_relaxed", "custom", "serializable_server_state"] } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally can you put new dependencies at the workspace level?
let display_name = matrix_user | ||
.displayname | ||
.unwrap_or_else(|| user.username.clone()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should either default to None if there is no displayname, or to the MXID instead. We already use the username as the user entity name
) -> Result<UserPasskey> { | ||
let server_state = RegistrationServerState::decode(&user_passkey_challenge.state)?; | ||
|
||
let response = serde_json::from_str::<RegistrationRelaxed>(&response)?.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let response = serde_json::from_str::<RegistrationRelaxed>(&response)?.0; | |
let RegistrationRelaxed(response) = serde_json::from_str(&response).context("Invalid PublicKeyCredential sent by client")?; |
repo: &mut impl RepositoryAccess, | ||
response: String, | ||
) -> Result<(DiscoverableAuthentication16, User, UserPasskey)> { | ||
let response = serde_json::from_str::<DiscoverableAuthenticationRelaxed16>(&response)?.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let response = serde_json::from_str::<DiscoverableAuthenticationRelaxed16>(&response)?.0; | |
let DiscoverableAuthenticationRelaxed16(response) = serde_json::from_str(&response)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally this shouldn't be its own page for the form, but on the login page.
The "sign in with passkey" should trigger the challenge directly; which also means we can unconditionally create a challenge on the login page, even if this means we get a bunch of "ghost" challenges.
This will allow us to do conditional mediation on the username form on the login page
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could render a React component as well. This can feel overkill, but I'd rather do this than manual DOM manipulation
Something like:
- in the template, put in a root, which will not render anything if JS is disabled (which is fine)
<div id="passkey-root"></div>
- then render a react component in it, like
createRoot(document.getElementById("passkey-root" as HTMLElement).render( <Suspense fallback="Loading…"> <I18nextProvider i18n={i18n}> <WebAuthnButton options={options} /> </I18nextProvider> </Suspense> )
or something like that. Vite should properly code-split and this shouldn't result into a giant JS payload for this page
userHandle?: Base64URLString; | ||
}; | ||
|
||
if (!window.PublicKeyCredential.parseCreationOptionsFromJSON) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this polyfill exists to do that: https://github.com/MasterKale/webauthn-polyfills
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did use that at first, but it turned out that it didn't follow the spec and was missing fields.
Adds support for passkeys behind an experimental config option.
Includes:
Does not include (will probably have to be separate PRs later):
Can be reviewed commit per commit.
Frontend is hacked together from existing components pending designs.