mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: webauthn prf passkey unlock engine as alternative wrap of the linking key
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// The pure crypto core of passkey unlock (passkeys.ts): HKDF-SHA256 from a
|
||||
// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of the
|
||||
// linking key under it. No WebAuthn, no storage - a PRF output is just 32
|
||||
// bytes, so everything here is unit-testable in plain Node.
|
||||
//
|
||||
// A per-slot random HKDF salt separates the wrap keys of different passkeys
|
||||
// even though the ceremony-side PRF salt is fixed (see passkeys.ts); the
|
||||
// info string domain-separates these keys from any other key ever derived
|
||||
// from the same PRF output.
|
||||
|
||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||
|
||||
import type {PasskeyWrap} from './storage/passkeySlots'
|
||||
|
||||
const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1'
|
||||
|
||||
export const derivePasskeyWrapKey = async (
|
||||
prfOutput: Uint8Array,
|
||||
hkdfSalt: Uint8Array
|
||||
): Promise<CryptoKey> => {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new Uint8Array(prfOutput),
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
return crypto.subtle.deriveKey(
|
||||
// the copies pin the TS type to Uint8Array<ArrayBuffer> - hexToBytes
|
||||
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: new Uint8Array(hkdfSalt),
|
||||
info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO))
|
||||
},
|
||||
baseKey,
|
||||
{name: 'AES-GCM', length: 256},
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
export const wrapLinkingKeyWithPrf = async (
|
||||
prfOutput: Uint8Array,
|
||||
linkingKey: Uint8Array
|
||||
): Promise<PasskeyWrap> => {
|
||||
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt)
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{name: 'AES-GCM', iv},
|
||||
wrapKey,
|
||||
new Uint8Array(linkingKey)
|
||||
)
|
||||
)
|
||||
return {
|
||||
hkdfSalt: bytesToHex(hkdfSalt),
|
||||
iv: bytesToHex(iv),
|
||||
wrappedKey: bytesToHex(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
// rejects (WebCrypto's own auth-tag check) if the PRF output is wrong -
|
||||
// i.e. a different passkey than the one that created the slot
|
||||
export const unwrapLinkingKeyWithPrf = async (
|
||||
prfOutput: Uint8Array,
|
||||
wrap: PasskeyWrap
|
||||
): Promise<Uint8Array> => {
|
||||
const wrapKey = await derivePasskeyWrapKey(
|
||||
prfOutput,
|
||||
hexToBytes(wrap.hkdfSalt)
|
||||
)
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))},
|
||||
wrapKey,
|
||||
new Uint8Array(hexToBytes(wrap.wrappedKey))
|
||||
)
|
||||
return new Uint8Array(plaintext)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// Passkey engine tests. The WebAuthn ceremony is faked by an injected
|
||||
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
|
||||
// the real extension's exact contract: deterministic per credential+salt,
|
||||
// unguessable without the authenticator. Everything except a real
|
||||
// authenticator's touch is covered here.
|
||||
|
||||
import {beforeEach, describe, expect, it} from 'vitest'
|
||||
import {hmac} from '@noble/hashes/hmac.js'
|
||||
import {sha256} from '@noble/hashes/sha2.js'
|
||||
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||
|
||||
import type {CeremonyCredential, PasskeyCredentials} from './passkeys'
|
||||
import {
|
||||
derivePasskeyWrapKey,
|
||||
getPasskeyPrfOutput,
|
||||
hasPasskeySlots,
|
||||
passkeySupported,
|
||||
readPasskeySlots,
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapLinkingKeyWithPrf,
|
||||
wrapLinkingKeyWithPrf
|
||||
} from './passkeys'
|
||||
import {
|
||||
decryptRecord,
|
||||
decryptSavedLinkingKey,
|
||||
deriveBearerAesKey,
|
||||
encryptRecord,
|
||||
saveLinkingKey
|
||||
} from './keys'
|
||||
import {stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
||||
const PRF_OUTPUT = new Uint8Array(32).fill(3)
|
||||
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
|
||||
|
||||
const toBytes = (source: BufferSource): Uint8Array =>
|
||||
source instanceof ArrayBuffer
|
||||
? new Uint8Array(source)
|
||||
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
|
||||
|
||||
// Fake platform authenticator: holds credentials (id -> secret), evaluates
|
||||
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
|
||||
// found in the wild: PRF unsupported, results only on get, results never.
|
||||
class FakeAuthenticator implements PasskeyCredentials {
|
||||
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
|
||||
private held = new Map<
|
||||
string,
|
||||
{id: Uint8Array<ArrayBuffer>; secret: Uint8Array}
|
||||
>()
|
||||
supportsPrf = true
|
||||
prfResultsOnCreate = true
|
||||
prfResultsOnGet = true
|
||||
createCalls = 0
|
||||
getCalls = 0
|
||||
|
||||
create = async (
|
||||
options?: CredentialCreationOptions
|
||||
): Promise<CeremonyCredential | null> => {
|
||||
this.createCalls += 1
|
||||
const salt = options?.publicKey?.extensions?.prf?.eval?.first
|
||||
const id = crypto.getRandomValues(new Uint8Array(16))
|
||||
const secret = crypto.getRandomValues(new Uint8Array(32))
|
||||
this.held.set(bytesToHex(id), {id, secret})
|
||||
return {
|
||||
type: 'public-key',
|
||||
rawId: id,
|
||||
getClientExtensionResults: () => ({
|
||||
prf:
|
||||
this.supportsPrf && salt
|
||||
? {
|
||||
enabled: true,
|
||||
...(this.prfResultsOnCreate
|
||||
? {results: {first: this.prf(secret, salt)}}
|
||||
: {})
|
||||
}
|
||||
: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// answers with the first allowed credential it holds, like a real
|
||||
// authenticator picking among allowCredentials; null when it holds none
|
||||
get = async (
|
||||
options?: CredentialRequestOptions
|
||||
): Promise<CeremonyCredential | null> => {
|
||||
this.getCalls += 1
|
||||
const pk = options?.publicKey
|
||||
const allowed = (pk?.allowCredentials ?? []).map(d =>
|
||||
bytesToHex(toBytes(d.id))
|
||||
)
|
||||
const match = allowed.find(hex => this.held.has(hex))
|
||||
const held = match ? this.held.get(match) : undefined
|
||||
if (!held) return null
|
||||
const salt = pk?.extensions?.prf?.eval?.first
|
||||
return {
|
||||
type: 'public-key',
|
||||
rawId: held.id,
|
||||
getClientExtensionResults: () => ({
|
||||
prf:
|
||||
salt && this.prfResultsOnGet
|
||||
? {enabled: true, results: {first: this.prf(held.secret, salt)}}
|
||||
: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// simulates the passkey's secret changing underneath a slot (credential
|
||||
// re-created on the authenticator while the slot stayed behind)
|
||||
rotateSecret = (credentialId: string): void => {
|
||||
const held = this.held.get(credentialId)
|
||||
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32))
|
||||
}
|
||||
|
||||
private prf = (
|
||||
secret: Uint8Array,
|
||||
salt: BufferSource
|
||||
): Uint8Array<ArrayBuffer> => {
|
||||
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
|
||||
// which BufferSource rejects
|
||||
const out = new Uint8Array(32)
|
||||
out.set(hmac(sha256, secret, toBytes(salt)))
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubLocalStorage()
|
||||
})
|
||||
|
||||
describe('pure wrap crypto', () => {
|
||||
it('round-trips a linking key through a PRF-derived wrap', async () => {
|
||||
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
|
||||
const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap)
|
||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
||||
})
|
||||
|
||||
it('rejects unwrap with a different PRF output', async () => {
|
||||
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
|
||||
await expect(
|
||||
unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unwrap with a tampered HKDF salt', async () => {
|
||||
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
|
||||
await expect(
|
||||
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unwrap with a tampered ciphertext', async () => {
|
||||
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
|
||||
const flipped = `${wrap.wrappedKey.slice(0, -2)}${
|
||||
wrap.wrappedKey.endsWith('00') ? '01' : '00'
|
||||
}`
|
||||
await expect(
|
||||
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('derives wrap keys deterministically from the same PRF output and salt', async () => {
|
||||
const salt = new Uint8Array(16).fill(1)
|
||||
const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt)
|
||||
const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt)
|
||||
const record = await encryptRecord(a, {v: 1})
|
||||
await expect(decryptRecord(b, record)).resolves.toEqual({v: 1})
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration and unlock', () => {
|
||||
it('registers a passkey and unlocks the same linking key', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {
|
||||
credentials: auth,
|
||||
name: 'laptop'
|
||||
})
|
||||
expect(slot.name).toBe('laptop')
|
||||
expect(readPasskeySlots()).toEqual([slot])
|
||||
expect(hasPasskeySlots()).toBe(true)
|
||||
|
||||
const unwrapped = await unlockWithPasskey({credentials: auth})
|
||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
||||
})
|
||||
|
||||
it('never stores the linking key in the clear', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const raw = localStorage.getItem('sattle_passkey_slots')
|
||||
expect(raw).toBeTruthy()
|
||||
expect(raw).not.toContain(bytesToHex(LINKING_KEY))
|
||||
})
|
||||
|
||||
it('yields the same key material unlock(password) yields', async () => {
|
||||
const linkingKey = crypto.getRandomValues(new Uint8Array(32))
|
||||
await saveLinkingKey(linkingKey, 'correct horse')
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(linkingKey, {credentials: auth})
|
||||
|
||||
const viaPassword = await decryptSavedLinkingKey('correct horse')
|
||||
const viaPasskey = await unlockWithPasskey({credentials: auth})
|
||||
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword))
|
||||
|
||||
// and the practical consequence: a bearer record encrypted after a
|
||||
// password unlock decrypts after a passkey unlock
|
||||
const passwordAes = await deriveBearerAesKey(viaPassword)
|
||||
const record = await encryptRecord(passwordAes, {note: 'still readable'})
|
||||
const passkeyAes = await deriveBearerAesKey(viaPasskey)
|
||||
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
|
||||
note: 'still readable'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a get ceremony when create only reports prf.enabled', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
auth.prfResultsOnCreate = false
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
expect(auth.getCalls).toBe(1)
|
||||
const unwrapped = await unlockWithPasskey({credentials: auth})
|
||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId)
|
||||
})
|
||||
|
||||
it('refuses registration when the authenticator has no PRF support', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
auth.supportsPrf = false
|
||||
await expect(
|
||||
registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
).rejects.toThrow('PRF')
|
||||
expect(hasPasskeySlots()).toBe(false)
|
||||
})
|
||||
|
||||
it('throws on a cancelled registration ceremony', async () => {
|
||||
const cancelled: PasskeyCredentials = {
|
||||
create: async () => null,
|
||||
get: async () => null
|
||||
}
|
||||
await expect(
|
||||
registerPasskey(LINKING_KEY, {credentials: cancelled})
|
||||
).rejects.toThrow('cancelled')
|
||||
expect(hasPasskeySlots()).toBe(false)
|
||||
})
|
||||
|
||||
it('throws before any ceremony when no passkeys are registered', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow(
|
||||
'No passkeys'
|
||||
)
|
||||
expect(auth.getCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects unlock when the passkey returns no PRF secret', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
auth.prfResultsOnGet = false
|
||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow(
|
||||
'PRF secret'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unlock when the ceremony yields an unregistered credential', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const rogue: PasskeyCredentials = {
|
||||
create: async () => null,
|
||||
get: async () => ({
|
||||
type: 'public-key',
|
||||
rawId: crypto.getRandomValues(new Uint8Array(16)),
|
||||
getClientExtensionResults: () => ({
|
||||
prf: {enabled: true, results: {first: new Uint8Array(32)}}
|
||||
})
|
||||
})
|
||||
}
|
||||
await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow(
|
||||
'not registered'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unlock after the authenticator secret changed underneath the slot', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
auth.rotateSecret(slot.credentialId)
|
||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple passkeys', () => {
|
||||
it('keeps slots independent: each passkey unlocks the same key', async () => {
|
||||
const laptop = new FakeAuthenticator()
|
||||
const phone = new FakeAuthenticator()
|
||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
||||
credentials: laptop,
|
||||
name: 'laptop'
|
||||
})
|
||||
const phoneSlot = await registerPasskey(LINKING_KEY, {
|
||||
credentials: phone,
|
||||
name: 'phone'
|
||||
})
|
||||
expect(readPasskeySlots()).toHaveLength(2)
|
||||
// independent wrap keys: same plaintext, different salts and ciphertexts
|
||||
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
|
||||
expect(laptopSlot.wrappedKey).not.toBe(phoneSlot.wrappedKey)
|
||||
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(
|
||||
bytesToHex(LINKING_KEY)
|
||||
)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(
|
||||
bytesToHex(LINKING_KEY)
|
||||
)
|
||||
})
|
||||
|
||||
it('removePasskey drops exactly one slot and leaves the rest working', async () => {
|
||||
const laptop = new FakeAuthenticator()
|
||||
const phone = new FakeAuthenticator()
|
||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
||||
credentials: laptop
|
||||
})
|
||||
await registerPasskey(LINKING_KEY, {credentials: phone})
|
||||
|
||||
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true)
|
||||
expect(readPasskeySlots()).toHaveLength(1)
|
||||
|
||||
// the removed passkey no longer matches any offered credential
|
||||
await expect(unlockWithPasskey({credentials: laptop})).rejects.toThrow(
|
||||
'cancelled'
|
||||
)
|
||||
// the survivor is unaffected
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(
|
||||
bytesToHex(LINKING_KEY)
|
||||
)
|
||||
// removing again is a no-op
|
||||
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewrap on linking-key rotation', () => {
|
||||
it('re-wraps every slot onto the new key, all-or-nothing', async () => {
|
||||
const laptop = new FakeAuthenticator()
|
||||
const phone = new FakeAuthenticator()
|
||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
||||
credentials: laptop
|
||||
})
|
||||
const phoneSlot = await registerPasskey(LINKING_KEY, {
|
||||
credentials: phone
|
||||
})
|
||||
|
||||
// partial coverage aborts before writing: both slots still unwrap the
|
||||
// OLD key afterwards
|
||||
const partial = new Map([
|
||||
[
|
||||
laptopSlot.credentialId,
|
||||
await getPasskeyPrfOutput(laptopSlot.credentialId, {
|
||||
credentials: laptop
|
||||
})
|
||||
]
|
||||
])
|
||||
await expect(rewrapAllSlots(OTHER_LINKING_KEY, partial)).rejects.toThrow(
|
||||
'partial re-wrap'
|
||||
)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(
|
||||
bytesToHex(LINKING_KEY)
|
||||
)
|
||||
|
||||
// full coverage: both slots now unwrap the NEW key
|
||||
const fresh = new Map([
|
||||
[
|
||||
laptopSlot.credentialId,
|
||||
await getPasskeyPrfOutput(laptopSlot.credentialId, {
|
||||
credentials: laptop
|
||||
})
|
||||
],
|
||||
[
|
||||
phoneSlot.credentialId,
|
||||
await getPasskeyPrfOutput(phoneSlot.credentialId, {
|
||||
credentials: phone
|
||||
})
|
||||
]
|
||||
])
|
||||
await rewrapAllSlots(OTHER_LINKING_KEY, fresh)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(
|
||||
bytesToHex(OTHER_LINKING_KEY)
|
||||
)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(
|
||||
bytesToHex(OTHER_LINKING_KEY)
|
||||
)
|
||||
// credential ids and labels survive the re-wrap
|
||||
expect(readPasskeySlots().map(s => s.credentialId).sort()).toEqual(
|
||||
[laptopSlot.credentialId, phoneSlot.credentialId].sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot storage hygiene', () => {
|
||||
it('drops malformed entries instead of throwing', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const stored: unknown[] = JSON.parse(
|
||||
localStorage.getItem('sattle_passkey_slots') ?? '[]'
|
||||
) as unknown[]
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
JSON.stringify([...stored, {credentialId: 'zz', hkdfSalt: 1}, 'garbage', null])
|
||||
)
|
||||
expect(readPasskeySlots()).toEqual([slot])
|
||||
})
|
||||
|
||||
it('treats unparseable storage as empty', () => {
|
||||
localStorage.setItem('sattle_passkey_slots', '{not json')
|
||||
expect(readPasskeySlots()).toEqual([])
|
||||
expect(hasPasskeySlots()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('passkeySupported', () => {
|
||||
it('is false without a PublicKeyCredential probe', async () => {
|
||||
// node test env has no PublicKeyCredential global: the default lookup
|
||||
// finds nothing
|
||||
await expect(passkeySupported()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is false without a user-verifying platform authenticator', async () => {
|
||||
await expect(
|
||||
passkeySupported({
|
||||
isUserVerifyingPlatformAuthenticatorAvailable: async () => false,
|
||||
getClientCapabilities: async () => ({'extension:prf': true})
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('checks extension:prf when client capabilities are available', async () => {
|
||||
const platform = {
|
||||
isUserVerifyingPlatformAuthenticatorAvailable: async () => true
|
||||
}
|
||||
await expect(
|
||||
passkeySupported({
|
||||
...platform,
|
||||
getClientCapabilities: async () => ({'extension:prf': true})
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
passkeySupported({
|
||||
...platform,
|
||||
getClientCapabilities: async () => ({'extension:prf': false})
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is optimistic when capabilities cannot be pre-detected', async () => {
|
||||
await expect(
|
||||
passkeySupported({
|
||||
isUserVerifyingPlatformAuthenticatorAvailable: async () => true
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
// Passkey (WebAuthn PRF) unlock: an ALTERNATIVE wrap of the same linking
|
||||
// key the password path protects (keys.ts) - never a second key, so notes
|
||||
// encrypted under a password unlock stay readable after a passkey unlock
|
||||
// and vice versa (both yield the identical linking key, from which the
|
||||
// bearer AES key derives).
|
||||
//
|
||||
// No master-key indirection is introduced: unlike Bitwarden, this wallet
|
||||
// persists exactly one secret - the seed-derived linking key - and the
|
||||
// bearer-encryption key is derived from it (not wrapped by it), so a random
|
||||
// master key would only ever encrypt that one 32-byte value while forcing a
|
||||
// migration of every existing store. The linking key IS the "master key"
|
||||
// here: the password wrap (keys.ts) and each passkey slot below are
|
||||
// independent wraps of the same key material.
|
||||
//
|
||||
// The module is split into a pure-crypto core (passkeyWrap.ts: HKDF from a
|
||||
// PRF output to an AES-GCM wrap key, slot wrap/unwrap - fully unit-tested)
|
||||
// and a thin WebAuthn glue layer whose credentials container is injected,
|
||||
// so tests drive the ceremonies with a fake authenticator. Slot records
|
||||
// live in storage/passkeySlots.ts.
|
||||
//
|
||||
// PRF salt strategy: one FIXED 32-byte salt for every slot. A get()
|
||||
// ceremony can evaluate only one prf.eval input for whichever credential
|
||||
// the authenticator ends up using, and per-credential evalByCredential is
|
||||
// not widely implemented - a shared salt keeps multi-passkey unlock a
|
||||
// single ceremony. The salt is not a secret: the PRF output is HMAC over
|
||||
// the authenticator's per-credential secret, so each passkey still yields
|
||||
// an independent, unguessable wrap secret. A per-slot random HKDF salt then
|
||||
// separates the actual wrap keys.
|
||||
|
||||
import {sha256} from '@noble/hashes/sha2.js'
|
||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||
|
||||
import {withStorageLock} from './storageLock'
|
||||
import type {PasskeySlot} from './storage/passkeySlots'
|
||||
import {
|
||||
PASSKEY_SLOTS_STORAGE_KEY,
|
||||
readPasskeySlots,
|
||||
writePasskeySlots
|
||||
} from './storage/passkeySlots'
|
||||
import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap'
|
||||
|
||||
export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots'
|
||||
export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots'
|
||||
export {
|
||||
derivePasskeyWrapKey,
|
||||
wrapLinkingKeyWithPrf,
|
||||
unwrapLinkingKeyWithPrf
|
||||
} from './passkeyWrap'
|
||||
|
||||
// 32 bytes, fixed - the authenticator requires exactly 32
|
||||
const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1'))
|
||||
|
||||
// ---- WebAuthn glue (browser-only; credentials container injected) ----
|
||||
|
||||
// the structural slice of a PublicKeyCredential the engine consumes - a
|
||||
// fake authenticator in tests implements exactly this
|
||||
export type CeremonyCredential = {
|
||||
type: string
|
||||
rawId: BufferSource
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs
|
||||
}
|
||||
|
||||
// the slice of navigator.credentials the ceremonies need
|
||||
export type PasskeyCredentials = {
|
||||
create(
|
||||
options?: CredentialCreationOptions
|
||||
): Promise<CeremonyCredential | null>
|
||||
get(options?: CredentialRequestOptions): Promise<CeremonyCredential | null>
|
||||
}
|
||||
|
||||
export type PasskeySupportProbe = {
|
||||
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
||||
getClientCapabilities?(): Promise<Record<string, boolean>>
|
||||
}
|
||||
|
||||
// the one runtime narrow at the browser boundary: navigator.credentials
|
||||
// resolves to the Credential supertype, but a publicKey ceremony always
|
||||
// produces a PublicKeyCredential
|
||||
const asCeremonyCredential = (
|
||||
credential: Credential | null
|
||||
): CeremonyCredential | null => {
|
||||
if (!credential || credential.type !== 'public-key') return null
|
||||
if (!('rawId' in credential)) return null
|
||||
if (!('getClientExtensionResults' in credential)) return null
|
||||
return credential as unknown as CeremonyCredential
|
||||
}
|
||||
|
||||
const defaultCredentials = (): PasskeyCredentials => {
|
||||
if (typeof navigator === 'undefined' || !navigator.credentials) {
|
||||
throw new Error('WebAuthn is not available in this environment.')
|
||||
}
|
||||
const container = navigator.credentials
|
||||
return {
|
||||
create: options => container.create(options).then(asCeremonyCredential),
|
||||
get: options => container.get(options).then(asCeremonyCredential)
|
||||
}
|
||||
}
|
||||
|
||||
// Feature detection: a user-verifying platform authenticator (Touch ID,
|
||||
// Windows Hello, Android biometrics) plus the PRF extension. PRF has no
|
||||
// direct pre-flight check on older clients - where getClientCapabilities
|
||||
// exists we can ask for it, elsewhere this returns true optimistically and
|
||||
// registration itself fails with a clear error.
|
||||
export const passkeySupported = async (
|
||||
probe?: PasskeySupportProbe
|
||||
): Promise<boolean> => {
|
||||
const p =
|
||||
probe ??
|
||||
(typeof PublicKeyCredential !== 'undefined'
|
||||
? PublicKeyCredential
|
||||
: undefined)
|
||||
if (!p) return false
|
||||
if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false
|
||||
if (p.getClientCapabilities) {
|
||||
const capabilities = await p.getClientCapabilities()
|
||||
return capabilities['extension:prf'] === true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const toBytes = (source: BufferSource): Uint8Array =>
|
||||
source instanceof ArrayBuffer
|
||||
? new Uint8Array(source)
|
||||
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
|
||||
|
||||
// pulls the evaluated PRF secret out of a ceremony result; null when the
|
||||
// authenticator did not evaluate the extension (no hmac-secret support)
|
||||
const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => {
|
||||
const first = credential.getClientExtensionResults().prf?.results?.first
|
||||
return first ? toBytes(first) : null
|
||||
}
|
||||
|
||||
// one get() ceremony against a single known credential, returning its fresh
|
||||
// PRF output - the building block for re-wrap ceremonies during linking-key
|
||||
// rotation
|
||||
export const getPasskeyPrfOutput = async (
|
||||
credentialId: string,
|
||||
options: {credentials?: PasskeyCredentials} = {}
|
||||
): Promise<Uint8Array> => {
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const assertion = await credentials.get({
|
||||
publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
allowCredentials: [
|
||||
{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}
|
||||
],
|
||||
userVerification: 'required',
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}
|
||||
}
|
||||
})
|
||||
if (!assertion) throw new Error('Passkey ceremony was cancelled.')
|
||||
const prfOutput = prfOutputOf(assertion)
|
||||
if (!prfOutput) {
|
||||
throw new Error(
|
||||
'This passkey did not return a PRF secret - it cannot unlock this wallet.'
|
||||
)
|
||||
}
|
||||
return prfOutput
|
||||
}
|
||||
|
||||
export type RegisterPasskeyOptions = {
|
||||
credentials?: PasskeyCredentials
|
||||
name?: string
|
||||
authenticatorAttachment?: AuthenticatorAttachment
|
||||
}
|
||||
|
||||
// Registers a new passkey and persists a slot wrapping the given linking
|
||||
// key. The caller supplies the linking key from the currently unlocked
|
||||
// wallet; the ceremony is navigator.credentials.create with the PRF
|
||||
// extension evaluated on creation. Some authenticators only report
|
||||
// prf.enabled during create and evaluate the secret on the first get -
|
||||
// those get a follow-up get() against the fresh credential.
|
||||
export const registerPasskey = async (
|
||||
linkingKey: Uint8Array,
|
||||
options: RegisterPasskeyOptions = {}
|
||||
): Promise<PasskeySlot> => {
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const credential = await credentials.create({
|
||||
publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
rp: {name: 'sattle'},
|
||||
user: {
|
||||
// random per registration: slots address credentials by id, no
|
||||
// discoverable-credential login is used
|
||||
id: crypto.getRandomValues(new Uint8Array(16)),
|
||||
name: 'sattle wallet',
|
||||
displayName: 'sattle wallet'
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{type: 'public-key', alg: -7}, // ES256
|
||||
{type: 'public-key', alg: -257} // RS256
|
||||
],
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: options.authenticatorAttachment ?? 'platform',
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'required'
|
||||
},
|
||||
attestation: 'none',
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}
|
||||
}
|
||||
})
|
||||
if (!credential) throw new Error('Passkey registration was cancelled.')
|
||||
const credentialId = bytesToHex(toBytes(credential.rawId))
|
||||
let prfOutput = prfOutputOf(credential)
|
||||
if (!prfOutput) {
|
||||
if (credential.getClientExtensionResults().prf?.enabled !== true) {
|
||||
throw new Error(
|
||||
'This authenticator does not support the WebAuthn PRF extension.'
|
||||
)
|
||||
}
|
||||
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
|
||||
}
|
||||
const wrap = await wrapLinkingKeyWithPrf(prfOutput, linkingKey)
|
||||
const slot: PasskeySlot = {
|
||||
credentialId,
|
||||
...wrap,
|
||||
createdAt: Date.now(),
|
||||
...(options.name !== undefined ? {name: options.name} : {})
|
||||
}
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||
const slots = readPasskeySlots().filter(
|
||||
s => s.credentialId !== credentialId
|
||||
)
|
||||
slots.push(slot)
|
||||
writePasskeySlots(slots)
|
||||
})
|
||||
return slot
|
||||
}
|
||||
|
||||
// Unlocks via any registered passkey: one get() ceremony offering every
|
||||
// slot's credential, then unwrap. Yields the exact same linking key
|
||||
// unlock(password) yields - the caller activates the wallet with it.
|
||||
export const unlockWithPasskey = async (
|
||||
options: {credentials?: PasskeyCredentials} = {}
|
||||
): Promise<Uint8Array> => {
|
||||
const slots = readPasskeySlots()
|
||||
if (slots.length === 0) {
|
||||
throw new Error('No passkeys registered on this device.')
|
||||
}
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const assertion = await credentials.get({
|
||||
publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
allowCredentials: slots.map(slot => ({
|
||||
type: 'public-key',
|
||||
id: new Uint8Array(hexToBytes(slot.credentialId))
|
||||
})),
|
||||
userVerification: 'required',
|
||||
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}
|
||||
}
|
||||
})
|
||||
if (!assertion) throw new Error('Passkey ceremony was cancelled.')
|
||||
const credentialId = bytesToHex(toBytes(assertion.rawId))
|
||||
const slot = slots.find(s => s.credentialId === credentialId)
|
||||
if (!slot) {
|
||||
throw new Error('The passkey used is not registered with this wallet.')
|
||||
}
|
||||
const prfOutput = prfOutputOf(assertion)
|
||||
if (!prfOutput) {
|
||||
throw new Error(
|
||||
'This passkey did not return a PRF secret - it cannot unlock this wallet.'
|
||||
)
|
||||
}
|
||||
return unwrapLinkingKeyWithPrf(prfOutput, slot)
|
||||
}
|
||||
|
||||
// Removes the slot only: WebAuthn has no API to delete the credential from
|
||||
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
|
||||
// Returns whether a slot was actually removed.
|
||||
export const removePasskey = async (
|
||||
credentialId: string
|
||||
): Promise<boolean> => {
|
||||
let removed = false
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||
const slots = readPasskeySlots()
|
||||
const kept = slots.filter(s => s.credentialId !== credentialId)
|
||||
removed = kept.length !== slots.length
|
||||
if (removed) writePasskeySlots(kept)
|
||||
})
|
||||
return removed
|
||||
}
|
||||
|
||||
// Re-wraps every slot around NEW key material - needed on linking-key
|
||||
// rotation (restoring a different seed while keeping the passkeys). Each
|
||||
// slot's wrap secret lives only inside its authenticator, so the caller
|
||||
// must supply a fresh PRF output per credential (one getPasskeyPrfOutput
|
||||
// ceremony each). All-or-nothing: a slot without a PRF output aborts the
|
||||
// whole re-wrap before anything is written, since a half-rewrapped set
|
||||
// would keep unlocking the OLD key with the uncovered passkeys.
|
||||
//
|
||||
// A password change does NOT need this: the password wrap (keys.ts) and the
|
||||
// passkey slots wrap the same linking key independently, so re-encrypting
|
||||
// the stored key under a new password leaves every slot valid.
|
||||
export const rewrapAllSlots = async (
|
||||
linkingKey: Uint8Array,
|
||||
prfOutputs: ReadonlyMap<string, Uint8Array>
|
||||
): Promise<void> => {
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
|
||||
const slots = readPasskeySlots()
|
||||
const rewrapped: PasskeySlot[] = []
|
||||
for (const slot of slots) {
|
||||
const prfOutput = prfOutputs.get(slot.credentialId)
|
||||
if (!prfOutput) {
|
||||
throw new Error(
|
||||
'Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.'
|
||||
)
|
||||
}
|
||||
rewrapped.push({
|
||||
...slot,
|
||||
...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey))
|
||||
})
|
||||
}
|
||||
writePasskeySlots(rewrapped)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Passkey-slot persistence: one localStorage record holding every passkey
|
||||
// wrap of the linking key (see passkeys.ts). Slots are public metadata plus
|
||||
// AES-GCM wrapped keys - a wrapped blob is useless without the passkey's
|
||||
// authenticator, so this sits next to the plaintext registries. Read/write
|
||||
// are exported bare; callers serialize read-modify-write cycles with
|
||||
// withStorageLock, same convention as bearers.ts.
|
||||
|
||||
// the encrypted half of a slot: the linking key under a passkey wrap key
|
||||
export type PasskeyWrap = {
|
||||
hkdfSalt: string // hex, 16 bytes - per-slot HKDF salt
|
||||
iv: string // hex, 12 bytes
|
||||
wrappedKey: string // hex, AES-GCM ciphertext of the 32-byte linking key
|
||||
}
|
||||
|
||||
export type PasskeySlot = PasskeyWrap & {
|
||||
credentialId: string // hex of the raw WebAuthn credential id
|
||||
createdAt: number
|
||||
name?: string // optional holder label ('laptop', 'phone', ...)
|
||||
}
|
||||
|
||||
export const PASSKEY_SLOTS_STORAGE_KEY = 'sattle_passkey_slots'
|
||||
|
||||
// strict shape check, same spirit as keys.ts's isValidStoredSecret:
|
||||
// localStorage content is not trustworthy input (hand-edited, restored
|
||||
// backups), so slots are validated before use
|
||||
const isValidPasskeySlot = (slot: unknown): slot is PasskeySlot => {
|
||||
if (typeof slot !== 'object' || slot === null) return false
|
||||
const s = slot as Record<string, unknown>
|
||||
return (
|
||||
typeof s.credentialId === 'string' &&
|
||||
s.credentialId.length > 0 &&
|
||||
s.credentialId.length % 2 === 0 &&
|
||||
/^[0-9a-f]+$/i.test(s.credentialId) &&
|
||||
typeof s.hkdfSalt === 'string' &&
|
||||
/^[0-9a-f]{32}$/i.test(s.hkdfSalt) &&
|
||||
typeof s.iv === 'string' &&
|
||||
/^[0-9a-f]{24}$/i.test(s.iv) &&
|
||||
typeof s.wrappedKey === 'string' &&
|
||||
s.wrappedKey.length > 0 &&
|
||||
s.wrappedKey.length % 2 === 0 &&
|
||||
/^[0-9a-f]+$/i.test(s.wrappedKey) &&
|
||||
typeof s.createdAt === 'number' &&
|
||||
(s.name === undefined || typeof s.name === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
// malformed entries are dropped, not thrown on - one corrupted slot must
|
||||
// not take the remaining passkeys down with it
|
||||
export const readPasskeySlots = (): PasskeySlot[] => {
|
||||
const raw = localStorage.getItem(PASSKEY_SLOTS_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter(isValidPasskeySlot) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0
|
||||
|
||||
export const writePasskeySlots = (slots: PasskeySlot[]): void => {
|
||||
localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(slots))
|
||||
}
|
||||
Reference in New Issue
Block a user