feat: harden passkey ceremonies

This commit is contained in:
2026-08-22 16:54:49 +02:00
parent 058b10ddf1
commit d9be457221
5 changed files with 678 additions and 534 deletions
+178
View File
@@ -0,0 +1,178 @@
// 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,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, 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
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
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)
})
})
@@ -0,0 +1,245 @@
// 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,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, 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
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
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()
})
})
+180
View File
@@ -0,0 +1,180 @@
// 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,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, 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
}
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots))
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
}
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
})
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)
})
})
+9 -458
View File
@@ -1,458 +1,9 @@
// Passkey engine tests. The WebAuthn ceremony is faked by an injected import './passkeys.crypto.cases'
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - import './passkeys.registration.cases'
// the real extension's exact contract: deterministic per credential+salt, import './passkeys.ownership-a.cases'
// unguessable without the authenticator. Everything except a real import './passkeys.ownership-b.cases'
// authenticator's touch is covered here. import './passkeys.multiple.cases'
import './passkeys.rewrap.cases'
import {beforeEach, describe, expect, it} from 'vitest' import './passkeys.storage.cases'
import {hmac} from '@noble/hashes/hmac.js' import './passkeys.support.cases'
import {sha256} from '@noble/hashes/sha2.js' import './passkeys.version.cases'
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)
})
})
+66 -76
View File
@@ -30,22 +30,21 @@
import {sha256} from '@noble/hashes/sha2.js' import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {withStorageLock} from './storageLock' import {withStorageLock} from './storageLock'
import type {PasskeySlot} from './storage/passkeySlots' import type {PasskeySlot} from './storage/passkeySlots'
import { import {
PASSKEY_SLOTS_STORAGE_KEY, PASSKEY_SLOTS_STORAGE_KEY,
PASSKEY_SLOT_VERSION,
readPasskeySlots, readPasskeySlots,
writePasskeySlots writePasskeySlots,
} from './storage/passkeySlots' } from './storage/passkeySlots'
import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap' import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap'
export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots'
export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots'
export { export {migrateLegacyPasskeySlots} from './passkeyOwnership'
derivePasskeyWrapKey, export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap'
wrapLinkingKeyWithPrf,
unwrapLinkingKeyWithPrf
} from './passkeyWrap'
// 32 bytes, fixed - the authenticator requires exactly 32 // 32 bytes, fixed - the authenticator requires exactly 32
const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1'))
@@ -62,9 +61,7 @@ export type CeremonyCredential = {
// the slice of navigator.credentials the ceremonies need // the slice of navigator.credentials the ceremonies need
export type PasskeyCredentials = { export type PasskeyCredentials = {
create( create(options?: CredentialCreationOptions): Promise<CeremonyCredential | null>
options?: CredentialCreationOptions
): Promise<CeremonyCredential | null>
get(options?: CredentialRequestOptions): Promise<CeremonyCredential | null> get(options?: CredentialRequestOptions): Promise<CeremonyCredential | null>
} }
@@ -76,13 +73,11 @@ export type PasskeySupportProbe = {
// the one runtime narrow at the browser boundary: navigator.credentials // the one runtime narrow at the browser boundary: navigator.credentials
// resolves to the Credential supertype, but a publicKey ceremony always // resolves to the Credential supertype, but a publicKey ceremony always
// produces a PublicKeyCredential // produces a PublicKeyCredential
const asCeremonyCredential = ( const asCeremonyCredential = (credential: Credential | null): CeremonyCredential | null => {
credential: Credential | null if (typeof PublicKeyCredential === 'undefined' || !(credential instanceof PublicKeyCredential)) {
): CeremonyCredential | null => { return null
if (!credential || credential.type !== 'public-key') return null }
if (!('rawId' in credential)) return null return credential
if (!('getClientExtensionResults' in credential)) return null
return credential as unknown as CeremonyCredential
} }
const defaultCredentials = (): PasskeyCredentials => { const defaultCredentials = (): PasskeyCredentials => {
@@ -91,8 +86,8 @@ const defaultCredentials = (): PasskeyCredentials => {
} }
const container = navigator.credentials const container = navigator.credentials
return { return {
create: options => container.create(options).then(asCeremonyCredential), create: (options) => container.create(options).then(asCeremonyCredential),
get: options => container.get(options).then(asCeremonyCredential) get: (options) => container.get(options).then(asCeremonyCredential),
} }
} }
@@ -101,14 +96,8 @@ const defaultCredentials = (): PasskeyCredentials => {
// direct pre-flight check on older clients - where getClientCapabilities // direct pre-flight check on older clients - where getClientCapabilities
// exists we can ask for it, elsewhere this returns true optimistically and // exists we can ask for it, elsewhere this returns true optimistically and
// registration itself fails with a clear error. // registration itself fails with a clear error.
export const passkeySupported = async ( export const passkeySupported = async (probe?: PasskeySupportProbe): Promise<boolean> => {
probe?: PasskeySupportProbe const p = probe ?? (typeof PublicKeyCredential !== 'undefined' ? PublicKeyCredential : undefined)
): Promise<boolean> => {
const p =
probe ??
(typeof PublicKeyCredential !== 'undefined'
? PublicKeyCredential
: undefined)
if (!p) return false if (!p) return false
if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false
if (p.getClientCapabilities) { if (p.getClientCapabilities) {
@@ -135,25 +124,21 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => {
// rotation // rotation
export const getPasskeyPrfOutput = async ( export const getPasskeyPrfOutput = async (
credentialId: string, credentialId: string,
options: {credentials?: PasskeyCredentials} = {} options: {credentials?: PasskeyCredentials} = {},
): Promise<Uint8Array> => { ): Promise<Uint8Array> => {
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials()
const assertion = await credentials.get({ const assertion = await credentials.get({
publicKey: { publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [ allowCredentials: [{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}],
{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}
],
userVerification: 'required', userVerification: 'required',
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}},
} },
}) })
if (!assertion) throw new Error('Passkey ceremony was cancelled.') if (!assertion) throw new Error('Passkey ceremony was cancelled.')
const prfOutput = prfOutputOf(assertion) const prfOutput = prfOutputOf(assertion)
if (!prfOutput) { if (!prfOutput) {
throw new Error( throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.')
'This passkey did not return a PRF secret - it cannot unlock this wallet.'
)
} }
return prfOutput return prfOutput
} }
@@ -172,8 +157,12 @@ export type RegisterPasskeyOptions = {
// those get a follow-up get() against the fresh credential. // those get a follow-up get() against the fresh credential.
export const registerPasskey = async ( export const registerPasskey = async (
linkingKey: Uint8Array, linkingKey: Uint8Array,
options: RegisterPasskeyOptions = {} options: RegisterPasskeyOptions = {},
): Promise<PasskeySlot> => { ): Promise<PasskeySlot> => {
const ownerId = savedKeyOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Passkey registration requires the proven saved wallet owner.')
}
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials()
const credential = await credentials.create({ const credential = await credentials.create({
publicKey: { publicKey: {
@@ -184,29 +173,27 @@ export const registerPasskey = async (
// discoverable-credential login is used // discoverable-credential login is used
id: crypto.getRandomValues(new Uint8Array(16)), id: crypto.getRandomValues(new Uint8Array(16)),
name: 'sattle wallet', name: 'sattle wallet',
displayName: 'sattle wallet' displayName: 'sattle wallet',
}, },
pubKeyCredParams: [ pubKeyCredParams: [
{type: 'public-key', alg: -7}, // ES256 {type: 'public-key', alg: -7}, // ES256
{type: 'public-key', alg: -257} // RS256 {type: 'public-key', alg: -257}, // RS256
], ],
authenticatorSelection: { authenticatorSelection: {
authenticatorAttachment: options.authenticatorAttachment ?? 'platform', authenticatorAttachment: options.authenticatorAttachment ?? 'platform',
residentKey: 'preferred', residentKey: 'preferred',
userVerification: 'required' userVerification: 'required',
}, },
attestation: 'none', attestation: 'none',
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}},
} },
}) })
if (!credential) throw new Error('Passkey registration was cancelled.') if (!credential) throw new Error('Passkey registration was cancelled.')
const credentialId = bytesToHex(toBytes(credential.rawId)) const credentialId = bytesToHex(toBytes(credential.rawId))
let prfOutput = prfOutputOf(credential) let prfOutput = prfOutputOf(credential)
if (!prfOutput) { if (!prfOutput) {
if (credential.getClientExtensionResults().prf?.enabled !== true) { if (credential.getClientExtensionResults().prf?.enabled !== true) {
throw new Error( throw new Error('This authenticator does not support the WebAuthn PRF extension.')
'This authenticator does not support the WebAuthn PRF extension.'
)
} }
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
} }
@@ -215,14 +202,14 @@ export const registerPasskey = async (
credentialId, credentialId,
...wrap, ...wrap,
createdAt: Date.now(), createdAt: Date.now(),
...(options.name !== undefined ? {name: options.name} : {}) ...(options.name !== undefined ? {name: options.name} : {}),
ownerId,
version: PASSKEY_SLOT_VERSION,
} }
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
const slots = readPasskeySlots().filter( const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
s => s.credentialId !== credentialId
)
slots.push(slot) slots.push(slot)
writePasskeySlots(slots) writePasskeySlots(ownerId, slots)
}) })
return slot return slot
} }
@@ -231,85 +218,88 @@ export const registerPasskey = async (
// slot's credential, then unwrap. Yields the exact same linking key // slot's credential, then unwrap. Yields the exact same linking key
// unlock(password) yields - the caller activates the wallet with it. // unlock(password) yields - the caller activates the wallet with it.
export const unlockWithPasskey = async ( export const unlockWithPasskey = async (
options: {credentials?: PasskeyCredentials} = {} options: {credentials?: PasskeyCredentials} = {},
): Promise<Uint8Array> => { ): Promise<Uint8Array> => {
const ownerId = savedKeyOwnerId()
const slots = readPasskeySlots() const slots = readPasskeySlots()
if (slots.length === 0) { if (ownerId === null || slots.length === 0) {
throw new Error('No passkeys registered on this device.') throw new Error('No passkeys registered on this device.')
} }
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials()
const assertion = await credentials.get({ const assertion = await credentials.get({
publicKey: { publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: slots.map(slot => ({ allowCredentials: slots.map((slot) => ({
type: 'public-key', type: 'public-key',
id: new Uint8Array(hexToBytes(slot.credentialId)) id: new Uint8Array(hexToBytes(slot.credentialId)),
})), })),
userVerification: 'required', userVerification: 'required',
extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}},
} },
}) })
if (!assertion) throw new Error('Passkey ceremony was cancelled.') if (!assertion) throw new Error('Passkey ceremony was cancelled.')
const credentialId = bytesToHex(toBytes(assertion.rawId)) const credentialId = bytesToHex(toBytes(assertion.rawId))
const slot = slots.find(s => s.credentialId === credentialId) const slot = slots.find((s) => s.credentialId === credentialId)
if (!slot) { if (!slot) {
throw new Error('The passkey used is not registered with this wallet.') throw new Error('The passkey used is not registered with this wallet.')
} }
const prfOutput = prfOutputOf(assertion) const prfOutput = prfOutputOf(assertion)
if (!prfOutput) { if (!prfOutput) {
throw new Error( throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.')
'This passkey did not return a PRF secret - it cannot unlock this wallet.'
)
} }
return unwrapLinkingKeyWithPrf(prfOutput, slot) const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot)
if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('This passkey belongs to a different wallet.')
}
return linkingKey
} }
// Removes the slot only: WebAuthn has no API to delete the credential from // Removes the slot only: WebAuthn has no API to delete the credential from
// the authenticator - an orphaned passkey simply finds nothing to unwrap. // the authenticator - an orphaned passkey simply finds nothing to unwrap.
// Returns whether a slot was actually removed. // Returns whether a slot was actually removed.
export const removePasskey = async ( export const removePasskey = async (credentialId: string): Promise<boolean> => {
credentialId: string const ownerId = savedKeyOwnerId()
): Promise<boolean> => { if (ownerId === null) return false
let removed = false let removed = false
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
const slots = readPasskeySlots() const slots = readPasskeySlots()
const kept = slots.filter(s => s.credentialId !== credentialId) const kept = slots.filter((s) => s.credentialId !== credentialId)
removed = kept.length !== slots.length removed = kept.length !== slots.length
if (removed) writePasskeySlots(kept) if (removed) writePasskeySlots(ownerId, kept)
}) })
return removed return removed
} }
// Re-wraps every slot around NEW key material - needed on linking-key // Refreshes every current-owner slot around the same proven key material.
// rotation (restoring a different seed while keeping the passkeys). Each // Each slot's wrap secret lives only inside its authenticator, so the caller
// slot's wrap secret lives only inside its authenticator, so the caller
// must supply a fresh PRF output per credential (one getPasskeyPrfOutput // must supply a fresh PRF output per credential (one getPasskeyPrfOutput
// ceremony each). All-or-nothing: a slot without a PRF output aborts the // 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 // whole refresh before anything is written.
// would keep unlocking the OLD key with the uncovered passkeys.
// //
// A password change does NOT need this: the password wrap (keys.ts) and the // 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 // passkey slots wrap the same linking key independently, so re-encrypting
// the stored key under a new password leaves every slot valid. // the stored key under a new password leaves every slot valid.
export const rewrapAllSlots = async ( export const rewrapAllSlots = async (
linkingKey: Uint8Array, linkingKey: Uint8Array,
prfOutputs: ReadonlyMap<string, Uint8Array> prfOutputs: ReadonlyMap<string, Uint8Array>,
): Promise<void> => { ): Promise<void> => {
const ownerId = savedKeyOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Passkey re-wrap requires the proven saved wallet owner.')
}
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
const slots = readPasskeySlots() const slots = readPasskeySlots()
const rewrapped: PasskeySlot[] = [] const rewrapped: PasskeySlot[] = []
for (const slot of slots) { for (const slot of slots) {
const prfOutput = prfOutputs.get(slot.credentialId) const prfOutput = prfOutputs.get(slot.credentialId)
if (!prfOutput) { if (!prfOutput) {
throw new Error( throw new Error('Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.')
'Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.'
)
} }
rewrapped.push({ rewrapped.push({
...slot, ...slot,
...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)) ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)),
}) })
} }
writePasskeySlots(rewrapped) writePasskeySlots(ownerId, rewrapped)
}) })
} }