mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: scope passkey slots to proven owners
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
// Legacy passkey slots may be adopted only after another unlock path has
|
||||||
|
// proven and stamped the saved wallet owner. The linking key is checked
|
||||||
|
// against that marker before markerless slots are changed under the lock.
|
||||||
|
|
||||||
|
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||||
|
import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
|
||||||
|
import {withStorageLock} from './storageLock'
|
||||||
|
|
||||||
|
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
|
||||||
|
const ownerId = savedKeyOwnerId()
|
||||||
|
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
|
||||||
|
throw new Error('Legacy passkey migration requires a proven owner.')
|
||||||
|
}
|
||||||
|
return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId))
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// 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('slot ownership', () => {
|
||||||
|
it('binds a new slot to the proven saved wallet owner', async () => {
|
||||||
|
// Given the saved wallet has a canonical owner marker
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
|
||||||
|
// When its linking key registers a passkey
|
||||||
|
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
|
||||||
|
// Then the slot carries that same canonical owner
|
||||||
|
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||||
|
expect(readPasskeySlots()).toEqual([slot])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters foreign, malformed, and unowned slots from reads and availability', async () => {
|
||||||
|
// Given one valid current-owner slot plus copies with untrusted owners
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
const current = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const foreign = {
|
||||||
|
...current,
|
||||||
|
credentialId: '11'.repeat(16),
|
||||||
|
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
|
||||||
|
}
|
||||||
|
const malformed = {
|
||||||
|
...current,
|
||||||
|
credentialId: '22'.repeat(16),
|
||||||
|
ownerId: 'not-an-owner',
|
||||||
|
}
|
||||||
|
const unowned = {
|
||||||
|
credentialId: '33'.repeat(16),
|
||||||
|
hkdfSalt: current.hkdfSalt,
|
||||||
|
iv: current.iv,
|
||||||
|
wrappedKey: current.wrappedKey,
|
||||||
|
createdAt: current.createdAt,
|
||||||
|
}
|
||||||
|
writeRawSlots([foreign, malformed, unowned])
|
||||||
|
|
||||||
|
// When the current wallet asks for its slots
|
||||||
|
const slots = readPasskeySlots()
|
||||||
|
|
||||||
|
// Then no foreign or unproven slot is exposed
|
||||||
|
expect(slots).toEqual([])
|
||||||
|
expect(hasPasskeySlots()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer markerless slots for passkey-first unlock', async () => {
|
||||||
|
// Given a legacy slot and a saved key with no proven owner marker
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const legacy = readRawSlots()
|
||||||
|
delete legacy[0]?.ownerId
|
||||||
|
delete legacy[0]?.version
|
||||||
|
writeRawSlots(legacy)
|
||||||
|
removeSavedOwnerMarker()
|
||||||
|
|
||||||
|
// When passkey unlock is attempted before another proof path
|
||||||
|
const attempt = unlockWithPasskey({credentials: auth})
|
||||||
|
|
||||||
|
// Then it fails before asking the authenticator
|
||||||
|
await expect(attempt).rejects.toThrow('No passkeys')
|
||||||
|
expect(auth.getCalls).toBe(0)
|
||||||
|
expect(hasPasskeySlots()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => {
|
||||||
|
// Given markerless residue from the old wallet
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const legacy = readRawSlots()
|
||||||
|
delete legacy[0]?.ownerId
|
||||||
|
delete legacy[0]?.version
|
||||||
|
writeRawSlots(legacy)
|
||||||
|
|
||||||
|
// When a different wallet is installed with its canonical owner
|
||||||
|
await saveLinkingKey(OTHER_LINKING_KEY)
|
||||||
|
|
||||||
|
// Then the residue stays unowned and unavailable to the new wallet
|
||||||
|
expect(readPasskeySlots()).toEqual([])
|
||||||
|
expect(hasPasskeySlots()).toBe(false)
|
||||||
|
expect(readRawSlots()).toEqual(legacy)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adopts legacy slots only after the saved wallet owner is proven', async () => {
|
||||||
|
// Given a legacy encrypted wallet and its markerless passkey slot
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const legacy = readRawSlots()
|
||||||
|
delete legacy[0]?.ownerId
|
||||||
|
delete legacy[0]?.version
|
||||||
|
writeRawSlots(legacy)
|
||||||
|
await saveLinkingKey(LINKING_KEY, 'correct horse')
|
||||||
|
removeSavedOwnerMarker()
|
||||||
|
|
||||||
|
// When migration is attempted before and then after password proof
|
||||||
|
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||||
|
const provenKey = await decryptSavedLinkingKey('correct horse')
|
||||||
|
ensureSavedKeyOwner(provenKey)
|
||||||
|
await migrateLegacyPasskeySlots(provenKey)
|
||||||
|
|
||||||
|
// Then the same slot is stamped once for that proven owner and unlocks
|
||||||
|
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||||
|
expect(readPasskeySlots()).toHaveLength(1)
|
||||||
|
expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||||
|
await expect(unlockWithPasskey({credentials: auth})).resolves.toEqual(LINKING_KEY)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// 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('slot ownership (continued)', () => {
|
||||||
|
it('rejects an unwrapped key that does not match the saved proven owner', async () => {
|
||||||
|
// Given a current-owner slot whose authenticated wrap was replaced with
|
||||||
|
// a valid wrap of another wallet key
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
|
||||||
|
credentials: auth,
|
||||||
|
})
|
||||||
|
const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY)
|
||||||
|
writeRawSlots([{...slot, ...foreignWrap}])
|
||||||
|
|
||||||
|
// When the authenticator successfully unwraps that foreign key
|
||||||
|
const attempt = unlockWithPasskey({credentials: auth})
|
||||||
|
|
||||||
|
// Then owner validation rejects it before activation can receive it
|
||||||
|
await expect(attempt).rejects.toThrow('different wallet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a stale unlock when the saved owner changes during the ceremony', async () => {
|
||||||
|
// Given an authenticator that replaces the saved wallet before returning
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const stale: PasskeyCredentials = {
|
||||||
|
create: auth.create,
|
||||||
|
get: async (options) => {
|
||||||
|
const credential = await auth.get(options)
|
||||||
|
await saveLinkingKey(OTHER_LINKING_KEY)
|
||||||
|
return credential
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the old wallet's ceremony completes after replacement
|
||||||
|
const attempt = unlockWithPasskey({credentials: stale})
|
||||||
|
|
||||||
|
// Then the old linking key is never returned for activation
|
||||||
|
await expect(attempt).rejects.toThrow('different wallet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not adopt a slot carrying a malformed owner marker', async () => {
|
||||||
|
// Given an otherwise valid slot whose owner claim is malformed
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const malformed = {...slot, ownerId: 'not-an-owner'}
|
||||||
|
writeRawSlots([malformed])
|
||||||
|
|
||||||
|
// When the current owner performs the legacy migration
|
||||||
|
await migrateLegacyPasskeySlots(LINKING_KEY)
|
||||||
|
|
||||||
|
// Then only truly markerless legacy slots are eligible
|
||||||
|
expect(readPasskeySlots()).toEqual([])
|
||||||
|
expect(readRawSlots()).toEqual([malformed])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot remove a foreign-owner slot', async () => {
|
||||||
|
// Given a slot owned by another wallet remains in shared storage
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)}
|
||||||
|
writeRawSlots([foreign])
|
||||||
|
|
||||||
|
// When the current owner asks to remove that credential id
|
||||||
|
const removed = await removePasskey(slot.credentialId)
|
||||||
|
|
||||||
|
// Then the foreign record is untouched
|
||||||
|
expect(removed).toBe(false)
|
||||||
|
expect(readRawSlots()).toEqual([foreign])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewraps only current-owner slots and preserves foreign slots', async () => {
|
||||||
|
// Given current and foreign slots share storage
|
||||||
|
const auth = new FakeAuthenticator()
|
||||||
|
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||||
|
const foreign = {
|
||||||
|
...slot,
|
||||||
|
credentialId: '44'.repeat(16),
|
||||||
|
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
|
||||||
|
}
|
||||||
|
writeRawSlots([slot, foreign])
|
||||||
|
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
|
||||||
|
credentials: auth,
|
||||||
|
})
|
||||||
|
|
||||||
|
// When the current wallet rewraps its slots
|
||||||
|
await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]]))
|
||||||
|
|
||||||
|
// Then the foreign slot did not require output and remains byte-identical
|
||||||
|
expect(readRawSlots()).toContainEqual(foreign)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 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 {parseJsonArray, 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('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 = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||||
|
|
||||||
|
import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||||
|
import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys'
|
||||||
|
import type {PasskeySlot} from './passkeys'
|
||||||
|
import {parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||||
|
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
||||||
|
const KEY_STORAGE = 'sattle_linking_key'
|
||||||
|
const SLOT_STORAGE = 'sattle_passkey_slots'
|
||||||
|
|
||||||
|
const SLOT_BASE = {
|
||||||
|
credentialId: '11'.repeat(16),
|
||||||
|
hkdfSalt: '22'.repeat(16),
|
||||||
|
iv: '33'.repeat(12),
|
||||||
|
wrappedKey: '44'.repeat(48),
|
||||||
|
createdAt: 1,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
|
||||||
|
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('passkey-slot schema version', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage()
|
||||||
|
})
|
||||||
|
it('reads a current version 1 slot for the exact saved owner', () => {
|
||||||
|
// Given a current saved-key marker and passkey record
|
||||||
|
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY_STORAGE,
|
||||||
|
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||||
|
)
|
||||||
|
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 1}
|
||||||
|
writeRawSlots([slot])
|
||||||
|
|
||||||
|
// When current-owner slots are read
|
||||||
|
// Then the recognized version is exposed unchanged
|
||||||
|
expect(readPasskeySlots()).toEqual([slot])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => {
|
||||||
|
// Given records written before schema versioning
|
||||||
|
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY_STORAGE,
|
||||||
|
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
|
||||||
|
)
|
||||||
|
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}])
|
||||||
|
|
||||||
|
// When migration is attempted before and after the saved key proves ownership
|
||||||
|
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||||
|
const provenKey = getPlainLinkingKey()
|
||||||
|
if (provenKey === null) throw new Error('expected the compatible plaintext key')
|
||||||
|
ensureSavedKeyOwner(provenKey)
|
||||||
|
await migrateLegacyPasskeySlots(provenKey)
|
||||||
|
|
||||||
|
// Then both compatible legacy forms become current records
|
||||||
|
expect(savedKeyOwnerId()).toBe(ownerId)
|
||||||
|
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
|
||||||
|
{...SLOT_BASE, ownerId, version: 1},
|
||||||
|
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 1},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => {
|
||||||
|
// Given an otherwise valid slot carrying unrecognized metadata
|
||||||
|
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY_STORAGE,
|
||||||
|
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||||
|
)
|
||||||
|
writeRawSlots([{...SLOT_BASE, ownerId, version}])
|
||||||
|
|
||||||
|
// When slots are parsed
|
||||||
|
// Then future or malformed records are unavailable and not downgraded to legacy
|
||||||
|
expect(readPasskeySlots()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides a foreign version 1 slot from the current owner', () => {
|
||||||
|
// Given the saved owner and slot owner differ
|
||||||
|
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY_STORAGE,
|
||||||
|
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||||
|
)
|
||||||
|
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}])
|
||||||
|
|
||||||
|
// When the current wallet reads passkeys
|
||||||
|
// Then the foreign credential is unavailable
|
||||||
|
expect(readPasskeySlots()).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,64 +1,202 @@
|
|||||||
// Passkey-slot persistence: one localStorage record holding every passkey
|
// Passkey-slot persistence: every new slot is bound to the canonical owner
|
||||||
// wrap of the linking key (see passkeys.ts). Slots are public metadata plus
|
// of the saved linking key. localStorage remains hostile input, so owner
|
||||||
// AES-GCM wrapped keys - a wrapped blob is useless without the passkey's
|
// markers are parsed strictly and reads expose only slots belonging to the
|
||||||
// authenticator, so this sits next to the plaintext registries. Read/write
|
// currently proven saved owner. Mutations preserve every other record.
|
||||||
// are exported bare; callers serialize read-modify-write cycles with
|
|
||||||
// withStorageLock, same convention as bearers.ts.
|
import {savedKeyOwnerId} from '../keys'
|
||||||
|
import {isJsonObject} from '../jsonParsing'
|
||||||
|
import {isWalletOwnerId} from './walletOwner'
|
||||||
|
|
||||||
// the encrypted half of a slot: the linking key under a passkey wrap key
|
|
||||||
export type PasskeyWrap = {
|
export type PasskeyWrap = {
|
||||||
hkdfSalt: string // hex, 16 bytes - per-slot HKDF salt
|
readonly hkdfSalt: string
|
||||||
iv: string // hex, 12 bytes
|
readonly iv: string
|
||||||
wrappedKey: string // hex, AES-GCM ciphertext of the 32-byte linking key
|
readonly wrappedKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PASSKEY_SLOT_VERSION = 1 as const
|
||||||
|
|
||||||
export type PasskeySlot = PasskeyWrap & {
|
export type PasskeySlot = PasskeyWrap & {
|
||||||
credentialId: string // hex of the raw WebAuthn credential id
|
readonly credentialId: string
|
||||||
createdAt: number
|
readonly createdAt: number
|
||||||
name?: string // optional holder label ('laptop', 'phone', ...)
|
readonly name?: string
|
||||||
|
readonly ownerId: string
|
||||||
|
readonly version: typeof PASSKEY_SLOT_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredPasskeySlot = PasskeyWrap & {
|
||||||
|
readonly credentialId: string
|
||||||
|
readonly createdAt: number
|
||||||
|
readonly name?: string
|
||||||
|
readonly ownerId?: unknown
|
||||||
|
readonly version?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedPasskeySlot = {
|
||||||
|
readonly record: StoredPasskeySlot
|
||||||
|
readonly claimedOwnerId: string | null
|
||||||
|
readonly isCurrent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PASSKEY_SLOTS_STORAGE_KEY = 'sattle_passkey_slots'
|
export const PASSKEY_SLOTS_STORAGE_KEY = 'sattle_passkey_slots'
|
||||||
|
|
||||||
// strict shape check, same spirit as keys.ts's isValidStoredSecret:
|
const SLOT_KEYS: readonly string[] = [
|
||||||
// localStorage content is not trustworthy input (hand-edited, restored
|
'credentialId',
|
||||||
// backups), so slots are validated before use
|
'hkdfSalt',
|
||||||
const isValidPasskeySlot = (slot: unknown): slot is PasskeySlot => {
|
'iv',
|
||||||
if (typeof slot !== 'object' || slot === null) return false
|
'wrappedKey',
|
||||||
const s = slot as Record<string, unknown>
|
'createdAt',
|
||||||
return (
|
'name',
|
||||||
typeof s.credentialId === 'string' &&
|
'ownerId',
|
||||||
s.credentialId.length > 0 &&
|
'version',
|
||||||
s.credentialId.length % 2 === 0 &&
|
]
|
||||||
/^[0-9a-f]+$/i.test(s.credentialId) &&
|
|
||||||
typeof s.hkdfSalt === 'string' &&
|
const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => {
|
||||||
/^[0-9a-f]{32}$/i.test(s.hkdfSalt) &&
|
if (!isJsonObject(slot)) return null
|
||||||
typeof s.iv === 'string' &&
|
if (
|
||||||
/^[0-9a-f]{24}$/i.test(s.iv) &&
|
typeof slot.credentialId === 'string' &&
|
||||||
typeof s.wrappedKey === 'string' &&
|
slot.credentialId.length > 0 &&
|
||||||
s.wrappedKey.length > 0 &&
|
slot.credentialId.length % 2 === 0 &&
|
||||||
s.wrappedKey.length % 2 === 0 &&
|
/^[0-9a-f]+$/i.test(slot.credentialId) &&
|
||||||
/^[0-9a-f]+$/i.test(s.wrappedKey) &&
|
typeof slot.hkdfSalt === 'string' &&
|
||||||
typeof s.createdAt === 'number' &&
|
/^[0-9a-f]{32}$/i.test(slot.hkdfSalt) &&
|
||||||
(s.name === undefined || typeof s.name === 'string')
|
typeof slot.iv === 'string' &&
|
||||||
)
|
/^[0-9a-f]{24}$/i.test(slot.iv) &&
|
||||||
|
typeof slot.wrappedKey === 'string' &&
|
||||||
|
slot.wrappedKey.length > 0 &&
|
||||||
|
slot.wrappedKey.length % 2 === 0 &&
|
||||||
|
/^[0-9a-f]+$/i.test(slot.wrappedKey) &&
|
||||||
|
typeof slot.createdAt === 'number' &&
|
||||||
|
(slot.name === undefined || typeof slot.name === 'string') &&
|
||||||
|
Object.keys(slot).every((key) => SLOT_KEYS.includes(key))
|
||||||
|
) {
|
||||||
|
const record: StoredPasskeySlot = {
|
||||||
|
credentialId: slot.credentialId,
|
||||||
|
hkdfSalt: slot.hkdfSalt,
|
||||||
|
iv: slot.iv,
|
||||||
|
wrappedKey: slot.wrappedKey,
|
||||||
|
createdAt: slot.createdAt,
|
||||||
|
...(slot.name !== undefined ? {name: slot.name} : {}),
|
||||||
|
}
|
||||||
|
const hasOwner = Object.hasOwn(slot, 'ownerId')
|
||||||
|
const hasVersion = Object.hasOwn(slot, 'version')
|
||||||
|
if (!hasOwner && !hasVersion) return {record, claimedOwnerId: null, isCurrent: false}
|
||||||
|
if (!isWalletOwnerId(slot.ownerId)) return null
|
||||||
|
if (!hasVersion) {
|
||||||
|
return {
|
||||||
|
record: {...record, ownerId: slot.ownerId},
|
||||||
|
claimedOwnerId: slot.ownerId,
|
||||||
|
isCurrent: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot.version !== PASSKEY_SLOT_VERSION) return null
|
||||||
|
return {
|
||||||
|
record: {...record, ownerId: slot.ownerId, version: PASSKEY_SLOT_VERSION},
|
||||||
|
claimedOwnerId: slot.ownerId,
|
||||||
|
isCurrent: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// malformed entries are dropped, not thrown on - one corrupted slot must
|
const readStoredPasskeySlots = (): ParsedPasskeySlot[] => {
|
||||||
// not take the remaining passkeys down with it
|
|
||||||
export const readPasskeySlots = (): PasskeySlot[] => {
|
|
||||||
const raw = localStorage.getItem(PASSKEY_SLOTS_STORAGE_KEY)
|
const raw = localStorage.getItem(PASSKEY_SLOTS_STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(raw)
|
const parsed: unknown = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? parsed.filter(isValidPasskeySlot) : []
|
if (!Array.isArray(parsed)) return []
|
||||||
|
const slots: ParsedPasskeySlot[] = []
|
||||||
|
for (const value of parsed) {
|
||||||
|
const slot = parseStoredPasskeySlot(value)
|
||||||
|
if (slot !== null) slots.push(slot)
|
||||||
|
}
|
||||||
|
return slots
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | null => {
|
||||||
|
if (!stored.isCurrent || stored.claimedOwnerId !== ownerId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const record = stored.record
|
||||||
|
return {
|
||||||
|
credentialId: record.credentialId,
|
||||||
|
hkdfSalt: record.hkdfSalt,
|
||||||
|
iv: record.iv,
|
||||||
|
wrappedKey: record.wrappedKey,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
...(record.name !== undefined ? {name: record.name} : {}),
|
||||||
|
ownerId,
|
||||||
|
version: PASSKEY_SLOT_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readPasskeySlots = (): PasskeySlot[] => {
|
||||||
|
const ownerId = savedKeyOwnerId()
|
||||||
|
if (ownerId === null) return []
|
||||||
|
return readStoredPasskeySlots()
|
||||||
|
.map((slot) => asOwnedSlot(slot, ownerId))
|
||||||
|
.filter((slot): slot is PasskeySlot => slot !== null)
|
||||||
|
}
|
||||||
|
|
||||||
export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0
|
export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0
|
||||||
|
|
||||||
export const writePasskeySlots = (slots: PasskeySlot[]): void => {
|
export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) {
|
||||||
|
throw new Error('Passkey slots require the proven saved wallet owner.')
|
||||||
|
}
|
||||||
|
if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) {
|
||||||
|
throw new Error('Refusing to write a passkey slot for a different wallet.')
|
||||||
|
}
|
||||||
|
const preserved = readStoredPasskeySlots()
|
||||||
|
.filter((slot) => slot.claimedOwnerId !== ownerId)
|
||||||
|
.map((slot) => slot.record)
|
||||||
|
localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify([...preserved, ...slots]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adoptLegacyPasskeySlots = (ownerId: string): number => {
|
||||||
|
if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) {
|
||||||
|
throw new Error('Legacy passkey migration requires a proven owner.')
|
||||||
|
}
|
||||||
|
const stored = readStoredPasskeySlots()
|
||||||
|
let adopted = 0
|
||||||
|
const migrated = stored.map((slot) => {
|
||||||
|
if (slot.isCurrent || (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId)) {
|
||||||
|
return slot.record
|
||||||
|
}
|
||||||
|
adopted += 1
|
||||||
|
return {...slot.record, ownerId, version: PASSKEY_SLOT_VERSION}
|
||||||
|
})
|
||||||
|
if (adopted > 0) {
|
||||||
|
localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(migrated))
|
||||||
|
}
|
||||||
|
return adopted
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => {
|
||||||
|
if (slots.length === 0) {
|
||||||
|
localStorage.removeItem(PASSKEY_SLOTS_STORAGE_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(slots))
|
localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(slots))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const clearPasskeySlotsForOwner = (ownerId: string): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) {
|
||||||
|
throw new Error('Passkey teardown requires the proven saved wallet owner.')
|
||||||
|
}
|
||||||
|
persistStoredPasskeySlots(
|
||||||
|
readStoredPasskeySlots()
|
||||||
|
.filter((slot) => slot.claimedOwnerId !== ownerId)
|
||||||
|
.map((slot) => slot.record),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearUnownedPasskeySlots = (): void => {
|
||||||
|
persistStoredPasskeySlots(
|
||||||
|
readStoredPasskeySlots()
|
||||||
|
.filter((slot) => slot.claimedOwnerId !== null)
|
||||||
|
.map((slot) => slot.record),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user