mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(wallet): activate complete wallet material
This commit is contained in:
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user