
Security News
vlt Launches "reproduce": A New Tool Challenging the Limits of Package Provenance
vlt's new "reproduce" tool verifies npm packages against their source code, outperforming traditional provenance adoption in the JavaScript ecosystem.
@lo-fi/webauthn-local-client
Advanced tools
Browser-only utils for locally managing WebAuthn (passkey) API
WebAuthn-Local-Client is a web (browser) client for locally managing the "Web Authentication" (WebAuthn
) API.
Check out vella.ai/auth for a demo app using this library for local-only authentication with WebAuthn and local encryption.
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 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 applications to handle user login locally on a device, without any server (FIDO2 or otherwise).
Note: This package may be used in combination with a traditional FIDO2 server application architecture, but does not include any specific functionality for that purpose. For server integration with WebAuthn
, you may instead consider alternative libraries, like this one or this one.
npm install @lo-fi/webauthn-local-client
The @lo-fi/webauthn-local-client npm package includes a dist/
directory with all files you need to deploy WebAuthn-Local-Client (and its dependencies) into your application/project.
Note: If you obtain this library via git instead of npm, you'll need to build dist/
manually before deployment.
USING A WEB BUNDLER? (Astro, Vite, Webpack, etc) Use the dist/bundlers/*
files and see Bundler Deployment for instructions.
Otherwise, use the dist/auto/*
files and see Non-Bundler Deployment for instructions.
WebAuthn
Supported?To check if WebAuthn
API and functionality is supported on the device:
import { supportsWebAuthn } from "..";
if (supportsWebAuthn) {
// welcome to the future, without passwords!
}
else {
// sigh, use fallback authentication, like
// icky passwords :(
}
To check if passkey autofill (aka "Conditional Mediation") is supported on the device:
import { supportsConditionalMediation } from "..";
if (supportsConditionalMediation) {
// provide an <input> and UX for user to
// click on, to select their passkey
// credential via autofill
}
else {
// provide UX for user to trigger
// authentication, where the browser will
// provide a modal for the user to select
// their credential
}
To register a new credential in a WebAuthn
-exposed authenticator, use register()
:
import { regDefaults, register } from "..";
// optional:
var regOptions = regDefaults({
// ..options..
});
var regResult = await register(regOptions);
register()
returns a promise that will resolve to an object (regResult
above) if successful. Otherwise, it will be rejected (await
will throw).
To configure the registration options, but include all the defaults for anything not being overridden, use regDefaults(..)
.
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
, 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.
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
instance to cancel the registration request
See regDefaults()
function signature for more options.
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 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
), 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).
This library by default does NOT ask for any attestation information (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, so it's out of scope for this library's intended local-in-browser-only operation.
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 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.
To authenticate (i.e., perform an assertion) with an existing credential via a WebAuthn
-exposed authenticator, use auth()
:
import { authDefaults, auth } from "..";
// optional:
var authOptions = authDefaults({
// ..options..
});
var authResult = await auth(authOptions);
auth()
returns a promise that will resolve to an object (authResult
above) if successful. Otherwise, it will be rejected (await
will throw).
To configure the authentication options, but include all the defaults for anything not being overridden, use authDefaults(..)
.
Typical auth()
configuration options:
allowCredentials
(array): Defaults to an empty array, which allows the user to select any available discoverable credential (aka, "resident key").
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: ... }
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"), if the browser/device supports it (see supportsConditionalMediation
).
Note: If conditional-mediation is supported and mediation: "conditional"
is specified, the promise result of auth()
will remain pending until the user clicks into a suitable <input autocomplete="username webauthn">
element in the page, and then selects their credential from the autofill prompt. Make sure you provide the user such a form element and suitable UX/flow to explain to them what to do. Also, such a request should likely be specified as cancelable (via signal
) in case the user does not want to use autofill.
challenge
(Uint8Array): Defaults to 20 bytes of generated randomness, but can be provided manually if you have another source of suitable information to use for a challenge. The returned result will include a signature (response.signature
) that was generated against this challenge (along with other request info), helping to strengthen the security of the system (i.e., preventing "replay attacks").
signal
(AbortSignal): an AbortController.signal
instance to cancel the authentication request.
For certain UX flows, such as switching from the conditional-mediation to another authentication approach, you will need to cancel (via signal
) a previous call to auth()
before invoking an auth()
call with different options. But calling abort()
causes that pending auth()
to throw an exception. To suppress this exception when resetting, pass the resetAbortReason
value:
import { resetAbortReason, authDefaults, auth } from "..";
var cancelToken = new AbortController();
var authResult = await auth({ /* .. */ , signal: cancelToken.signal });
// elsewhere:
cancelToken.abort(resetAbortReason);
cancelToken = new AbortController();
var newAuthResult = await auth({ /* .. */ , signal: cancelToken.signal });
// ..
See authDefaults()
function signature for more options.
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 be an object that includes request
and response
properties:
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:
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).
To verify an authentication response (from auth()
), use verifyAuthResponse()
:
import { verifyAuthResponse, } from "..";
var publicKey = ... // aka, regResult.response.publicKey
var verified = await verifyAuthResponse(
authResult.response,
publicKey
);
verifyAuthResponse()
returns a promise that resolves to true
if verification was successful. false
indicates everything was well-formed, but the signature verification failed for some other reason. Otherwise, the promise is rejected (await
will throw) if something was malformed/unexpected.
You will need to have preserved regResult.response.publicKey
(and likely regResult.response.credentialID
) from the original register()
call for a credential -- either locally in e.g. LocalStorage
or remotely on a server -- and later restore that to pass in on subsequent authentication and verification attempts; registration and authentication will not typically happen in the same page instance (where regResult
would still be present).
Further, if you used packPublicKeyJSON()
on the original publicKey
value to store/transmit it, you'll need to use unpackPublicKeyJSON()
before passing it to verifyAuthResponse()
:
import { verifyAuthResponse, unpackPublicKeyJSON } from "..";
var packedPublicKey = ... // result from previous packPublicKeyJSON()
var verified = await verifyAuthResponse(
authResult.response,
unpackPublicKeyJSON(packedPublicKey)
);
dist/*
If you need to rebuild the dist/*
files for any reason, run:
# only needed one time
npm install
npm run build:all
Since the library involves non-automatable behaviors (requiring user intervention in browser), an automated unit-test suite is not included. Instead, a simple interactive browser test page is provided.
Visit https://mylofi.github.io/webauthn-local-client/
, and follow instructions in-page from there to perform the interactive tests.
Note: You will either need a device with a built-in authenticator (i.e., Touch-ID, Face-ID, etc), or you can use Chrome DevTools to setup a virtual authenticator, or similar in Safari, or this Firefox add-on. For the virtual authenticator approach, it's recommended you use "ctap2", "internal", "resident keys", "large blob", and "user verification" for the settings. Also, since the tests do not save any generated credentials, you'll likely want to reset the authenticator by removing and re-adding it, before each page load; otherwise, you'll end up with lots of extraneous credentials while testing.
To instead run the tests locally, first make sure you've already run the build, then:
npm test
This will start a static file webserver (no server logic), serving the interactive test page from http://localhost:8080/
; visit this page in your browser to perform tests.
By default, the test/test.js
file imports the code from the src/*
directly. However, to test against the dist/auto/*
files (as included in the npm package), you can modify test/test.js
, updating the /src
in its import
statement to /dist
(see the import-map in test/index.html
for more details).
All code and documentation are (c) 2024 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.
FAQs
Browser-only utils for locally managing WebAuthn (passkey) API
The npm package @lo-fi/webauthn-local-client receives a total of 87 weekly downloads. As such, @lo-fi/webauthn-local-client popularity was classified as not popular.
We found that @lo-fi/webauthn-local-client demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 0 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
vlt's new "reproduce" tool verifies npm packages against their source code, outperforming traditional provenance adoption in the JavaScript ecosystem.
Research
Security News
Socket researchers uncovered a malicious PyPI package exploiting Deezer’s API to enable coordinated music piracy through API abuse and C2 server control.
Research
The Socket Research Team discovered a malicious npm package, '@ton-wallet/create', stealing cryptocurrency wallet keys from developers and users in the TON ecosystem.