fix(wallet): activate complete wallet material

This commit is contained in:
2026-09-05 09:17:12 +02:00
parent 641608eff0
commit e94549a16e
33 changed files with 906 additions and 324 deletions
+5 -2
View File
@@ -1,13 +1,16 @@
// 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.
// The proven marker lives on the v2 wallet-material record; adoption itself
// additionally requires the slot to commit to the exact saved material.
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {linkingPubKeyHex} from './keys'
import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
import {withStorageLock} from './storageLock'
import {savedWalletMaterialOwnerId} from './walletMaterialStorage'
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
const ownerId = savedKeyOwnerId()
const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.')
}
-1
View File
@@ -20,7 +20,6 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
+9 -9
View File
@@ -20,7 +20,7 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
@@ -149,11 +149,11 @@ 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, {
const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop,
name: 'laptop',
})
const phoneSlot = await registerPasskey(LINKING_KEY, {
const phoneSlot = await registerPasskey(MATERIAL, {
credentials: phone,
name: 'phone',
})
@@ -162,25 +162,25 @@ describe('multiple passkeys', () => {
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
})
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, {
const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop,
})
await registerPasskey(LINKING_KEY, {credentials: phone})
await registerPasskey(MATERIAL, {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')
await expect(unlockWalletMaterialWithPasskey({credentials: laptop})).rejects.toThrow('cancelled')
// the survivor is unaffected
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
// removing again is a no-op
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
})
+20 -19
View File
@@ -20,21 +20,22 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
decryptSavedWalletMaterial,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
ensureSavedWalletMaterialOwner,
linkingPubKeyHex,
savedKeyOwnerId,
savedWalletMaterialOwnerId,
saveLinkingKey,
saveWalletMaterial,
walletMaterialLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -133,10 +134,10 @@ const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
}
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
const stored = parseJsonObject(localStorage.getItem('sattle_wallet_material_v2') ?? '{}')
delete stored.ownerId
delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored))
}
beforeEach(async () => {
@@ -151,7 +152,7 @@ describe('slot ownership', () => {
const auth = new FakeAuthenticator()
// When its linking key registers a passkey
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
const slot = await registerPasskey(MATERIAL, {credentials: auth})
// Then the slot carries that same canonical owner
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
@@ -161,7 +162,7 @@ describe('slot ownership', () => {
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 current = await registerPasskey(MATERIAL, {credentials: auth})
const foreign = {
...current,
credentialId: '11'.repeat(16),
@@ -191,9 +192,9 @@ describe('slot ownership', () => {
})
it('does not offer markerless slots for passkey-first unlock', async () => {
// Given a legacy slot and a saved key with no proven owner marker
// Given a legacy slot and a saved wallet with no proven owner marker
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
await registerPasskey(MATERIAL, {credentials: auth})
const legacy = readRawSlots()
delete legacy[0]?.ownerId
delete legacy[0]?.version
@@ -201,7 +202,7 @@ describe('slot ownership', () => {
removeSavedOwnerMarker()
// When passkey unlock is attempted before another proof path
const attempt = unlockWithPasskey({credentials: auth})
const attempt = unlockWalletMaterialWithPasskey({credentials: auth})
// Then it fails before asking the authenticator
await expect(attempt).rejects.toThrow('No passkeys')
@@ -212,7 +213,7 @@ describe('slot ownership', () => {
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})
await registerPasskey(MATERIAL, {credentials: auth})
const legacy = readRawSlots()
delete legacy[0]?.ownerId
delete legacy[0]?.version
@@ -231,24 +232,24 @@ describe('slot ownership', () => {
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})
await registerPasskey(MATERIAL, {credentials: auth})
const legacy = readRawSlots()
delete legacy[0]?.ownerId
delete legacy[0]?.version
writeRawSlots(legacy)
await saveLinkingKey(LINKING_KEY, 'correct horse')
await saveWalletMaterial(MATERIAL, '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)
const provenMaterial = await decryptSavedWalletMaterial('correct horse')
ensureSavedWalletMaterialOwner(provenMaterial)
await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial))
// Then the same slot is stamped once for that proven owner and unlocks
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
expect(savedWalletMaterialOwnerId()).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)
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).resolves.toEqual(MATERIAL)
})
})
+8 -8
View File
@@ -20,7 +20,7 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
@@ -151,7 +151,7 @@ describe('slot ownership (continued)', () => {
// 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 slot = await registerPasskey(MATERIAL, {credentials: auth})
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth,
})
@@ -159,7 +159,7 @@ describe('slot ownership (continued)', () => {
writeRawSlots([{...slot, ...foreignWrap}])
// When the authenticator successfully unwraps that foreign key
const attempt = unlockWithPasskey({credentials: auth})
const attempt = unlockWalletMaterialWithPasskey({credentials: auth})
// Then owner validation rejects it before activation can receive it
await expect(attempt).rejects.toThrow('different wallet')
@@ -168,7 +168,7 @@ describe('slot ownership (continued)', () => {
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})
await registerPasskey(MATERIAL, {credentials: auth})
const stale: PasskeyCredentials = {
create: auth.create,
get: async (options) => {
@@ -180,7 +180,7 @@ describe('slot ownership (continued)', () => {
}
// When the old wallet's ceremony completes after replacement
const attempt = unlockWithPasskey({credentials: stale})
const attempt = unlockWalletMaterialWithPasskey({credentials: stale})
// Then the old linking key is never returned for activation
await expect(attempt).rejects.toThrow('different wallet')
@@ -189,7 +189,7 @@ describe('slot ownership (continued)', () => {
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 slot = await registerPasskey(MATERIAL, {credentials: auth})
const malformed = {...slot, ownerId: 'not-an-owner'}
writeRawSlots([malformed])
@@ -204,7 +204,7 @@ describe('slot ownership (continued)', () => {
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 slot = await registerPasskey(MATERIAL, {credentials: auth})
const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)}
writeRawSlots([foreign])
@@ -219,7 +219,7 @@ describe('slot ownership (continued)', () => {
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 slot = await registerPasskey(MATERIAL, {credentials: auth})
const foreign = {
...slot,
credentialId: '44'.repeat(16),
+29 -24
View File
@@ -20,7 +20,7 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
@@ -147,9 +147,9 @@ beforeEach(async () => {
})
describe('registration and unlock', () => {
it('registers a passkey and unlocks the same linking key', async () => {
it('registers a passkey and unlocks the same complete material', async () => {
const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {
const slot = await registerPasskey(MATERIAL, {
credentials: auth,
name: 'laptop',
})
@@ -157,33 +157,32 @@ describe('registration and unlock', () => {
expect(readPasskeySlots()).toEqual([slot])
expect(hasPasskeySlots()).toBe(true)
const unwrapped = await unlockWithPasskey({credentials: auth})
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
expect(unwrapped).toEqual(MATERIAL)
})
it('never stores the linking key in the clear', async () => {
const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth})
await registerPasskey(MATERIAL, {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 () => {
it('yields the same wallet material unlock(password) yields', async () => {
await saveWalletMaterial(MATERIAL, 'correct horse')
const auth = new FakeAuthenticator()
await registerPasskey(MATERIAL, {credentials: auth})
const viaPassword = await decryptSavedWalletMaterial('correct horse')
const viaPasskey = await unlockWithPasskey({credentials: auth})
const passwordLinkingKey = walletMaterialLinkingKey(viaPassword)
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey))
const viaPasskey = await unlockWalletMaterialWithPasskey({credentials: auth})
expect(viaPasskey).toEqual(viaPassword)
// and the practical consequence: a bearer record encrypted after a
// password unlock decrypts after a passkey unlock
const passwordAes = await deriveBearerAesKey(passwordLinkingKey)
const passwordAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPassword))
const record = await encryptRecord(passwordAes, {note: 'still readable'})
const passkeyAes = await deriveBearerAesKey(viaPasskey)
const passkeyAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPasskey))
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
note: 'still readable',
})
@@ -192,17 +191,17 @@ describe('registration and unlock', () => {
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})
const slot = await registerPasskey(MATERIAL, {credentials: auth})
expect(auth.getCalls).toBe(1)
const unwrapped = await unlockWithPasskey({credentials: auth})
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
expect(unwrapped).toEqual(MATERIAL)
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')
await expect(registerPasskey(MATERIAL, {credentials: auth})).rejects.toThrow('PRF')
expect(hasPasskeySlots()).toBe(false)
})
@@ -211,7 +210,7 @@ describe('registration and unlock', () => {
create: async () => null,
get: async () => null,
}
await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow(
await expect(registerPasskey(MATERIAL, {credentials: cancelled})).rejects.toThrow(
'cancelled',
)
expect(hasPasskeySlots()).toBe(false)
@@ -219,20 +218,24 @@ describe('registration and unlock', () => {
it('throws before any ceremony when no passkeys are registered', async () => {
const auth = new FakeAuthenticator()
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('No passkeys')
await expect(unlockWalletMaterialWithPasskey({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})
await registerPasskey(MATERIAL, {credentials: auth})
auth.prfResultsOnGet = false
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('PRF secret')
await expect(unlockWalletMaterialWithPasskey({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})
await registerPasskey(MATERIAL, {credentials: auth})
const rogue: PasskeyCredentials = {
create: async () => null,
get: async () => ({
@@ -243,13 +246,15 @@ describe('registration and unlock', () => {
}),
}),
}
await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow('not registered')
await expect(unlockWalletMaterialWithPasskey({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})
const slot = await registerPasskey(MATERIAL, {credentials: auth})
auth.rotateSecret(slot.credentialId)
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow()
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow()
})
})
+6 -6
View File
@@ -20,7 +20,7 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
@@ -149,10 +149,10 @@ describe('rewrap for the current owner', () => {
it('refreshes every current-owner wrap all-or-nothing', async () => {
const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, {
const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop,
})
const phoneSlot = await registerPasskey(LINKING_KEY, {
const phoneSlot = await registerPasskey(MATERIAL, {
credentials: phone,
})
@@ -166,7 +166,7 @@ describe('rewrap for the current owner', () => {
],
])
await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap')
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
// full coverage refreshes both wraps around the same proven owner key
const fresh = new Map([
@@ -184,8 +184,8 @@ describe('rewrap for the current owner', () => {
],
])
await rewrapAllSlots(MATERIAL, fresh)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial)
// credential ids and labels survive the re-wrap
expect(
+1 -2
View File
@@ -20,7 +20,6 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
@@ -148,7 +147,7 @@ beforeEach(async () => {
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 slot = await registerPasskey(MATERIAL, {credentials: auth})
const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
localStorage.setItem(
'sattle_passkey_slots',
-1
View File
@@ -20,7 +20,6 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
+5 -25
View File
@@ -24,7 +24,6 @@
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys'
import {withStorageLock} from './storageLock'
import type {PasskeySlot} from './storage/passkeySlots'
import {
@@ -154,27 +153,15 @@ export type RegisterPasskeyOptions = {
}
// Registers a new passkey and persists a slot wrapping the complete material.
// The Uint8Array input exists only until Task 5 updates the UI caller; it can
// select already-saved plaintext v2 material but can never create a key-only
// slot. The ceremony is navigator.credentials.create with the PRF
// The ceremony is navigator.credentials.create with the PRF
// extension evaluated on creation. Some authenticators only report
// prf.enabled during create and evaluate the secret on the first get -
// those get a follow-up get() against the fresh credential.
export const registerPasskey = async (
material: WalletMaterialV2 | Uint8Array,
material: WalletMaterialV2,
options: RegisterPasskeyOptions = {},
): Promise<PasskeySlot> => {
let walletMaterial: WalletMaterialV2
if (material instanceof Uint8Array) {
const savedMaterial = getPlainWalletMaterial()
if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) {
throw new Error('Passkey registration requires complete v2 wallet material.')
}
walletMaterial = savedMaterial
} else {
walletMaterial = material
}
const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial)
const ownerId = requireCurrentPasskeyMaterialOwner(material)
const credentials = options.credentials ?? defaultCredentials()
const credential = await credentials.create({
publicKey: {
@@ -209,7 +196,7 @@ export const registerPasskey = async (
}
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
}
const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial)
const wrap = await wrapWalletMaterialWithPrf(prfOutput, material)
const slot: PasskeySlot = {
credentialId,
...wrap,
@@ -219,7 +206,7 @@ export const registerPasskey = async (
version: PASSKEY_SLOT_VERSION,
}
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) {
if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
throw new Error('Saved wallet material changed during passkey registration.')
}
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
@@ -286,13 +273,6 @@ export const unlockWalletMaterialWithPasskey = async (
return material
}
// Task 5 replaces this temporary projection when lifecycle activation accepts
// complete wallet material. Slots already contain only canonical v2 material.
export const unlockWithPasskey = async (
options: {credentials?: PasskeyCredentials} = {},
): Promise<Uint8Array> =>
walletMaterialLinkingKey(await unlockWalletMaterialWithPasskey(options))
// Removes the slot only: WebAuthn has no API to delete the credential from
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
// Returns whether a slot was actually removed.
+25 -30
View File
@@ -3,10 +3,10 @@ import {bytesToHex} from '@noble/hashes/utils.js'
import {
deriveWalletMaterial,
ensureSavedKeyOwner,
getPlainLinkingKey,
ensureSavedWalletMaterialOwner,
getPlainWalletMaterial,
linkingPubKeyHex,
savedKeyOwnerId,
savedWalletMaterialOwnerId,
saveWalletMaterial,
walletMaterialHash,
} from './keys'
@@ -16,7 +16,7 @@ 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 MATERIAL_STORAGE = 'sattle_wallet_material_v2'
const SLOT_STORAGE = 'sattle_passkey_slots'
const MATERIAL = {
...deriveWalletMaterial(
@@ -38,18 +38,24 @@ const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots))
}
// strips the v2 record's owner marker back to the unproven post-restore form
const removeSavedOwnerMarker = (): void => {
const stored: unknown = JSON.parse(localStorage.getItem(MATERIAL_STORAGE) ?? '{}')
if (typeof stored !== 'object' || stored === null) throw new TypeError('expected a record')
const stripped: Record<string, unknown> = {...stored}
delete stripped.ownerId
delete stripped.version
localStorage.setItem(MATERIAL_STORAGE, JSON.stringify(stripped))
}
describe('passkey-slot schema version', () => {
beforeEach(async () => {
stubLocalStorage()
await saveWalletMaterial(MATERIAL)
})
it('reads a current version 2 slot for the exact saved owner', () => {
// Given a current saved-key marker and passkey record
// Given a current saved-material owner 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: 2}
writeRawSlots([slot])
@@ -58,24 +64,22 @@ describe('passkey-slot schema version', () => {
expect(readPasskeySlots()).toEqual([slot])
})
it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => {
// Given records written before schema versioning
it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => {
// Given records written before schema versioning and a saved material
// whose owner marker was never proven (the post-restore form)
const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem(
KEY_STORAGE,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
)
removeSavedOwnerMarker()
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}])
// When migration is attempted before and after the saved key proves ownership
// When migration is attempted before and after the saved material 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)
const provenMaterial = getPlainWalletMaterial()
if (provenMaterial === null) throw new Error('expected the compatible plaintext material')
ensureSavedWalletMaterialOwner(provenMaterial)
await migrateLegacyPasskeySlots(LINKING_KEY)
// Then both compatible legacy forms become current records
expect(savedKeyOwnerId()).toBe(ownerId)
expect(savedWalletMaterialOwnerId()).toBe(ownerId)
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
{...SLOT_BASE, ownerId, version: 2},
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2},
@@ -85,10 +89,6 @@ describe('passkey-slot schema version', () => {
it.each([1, 3, '2', 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
@@ -98,11 +98,6 @@ describe('passkey-slot schema version', () => {
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: 2}])
// When the current wallet reads passkeys
+9 -1
View File
@@ -1,8 +1,16 @@
// Mutators must not let a still-running old tab overwrite namespaces after a
// successor has installed its saved-key owner marker. Ownerless data may only
// move through explicit migration APIs after their own proof checks succeed.
//
// The canonical owner lives on the v2 wallet-material record. The legacy
// linking-key record is accepted as a fallback so engine-level consumers and
// their harnesses keep working while the lifecycle cuts over; the cut-over
// lifecycle itself never writes that record and deletes it at install,
// reset, backup-restore, and teardown time, so in the shipped app only the
// v2 branch can ever match.
import {savedKeyOwnerId} from '../keys'
import {savedWalletMaterialOwnerId} from '../walletMaterialStorage'
export class WalletOwnerMismatchError extends Error {
override readonly name = 'WalletOwnerMismatchError'
@@ -12,7 +20,7 @@ export class WalletOwnerMismatchError extends Error {
}
export const savedKeyOwnerAllows = (ownerId: string): boolean => {
return savedKeyOwnerId() === ownerId
return savedWalletMaterialOwnerId() === ownerId || savedKeyOwnerId() === ownerId
}
export const assertSavedKeyOwner = (ownerId: string): void => {
+2 -2
View File
@@ -4,7 +4,7 @@
// parsed before use; ownerless v0 records remain hidden until an already
// proven saved wallet explicitly migrates them.
import {linkingPubKeyHex, savedKeyOwnerId} from '../keys'
import {linkingPubKeyHex} from '../keys'
import {
clearNwcEnabledForOwner,
clearUnownedNwcEnabled,
@@ -177,7 +177,7 @@ export type NwcLegacyMigrationResult = {
export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => {
const ownerId = linkingPubKeyHex(linkingKey)
if (savedKeyOwnerId() !== ownerId) {
if (!savedKeyOwnerAllows(ownerId)) {
throw new Error('Legacy NWC migration requires a proven saved wallet owner.')
}
+3 -2
View File
@@ -7,7 +7,8 @@ import {
removeStoredTrustedMintsForOwner,
resetStoredTrustedMints,
} from './trustedMintsRepository'
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {linkingPubKeyHex} from './keys'
import {savedKeyOwnerAllows} from './storage/currentOwner'
import {
addMint,
cacheMintNodeInfo,
@@ -259,7 +260,7 @@ export const clearTrustedMints = (ownerId?: string): Promise<void> =>
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
const ownerId = linkingPubKeyHex(linkingKey)
if (savedKeyOwnerId() !== ownerId) {
if (!savedKeyOwnerAllows(ownerId)) {
throw new Error('Legacy trusted-mint migration requires a proven owner.')
}
return adoptLegacyStoredTrustedMints(ownerId)