Skip to content

Commit

Permalink
improving docs (including 'excludeCredentials'), adding base64 utils …
Browse files Browse the repository at this point in the history
…to public API, adding 're-register' flow to the test
  • Loading branch information
getify committed Mar 14, 2024
1 parent 592e975 commit f0bce7b
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 61 deletions.
56 changes: 45 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

**WebAuthn-Local-Client** is a web (browser) client for locally managing the ["Web Authentication" (`WebAuthn`) API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).

----

[Demo/Tests](https://mylofi.github.io/webauthn-local-client/)

----

The `WebAuthn` API lets users of web applications avoid the long-troubled use of (often insecure) passwords, and instead present personal biometric factors (Touch-ID, Face-ID, etc) via their device to prove their identity for login/authentication, authorization, etc. Traditionally, this authentication process involves an application interacting with a [FIDO2 Server](https://fidoalliance.org/fido2/) to initiate, verify, and store responses to such `WebAuthn` API interactions.

However, the intended use-case for **WebAuthn-Local-Client** is to allow [Local-First Web](https://localfirstweb.dev/) applications to handle user login locally on a device, without any server (FIDO2 or otherwise).
Expand Down Expand Up @@ -100,37 +106,55 @@ To configure the registration options, but include all the defaults for anything

Typical `register()` configuration options:

* `relyingPartyName` (string): the common name of your application (that a user will recognize), e.g. "Cool Notes App".

**Note:** `relyingPartyID` (string) is also available, defaulting to the *origin hostname* of your web application (e.g., `hostname.tld`); unless you have an specific reason, you should generally leave that as default.

* `user` (object): specifies the user's identity (as it's defined in your application), including up to these 3 sub properties:

- `name` (string): the user's name
- `displayName` (string): a displayable version of the user's name (typically the same as `name`)
- `id` (Uint8Array): any application-defined value (string, integer, etc), but must represented in as a `Uint8Array` byte array

* `relyingPartyName` (string): the common name of your application (that a user will recognize).
- `displayName` (string): a displayable version of the user's name (typically the same as `name`, but can be a shorter abbreviation/nickname if `name` is too long)

- `id` (Uint8Array): any application-defined value (string, integer, etc), but must be represented as a `Uint8Array` byte array.

**Note:** This value can be anything your application needs for its normal operation, but it can never be updated for a specific credential after registration; the user will have to `register()` a new credential if your application ever needs to *change* this value. Also, be careful not to use a value with too many bytes, or some authenticators may reject it. Generally, 30-40 bytes is *safe* (and more than sufficient for most common use-cases), but you likely will not be able to use hundreds or thousands of bytes for this value. This is *not* a secret user-data storage location!

* `excludeCredentials` (array): Defaults to an empty array, which allows subsequent `register()` calls on the same authenticator, with the same `user.id` value, to *overwrite* a credential (regenerate its internal keypair).

This is generally only useful in cases where a credential keypair needs to be reset (such as losing the originally returned public-key). As such, `excludeCredentials` should only be left to its default empty array if there are no known credentials for the user, or the UX has clearly indicated to the user that a *reset* is being performed.

**Note:** `relyingPartyID` (string) is also available, defaulting to the *origin hostname* of your web application (e.g., `hostname.tld`); unless you have an specific reason, you should generally leave it as default.
If you pass a non-empty array (object values, e.g. `{ type: "public-key", id: ... }`, where `id` is the *credential ID*), and the `user.id` passed in matches the internally stored `userID` (aka `userHandle`) of any of those credentials, the `register()` call will throw an exception (asynchronously in the promise).

* `signal` (AbortSignal): an [`AbortController.signal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal) instance to cancel the registration request

See `regDefaults()` function signature for more options.

#### Result

If `register()` completes without an exception, then registration is successful, and the `regResult` object (as above) will include both a `request` and `response` property:
`register()` returns a promise that's fulfilled (success or rejection) once the user completes or cancels a credential (aka "passkey") registration with their device's authenticator.

If `register()` completes successfully, the return value (`regResult` above) will include both a `request` and `response` property:

* The `request` property includes all relevant configurations that were applied to the registration request, and is provided mostly for debugging purposes.

* The `response` property will include the data needed to use (and subsequently identify) the newly registered credential.

The most important parts are `credentialID` (base64 padded encoding string) and `publicKey`, with various pieces of information about the keypair ([COSE ID](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) for the algorithm, the OID of the algorithm in hex-string format, and the `spki` and `raw` representations of the public-key) generated for the credential; this info is used for verifying the signature on subsequent `auth()` requests.

The `publicKey` object includes byte-arrays ([`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)), which are not as conveniently serialized to/from JSON. Two helper methods are provided to make this easy: `packPublicKeyJSON()` (to store/transmit) and `unpackPublicKeyJSON()` (to restore).
The `publicKey` object includes byte-arrays ([`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)), which are *not* as conveniently serialized to/from JSON. Two helper methods are provided to make this easy: `packPublicKeyJSON()` (to store/transmit in base64 string form) and `unpackPublicKeyJSON()` (to restore from base64 string form).

#### Attestation

This library by default does **NOT** ask for any [attestation information](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Attestation_and_Assertion#attestation) (i.e., `attestation: "none"` in `regDefaults()`) from a device authenticator -- for verifying the authenticity of its response via certificate chains -- nor does it perform any such verification on the registration result. Such verification is quite a complex process, best suited for a [FIDO2 Server](https://fidoalliance.org/fido2/), so it's out of scope for this library's intended local-in-browser-only operation.

**Note:** This library by default does not ask for any attestation information -- for verifying the authenticity of the device's authenticator, via certificate chains -- nor does it perform any such verification. Such verification is quite a complex process, best suited for a [FIDO2 Server](https://fidoalliance.org/fido2/), so it's out of scope for this library's intended local-in-browser-only operation. You can however override the configuration to `register(..)` to ask for attestation information, and pass that along (from `response.raw`) to a separate verification process (on server, or in browser) as desired. Typically, though, [web applications *assume*](https://medium.com/webauthnworks/webauthn-fido2-demystifying-attestation-and-mds-efc3b3cb3651) that if a device is compromised in such a way that it's able to bypass/MITM a device authenticator, the app is *not* the appropriate or responsible party to detect or alert an end-user to such.
You can however override the configuration (via `attestation: ".."`) for `register(..)` to ask for attestation information, and pass that along (from `response.raw`) to a separate verification process (on server, or in browser) as desired.

Typically, though, [web applications *assume*](https://medium.com/webauthnworks/webauthn-fido2-demystifying-attestation-and-mds-efc3b3cb3651) that if a device is compromised in such a way that it's able to bypass/MITM a device authenticator, the app is *not* the appropriate or responsible party to detect or alert an end-user to such. Most applications skip verifying attestation certificate chains, unless there's very specific, elevated-risk security reasons they must do so.

### Authenticating with an existing credential

To authenticate with an existing credential via a `WebAuthn`-exposed authenticator, use `auth()`:
To authenticate (i.e., [perform an assertion](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Attestation_and_Assertion#assertion)) with an existing credential via a `WebAuthn`-exposed authenticator, use `auth()`:

```js
import { auth, authDefaults } from "..";
Expand All @@ -153,7 +177,7 @@ Typical `auth()` configuration options:

**Note:** If you use the "discoverable credential" approach, and don't preserve the `credentialID` and `publicKey` from an initial `register()` call, you won't be able to verify any authorization responses (`verifyAuthResponse()`), since that requires the public key (only returned from `register()`).

If you pass a non-empty array (object values, e.g. `{ type: "public-key", id: ... }`), the browser will present a narrowed list of credentials for the user to select from.
If you pass a non-empty array (object values, e.g. `{ type: "public-key", id: ... }` where `id` is the *credential ID*), the browser will present a narrowed list of credentials for the user to select from.

* `mediation` (string): Defaults to `"optional"`, but can also be set to `"conditional"` to trigger [passkey autofill (aka "Conditional Mediation")](https://web.dev/articles/passkey-form-autofill), if the browser/device supports it (see `supportsConditionalMediation`).

Expand Down Expand Up @@ -184,13 +208,23 @@ See `authDefaults()` function signature for more options.

#### Result

If `auth()` completes without an exception, then authentication is successful, and the `authResult` object (as above) will include both a `request` and `response` property:
`auth()` returns a promise that's fulfilled (success or rejection) once the user completes or cancels a credential (aka "passkey") authentication with their device's authenticator.

If `auth()` completes completes successfully, the return value (`authResult` above) will include both a `request` and `response` property:

* The `request` property includes all relevant configurations that were applied to the authentication request, and is provided mostly for debugging purposes.

* The `response` property will include information about the credential used, as well as a signature to verify the authentication response.

The most important parts of `response` are the `userID` (as passed in the `user.id` configuration to the originating `register()` call), as well as `signature`, which is used (via `verifyAuthResponse(..)`, along with the public key from the original `register()` call for that credential) to verify the signature against the `request.challenge` (and other request settings).
The most important parts of `response` are:

- `credentialID`: will match the `credentialID` from the originating `register()` call

- `userID`: will match the `user.id` configuration from the originating `register()` call

**Note:** For security reasons, authenticators only return this value when the type of authentication performed was interactive (user was present and affirmatively presented their passkey). The default `userVerification` configuration value (in `authDefaults()`) is `"required"`, which ensures the authentication will satisfy that requirement and thus return `userID`. Moreover, two additional `response` properties (`userPresence`, `userVerification`) will be `true` if those conditions were indeed met.

- `signature`: used via `verifyAuthResponse(..)` -- along with the public key from the original `register()` call for that credential -- to verify the signature against the `request.challenge` (and other request settings/info).

### Verifying an authentication response

Expand Down
37 changes: 21 additions & 16 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export {
verifyAuthResponse,
packPublicKeyJSON,
unpackPublicKeyJSON,
toBase64String,
fromBase64String,
};
var publicAPI = {
supportsWebAuthn,
Expand All @@ -118,6 +120,8 @@ var publicAPI = {
verifyAuthResponse,
packPublicKeyJSON,
unpackPublicKeyJSON,
toBase64String,
fromBase64String,
};
export default publicAPI;

Expand Down Expand Up @@ -176,9 +180,8 @@ async function register(regOptions = regDefaults()) {
credentialType: regResult.type,
...regOptions[regOptions[credentialTypeKey]],

challenge: sodium.to_base64(
regOptions[regOptions[credentialTypeKey]].challenge,
sodium.base64_variants.ORIGINAL
challenge: toBase64String(
regOptions[regOptions[credentialTypeKey]].challenge
),
...(Object.fromEntries(
Object.entries(regClientData).filter(([ key, val ]) => (
Expand All @@ -187,10 +190,7 @@ async function register(regOptions = regDefaults()) {
)),
},
response: {
credentialID: sodium.to_base64(
new Uint8Array(regResult.rawId),
sodium.base64_variants.ORIGINAL
),
credentialID: toBase64String(new Uint8Array(regResult.rawId)),
credentialType: regResult.type,
authenticatorAttachment: regResult.authenticatorAttachment,
publicKey: {
Expand Down Expand Up @@ -341,10 +341,7 @@ async function auth(authOptions = authDefaults()) {
)),
},
response: {
credentialID: sodium.to_base64(
new Uint8Array(authResult.rawId),
sodium.base64_variants.ORIGINAL
),
credentialID: toBase64String(new Uint8Array(authResult.rawId)),
signature: signatureRaw,
...(Object.fromEntries(
Object.entries(authData).filter(([ key, val ]) => (
Expand Down Expand Up @@ -629,12 +626,12 @@ function packPublicKeyJSON(publicKeyEntry,stringify = false) {
...publicKeyEntry,
spki: (
typeof publicKeyEntry.spki != "string" ?
sodium.to_base64(publicKeyEntry.spki,sodium.base64_variants.ORIGINAL) :
toBase64String(publicKeyEntry.spki) :
publicKeyEntry.spki
),
raw: (
typeof publicKeyEntry.raw != "string" ?
sodium.to_base64(publicKeyEntry.raw,sodium.base64_variants.ORIGINAL) :
toBase64String(publicKeyEntry.raw) :
publicKeyEntry.raw
),
};
Expand All @@ -649,12 +646,12 @@ function unpackPublicKeyJSON(publicKeyEntryJSON) {
...publicKeyEntry,
spki: (
typeof publicKeyEntry.spki == "string" ?
sodium.from_base64(publicKeyEntry.spki,sodium.base64_variants.ORIGINAL) :
fromBase64String(publicKeyEntry.spki) :
publicKeyEntry.spki
),
raw: (
typeof publicKeyEntry.raw == "string" ?
sodium.from_base64(publicKeyEntry.raw,sodium.base64_variants.ORIGINAL) :
fromBase64String(publicKeyEntry.raw) :
publicKeyEntry.raw
),
};
Expand All @@ -666,9 +663,17 @@ function normalizeCredentialsList(credList) {
...entry,
id: (
typeof entry.id == "string" ?
sodium.from_base64(entry.id,sodium.base64_variants.ORIGINAL) :
fromBase64String(entry.id) :
entry.id
),
}));
}
}

function toBase64String(val) {
return sodium.to_base64(val,sodium.base64_variants.ORIGINAL);
}

function fromBase64String(val) {
return sodium.from_base64(val,sodium.base64_variants.ORIGINAL);
}
6 changes: 4 additions & 2 deletions test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebAuthn Local Client: Tests</title>
<style>
.modal-btn { display: inline-block; color: white; background-color: black; border: 0; border-radius: 0.25em; font-size: 1em; }
.modal-btn { display: inline-block; color: white; background-color: black; border: 0; border-radius: 0.25em; font-size: 1rem; }
.toast-popup .swal2-html-container { white-space: nowrap; }
</style>
</head>
Expand All @@ -14,6 +14,8 @@
<main>
<h1>WebAuthn Local Client: Tests</h1>

<h2><a href="https://github.com/mylofi/webauthn-local-client">Github</a></h2>

<p>
Before running these tests, make sure you're on a device that supports <a href="https://webauthn.io/" target="_blank">Web Authentication</a>, or alternatively use browser DevTools to <a href="https://developer.chrome.com/docs/devtools/webauthn" target="_blank">setup a virtual authenticator in Chrome</a>, or similar in Safari, or <a href="https://addons.mozilla.org/en-US/firefox/addon/webdevauthn/" target="_blank">this Firefox add-on</a>.
</p>
Expand All @@ -32,7 +34,7 @@ <h3>Steps To Run All 4 Authentication Tests:</h3>
<li>Click "choose authentication" yet again, then "Provide my user ID" again. Click into the input box, and paste in the User ID you copied to the clipboard in step (1). Click "Authenticate". Notice one last time, the count goes up on success.</li>
</ol>
<p>
<button type="button" id="register-btn">register new credential</button>
<button type="button" id="register-btn">register new credential</button> (or <button type="button" id="reregister-btn">re-register credential</button>)
</p>
<p>
<button type="button" id="auth-btn">choose authentication method</button>
Expand Down
Loading

0 comments on commit f0bce7b

Please sign in to comment.