mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: harden passkey ceremonies
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user