A simple way to use crypto keys, protected by webauthn (biometric authentication).
Save an ECC keypair, then access it iff the user authenticates via webauthn.
npm i -S @bicycle-codes/webauthn-keysWe 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.
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<script type="module" src="./webauthn-keys.min.js"></script>Create a new keypair, and protect it 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
})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?
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
})):LockKeyimport { 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):stringimport { 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
})
// => trueexport function encrypt (
data:JSONValue,
lockKey:LockKey,
opts:{
outputFormat:'base64'|'raw';
} = { outputFormat: 'base64' }
// return type depends on the given output format
):string|Uint8Arrayimport { 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|JSONValueimport { 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()npm startRun some automated tests of the cryptography API, not webauthn.
npm testnpm 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.