A simple way to use crypto keys with webauthn (biometric authentication).
Save an ECC keypair, then access it iff the user authenticates via webauthn
.
npm i -S @bicycle-codes/webauthn-keys
We save the iv
of the our keypair, which lets us
re-create the same keypair
on subsequent sessions.
The secret iv
is set in the user.id
property in a
PublicKeyCredentialCreationOptions
object. The browser saves the credential, and will only read it after
successful authentication with the webauthn
API.
Note
We are not using the webcrypto API for creating keys, because we are waiting on ECC support in all browsers.
Note
We only need 1 keypair for both signing and encrypting. Internally, we create 2 keypairs -- one for signing and one for encryption -- but this is hidden from the interface.
Create a new keypair.
import { create } from '@bicycle-codes/webauthn-keys'
const id = await create({ // create a new user
username: 'alice'
})
Save the new user to indexedDB
import { pushLocalIdentity } from '@bicycle-codes/webauthn-keys'
await pushLocalIdentity(id.localID, id.record)
Login with this user
import { auth } from '@bicycle-codes/webauthn-keys'
// ... sometime in the future, login again ...
const localID = buttonElement.dataset.localId
const authResult = await auth(localID!)
This exposes ESM via package.json exports
field.
import {
create,
getKeys,
encrypt,
decrypt,
signData,
verify,
toBase64String,
fromBase64String,
localIdentities,
storeLocalIdentities,
pushLocalIdentity,
} from '@bicycle-codes/webauthn-keys'
// and types
import type {
Identity,
RegistrationResult,
LockKey,
JSONValue,
AuthResponse
} from '@bicycle-codes/webauthn-keys'
This package exposes minified JS files too. Copy them to a location that is accessible to your web server, then link to them in HTML.
cp ./node_modules/@bicycle-codes/package/dist/index.min.js ./public/webauthn-keys.min.js
Link to the file you copied.
<script type="module" src="./webauthn-keys.min.js"></script>
Create a new keypair, and keep it secret with the webatuhn
API.
import { create } from '@bicycle-codes/webauthn-keys'
const id = await create({
username: 'alice', // unique within relying party (this device)
displayName: 'Alice Example', // human-readable name
relyingPartyName: 'Example application' // rp.name. Default is domain name
})
Save the public data of the new ID to indexedDB
:
import { pushLocalIdentity } from '@bicycle-codes/webauthn-keys'
// save to indexedDB
await pushLocalIdentity(id.localID, id.record)
Login again, and get the same keypair in memory. This will prompt for biometric authentication.
import { auth, getKeys } from '@bicycle-codes/webauthn-keys'
const authResult = await auth()
const keys = getKeys(authResult)
- username property
- displayName property
- What's the Difference Between User Name and User Display Name?
Tip
You can use the browser dev tools to setup a virtual authenticator
npm start
Create a new keypair. The relying party ID defaults to the current location.hostname
.
async function create (
lockKey = deriveLockKey(),
opts:Partial<{
username:string
displayName:string
relyingPartyID:string
relyingPartyName:string
}> = {
username: 'local-user',
displayName: 'Local User',
relyingPartyID: document.location.hostname,
relyingPartyName: 'wacg'
}
):Promise<{ localID:string, record:Identity, keys:LockKey }>
import {
create,
pushLocalIdentity
} from '@bicycle-codes/webauthn-keys'
const { record, keys, localID } = await create(undefined, {
username: 'alice',
displayName: 'Alice Example',
relyingPartyID: location.hostname,
relyingPartyName: 'Example application'
})
//
// Save the ID to indexedDB.
// This saves public info only, not keys.
//
await pushLocalIdentity(id.localID, record)
Prompt the user for authentication with webauthn
.
async function auth (
opts:Partial<CredentialRequestOptions> = {}
):Promise<PublicKeyCredential & { response:AuthenticatorAssertionResponse }>
import { auth, getKeys } from '@bicycle-codes/webauthn'
const authResult = await auth()
const keys = getKeys(authResult)
Take the localId
created by the create
call, and save it to indexedDB
.
async function pushLocalIdentity (localId:string, id:Identity):Promise<void>
const id = await create({
username,
relyingPartyName: 'Example application'
})
await pushLocalIdentity(id.localID, id.record)
Authenticate with a saved identity; takes the response from auth()
.
function getKeys (opts:(PublicKeyCredential & {
response:AuthenticatorAssertionResponse
})):LockKey
import { getKeys, auth } from '@bicycle-codes/webauthn-keys'
// authenticate
const authData = await auth()
// get keys from auth response
const keys = getKeys(authData)
Return a base64
encoded string of the given public key.
function stringify (keys:LockKey):string
import { stringify } from '@bicycle-codes/webauthn-keys'
const keyString = stringify(myKeys)
// => 'welOX9O96R6WH0S8cqqwMlPAJ3VwMgAZEnc1wa1MN70='
export async function signData (data:string|Uint8Array, key:LockKey, opts?:{
outputFormat?:'base64'|'raw'
}):Promise<Uint8Array>
import { signData, deriveLockKey } from '@bicycle-codes/webauthn-keys'
// create a new keypair
const key = await deriveLockKey()
const sig = await signData('hello world', key)
// => INZ2A9Lt/zL6Uf6d6D6fNi95xSGYDiUpK3tr/zz5a9iYyG5u...
Check that the given signature is valid with the given data.
export async function verify (
data:string|Uint8Array,
sig:string|Uint8Array,
keys:{ publicKey:Uint8Array|string }
):Promise<boolean>
import { verify } from '@bicycle-codes/webauthn-keys'
const isOk = await verify('hello', 'dxKmG3oTEN2i23N9d...', {
publicKey: '...' // Uint8Array or string
})
// => true
export function encrypt (
data:JSONValue,
lockKey:LockKey,
opts:{
outputFormat:'base64'|'raw';
} = { outputFormat: 'base64' }
// return type depends on the given output format
):string|Uint8Array
import { encrypt } from '@bicycle-codes/webauthn-keys'
const encrypted = encrypt('hello encryption', myKeys)
// => XcxWEwijaHq2u7aui6BBYGjIrjVTkLIS5...
function decrypt (
data:string|Uint8Array,
lockKey:LockKey,
opts:{ outputFormat?:'utf8'|'raw', parseJSON?:boolean } = {
outputFormat: 'utf8',
parseJSON: true
}
):string|Uint8Array|JSONValue
import { decrypt } from '@bicycle-codes/webauthn-keys'
const decrypted = decrypt('XcxWEwijaHq2u7aui6B...', myKeys, {
parseJSON: false
})
// => 'hello encryption'
Load local identities from indexed DB, return a dictionary from user ID to the identity record.
async function localIdentities ():Promise<Record<string, Identity>>
import { localIdentites } from '@bicycle-codes/webauthn-keys'
const ids = await localIdentities()
Run some automated tests of the cryptography API, not webauthn
.
npm test
npm run test:ci
- Passkey vs. WebAuthn: What's the Difference?
- Discoverable credentials deep dive
- Sign in with a passkey through form autofill
- an opinionated, “quick-start” guide to using passkeys
Its primary function is to enable the authenticator to map a set of credentials (passkeys) to a specific user account.
A secondary use of the User Handle (response.userHandle) is to allow authenticators to know when to replace an existing resident key (discoverable credential) with a new one during the registration ceremony.
This is heavily influenced by @lo-fi/local-data-lock and @lo-fi/webauthn-local-client. Thanks @lo-fi organization and @getify for working in open source; this would not have been possible otherwise.