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:
+6
-3
@@ -13,9 +13,12 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
||||||
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
// wallet store at app start - a plaintext-stored material record unlocks
|
||||||
// a password-encrypted one lands on 'locked' for the unlock screen, and no
|
// straight away, a password-encrypted one lands on 'locked' for the unlock
|
||||||
// key at all lands on 'none' for onboarding.
|
// screen, and no record at all lands on 'none' for onboarding. init() first
|
||||||
|
// runs the alpha reset: unsupported legacy state (the linking-key-only
|
||||||
|
// record or the pre-v2 bearer namespace) is wiped, never migrated, before
|
||||||
|
// any route can expose it.
|
||||||
export default defineBoot(async () => {
|
export default defineBoot(async () => {
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
await wallet.init();
|
await wallet.init();
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
savedWalletMaterialOwnerId,
|
savedWalletMaterialOwnerId,
|
||||||
serializeWalletMaterial,
|
serializeWalletMaterial,
|
||||||
walletMaterialHash,
|
walletMaterialHash,
|
||||||
walletMaterialLinkingKey,
|
|
||||||
type WalletMaterialV2,
|
type WalletMaterialV2,
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
import { isJsonObject } from '@/lnurlcash/jsonParsing';
|
import { isJsonObject } from '@/lnurlcash/jsonParsing';
|
||||||
@@ -188,12 +187,9 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const enableBiometricUnlock = async (
|
export const enableBiometricUnlock = async (
|
||||||
material: WalletMaterialV2 | Uint8Array,
|
material: WalletMaterialV2,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
||||||
if (material instanceof Uint8Array) {
|
|
||||||
throw new Error('Biometric enrollment requires complete v2 wallet material.');
|
|
||||||
}
|
|
||||||
const serialized = serializeWalletMaterial(material);
|
const serialized = serializeWalletMaterial(material);
|
||||||
const canonicalMaterial = parseWalletMaterial(serialized);
|
const canonicalMaterial = parseWalletMaterial(serialized);
|
||||||
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
|
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
|
||||||
@@ -306,9 +302,6 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMateri
|
|||||||
return material;
|
return material;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unlockWithBiometrics = async (): Promise<Uint8Array> =>
|
|
||||||
walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics());
|
|
||||||
|
|
||||||
// Native deletion goes first so a failure leaves the complete enrollment
|
// Native deletion goes first so a failure leaves the complete enrollment
|
||||||
// available for the locked lifecycle to retry safely.
|
// available for the locked lifecycle to retry safely.
|
||||||
export const disableBiometricUnlock = async (): Promise<void> => {
|
export const disableBiometricUnlock = async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// Legacy passkey slots may be adopted only after another unlock path has
|
// Legacy passkey slots may be adopted only after another unlock path has
|
||||||
// proven and stamped the saved wallet owner. The linking key is checked
|
// proven and stamped the saved wallet owner. The linking key is checked
|
||||||
// against that marker before markerless slots are changed under the lock.
|
// 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 {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
|
||||||
import {withStorageLock} from './storageLock'
|
import {withStorageLock} from './storageLock'
|
||||||
|
import {savedWalletMaterialOwnerId} from './walletMaterialStorage'
|
||||||
|
|
||||||
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
|
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
|
||||||
const ownerId = savedKeyOwnerId()
|
const ownerId = savedWalletMaterialOwnerId()
|
||||||
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
|
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
|
||||||
throw new Error('Legacy passkey migration requires a proven owner.')
|
throw new Error('Legacy passkey migration requires a proven owner.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
unlockWalletMaterialWithPasskey,
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
@@ -149,11 +149,11 @@ describe('multiple passkeys', () => {
|
|||||||
it('keeps slots independent: each passkey unlocks the same key', async () => {
|
it('keeps slots independent: each passkey unlocks the same key', async () => {
|
||||||
const laptop = new FakeAuthenticator()
|
const laptop = new FakeAuthenticator()
|
||||||
const phone = new FakeAuthenticator()
|
const phone = new FakeAuthenticator()
|
||||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
const laptopSlot = await registerPasskey(MATERIAL, {
|
||||||
credentials: laptop,
|
credentials: laptop,
|
||||||
name: 'laptop',
|
name: 'laptop',
|
||||||
})
|
})
|
||||||
const phoneSlot = await registerPasskey(LINKING_KEY, {
|
const phoneSlot = await registerPasskey(MATERIAL, {
|
||||||
credentials: phone,
|
credentials: phone,
|
||||||
name: 'phone',
|
name: 'phone',
|
||||||
})
|
})
|
||||||
@@ -162,25 +162,25 @@ describe('multiple passkeys', () => {
|
|||||||
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
|
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
|
||||||
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial)
|
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial)
|
||||||
|
|
||||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
|
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
|
||||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
|
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removePasskey drops exactly one slot and leaves the rest working', async () => {
|
it('removePasskey drops exactly one slot and leaves the rest working', async () => {
|
||||||
const laptop = new FakeAuthenticator()
|
const laptop = new FakeAuthenticator()
|
||||||
const phone = new FakeAuthenticator()
|
const phone = new FakeAuthenticator()
|
||||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
const laptopSlot = await registerPasskey(MATERIAL, {
|
||||||
credentials: laptop,
|
credentials: laptop,
|
||||||
})
|
})
|
||||||
await registerPasskey(LINKING_KEY, {credentials: phone})
|
await registerPasskey(MATERIAL, {credentials: phone})
|
||||||
|
|
||||||
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true)
|
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true)
|
||||||
expect(readPasskeySlots()).toHaveLength(1)
|
expect(readPasskeySlots()).toHaveLength(1)
|
||||||
|
|
||||||
// the removed passkey no longer matches any offered credential
|
// 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
|
// 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
|
// removing again is a no-op
|
||||||
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
|
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,21 +20,22 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
unlockWalletMaterialWithPasskey,
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
import {
|
import {
|
||||||
decryptRecord,
|
decryptRecord,
|
||||||
decryptSavedLinkingKey,
|
decryptSavedWalletMaterial,
|
||||||
deriveBearerAesKey,
|
deriveBearerAesKey,
|
||||||
deriveWalletMaterial,
|
deriveWalletMaterial,
|
||||||
ensureSavedKeyOwner,
|
|
||||||
encryptRecord,
|
encryptRecord,
|
||||||
|
ensureSavedWalletMaterialOwner,
|
||||||
linkingPubKeyHex,
|
linkingPubKeyHex,
|
||||||
savedKeyOwnerId,
|
savedWalletMaterialOwnerId,
|
||||||
saveLinkingKey,
|
saveLinkingKey,
|
||||||
saveWalletMaterial,
|
saveWalletMaterial,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
} from './keys'
|
} from './keys'
|
||||||
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
@@ -133,10 +134,10 @@ const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeSavedOwnerMarker = (): 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.ownerId
|
||||||
delete stored.version
|
delete stored.version
|
||||||
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
|
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored))
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -151,7 +152,7 @@ describe('slot ownership', () => {
|
|||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
|
|
||||||
// When its linking key registers a passkey
|
// 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
|
// Then the slot carries that same canonical owner
|
||||||
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
|
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 () => {
|
it('filters foreign, malformed, and unowned slots from reads and availability', async () => {
|
||||||
// Given one valid current-owner slot plus copies with untrusted owners
|
// Given one valid current-owner slot plus copies with untrusted owners
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
const current = await registerPasskey(LINKING_KEY, {credentials: auth})
|
const current = await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const foreign = {
|
const foreign = {
|
||||||
...current,
|
...current,
|
||||||
credentialId: '11'.repeat(16),
|
credentialId: '11'.repeat(16),
|
||||||
@@ -191,9 +192,9 @@ describe('slot ownership', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not offer markerless slots for passkey-first unlock', async () => {
|
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()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const legacy = readRawSlots()
|
const legacy = readRawSlots()
|
||||||
delete legacy[0]?.ownerId
|
delete legacy[0]?.ownerId
|
||||||
delete legacy[0]?.version
|
delete legacy[0]?.version
|
||||||
@@ -201,7 +202,7 @@ describe('slot ownership', () => {
|
|||||||
removeSavedOwnerMarker()
|
removeSavedOwnerMarker()
|
||||||
|
|
||||||
// When passkey unlock is attempted before another proof path
|
// 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
|
// Then it fails before asking the authenticator
|
||||||
await expect(attempt).rejects.toThrow('No passkeys')
|
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 () => {
|
it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => {
|
||||||
// Given markerless residue from the old wallet
|
// Given markerless residue from the old wallet
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const legacy = readRawSlots()
|
const legacy = readRawSlots()
|
||||||
delete legacy[0]?.ownerId
|
delete legacy[0]?.ownerId
|
||||||
delete legacy[0]?.version
|
delete legacy[0]?.version
|
||||||
@@ -231,24 +232,24 @@ describe('slot ownership', () => {
|
|||||||
it('adopts legacy slots only after the saved wallet owner is proven', async () => {
|
it('adopts legacy slots only after the saved wallet owner is proven', async () => {
|
||||||
// Given a legacy encrypted wallet and its markerless passkey slot
|
// Given a legacy encrypted wallet and its markerless passkey slot
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const legacy = readRawSlots()
|
const legacy = readRawSlots()
|
||||||
delete legacy[0]?.ownerId
|
delete legacy[0]?.ownerId
|
||||||
delete legacy[0]?.version
|
delete legacy[0]?.version
|
||||||
writeRawSlots(legacy)
|
writeRawSlots(legacy)
|
||||||
await saveLinkingKey(LINKING_KEY, 'correct horse')
|
await saveWalletMaterial(MATERIAL, 'correct horse')
|
||||||
removeSavedOwnerMarker()
|
removeSavedOwnerMarker()
|
||||||
|
|
||||||
// When migration is attempted before and then after password proof
|
// When migration is attempted before and then after password proof
|
||||||
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||||
const provenKey = await decryptSavedLinkingKey('correct horse')
|
const provenMaterial = await decryptSavedWalletMaterial('correct horse')
|
||||||
ensureSavedKeyOwner(provenKey)
|
ensureSavedWalletMaterialOwner(provenMaterial)
|
||||||
await migrateLegacyPasskeySlots(provenKey)
|
await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial))
|
||||||
|
|
||||||
// Then the same slot is stamped once for that proven owner and unlocks
|
// 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()).toHaveLength(1)
|
||||||
expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
|
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,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
unlockWalletMaterialWithPasskey,
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
@@ -151,7 +151,7 @@ describe('slot ownership (continued)', () => {
|
|||||||
// Given a current-owner slot whose authenticated wrap was replaced with
|
// Given a current-owner slot whose authenticated wrap was replaced with
|
||||||
// a valid wrap of another wallet key
|
// a valid wrap of another wallet key
|
||||||
const auth = new FakeAuthenticator()
|
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, {
|
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
|
||||||
credentials: auth,
|
credentials: auth,
|
||||||
})
|
})
|
||||||
@@ -159,7 +159,7 @@ describe('slot ownership (continued)', () => {
|
|||||||
writeRawSlots([{...slot, ...foreignWrap}])
|
writeRawSlots([{...slot, ...foreignWrap}])
|
||||||
|
|
||||||
// When the authenticator successfully unwraps that foreign key
|
// 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
|
// Then owner validation rejects it before activation can receive it
|
||||||
await expect(attempt).rejects.toThrow('different wallet')
|
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 () => {
|
it('rejects a stale unlock when the saved owner changes during the ceremony', async () => {
|
||||||
// Given an authenticator that replaces the saved wallet before returning
|
// Given an authenticator that replaces the saved wallet before returning
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const stale: PasskeyCredentials = {
|
const stale: PasskeyCredentials = {
|
||||||
create: auth.create,
|
create: auth.create,
|
||||||
get: async (options) => {
|
get: async (options) => {
|
||||||
@@ -180,7 +180,7 @@ describe('slot ownership (continued)', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When the old wallet's ceremony completes after replacement
|
// 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
|
// Then the old linking key is never returned for activation
|
||||||
await expect(attempt).rejects.toThrow('different wallet')
|
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 () => {
|
it('does not adopt a slot carrying a malformed owner marker', async () => {
|
||||||
// Given an otherwise valid slot whose owner claim is malformed
|
// Given an otherwise valid slot whose owner claim is malformed
|
||||||
const auth = new FakeAuthenticator()
|
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'}
|
const malformed = {...slot, ownerId: 'not-an-owner'}
|
||||||
writeRawSlots([malformed])
|
writeRawSlots([malformed])
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ describe('slot ownership (continued)', () => {
|
|||||||
it('cannot remove a foreign-owner slot', async () => {
|
it('cannot remove a foreign-owner slot', async () => {
|
||||||
// Given a slot owned by another wallet remains in shared storage
|
// Given a slot owned by another wallet remains in shared storage
|
||||||
const auth = new FakeAuthenticator()
|
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)}
|
const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)}
|
||||||
writeRawSlots([foreign])
|
writeRawSlots([foreign])
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ describe('slot ownership (continued)', () => {
|
|||||||
it('rewraps only current-owner slots and preserves foreign slots', async () => {
|
it('rewraps only current-owner slots and preserves foreign slots', async () => {
|
||||||
// Given current and foreign slots share storage
|
// Given current and foreign slots share storage
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const foreign = {
|
const foreign = {
|
||||||
...slot,
|
...slot,
|
||||||
credentialId: '44'.repeat(16),
|
credentialId: '44'.repeat(16),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
unlockWalletMaterialWithPasskey,
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
@@ -147,9 +147,9 @@ beforeEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('registration and unlock', () => {
|
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 auth = new FakeAuthenticator()
|
||||||
const slot = await registerPasskey(LINKING_KEY, {
|
const slot = await registerPasskey(MATERIAL, {
|
||||||
credentials: auth,
|
credentials: auth,
|
||||||
name: 'laptop',
|
name: 'laptop',
|
||||||
})
|
})
|
||||||
@@ -157,33 +157,32 @@ describe('registration and unlock', () => {
|
|||||||
expect(readPasskeySlots()).toEqual([slot])
|
expect(readPasskeySlots()).toEqual([slot])
|
||||||
expect(hasPasskeySlots()).toBe(true)
|
expect(hasPasskeySlots()).toBe(true)
|
||||||
|
|
||||||
const unwrapped = await unlockWithPasskey({credentials: auth})
|
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
|
||||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
expect(unwrapped).toEqual(MATERIAL)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never stores the linking key in the clear', async () => {
|
it('never stores the linking key in the clear', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const raw = localStorage.getItem('sattle_passkey_slots')
|
const raw = localStorage.getItem('sattle_passkey_slots')
|
||||||
expect(raw).toBeTruthy()
|
expect(raw).toBeTruthy()
|
||||||
expect(raw).not.toContain(bytesToHex(LINKING_KEY))
|
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')
|
await saveWalletMaterial(MATERIAL, 'correct horse')
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(MATERIAL, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
|
|
||||||
const viaPassword = await decryptSavedWalletMaterial('correct horse')
|
const viaPassword = await decryptSavedWalletMaterial('correct horse')
|
||||||
const viaPasskey = await unlockWithPasskey({credentials: auth})
|
const viaPasskey = await unlockWalletMaterialWithPasskey({credentials: auth})
|
||||||
const passwordLinkingKey = walletMaterialLinkingKey(viaPassword)
|
expect(viaPasskey).toEqual(viaPassword)
|
||||||
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey))
|
|
||||||
|
|
||||||
// and the practical consequence: a bearer record encrypted after a
|
// and the practical consequence: a bearer record encrypted after a
|
||||||
// password unlock decrypts after a passkey unlock
|
// 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 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({
|
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
|
||||||
note: 'still readable',
|
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 () => {
|
it('falls back to a get ceremony when create only reports prf.enabled', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
auth.prfResultsOnCreate = false
|
auth.prfResultsOnCreate = false
|
||||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
expect(auth.getCalls).toBe(1)
|
expect(auth.getCalls).toBe(1)
|
||||||
const unwrapped = await unlockWithPasskey({credentials: auth})
|
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
|
||||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
expect(unwrapped).toEqual(MATERIAL)
|
||||||
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId)
|
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses registration when the authenticator has no PRF support', async () => {
|
it('refuses registration when the authenticator has no PRF support', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
auth.supportsPrf = false
|
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)
|
expect(hasPasskeySlots()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -211,7 +210,7 @@ describe('registration and unlock', () => {
|
|||||||
create: async () => null,
|
create: async () => null,
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
}
|
}
|
||||||
await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow(
|
await expect(registerPasskey(MATERIAL, {credentials: cancelled})).rejects.toThrow(
|
||||||
'cancelled',
|
'cancelled',
|
||||||
)
|
)
|
||||||
expect(hasPasskeySlots()).toBe(false)
|
expect(hasPasskeySlots()).toBe(false)
|
||||||
@@ -219,20 +218,24 @@ describe('registration and unlock', () => {
|
|||||||
|
|
||||||
it('throws before any ceremony when no passkeys are registered', async () => {
|
it('throws before any ceremony when no passkeys are registered', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
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)
|
expect(auth.getCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects unlock when the passkey returns no PRF secret', async () => {
|
it('rejects unlock when the passkey returns no PRF secret', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
auth.prfResultsOnGet = false
|
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 () => {
|
it('rejects unlock when the ceremony yields an unregistered credential', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
const rogue: PasskeyCredentials = {
|
const rogue: PasskeyCredentials = {
|
||||||
create: async () => null,
|
create: async () => null,
|
||||||
get: async () => ({
|
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 () => {
|
it('rejects unlock after the authenticator secret changed underneath the slot', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
const auth = new FakeAuthenticator()
|
||||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||||
auth.rotateSecret(slot.credentialId)
|
auth.rotateSecret(slot.credentialId)
|
||||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow()
|
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
unlockWalletMaterialWithPasskey,
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
@@ -149,10 +149,10 @@ describe('rewrap for the current owner', () => {
|
|||||||
it('refreshes every current-owner wrap all-or-nothing', async () => {
|
it('refreshes every current-owner wrap all-or-nothing', async () => {
|
||||||
const laptop = new FakeAuthenticator()
|
const laptop = new FakeAuthenticator()
|
||||||
const phone = new FakeAuthenticator()
|
const phone = new FakeAuthenticator()
|
||||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
const laptopSlot = await registerPasskey(MATERIAL, {
|
||||||
credentials: laptop,
|
credentials: laptop,
|
||||||
})
|
})
|
||||||
const phoneSlot = await registerPasskey(LINKING_KEY, {
|
const phoneSlot = await registerPasskey(MATERIAL, {
|
||||||
credentials: phone,
|
credentials: phone,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ describe('rewrap for the current owner', () => {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap')
|
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
|
// full coverage refreshes both wraps around the same proven owner key
|
||||||
const fresh = new Map([
|
const fresh = new Map([
|
||||||
@@ -184,8 +184,8 @@ describe('rewrap for the current owner', () => {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
await rewrapAllSlots(MATERIAL, fresh)
|
await rewrapAllSlots(MATERIAL, fresh)
|
||||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
|
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
|
||||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
|
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
|
||||||
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial)
|
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial)
|
||||||
// credential ids and labels survive the re-wrap
|
// credential ids and labels survive the re-wrap
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
@@ -148,7 +147,7 @@ beforeEach(async () => {
|
|||||||
describe('slot storage hygiene', () => {
|
describe('slot storage hygiene', () => {
|
||||||
it('drops malformed entries instead of throwing', async () => {
|
it('drops malformed entries instead of throwing', async () => {
|
||||||
const auth = new FakeAuthenticator()
|
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') ?? '[]')
|
const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'sattle_passkey_slots',
|
'sattle_passkey_slots',
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
registerPasskey,
|
registerPasskey,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
rewrapAllSlots,
|
rewrapAllSlots,
|
||||||
unlockWithPasskey,
|
|
||||||
unwrapWalletMaterialWithPrf,
|
unwrapWalletMaterialWithPrf,
|
||||||
wrapWalletMaterialWithPrf,
|
wrapWalletMaterialWithPrf,
|
||||||
} from './passkeys'
|
} from './passkeys'
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
import {sha256} from '@noble/hashes/sha2.js'
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
|
|
||||||
import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys'
|
|
||||||
import {withStorageLock} from './storageLock'
|
import {withStorageLock} from './storageLock'
|
||||||
import type {PasskeySlot} from './storage/passkeySlots'
|
import type {PasskeySlot} from './storage/passkeySlots'
|
||||||
import {
|
import {
|
||||||
@@ -154,27 +153,15 @@ export type RegisterPasskeyOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Registers a new passkey and persists a slot wrapping the complete material.
|
// 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
|
// The ceremony is navigator.credentials.create with the PRF
|
||||||
// select already-saved plaintext v2 material but can never create a key-only
|
|
||||||
// slot. The ceremony is navigator.credentials.create with the PRF
|
|
||||||
// extension evaluated on creation. Some authenticators only report
|
// extension evaluated on creation. Some authenticators only report
|
||||||
// prf.enabled during create and evaluate the secret on the first get -
|
// prf.enabled during create and evaluate the secret on the first get -
|
||||||
// those get a follow-up get() against the fresh credential.
|
// those get a follow-up get() against the fresh credential.
|
||||||
export const registerPasskey = async (
|
export const registerPasskey = async (
|
||||||
material: WalletMaterialV2 | Uint8Array,
|
material: WalletMaterialV2,
|
||||||
options: RegisterPasskeyOptions = {},
|
options: RegisterPasskeyOptions = {},
|
||||||
): Promise<PasskeySlot> => {
|
): Promise<PasskeySlot> => {
|
||||||
let walletMaterial: WalletMaterialV2
|
const ownerId = requireCurrentPasskeyMaterialOwner(material)
|
||||||
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 credentials = options.credentials ?? defaultCredentials()
|
const credentials = options.credentials ?? defaultCredentials()
|
||||||
const credential = await credentials.create({
|
const credential = await credentials.create({
|
||||||
publicKey: {
|
publicKey: {
|
||||||
@@ -209,7 +196,7 @@ export const registerPasskey = async (
|
|||||||
}
|
}
|
||||||
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
|
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
|
||||||
}
|
}
|
||||||
const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial)
|
const wrap = await wrapWalletMaterialWithPrf(prfOutput, material)
|
||||||
const slot: PasskeySlot = {
|
const slot: PasskeySlot = {
|
||||||
credentialId,
|
credentialId,
|
||||||
...wrap,
|
...wrap,
|
||||||
@@ -219,7 +206,7 @@ export const registerPasskey = async (
|
|||||||
version: PASSKEY_SLOT_VERSION,
|
version: PASSKEY_SLOT_VERSION,
|
||||||
}
|
}
|
||||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||||
if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) {
|
if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
|
||||||
throw new Error('Saved wallet material changed during passkey registration.')
|
throw new Error('Saved wallet material changed during passkey registration.')
|
||||||
}
|
}
|
||||||
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
|
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
|
||||||
@@ -286,13 +273,6 @@ export const unlockWalletMaterialWithPasskey = async (
|
|||||||
return material
|
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
|
// Removes the slot only: WebAuthn has no API to delete the credential from
|
||||||
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
|
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
|
||||||
// Returns whether a slot was actually removed.
|
// Returns whether a slot was actually removed.
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import {bytesToHex} from '@noble/hashes/utils.js'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
deriveWalletMaterial,
|
deriveWalletMaterial,
|
||||||
ensureSavedKeyOwner,
|
ensureSavedWalletMaterialOwner,
|
||||||
getPlainLinkingKey,
|
getPlainWalletMaterial,
|
||||||
linkingPubKeyHex,
|
linkingPubKeyHex,
|
||||||
savedKeyOwnerId,
|
savedWalletMaterialOwnerId,
|
||||||
saveWalletMaterial,
|
saveWalletMaterial,
|
||||||
walletMaterialHash,
|
walletMaterialHash,
|
||||||
} from './keys'
|
} from './keys'
|
||||||
@@ -16,7 +16,7 @@ import {parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
|||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
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 SLOT_STORAGE = 'sattle_passkey_slots'
|
||||||
const MATERIAL = {
|
const MATERIAL = {
|
||||||
...deriveWalletMaterial(
|
...deriveWalletMaterial(
|
||||||
@@ -38,18 +38,24 @@ const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
|
|||||||
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots))
|
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', () => {
|
describe('passkey-slot schema version', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
stubLocalStorage()
|
stubLocalStorage()
|
||||||
await saveWalletMaterial(MATERIAL)
|
await saveWalletMaterial(MATERIAL)
|
||||||
})
|
})
|
||||||
it('reads a current version 2 slot for the exact saved owner', () => {
|
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)
|
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}
|
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2}
|
||||||
writeRawSlots([slot])
|
writeRawSlots([slot])
|
||||||
|
|
||||||
@@ -58,24 +64,22 @@ describe('passkey-slot schema version', () => {
|
|||||||
expect(readPasskeySlots()).toEqual([slot])
|
expect(readPasskeySlots()).toEqual([slot])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => {
|
it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => {
|
||||||
// Given records written before schema versioning
|
// 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)
|
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||||
localStorage.setItem(
|
removeSavedOwnerMarker()
|
||||||
KEY_STORAGE,
|
|
||||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
|
|
||||||
)
|
|
||||||
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}])
|
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')
|
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||||
const provenKey = getPlainLinkingKey()
|
const provenMaterial = getPlainWalletMaterial()
|
||||||
if (provenKey === null) throw new Error('expected the compatible plaintext key')
|
if (provenMaterial === null) throw new Error('expected the compatible plaintext material')
|
||||||
ensureSavedKeyOwner(provenKey)
|
ensureSavedWalletMaterialOwner(provenMaterial)
|
||||||
await migrateLegacyPasskeySlots(provenKey)
|
await migrateLegacyPasskeySlots(LINKING_KEY)
|
||||||
|
|
||||||
// Then both compatible legacy forms become current records
|
// Then both compatible legacy forms become current records
|
||||||
expect(savedKeyOwnerId()).toBe(ownerId)
|
expect(savedWalletMaterialOwnerId()).toBe(ownerId)
|
||||||
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
|
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
|
||||||
{...SLOT_BASE, ownerId, version: 2},
|
{...SLOT_BASE, ownerId, version: 2},
|
||||||
{...SLOT_BASE, credentialId: '55'.repeat(16), 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) => {
|
it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => {
|
||||||
// Given an otherwise valid slot carrying unrecognized metadata
|
// Given an otherwise valid slot carrying unrecognized metadata
|
||||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
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}])
|
writeRawSlots([{...SLOT_BASE, ownerId, version}])
|
||||||
|
|
||||||
// When slots are parsed
|
// When slots are parsed
|
||||||
@@ -98,11 +98,6 @@ describe('passkey-slot schema version', () => {
|
|||||||
|
|
||||||
it('hides a foreign version 1 slot from the current owner', () => {
|
it('hides a foreign version 1 slot from the current owner', () => {
|
||||||
// Given the saved owner and slot owner differ
|
// 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}])
|
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}])
|
||||||
|
|
||||||
// When the current wallet reads passkeys
|
// When the current wallet reads passkeys
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// Mutators must not let a still-running old tab overwrite namespaces after a
|
// 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
|
// successor has installed its saved-key owner marker. Ownerless data may only
|
||||||
// move through explicit migration APIs after their own proof checks succeed.
|
// 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 {savedKeyOwnerId} from '../keys'
|
||||||
|
import {savedWalletMaterialOwnerId} from '../walletMaterialStorage'
|
||||||
|
|
||||||
export class WalletOwnerMismatchError extends Error {
|
export class WalletOwnerMismatchError extends Error {
|
||||||
override readonly name = 'WalletOwnerMismatchError'
|
override readonly name = 'WalletOwnerMismatchError'
|
||||||
@@ -12,7 +20,7 @@ export class WalletOwnerMismatchError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const savedKeyOwnerAllows = (ownerId: string): boolean => {
|
export const savedKeyOwnerAllows = (ownerId: string): boolean => {
|
||||||
return savedKeyOwnerId() === ownerId
|
return savedWalletMaterialOwnerId() === ownerId || savedKeyOwnerId() === ownerId
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assertSavedKeyOwner = (ownerId: string): void => {
|
export const assertSavedKeyOwner = (ownerId: string): void => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// parsed before use; ownerless v0 records remain hidden until an already
|
// parsed before use; ownerless v0 records remain hidden until an already
|
||||||
// proven saved wallet explicitly migrates them.
|
// proven saved wallet explicitly migrates them.
|
||||||
|
|
||||||
import {linkingPubKeyHex, savedKeyOwnerId} from '../keys'
|
import {linkingPubKeyHex} from '../keys'
|
||||||
import {
|
import {
|
||||||
clearNwcEnabledForOwner,
|
clearNwcEnabledForOwner,
|
||||||
clearUnownedNwcEnabled,
|
clearUnownedNwcEnabled,
|
||||||
@@ -177,7 +177,7 @@ export type NwcLegacyMigrationResult = {
|
|||||||
|
|
||||||
export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => {
|
export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => {
|
||||||
const ownerId = linkingPubKeyHex(linkingKey)
|
const ownerId = linkingPubKeyHex(linkingKey)
|
||||||
if (savedKeyOwnerId() !== ownerId) {
|
if (!savedKeyOwnerAllows(ownerId)) {
|
||||||
throw new Error('Legacy NWC migration requires a proven saved wallet owner.')
|
throw new Error('Legacy NWC migration requires a proven saved wallet owner.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
removeStoredTrustedMintsForOwner,
|
removeStoredTrustedMintsForOwner,
|
||||||
resetStoredTrustedMints,
|
resetStoredTrustedMints,
|
||||||
} from './trustedMintsRepository'
|
} from './trustedMintsRepository'
|
||||||
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
import {linkingPubKeyHex} from './keys'
|
||||||
|
import {savedKeyOwnerAllows} from './storage/currentOwner'
|
||||||
import {
|
import {
|
||||||
addMint,
|
addMint,
|
||||||
cacheMintNodeInfo,
|
cacheMintNodeInfo,
|
||||||
@@ -259,7 +260,7 @@ export const clearTrustedMints = (ownerId?: string): Promise<void> =>
|
|||||||
|
|
||||||
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
|
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
|
||||||
const ownerId = linkingPubKeyHex(linkingKey)
|
const ownerId = linkingPubKeyHex(linkingKey)
|
||||||
if (savedKeyOwnerId() !== ownerId) {
|
if (!savedKeyOwnerAllows(ownerId)) {
|
||||||
throw new Error('Legacy trusted-mint migration requires a proven owner.')
|
throw new Error('Legacy trusted-mint migration requires a proven owner.')
|
||||||
}
|
}
|
||||||
return adoptLegacyStoredTrustedMints(ownerId)
|
return adoptLegacyStoredTrustedMints(ownerId)
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ const doEnableBiometric = async () => {
|
|||||||
biometricBusy.value = true;
|
biometricBusy.value = true;
|
||||||
banner.value = '';
|
banner.value = '';
|
||||||
try {
|
try {
|
||||||
await enableBiometricUnlock(wallet.requireLinkingKey());
|
await enableBiometricUnlock(wallet.requireWalletMaterial());
|
||||||
biometricEnrolled.value = true;
|
biometricEnrolled.value = true;
|
||||||
toast('positive', 'Biometric unlock enabled.');
|
toast('positive', 'Biometric unlock enabled.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -289,7 +289,7 @@ const doRegister = async () => {
|
|||||||
banner.value = '';
|
banner.value = '';
|
||||||
try {
|
try {
|
||||||
const name = registerName.value.trim();
|
const name = registerName.value.trim();
|
||||||
await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {});
|
await registerPasskey(wallet.requireWalletMaterial(), name ? { name } : {});
|
||||||
slots.value = readPasskeySlots();
|
slots.value = readPasskeySlots();
|
||||||
registering.value = false;
|
registering.value = false;
|
||||||
toast('positive', 'Passkey added.');
|
toast('positive', 'Passkey added.');
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { createPinia, disposePinia, setActivePinia } from 'pinia';
|
import { createPinia, disposePinia, setActivePinia } from 'pinia';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
import { deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial } from '@/lnurlcash/keys';
|
||||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
import { useMintsStore } from './mints';
|
import { useMintsStore } from './mints';
|
||||||
import { useWalletStore } from './wallet';
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
const LINKING_KEY_HEX = '07'.repeat(32);
|
|
||||||
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7));
|
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7));
|
||||||
|
const MATERIAL = {
|
||||||
|
...deriveWalletMaterial(
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||||
|
),
|
||||||
|
linkingKeyHex: bytesToHex(new Uint8Array(32).fill(7)),
|
||||||
|
};
|
||||||
const MINT_KEY = '02' + 'aa'.repeat(32);
|
const MINT_KEY = '02' + 'aa'.repeat(32);
|
||||||
let testPinia: ReturnType<typeof createPinia>;
|
let testPinia: ReturnType<typeof createPinia>;
|
||||||
|
|
||||||
@@ -34,10 +40,7 @@ describe('mints store storage-event convergence', () => {
|
|||||||
// Given an unlocked wallet and its mounted mints store
|
// Given an unlocked wallet and its mounted mints store
|
||||||
const events = new EventTarget();
|
const events = new EventTarget();
|
||||||
vi.stubGlobal('window', events);
|
vi.stubGlobal('window', events);
|
||||||
localStorage.setItem(
|
await saveWalletMaterial(MATERIAL);
|
||||||
'sattle_linking_key',
|
|
||||||
JSON.stringify({ enc: false, value: LINKING_KEY_HEX, ownerId: OWNER_ID, version: 1 }),
|
|
||||||
);
|
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
await wallet.init();
|
await wallet.init();
|
||||||
const mints = useMintsStore();
|
const mints = useMintsStore();
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import {
|
|||||||
OWNER_ID,
|
OWNER_ID,
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
deferred,
|
deferred,
|
||||||
installLegacyEncryptedWallet,
|
|
||||||
installLegacyOwnerlessResidue,
|
installLegacyOwnerlessResidue,
|
||||||
|
installOwnerlessEncryptedWalletMaterial,
|
||||||
mocks,
|
mocks,
|
||||||
} from './wallet.lifecycle.testHarness';
|
} from './wallet.lifecycle.testHarness';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { savedKeyExists, savedKeyOwnerId } from '@/lnurlcash/keys';
|
import { savedWalletMaterialExists, savedWalletMaterialOwnerId } from '@/lnurlcash/keys';
|
||||||
import { readNwcConnections } from '@/lnurlcash/nwc';
|
import { readNwcConnections } from '@/lnurlcash/nwc';
|
||||||
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
||||||
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||||
@@ -17,8 +17,8 @@ import { useWalletStore } from './wallet';
|
|||||||
|
|
||||||
describe('serialized wallet activation', () => {
|
describe('serialized wallet activation', () => {
|
||||||
it('migrates a proven legacy owner before NWC can observe unlocked', async () => {
|
it('migrates a proven legacy owner before NWC can observe unlocked', async () => {
|
||||||
// Given an encrypted legacy wallet with ownerless authorization residue
|
// Given a restored (ownerless) v2 wallet with ownerless authorization residue
|
||||||
await installLegacyEncryptedWallet();
|
await installOwnerlessEncryptedWalletMaterial();
|
||||||
installLegacyOwnerlessResidue();
|
installLegacyOwnerlessResidue();
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
useNwcStore();
|
useNwcStore();
|
||||||
@@ -27,7 +27,7 @@ describe('serialized wallet activation', () => {
|
|||||||
await wallet.unlock(PASSWORD);
|
await wallet.unlock(PASSWORD);
|
||||||
|
|
||||||
// Then every legacy namespace belongs to the proven owner before startup
|
// Then every legacy namespace belongs to the proven owner before startup
|
||||||
expect(savedKeyOwnerId()).toBe(OWNER_ID);
|
expect(savedWalletMaterialOwnerId()).toBe(OWNER_ID);
|
||||||
expect(readPasskeySlots()).toHaveLength(1);
|
expect(readPasskeySlots()).toHaveLength(1);
|
||||||
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
|
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
|
||||||
expect(readTrustedMints(OWNER_ID)).toHaveLength(1);
|
expect(readTrustedMints(OWNER_ID)).toHaveLength(1);
|
||||||
@@ -50,7 +50,7 @@ describe('serialized wallet activation', () => {
|
|||||||
|
|
||||||
// Then the successor cannot install until the old owner drain completes,
|
// Then the successor cannot install until the old owner drain completes,
|
||||||
// and the session keeps its commit capability while the drain runs
|
// and the session keeps its commit capability while the drain runs
|
||||||
expect(savedKeyExists()).toBe(true);
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
expect(wallet.state).toBe('unlocked');
|
expect(wallet.state).toBe('unlocked');
|
||||||
drain.resolve();
|
drain.resolve();
|
||||||
await forgetting;
|
await forgetting;
|
||||||
@@ -70,9 +70,10 @@ describe('serialized wallet activation', () => {
|
|||||||
// When a valid file restore starts
|
// When a valid file restore starts
|
||||||
const restoring = wallet.restoreFromBackup({
|
const restoring = wallet.restoreFromBackup({
|
||||||
type: 'sattle-backup',
|
type: 'sattle-backup',
|
||||||
version: 1,
|
version: 2,
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
bearers: [],
|
bearers: [],
|
||||||
|
nextByHost: {},
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
|
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import {
|
import {
|
||||||
MINT_KEY,
|
MINT_KEY,
|
||||||
OTHER_OWNER_ID,
|
OTHER_OWNER_ID,
|
||||||
OWNER_ID,
|
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
encryptedLinkingKeyRecord,
|
encryptedLinkingKeyRecord,
|
||||||
} from './wallet.lifecycle.testHarness';
|
} from './wallet.lifecycle.testHarness';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
import { savedKeyExists, savedWalletMaterialExists } from '@/lnurlcash/keys';
|
||||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
import { addTrustedMint } from '@/lnurlcash/trustedMints';
|
||||||
import { useNwcStore } from './nwc';
|
import { useNwcStore } from './nwc';
|
||||||
import { useWalletStore } from './wallet';
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
describe('hostile file backup owner', () => {
|
describe('hostile file backup owner', () => {
|
||||||
it('drops file trust until the restored encrypted key proves its actual owner', async () => {
|
it('never installs or namespaces trust from a file-carried legacy key', async () => {
|
||||||
// Given a fresh device and an encrypted backup whose valid owner claim is foreign
|
// Given a fresh device and an encrypted backup whose valid owner claim is foreign
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
useNwcStore();
|
useNwcStore();
|
||||||
const result = await wallet.restoreFromBackup({
|
const result = await wallet.restoreFromBackup({
|
||||||
type: 'sattle-backup',
|
type: 'sattle-backup',
|
||||||
version: 1,
|
version: 2,
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
ownerId: OTHER_OWNER_ID,
|
ownerId: OTHER_OWNER_ID,
|
||||||
linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID },
|
linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID },
|
||||||
bearers: [],
|
bearers: [],
|
||||||
|
nextByHost: {},
|
||||||
trustedMints: [
|
trustedMints: [
|
||||||
{
|
{
|
||||||
server: 'file-mint.example',
|
server: 'file-mint.example',
|
||||||
@@ -34,24 +34,32 @@ describe('hostile file backup owner', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const registryBeforeProof: unknown = JSON.parse(
|
|
||||||
localStorage.getItem('sattle_trusted_mints') ?? 'null',
|
// Then the merge reports what the file carried, but the v2 lifecycle
|
||||||
);
|
// cannot activate a linking-key-only record (the cash root is
|
||||||
|
// unrecoverable from it): the residue is removed instead of leaving a
|
||||||
|
// half-installed locked wallet behind
|
||||||
expect(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 });
|
expect(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 });
|
||||||
expect(savedKeyOwnerId()).toBeNull();
|
expect(savedKeyExists()).toBe(false);
|
||||||
expect(registryBeforeProof).toBeNull();
|
expect(savedWalletMaterialExists()).toBe(false);
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
||||||
|
|
||||||
// When password proof activates the restored linking key
|
// And no unlock can ever succeed against the file's claim
|
||||||
await expect(wallet.unlock(PASSWORD)).resolves.toBeUndefined();
|
await expect(wallet.unlock(PASSWORD)).rejects.toThrow();
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
|
||||||
// Then the file claim left no residue and only the derived owner can initialize trust
|
// When the holder installs their own wallet afterward
|
||||||
expect(readTrustedMints(OTHER_OWNER_ID)).toEqual([]);
|
await wallet.create(PASSWORD);
|
||||||
expect(readTrustedMints(OWNER_ID)).toEqual([]);
|
const ownerId = wallet.pubkey;
|
||||||
|
if (ownerId === null) throw new Error('Expected an unlocked owner.');
|
||||||
|
|
||||||
|
// Then only the device's own proven wallet can initialize trust
|
||||||
await expect(
|
await expect(
|
||||||
addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId: OWNER_ID }),
|
addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId }),
|
||||||
).resolves.toBe('added');
|
).resolves.toBe('added');
|
||||||
expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual(
|
expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual(
|
||||||
expect.objectContaining({ ownerId: OWNER_ID }),
|
expect.objectContaining({ ownerId }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { MINT_KEY, mocks, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness';
|
import {
|
||||||
|
MINT_KEY,
|
||||||
|
OTHER_MATERIAL,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
PASSWORD,
|
||||||
|
mocks,
|
||||||
|
} from './wallet.lifecycle.testHarness';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
import { saveWalletMaterial, savedWalletMaterialOwnerId } from '@/lnurlcash/keys';
|
||||||
import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc';
|
import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc';
|
||||||
import type { NwcConnectionRecord } from '@/lnurlcash/nwc';
|
import type { NwcConnectionRecord } from '@/lnurlcash/nwc';
|
||||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||||
@@ -19,20 +25,18 @@ describe('cross-tab owner invalidation', () => {
|
|||||||
if (oldOwner === null) throw new Error('Expected an unlocked old owner.');
|
if (oldOwner === null) throw new Error('Expected an unlocked old owner.');
|
||||||
|
|
||||||
// When another tab has already recreated the wallet and a delayed event arrives
|
// When another tab has already recreated the wallet and a delayed event arrives
|
||||||
localStorage.setItem(
|
await saveWalletMaterial(OTHER_MATERIAL);
|
||||||
'sattle_linking_key',
|
|
||||||
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
|
|
||||||
);
|
|
||||||
events.dispatchEvent(
|
events.dispatchEvent(
|
||||||
Object.defineProperties(new Event('storage'), {
|
Object.defineProperties(new Event('storage'), {
|
||||||
key: { value: 'sattle_linking_key' },
|
key: { value: 'sattle_wallet_material_v2' },
|
||||||
|
// a stale payload on purpose: the monitor must re-read storage
|
||||||
newValue: { value: JSON.stringify({ ownerId: oldOwner }) },
|
newValue: { value: JSON.stringify({ ownerId: oldOwner }) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
|
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
|
||||||
|
|
||||||
// Then the stale runtime has no usable key and cannot recreate old-owner state
|
// Then the stale runtime has no usable material and cannot recreate old-owner state
|
||||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow(
|
await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow(
|
||||||
/owner/i,
|
/owner/i,
|
||||||
);
|
);
|
||||||
@@ -54,20 +58,17 @@ describe('cross-tab owner invalidation', () => {
|
|||||||
await nwc.setEnabled(true);
|
await nwc.setEnabled(true);
|
||||||
|
|
||||||
// When another tab replaces the saved wallet owner
|
// When another tab replaces the saved wallet owner
|
||||||
localStorage.setItem(
|
await saveWalletMaterial(OTHER_MATERIAL);
|
||||||
'sattle_linking_key',
|
|
||||||
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
|
|
||||||
);
|
|
||||||
events.dispatchEvent(
|
events.dispatchEvent(
|
||||||
Object.defineProperties(new Event('storage'), {
|
Object.defineProperties(new Event('storage'), {
|
||||||
key: { value: 'sattle_linking_key' },
|
key: { value: 'sattle_wallet_material_v2' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
|
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
|
||||||
|
|
||||||
// Then the stale runtime is cleared and the queue surfaces the drain failure
|
// Then the stale runtime is cleared and the queue surfaces the drain failure
|
||||||
expect(wallet.lifecycleError).toMatch(/stale drain failed/i);
|
expect(wallet.lifecycleError).toMatch(/stale drain failed/i);
|
||||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
expect(nwc.running).toBe(false);
|
expect(nwc.running).toBe(false);
|
||||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -90,7 +91,7 @@ describe('cross-tab owner invalidation', () => {
|
|||||||
|
|
||||||
// When A is forgotten before successor B is installed
|
// When A is forgotten before successor B is installed
|
||||||
await wallet.forgetWallet();
|
await wallet.forgetWallet();
|
||||||
expect(savedKeyOwnerId()).toBeNull();
|
expect(savedWalletMaterialOwnerId()).toBeNull();
|
||||||
|
|
||||||
// Then no stale normal mutation can recreate A-owned state in the gap
|
// Then no stale normal mutation can recreate A-owned state in the gap
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
} from './wallet.lifecycle.testHarness';
|
} from './wallet.lifecycle.testHarness';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { generateSeedPhrase, savedKeyExists } from '@/lnurlcash/keys';
|
import {
|
||||||
|
generateSeedPhrase,
|
||||||
|
savedWalletMaterialExists,
|
||||||
|
walletMaterialHash,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
import { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc';
|
import { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc';
|
||||||
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
||||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||||
@@ -48,7 +52,7 @@ describe('foreign wallet isolation', () => {
|
|||||||
// Then it fails explicitly and does not install a partial wallet
|
// Then it fails explicitly and does not install a partial wallet
|
||||||
await expect(restoring).rejects.toThrow(/valid sattle backup/i);
|
await expect(restoring).rejects.toThrow(/valid sattle backup/i);
|
||||||
expect(wallet.state).toBe('none');
|
expect(wallet.state).toBe('none');
|
||||||
expect(savedKeyExists()).toBe(false);
|
expect(savedWalletMaterialExists()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tears down the installed owner before a foreign seed restore', async () => {
|
it('tears down the installed owner before a foreign seed restore', async () => {
|
||||||
@@ -65,9 +69,11 @@ describe('foreign wallet isolation', () => {
|
|||||||
credentialId: '11'.repeat(16),
|
credentialId: '11'.repeat(16),
|
||||||
hkdfSalt: '22'.repeat(16),
|
hkdfSalt: '22'.repeat(16),
|
||||||
iv: '33'.repeat(12),
|
iv: '33'.repeat(12),
|
||||||
wrappedKey: '44'.repeat(48),
|
materialHash: walletMaterialHash(wallet.requireWalletMaterial()),
|
||||||
|
wrappedMaterial: '44'.repeat(48),
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
ownerId: oldOwner,
|
ownerId: oldOwner,
|
||||||
|
version: 2,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
LEGACY_BEARERS_KEY,
|
||||||
|
LEGACY_LINKING_KEY,
|
||||||
|
MATERIAL,
|
||||||
|
OWNER_ID,
|
||||||
|
WALLET_MATERIAL_KEY,
|
||||||
|
installLegacyOwnerlessResidue,
|
||||||
|
installLegacyWalletState,
|
||||||
|
mocks,
|
||||||
|
} from './wallet.lifecycle.testHarness';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { saveWalletMaterial, savedWalletMaterialExists } from '@/lnurlcash/keys';
|
||||||
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
|
describe('unsupported legacy install reset', () => {
|
||||||
|
it('resets legacy saved-key and bearer state before routing', async () => {
|
||||||
|
// Given an alpha install: the linking-key-only record, the legacy bearer
|
||||||
|
// namespace, and ownerless authorization residue
|
||||||
|
installLegacyWalletState();
|
||||||
|
installLegacyOwnerlessResidue();
|
||||||
|
|
||||||
|
// When the store is constructed, it must not advertise the legacy wallet
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
|
||||||
|
// When startup initialization runs
|
||||||
|
await wallet.init();
|
||||||
|
|
||||||
|
// Then every legacy namespace is gone and nothing was activated
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||||
|
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull();
|
||||||
|
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
|
||||||
|
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
|
||||||
|
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
|
||||||
|
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
||||||
|
expect(localStorage.getItem(WALLET_MATERIAL_KEY)).toBeNull();
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
expect(mocks.disableBiometricUnlock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets on a corrupt legacy record without parsing it', async () => {
|
||||||
|
// Given a legacy entry that no parser can read
|
||||||
|
localStorage.setItem(LEGACY_LINKING_KEY, '{{{not json');
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
|
||||||
|
// When startup initialization runs
|
||||||
|
await wallet.init();
|
||||||
|
|
||||||
|
// Then the reset keyed on raw presence, not on a successful parse
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a v2 install and only removes stray legacy key residue', async () => {
|
||||||
|
// Given a current plaintext v2 wallet plus a stray legacy record (an old
|
||||||
|
// tab or a restored backup left it behind)
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
localStorage.setItem(
|
||||||
|
LEGACY_LINKING_KEY,
|
||||||
|
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: 'ab'.repeat(33), version: 1 }),
|
||||||
|
);
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
|
||||||
|
// When startup initialization runs
|
||||||
|
await wallet.init();
|
||||||
|
|
||||||
|
// Then the v2 wallet activated and only the legacy residue is gone
|
||||||
|
expect(wallet.state).toBe('unlocked');
|
||||||
|
expect(wallet.pubkey).toBe(OWNER_ID);
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||||
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves legacy state intact and retryable when native biometric deletion fails', async () => {
|
||||||
|
// Given an alpha install whose secure-storage deletion rejects
|
||||||
|
installLegacyWalletState();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed'));
|
||||||
|
|
||||||
|
// When startup initialization runs
|
||||||
|
await wallet.init();
|
||||||
|
|
||||||
|
// Then the failure is surfaced, nothing was half-wiped, and the legacy
|
||||||
|
// state is still all there for the next attempt
|
||||||
|
expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).not.toBeNull();
|
||||||
|
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).not.toBeNull();
|
||||||
|
|
||||||
|
// When initialization runs again with native deletion succeeding
|
||||||
|
await wallet.init();
|
||||||
|
|
||||||
|
// Then the reset completes
|
||||||
|
expect(wallet.lifecycleError).toBe('');
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||||
|
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries the legacy reset before installing a successor wallet', async () => {
|
||||||
|
// Given an alpha install whose first reset attempt failed at native
|
||||||
|
// biometric deletion
|
||||||
|
installLegacyWalletState();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed'));
|
||||||
|
await wallet.init();
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).not.toBeNull();
|
||||||
|
|
||||||
|
// When the holder installs a fresh wallet
|
||||||
|
const phrase = await wallet.create();
|
||||||
|
|
||||||
|
// Then the pending reset ran first: no legacy residue survives into the
|
||||||
|
// successor installation
|
||||||
|
expect(phrase.split(' ')).toHaveLength(12);
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||||
|
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull();
|
||||||
|
expect(wallet.state).toBe('unlocked');
|
||||||
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
LEGACY_LINKING_KEY,
|
||||||
|
MATERIAL,
|
||||||
|
MINT_KEY,
|
||||||
|
OTHER_MATERIAL,
|
||||||
|
PASSWORD,
|
||||||
|
WALLET_MATERIAL_KEY,
|
||||||
|
deferredLocks,
|
||||||
|
installEncryptedWalletMaterial,
|
||||||
|
installLegacyOwnerlessResidue,
|
||||||
|
installOwnerlessEncryptedWalletMaterial,
|
||||||
|
mocks,
|
||||||
|
} from './wallet.lifecycle.testHarness';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveBearerAesKey,
|
||||||
|
encryptRecord,
|
||||||
|
saveWalletMaterial,
|
||||||
|
savedWalletMaterialExists,
|
||||||
|
savedWalletMaterialHash,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
walletMaterialHash,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
|
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||||
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
|
describe('complete material activation', () => {
|
||||||
|
it('persists v2 material on create and activates the same value on every unlock factor', async () => {
|
||||||
|
// Given a wallet created through the lifecycle
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const phrase = await wallet.create(PASSWORD);
|
||||||
|
expect(phrase.split(' ')).toHaveLength(12);
|
||||||
|
|
||||||
|
// Then the v2 envelope is the only saved secret record
|
||||||
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
|
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||||
|
const active = wallet.requireWalletMaterial();
|
||||||
|
expect(savedWalletMaterialHash()).toBe(walletMaterialHash(active));
|
||||||
|
expect(wallet.state).toBe('unlocked');
|
||||||
|
|
||||||
|
// When the wallet is unlocked through each factor in turn
|
||||||
|
await wallet.lock();
|
||||||
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
|
await wallet.unlock(PASSWORD);
|
||||||
|
expect(wallet.requireWalletMaterial()).toEqual(active);
|
||||||
|
|
||||||
|
await wallet.lock();
|
||||||
|
mocks.unlockWalletMaterialWithPasskey.mockResolvedValue(active);
|
||||||
|
await wallet.unlockWithPasskey();
|
||||||
|
expect(wallet.requireWalletMaterial()).toEqual(active);
|
||||||
|
|
||||||
|
await wallet.lock();
|
||||||
|
mocks.unlockWalletMaterialWithBiometrics.mockResolvedValue(active);
|
||||||
|
await wallet.unlockWithBiometric();
|
||||||
|
|
||||||
|
// Then every factor activated the exact same complete material
|
||||||
|
expect(wallet.requireWalletMaterial()).toEqual(active);
|
||||||
|
expect(wallet.state).toBe('unlocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the material private and memory-only while unlocked', async () => {
|
||||||
|
// Given an encrypted wallet
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create(PASSWORD);
|
||||||
|
|
||||||
|
// Then the persisted record holds only the wrap, never plaintext material
|
||||||
|
const stored = JSON.parse(localStorage.getItem(WALLET_MATERIAL_KEY) ?? '{}') as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(stored.enc).toBe(true);
|
||||||
|
expect(stored.value).toBeUndefined();
|
||||||
|
|
||||||
|
// And locking drops every runtime reference to it
|
||||||
|
await wallet.lock();
|
||||||
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
|
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fences activation when the saved owner is replaced during deferred loading', async () => {
|
||||||
|
// Given a restored (ownerless) wallet whose activation pauses inside the
|
||||||
|
// legacy-adoption storage lock
|
||||||
|
await installOwnerlessEncryptedWalletMaterial();
|
||||||
|
installLegacyOwnerlessResidue();
|
||||||
|
const deferred = deferredLocks();
|
||||||
|
vi.stubGlobal('navigator', { locks: deferred.locks });
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
|
||||||
|
// When the saved material is replaced by a different owner mid-activation
|
||||||
|
const unlocking = wallet.unlock(PASSWORD);
|
||||||
|
await vi.waitFor(() => expect(deferred.requests).toHaveLength(1));
|
||||||
|
await saveWalletMaterial(OTHER_MATERIAL);
|
||||||
|
await deferred.releaseNext();
|
||||||
|
|
||||||
|
// Then activation rejects, no runtime material survives, and the
|
||||||
|
// replacement's locked record is what remains
|
||||||
|
await expect(unlocking).rejects.toThrow();
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
expect(savedWalletMaterialOwnerId()).not.toBeNull();
|
||||||
|
expect(wallet.pubkey).toBeNull();
|
||||||
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
|
expect(wallet.bearers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fences activation on a final pre-unlocked owner re-read after deferred trust work', async () => {
|
||||||
|
// Given an owned wallet holding a mint-pinned bearer whose trust
|
||||||
|
// restoration pauses inside the trusted-mints storage lock
|
||||||
|
await installEncryptedWalletMaterial();
|
||||||
|
const aesKey = await deriveBearerAesKey(walletMaterialLinkingKey(MATERIAL));
|
||||||
|
const bearer = {
|
||||||
|
url: buildNoteUrl('https://mint.example/w', 'bb'.repeat(32), 21_000),
|
||||||
|
callback: 'https://mint.example/w/cb',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
mintPubkey: MINT_KEY,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
const record = await encryptRecord(aesKey, bearer);
|
||||||
|
localStorage.setItem(
|
||||||
|
FUNDS_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
bearers: [{ id: 'b1', ...record }],
|
||||||
|
pending: [],
|
||||||
|
nextByHost: {},
|
||||||
|
revision: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const deferred = deferredLocks();
|
||||||
|
vi.stubGlobal('navigator', { locks: deferred.locks });
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
|
||||||
|
// When the saved owner is replaced while trust restoration is suspended
|
||||||
|
const unlocking = wallet.unlock(PASSWORD);
|
||||||
|
await vi.waitFor(() => expect(deferred.requests).toHaveLength(1));
|
||||||
|
await saveWalletMaterial(OTHER_MATERIAL);
|
||||||
|
await deferred.releaseNext();
|
||||||
|
|
||||||
|
// Then the pre-unlocked re-read rejects the stale activation before the
|
||||||
|
// wallet ever exposes an unlocked state
|
||||||
|
await expect(unlocking).rejects.toThrow();
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
expect(wallet.pubkey).toBeNull();
|
||||||
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
|
expect(wallet.bearers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no half-installed wallet when the material write fails during create', async () => {
|
||||||
|
// Given a store whose material write hits a full disk
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const setItem = localStorage.setItem.bind(localStorage);
|
||||||
|
localStorage.setItem = (key: string, value: string): void => {
|
||||||
|
if (key === WALLET_MATERIAL_KEY) throw new Error('quota exceeded');
|
||||||
|
setItem(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// When create cannot persist the complete material
|
||||||
|
await expect(wallet.create(PASSWORD)).rejects.toThrow(/quota exceeded/);
|
||||||
|
|
||||||
|
// Then nothing is installed and no runtime material lingers
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
expect(savedWalletMaterialExists()).toBe(false);
|
||||||
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
|
|
||||||
|
// And the transition is retryable once storage works again
|
||||||
|
localStorage.setItem = setItem;
|
||||||
|
const phrase = await wallet.create(PASSWORD);
|
||||||
|
expect(phrase.split(' ')).toHaveLength(12);
|
||||||
|
expect(wallet.state).toBe('unlocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unlock without saved material and stays at none', async () => {
|
||||||
|
// Given no wallet on this device
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
|
||||||
|
// When an unlock is attempted anyway
|
||||||
|
await expect(wallet.unlock(PASSWORD)).rejects.toThrow();
|
||||||
|
|
||||||
|
// Then the device stays uninstalled and retryable
|
||||||
|
expect(wallet.state).toBe('none');
|
||||||
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MINT_KEY, PASSWORD, deferred, mocks } from './wallet.lifecycle.testHarness';
|
import { MINT_KEY, PASSWORD, WALLET_MATERIAL_KEY, deferred, mocks } from './wallet.lifecycle.testHarness';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { savedKeyExists } from '@/lnurlcash/keys';
|
import { savedWalletMaterialExists, walletMaterialHash } from '@/lnurlcash/keys';
|
||||||
import { addTrustedMint } from '@/lnurlcash/trustedMints';
|
import { addTrustedMint } from '@/lnurlcash/trustedMints';
|
||||||
import { useNwcStore } from './nwc';
|
import { useNwcStore } from './nwc';
|
||||||
import { useWalletStore } from './wallet';
|
import { useWalletStore } from './wallet';
|
||||||
@@ -24,7 +24,7 @@ describe('complete owner teardown', () => {
|
|||||||
await expect(locking).rejects.toThrow('NWC lock drain failed');
|
await expect(locking).rejects.toThrow('NWC lock drain failed');
|
||||||
expect(wallet.state).toBe('locked');
|
expect(wallet.state).toBe('locked');
|
||||||
expect(wallet.lifecycleError).toMatch(/NWC lock drain failed/i);
|
expect(wallet.lifecycleError).toMatch(/NWC lock drain failed/i);
|
||||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
expect(wallet.bearers).toEqual([]);
|
expect(wallet.bearers).toEqual([]);
|
||||||
expect(nwc.running).toBe(false);
|
expect(nwc.running).toBe(false);
|
||||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||||
@@ -58,9 +58,11 @@ describe('complete owner teardown', () => {
|
|||||||
credentialId: '11'.repeat(16),
|
credentialId: '11'.repeat(16),
|
||||||
hkdfSalt: '22'.repeat(16),
|
hkdfSalt: '22'.repeat(16),
|
||||||
iv: '33'.repeat(12),
|
iv: '33'.repeat(12),
|
||||||
wrappedKey: '44'.repeat(48),
|
materialHash: walletMaterialHash(wallet.requireWalletMaterial()),
|
||||||
|
wrappedMaterial: '44'.repeat(48),
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
ownerId,
|
ownerId,
|
||||||
|
version: 2,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -79,19 +81,19 @@ describe('complete owner teardown', () => {
|
|||||||
// Then completion and destructive storage removal wait for the drain,
|
// Then completion and destructive storage removal wait for the drain,
|
||||||
// and the session stays commit-capable (fence and keys live) until it
|
// and the session stays commit-capable (fence and keys live) until it
|
||||||
// finishes so accepted NWC work can still reach its durable outcome
|
// finishes so accepted NWC work can still reach its durable outcome
|
||||||
expect(savedKeyExists()).toBe(true);
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
expect(wallet.state).toBe('unlocked');
|
expect(wallet.state).toBe('unlocked');
|
||||||
drain.resolve();
|
drain.resolve();
|
||||||
await forgetting;
|
await forgetting;
|
||||||
expect(wallet.state).toBe('none');
|
expect(wallet.state).toBe('none');
|
||||||
expect(savedKeyExists()).toBe(false);
|
expect(savedWalletMaterialExists()).toBe(false);
|
||||||
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
|
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
|
||||||
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
|
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
|
||||||
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
|
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
|
||||||
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
||||||
expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true);
|
expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true);
|
||||||
expect(cleanupOrder.indexOf('listener:scroll')).toBeLessThan(
|
expect(cleanupOrder.indexOf('listener:scroll')).toBeLessThan(
|
||||||
cleanupOrder.indexOf('storage:sattle_linking_key'),
|
cleanupOrder.indexOf(`storage:${WALLET_MATERIAL_KEY}`),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,12 +107,12 @@ describe('complete owner teardown', () => {
|
|||||||
// When forget reaches biometric teardown
|
// When forget reaches biometric teardown
|
||||||
const forgetting = wallet.forgetWallet();
|
const forgetting = wallet.forgetWallet();
|
||||||
|
|
||||||
// Then the caller sees failure and the saved key is not falsely removed
|
// Then the caller sees failure and the saved material is not falsely removed
|
||||||
await expect(forgetting).rejects.toThrow('secure delete failed');
|
await expect(forgetting).rejects.toThrow('secure delete failed');
|
||||||
expect(wallet.state).toBe('locked');
|
expect(wallet.state).toBe('locked');
|
||||||
expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
|
expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
|
||||||
expect(savedKeyExists()).toBe(true);
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces NWC drain failure without removing the saved owner', async () => {
|
it('surfaces NWC drain failure without removing the saved owner', async () => {
|
||||||
@@ -127,8 +129,8 @@ describe('complete owner teardown', () => {
|
|||||||
await expect(forgetting).rejects.toThrow('NWC drain failed');
|
await expect(forgetting).rejects.toThrow('NWC drain failed');
|
||||||
expect(wallet.state).toBe('locked');
|
expect(wallet.state).toBe('locked');
|
||||||
expect(wallet.lifecycleError).toMatch(/NWC drain failed/i);
|
expect(wallet.lifecycleError).toMatch(/NWC drain failed/i);
|
||||||
expect(savedKeyExists()).toBe(true);
|
expect(savedWalletMaterialExists()).toBe(true);
|
||||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('completes teardown on retry after a transient biometric deletion failure', async () => {
|
it('completes teardown on retry after a transient biometric deletion failure', async () => {
|
||||||
@@ -146,6 +148,6 @@ describe('complete owner teardown', () => {
|
|||||||
// Then teardown completes and the error surface clears
|
// Then teardown completes and the error surface clears
|
||||||
expect(wallet.state).toBe('none');
|
expect(wallet.state).toBe('none');
|
||||||
expect(wallet.lifecycleError).toBe('');
|
expect(wallet.lifecycleError).toBe('');
|
||||||
expect(savedKeyExists()).toBe(false);
|
expect(savedWalletMaterialExists()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import './wallet.lifecycle.activation.cases';
|
import './wallet.lifecycle.activation.cases';
|
||||||
|
import './wallet.lifecycle.material.cases';
|
||||||
|
import './wallet.lifecycle.legacyReset.cases';
|
||||||
import './wallet.lifecycle.teardown.cases';
|
import './wallet.lifecycle.teardown.cases';
|
||||||
import './wallet.lifecycle.lockFailure.cases';
|
import './wallet.lifecycle.lockFailure.cases';
|
||||||
import './wallet.lifecycle.isolation.cases';
|
import './wallet.lifecycle.isolation.cases';
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { beforeEach, vi } from 'vitest';
|
import { beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
import { encryptSecretParts, linkingPubKeyHex } from '@/lnurlcash/keys';
|
import {
|
||||||
|
deriveWalletMaterial,
|
||||||
|
encryptSecretParts,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||||
import type * as NwcExports from '@/lnurlcash/nwc';
|
import type * as NwcExports from '@/lnurlcash/nwc';
|
||||||
import type * as NostrBackupExports from '@/lnurlcash/nostrBackup';
|
import type * as NostrBackupExports from '@/lnurlcash/nostrBackup';
|
||||||
|
import type * as PasskeysExports from '@/lnurlcash/passkeys';
|
||||||
|
import { WALLET_MATERIAL_STORAGE_KEY } from '@/lnurlcash/storage/walletOwnerEvents';
|
||||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
import { lifecycleMocks } from './wallet.lifecycle.testMocks';
|
import { lifecycleMocks } from './wallet.lifecycle.testMocks';
|
||||||
|
|
||||||
@@ -13,7 +23,16 @@ vi.mock('@/capabilities/biometricUnlock', async () => {
|
|||||||
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||||
return {
|
return {
|
||||||
disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock,
|
disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock,
|
||||||
unlockWithBiometrics: vi.fn(),
|
unlockWalletMaterialWithBiometrics: lifecycleMocks.unlockWalletMaterialWithBiometrics,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lnurlcash/passkeys', async (importOriginal) => {
|
||||||
|
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||||
|
const actual = await importOriginal<typeof PasskeysExports>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
unlockWalletMaterialWithPasskey: lifecycleMocks.unlockWalletMaterialWithPasskey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,19 +50,71 @@ vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => {
|
|||||||
|
|
||||||
export const LINKING_KEY = new Uint8Array(32).fill(7);
|
export const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||||
|
export const MATERIAL: WalletMaterialV2 = {
|
||||||
|
...deriveWalletMaterial(
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||||
|
),
|
||||||
|
linkingKeyHex: bytesToHex(LINKING_KEY),
|
||||||
|
};
|
||||||
|
export const OTHER_MATERIAL: WalletMaterialV2 = {
|
||||||
|
...MATERIAL,
|
||||||
|
linkingKeyHex: bytesToHex(OTHER_LINKING_KEY),
|
||||||
|
};
|
||||||
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
|
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
|
||||||
export const PASSWORD = 'correct horse battery staple';
|
export const PASSWORD = 'correct horse battery staple';
|
||||||
export const MINT_KEY = '02' + 'aa'.repeat(32);
|
export const MINT_KEY = '02' + 'aa'.repeat(32);
|
||||||
|
|
||||||
export const encryptedLinkingKeyRecord = async () => {
|
export const WALLET_MATERIAL_KEY = WALLET_MATERIAL_STORAGE_KEY;
|
||||||
const parts = await encryptSecretParts(
|
export const LEGACY_LINKING_KEY = 'sattle_linking_key';
|
||||||
Array.from(LINKING_KEY, (byte) => byte.toString(16).padStart(2, '0')).join(''),
|
// the frozen pre-v2 bearer namespace: current storage never writes it, so
|
||||||
PASSWORD,
|
// its presence means an alpha install
|
||||||
|
export const LEGACY_BEARERS_KEY = 'sattle_bearers';
|
||||||
|
|
||||||
|
export const encryptedWalletMaterialRecord = async (
|
||||||
|
material: WalletMaterialV2 = MATERIAL,
|
||||||
|
password: string = PASSWORD,
|
||||||
|
) => {
|
||||||
|
const parts = await encryptSecretParts(serializeWalletMaterial(material), password);
|
||||||
|
return { enc: true as const, ...parts, materialHash: walletMaterialHash(material) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// a v2 install whose owner marker is already proven (the steady state after
|
||||||
|
// any successful unlock)
|
||||||
|
export const installEncryptedWalletMaterial = async (): Promise<void> => {
|
||||||
|
const record = await encryptedWalletMaterialRecord();
|
||||||
|
localStorage.setItem(
|
||||||
|
WALLET_MATERIAL_KEY,
|
||||||
|
JSON.stringify({ ...record, ownerId: OWNER_ID, version: 2 }),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// a v2 install whose owner marker was stripped on restore - usable but not
|
||||||
|
// yet proven, the state a backup/relay restore leaves behind
|
||||||
|
export const installOwnerlessEncryptedWalletMaterial = async (): Promise<void> => {
|
||||||
|
localStorage.setItem(
|
||||||
|
WALLET_MATERIAL_KEY,
|
||||||
|
JSON.stringify(await encryptedWalletMaterialRecord()),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// the pre-v2 record shape a backup file can still carry - the lifecycle must
|
||||||
|
// never activate it, only clear it
|
||||||
|
export const encryptedLinkingKeyRecord = async () => {
|
||||||
|
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
|
||||||
return { enc: true as const, ...parts };
|
return { enc: true as const, ...parts };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// unsupported alpha state: the linking-key-only record and the legacy bearer
|
||||||
|
// namespace, never written by the v2 lifecycle
|
||||||
|
export const installLegacyWalletState = (): void => {
|
||||||
|
localStorage.setItem(
|
||||||
|
LEGACY_LINKING_KEY,
|
||||||
|
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId: OWNER_ID, version: 1 }),
|
||||||
|
);
|
||||||
|
localStorage.setItem(LEGACY_BEARERS_KEY, JSON.stringify([]));
|
||||||
|
};
|
||||||
|
|
||||||
type Deferred = {
|
type Deferred = {
|
||||||
readonly promise: Promise<void>;
|
readonly promise: Promise<void>;
|
||||||
readonly resolve: () => void;
|
readonly resolve: () => void;
|
||||||
@@ -60,6 +131,38 @@ export const deferred = (): Deferred => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LockRequest = {
|
||||||
|
readonly callback: () => unknown;
|
||||||
|
readonly resolve: (value: unknown) => void;
|
||||||
|
readonly reject: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// a navigator.locks stand-in whose requests queue up until the test releases
|
||||||
|
// them one at a time - the deferral hook for mid-activation interruptions.
|
||||||
|
// A callback failure rejects the lock promise (the real LockManager settles
|
||||||
|
// the caller's promise with the callback outcome)
|
||||||
|
export const deferredLocks = () => {
|
||||||
|
const requests: LockRequest[] = [];
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
locks: {
|
||||||
|
request: (_name: string, callback: () => unknown): Promise<unknown> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
requests.push({ callback, resolve, reject });
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
releaseNext: async (): Promise<void> => {
|
||||||
|
const request = requests.shift();
|
||||||
|
if (!request) throw new Error('Expected a queued lock request.');
|
||||||
|
try {
|
||||||
|
request.resolve(await request.callback());
|
||||||
|
} catch (error) {
|
||||||
|
request.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const installLegacyOwnerlessResidue = (): void => {
|
export const installLegacyOwnerlessResidue = (): void => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'sattle_passkey_slots',
|
'sattle_passkey_slots',
|
||||||
@@ -68,7 +171,8 @@ export const installLegacyOwnerlessResidue = (): void => {
|
|||||||
credentialId: '11'.repeat(16),
|
credentialId: '11'.repeat(16),
|
||||||
hkdfSalt: '22'.repeat(16),
|
hkdfSalt: '22'.repeat(16),
|
||||||
iv: '33'.repeat(12),
|
iv: '33'.repeat(12),
|
||||||
wrappedKey: '44'.repeat(48),
|
materialHash: walletMaterialHash(MATERIAL),
|
||||||
|
wrappedMaterial: '44'.repeat(48),
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@@ -99,16 +203,18 @@ export const installLegacyOwnerlessResidue = (): void => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const installLegacyEncryptedWallet = async (): Promise<void> => {
|
|
||||||
localStorage.setItem('sattle_linking_key', JSON.stringify(await encryptedLinkingKeyRecord()));
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
stubLocalStorage();
|
stubLocalStorage();
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
lifecycleMocks.disableBiometricUnlock.mockResolvedValue();
|
lifecycleMocks.disableBiometricUnlock.mockResolvedValue();
|
||||||
|
lifecycleMocks.unlockWalletMaterialWithBiometrics.mockRejectedValue(
|
||||||
|
new Error('Biometric unlock is not set up on this device.'),
|
||||||
|
);
|
||||||
|
lifecycleMocks.unlockWalletMaterialWithPasskey.mockRejectedValue(
|
||||||
|
new Error('No passkeys registered on this device.'),
|
||||||
|
);
|
||||||
lifecycleMocks.restoreFromNostr.mockResolvedValue({
|
lifecycleMocks.restoreFromNostr.mockResolvedValue({
|
||||||
added: 0,
|
added: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { vi } from 'vitest';
|
|||||||
|
|
||||||
export const lifecycleMocks = {
|
export const lifecycleMocks = {
|
||||||
disableBiometricUnlock: vi.fn<() => Promise<void>>(),
|
disableBiometricUnlock: vi.fn<() => Promise<void>>(),
|
||||||
|
unlockWalletMaterialWithBiometrics: vi.fn(),
|
||||||
|
unlockWalletMaterialWithPasskey: vi.fn(),
|
||||||
restoreFromNostr: vi.fn(),
|
restoreFromNostr: vi.fn(),
|
||||||
startService: vi.fn(),
|
startService: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
+94
-44
@@ -1,13 +1,18 @@
|
|||||||
|
// allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface.
|
||||||
import { computed, onScopeDispose, ref } from 'vue';
|
import { computed, onScopeDispose, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deriveBearerAesKey,
|
|
||||||
savedKeyExists,
|
|
||||||
savedKeyIsEncrypted,
|
|
||||||
savedKeyOwnerId,
|
|
||||||
clearSavedLinkingKey,
|
clearSavedLinkingKey,
|
||||||
|
clearSavedWalletMaterial,
|
||||||
|
deriveBearerAesKey,
|
||||||
|
savedWalletMaterialExists,
|
||||||
|
savedWalletMaterialIsEncrypted,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||||
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||||
import {
|
import {
|
||||||
loadBearers,
|
loadBearers,
|
||||||
clearAllBearers,
|
clearAllBearers,
|
||||||
@@ -32,7 +37,7 @@ import {
|
|||||||
createSeedInstaller,
|
createSeedInstaller,
|
||||||
createWalletTransitionQueue,
|
createWalletTransitionQueue,
|
||||||
migrateProvenLegacyOwner,
|
migrateProvenLegacyOwner,
|
||||||
ownerOf,
|
resetUnsupportedLegacyWalletState,
|
||||||
stopWalletNwcSession,
|
stopWalletNwcSession,
|
||||||
WalletLifecycleError,
|
WalletLifecycleError,
|
||||||
} from './walletLifecycle';
|
} from './walletLifecycle';
|
||||||
@@ -40,23 +45,25 @@ import {
|
|||||||
export { TrustedMintPostCommitError } from './walletFunds';
|
export { TrustedMintPostCommitError } from './walletFunds';
|
||||||
|
|
||||||
// 'none': no wallet on this device yet -> setup
|
// 'none': no wallet on this device yet -> setup
|
||||||
// 'locked': linking key present but password-encrypted -> unlock
|
// 'locked': wallet material present but password-encrypted -> unlock
|
||||||
// 'unlocked': linking key (and thus the bearer AES key) in memory
|
// 'unlocked': the complete material (linking key + cash root) is in memory
|
||||||
export type { WalletState } from './walletOwnerFence';
|
export type { WalletState } from './walletOwnerFence';
|
||||||
|
|
||||||
// a plaintext-stored key also starts 'locked' - init() unlocks it
|
// a plaintext-stored material record also starts 'locked' - init() unlocks
|
||||||
// immediately without a password, keeping a single code path for deriving
|
// it immediately without a password, keeping a single code path for deriving
|
||||||
// the AES key and loading bearers
|
// the AES key and loading bearers
|
||||||
export const useWalletStore = defineStore('wallet', () => {
|
export const useWalletStore = defineStore('wallet', () => {
|
||||||
const state = ref<WalletState>(savedKeyExists() ? 'locked' : 'none');
|
const state = ref<WalletState>(savedWalletMaterialExists() ? 'locked' : 'none');
|
||||||
const pubkey = ref<string | null>(null);
|
const pubkey = ref<string | null>(null);
|
||||||
const auxiliaryError = ref('');
|
const auxiliaryError = ref('');
|
||||||
const lifecycleError = ref('');
|
const lifecycleError = ref('');
|
||||||
let aesKey: CryptoKey | null = null;
|
let aesKey: CryptoKey | null = null;
|
||||||
// the linking key itself, only while unlocked - needed by backup/passkey
|
// the complete v2 wallet material, only while unlocked - the cash root
|
||||||
// operations (nostrBackup derives the backup key from it, passkey
|
// funds every BIP-32 secret derivation and the linking key inside it backs
|
||||||
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
// backup/passkey operations (nostrBackup derives the backup key from it,
|
||||||
let currentLinkingKey: Uint8Array | null = null;
|
// passkey and biometric enrollment wrap the whole value). Never exposed
|
||||||
|
// reactively; cleared on lock/forget/failed activation
|
||||||
|
let currentMaterial: WalletMaterialV2 | null = null;
|
||||||
let lifecycleToken = 0;
|
let lifecycleToken = 0;
|
||||||
let acceptingOwnerWork = false;
|
let acceptingOwnerWork = false;
|
||||||
|
|
||||||
@@ -68,7 +75,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
},
|
},
|
||||||
}).run;
|
}).run;
|
||||||
|
|
||||||
const encrypted = computed(() => savedKeyIsEncrypted());
|
const encrypted = computed(() => savedWalletMaterialIsEncrypted());
|
||||||
|
|
||||||
const ownerFence = createWalletOwnerFence({
|
const ownerFence = createWalletOwnerFence({
|
||||||
state: () => state.value,
|
state: () => state.value,
|
||||||
@@ -79,7 +86,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
|
|
||||||
const clearRuntime = (): void => {
|
const clearRuntime = (): void => {
|
||||||
aesKey = null;
|
aesKey = null;
|
||||||
currentLinkingKey = null;
|
currentMaterial = null;
|
||||||
pubkey.value = null;
|
pubkey.value = null;
|
||||||
funds.clear();
|
funds.clear();
|
||||||
useActivityStore().unload();
|
useActivityStore().unload();
|
||||||
@@ -124,12 +131,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
|
|
||||||
const lock = (): Promise<void> =>
|
const lock = (): Promise<void> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
if (!savedKeyIsEncrypted()) return;
|
if (!savedWalletMaterialIsEncrypted()) return;
|
||||||
await deactivateSession();
|
await deactivateSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
const idleWatch = createWalletIdleWatch({
|
const idleWatch = createWalletIdleWatch({
|
||||||
isEncrypted: savedKeyIsEncrypted,
|
isEncrypted: savedWalletMaterialIsEncrypted,
|
||||||
isUnlocked: () => state.value === 'unlocked',
|
isUnlocked: () => state.value === 'unlocked',
|
||||||
isLockWarningVisible: () => lockWarningSecondsLeft.value !== null,
|
isLockWarningVisible: () => lockWarningSecondsLeft.value !== null,
|
||||||
lock,
|
lock,
|
||||||
@@ -138,30 +145,53 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const activate = async (linkingKey: Uint8Array, ownerWasMissing: boolean): Promise<void> => {
|
// Activation enters the unlocked state for exactly the saved material.
|
||||||
|
// The owner monitor is subscribed BEFORE the first async gap and the saved
|
||||||
|
// owner is re-read immediately before exposing 'unlocked': a replacement
|
||||||
|
// that landed during deferred loading must fence this activation instead
|
||||||
|
// of letting a stale owner through
|
||||||
|
const activate = async (material: WalletMaterialV2, ownerWasMissing: boolean): Promise<void> => {
|
||||||
auxiliaryError.value = '';
|
auxiliaryError.value = '';
|
||||||
await migrateProvenLegacyOwner(linkingKey, ownerWasMissing);
|
const ownerId = walletMaterialOwnerId(material);
|
||||||
const key = await deriveBearerAesKey(linkingKey);
|
observeOwnerChanges();
|
||||||
|
try {
|
||||||
|
await migrateProvenLegacyOwner(material, ownerWasMissing);
|
||||||
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||||
|
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
|
||||||
|
}
|
||||||
|
const key = await deriveBearerAesKey(walletMaterialLinkingKey(material));
|
||||||
const loaded = await loadBearers(key);
|
const loaded = await loadBearers(key);
|
||||||
const activity = useActivityStore();
|
const activity = useActivityStore();
|
||||||
await activity.loadFor(key);
|
await activity.loadFor(key);
|
||||||
const ownerId = ownerOf(linkingKey);
|
|
||||||
await restoreHeldMintTrust(loaded, ownerId, (message) => {
|
await restoreHeldMintTrust(loaded, ownerId, (message) => {
|
||||||
auxiliaryError.value = message;
|
auxiliaryError.value = message;
|
||||||
});
|
});
|
||||||
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||||
|
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
|
||||||
|
}
|
||||||
aesKey = key;
|
aesKey = key;
|
||||||
currentLinkingKey = linkingKey;
|
currentMaterial = material;
|
||||||
lifecycleToken += 1;
|
lifecycleToken += 1;
|
||||||
observeOwnerChanges();
|
|
||||||
pubkey.value = ownerId;
|
pubkey.value = ownerId;
|
||||||
funds.replace(loaded);
|
funds.replace(loaded);
|
||||||
acceptingOwnerWork = true;
|
acceptingOwnerWork = true;
|
||||||
state.value = 'unlocked';
|
state.value = 'unlocked';
|
||||||
idleWatch.start();
|
idleWatch.start();
|
||||||
|
} catch (error) {
|
||||||
|
stopOwnerChanges();
|
||||||
|
acceptingOwnerWork = false;
|
||||||
|
lifecycleToken += 1;
|
||||||
|
pubkey.value = null;
|
||||||
|
clearRuntime();
|
||||||
|
// a failed activation is locked when a wallet record exists (retry via
|
||||||
|
// unlock) and uninstalled when none does - never half-installed
|
||||||
|
state.value = savedWalletMaterialExists() ? 'locked' : 'none';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const teardownCurrentOwner = async (resetRegistry = false): Promise<void> => {
|
const teardownCurrentOwner = async (resetRegistry = false): Promise<void> => {
|
||||||
const ownerId = savedKeyOwnerId() ?? pubkey.value;
|
const ownerId = savedWalletMaterialOwnerId() ?? pubkey.value;
|
||||||
acceptingOwnerWork = false;
|
acceptingOwnerWork = false;
|
||||||
stopOwnerChanges();
|
stopOwnerChanges();
|
||||||
idleWatch.stop();
|
idleWatch.stop();
|
||||||
@@ -178,7 +208,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
clearAllBearers();
|
clearAllBearers();
|
||||||
useActivityStore().unloadAndClear();
|
useActivityStore().unloadAndClear();
|
||||||
clearSettings();
|
clearSettings();
|
||||||
|
// invalidate the legacy saved-key entry too: old tabs watching it must
|
||||||
|
// fence themselves, and the record is residue under the v2 lifecycle
|
||||||
clearSavedLinkingKey();
|
clearSavedLinkingKey();
|
||||||
|
// the saved material goes last: a failure above leaves the complete
|
||||||
|
// enrollment (and this record) in place for a safe retry
|
||||||
|
clearSavedWalletMaterial();
|
||||||
state.value = 'none';
|
state.value = 'none';
|
||||||
} finally {
|
} finally {
|
||||||
// a failed teardown still ends the session: 'locked' must never hold
|
// a failed teardown still ends the session: 'locked' must never hold
|
||||||
@@ -189,12 +224,16 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const prepareInstallation = async (nextOwnerId: string): Promise<void> => {
|
const prepareInstallation = async (nextOwnerId: string): Promise<void> => {
|
||||||
const installedOwner = savedKeyOwnerId() ?? pubkey.value;
|
// the install path re-runs the alpha reset, so a boot-time reset that
|
||||||
if (savedKeyExists() && installedOwner === nextOwnerId) {
|
// failed (e.g. native biometric deletion) is retried before any
|
||||||
|
// successor is installed
|
||||||
|
await resetUnsupportedLegacyWalletState();
|
||||||
|
const installedOwner = savedWalletMaterialOwnerId() ?? pubkey.value;
|
||||||
|
if (savedWalletMaterialExists() && installedOwner === nextOwnerId) {
|
||||||
if (state.value === 'unlocked') await deactivateSession();
|
if (state.value === 'unlocked') await deactivateSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (savedKeyExists() || state.value === 'unlocked') {
|
if (savedWalletMaterialExists() || state.value === 'unlocked') {
|
||||||
await teardownCurrentOwner(true);
|
await teardownCurrentOwner(true);
|
||||||
}
|
}
|
||||||
await clearUnownedAuthorizations();
|
await clearUnownedAuthorizations();
|
||||||
@@ -202,7 +241,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
|
|
||||||
const installSeed = createSeedInstaller({
|
const installSeed = createSeedInstaller({
|
||||||
prepareInstallation,
|
prepareInstallation,
|
||||||
activate: (linkingKey) => activate(linkingKey, false),
|
activate: (material) => activate(material, false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const access = createWalletAccess({
|
const access = createWalletAccess({
|
||||||
@@ -210,19 +249,25 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
installSeed,
|
installSeed,
|
||||||
activate,
|
activate,
|
||||||
canInit: () => state.value === 'locked',
|
canInit: () => state.value === 'locked',
|
||||||
|
onResetError: (error) => {
|
||||||
|
lifecycleError.value = error instanceof Error ? error.message : 'Wallet reset failed.';
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreFromBackup = (data: unknown): Promise<RestoreResult> =>
|
const restoreFromBackup = (data: unknown): Promise<RestoreResult> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
const backup = parseBackupFile(data);
|
const backup = parseBackupFile(data);
|
||||||
const hadSavedKey = savedKeyExists();
|
const hadSavedMaterial = savedWalletMaterialExists();
|
||||||
const activeOwner = pubkey.value;
|
const activeOwner = pubkey.value;
|
||||||
const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null;
|
const activeMaterial = state.value === 'unlocked' ? requireWalletMaterial() : null;
|
||||||
if (activeKey !== null) await deactivateSession();
|
if (activeMaterial !== null) await deactivateSession();
|
||||||
if (!hadSavedKey) await clearUnownedAuthorizations();
|
if (!hadSavedMaterial) await clearUnownedAuthorizations();
|
||||||
const result = await applyBackup(backup, activeOwner ?? undefined);
|
const result = await applyBackup(backup, activeOwner ?? undefined);
|
||||||
if (activeKey !== null) await activate(activeKey, false);
|
// a file-carried legacy linking key can never become v2 material (the
|
||||||
else if (result.linkingKeyRestored) state.value = 'locked';
|
// cash root is unrecoverable from it): the record applyBackup just
|
||||||
|
// installed is residue here, and removing it keeps old tabs fenced
|
||||||
|
clearSavedLinkingKey();
|
||||||
|
if (activeMaterial !== null) await activate(activeMaterial, false);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,21 +277,21 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
password?: string,
|
password?: string,
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
runTransition(() =>
|
runTransition(() =>
|
||||||
installSeed(seedPhrase, password, async (linkingKey) => {
|
installSeed(seedPhrase, password, async (material) => {
|
||||||
await restoreFromNostrEngine(linkingKey, relays);
|
await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const restoreCurrentFromNostr = (relays: string[]) =>
|
const restoreCurrentFromNostr = (relays: string[]) =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
const linkingKey = requireLinkingKey();
|
const material = requireWalletMaterial();
|
||||||
await deactivateSession();
|
await deactivateSession();
|
||||||
const result = await restoreFromNostrEngine(linkingKey, relays);
|
const result = await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
|
||||||
await activate(linkingKey, false);
|
await activate(material, false);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// wipes this wallet from the device entirely - the linking key, every
|
// wipes this wallet from the device entirely - the wallet material, every
|
||||||
// bearer record, the activity log, and the non-secret registries that
|
// bearer record, the activity log, and the non-secret registries that
|
||||||
// would otherwise linger as a fingerprint of it. Not recoverable by
|
// would otherwise linger as a fingerprint of it. Not recoverable by
|
||||||
// restoring the same seed afterward (the ciphertexts themselves are
|
// restoring the same seed afterward (the ciphertexts themselves are
|
||||||
@@ -267,9 +312,13 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
// narrow accessor for the operations that need the key material itself
|
// narrow accessor for the operations that need the key material itself
|
||||||
// (nostr backup key derivation, passkey registration) - never reactive,
|
// (nostr backup key derivation, passkey registration) - never reactive,
|
||||||
// throws when locked, so callers can't accidentally hold a stale key
|
// throws when locked, so callers can't accidentally hold a stale key
|
||||||
const requireLinkingKey = (): Uint8Array => {
|
const requireLinkingKey = (): Uint8Array => walletMaterialLinkingKey(requireWalletMaterial());
|
||||||
if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.');
|
|
||||||
return currentLinkingKey;
|
// the complete material for the enrollment flows that wrap it (passkey,
|
||||||
|
// biometric) - same access rules as requireLinkingKey
|
||||||
|
const requireWalletMaterial = (): WalletMaterialV2 => {
|
||||||
|
if (!acceptingOwnerWork || !currentMaterial) throw new Error('Wallet is locked.');
|
||||||
|
return currentMaterial;
|
||||||
};
|
};
|
||||||
|
|
||||||
const funds = createWalletFunds({
|
const funds = createWalletFunds({
|
||||||
@@ -301,6 +350,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
forgetWallet,
|
forgetWallet,
|
||||||
postponeLock: idleWatch.postpone,
|
postponeLock: idleWatch.postpone,
|
||||||
requireLinkingKey,
|
requireLinkingKey,
|
||||||
|
requireWalletMaterial,
|
||||||
captureOwnerFence: ownerFence.capture,
|
captureOwnerFence: ownerFence.capture,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-18
@@ -1,22 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
decryptSavedLinkingKey,
|
decryptSavedWalletMaterial,
|
||||||
generateSeedPhrase,
|
generateSeedPhrase,
|
||||||
getPlainLinkingKey,
|
getPlainWalletMaterial,
|
||||||
savedKeyIsEncrypted,
|
savedWalletMaterialIsEncrypted,
|
||||||
savedKeyOwnerId,
|
savedWalletMaterialOwnerId,
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
import { unlockWithPasskey } from '@/lnurlcash/passkeys';
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||||
import { unlockWithBiometrics } from '@/capabilities/biometricUnlock';
|
import { unlockWalletMaterialWithPasskey } from '@/lnurlcash/passkeys';
|
||||||
|
import { unlockWalletMaterialWithBiometrics } from '@/capabilities/biometricUnlock';
|
||||||
|
|
||||||
|
import { resetUnsupportedLegacyWalletState } from './walletLifecycle';
|
||||||
|
|
||||||
type RunTransition = <T>(transition: () => Promise<T>) => Promise<T>;
|
type RunTransition = <T>(transition: () => Promise<T>) => Promise<T>;
|
||||||
type InstallSeed = (seedPhrase: string, password?: string) => Promise<void>;
|
type InstallSeed = (seedPhrase: string, password?: string) => Promise<void>;
|
||||||
type Activate = (linkingKey: Uint8Array, ownerWasMissing: boolean) => Promise<void>;
|
type Activate = (material: WalletMaterialV2, ownerWasMissing: boolean) => Promise<void>;
|
||||||
|
|
||||||
type WalletAccessOptions = Readonly<{
|
type WalletAccessOptions = Readonly<{
|
||||||
runTransition: RunTransition;
|
runTransition: RunTransition;
|
||||||
installSeed: InstallSeed;
|
installSeed: InstallSeed;
|
||||||
activate: Activate;
|
activate: Activate;
|
||||||
canInit: () => boolean;
|
canInit: () => boolean;
|
||||||
|
// reset failures must surface without killing boot: routing proceeds to
|
||||||
|
// the uninstalled state and the next install attempt retries the reset
|
||||||
|
onResetError: (error: unknown) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const createWalletAccess = ({
|
export const createWalletAccess = ({
|
||||||
@@ -24,10 +30,11 @@ export const createWalletAccess = ({
|
|||||||
installSeed,
|
installSeed,
|
||||||
activate,
|
activate,
|
||||||
canInit,
|
canInit,
|
||||||
|
onResetError,
|
||||||
}: WalletAccessOptions) => {
|
}: WalletAccessOptions) => {
|
||||||
const activateSavedKey = async (linkingKey: Uint8Array | null): Promise<void> => {
|
const activateSavedMaterial = async (material: WalletMaterialV2 | null): Promise<void> => {
|
||||||
if (!linkingKey) throw new Error('No wallet on this device.');
|
if (!material) throw new Error('No wallet on this device.');
|
||||||
await activate(linkingKey, savedKeyOwnerId() === null);
|
await activate(material, savedWalletMaterialOwnerId() === null);
|
||||||
};
|
};
|
||||||
const create = (password?: string): Promise<string> =>
|
const create = (password?: string): Promise<string> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
@@ -39,23 +46,30 @@ export const createWalletAccess = ({
|
|||||||
runTransition(() => installSeed(seedPhrase, password));
|
runTransition(() => installSeed(seedPhrase, password));
|
||||||
const unlock = (password?: string): Promise<void> =>
|
const unlock = (password?: string): Promise<void> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
const linkingKey = savedKeyIsEncrypted()
|
const material = savedWalletMaterialIsEncrypted()
|
||||||
? await decryptSavedLinkingKey(password || '')
|
? await decryptSavedWalletMaterial(password || '')
|
||||||
: getPlainLinkingKey();
|
: getPlainWalletMaterial();
|
||||||
await activateSavedKey(linkingKey);
|
await activateSavedMaterial(material);
|
||||||
});
|
});
|
||||||
const unlockWithPasskeyCredential = (): Promise<void> =>
|
const unlockWithPasskeyCredential = (): Promise<void> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
await activate(await unlockWithPasskey(), false);
|
await activate(await unlockWalletMaterialWithPasskey(), false);
|
||||||
});
|
});
|
||||||
const unlockWithBiometric = (): Promise<void> =>
|
const unlockWithBiometric = (): Promise<void> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
await activateSavedKey(await unlockWithBiometrics());
|
await activateSavedMaterial(await unlockWalletMaterialWithBiometrics());
|
||||||
});
|
});
|
||||||
const init = (): Promise<void> =>
|
const init = (): Promise<void> =>
|
||||||
runTransition(async () => {
|
runTransition(async () => {
|
||||||
if (!canInit() || savedKeyIsEncrypted()) return;
|
// alpha reset runs before any routing decision can expose the wallet:
|
||||||
await activateSavedKey(getPlainLinkingKey());
|
// an unsupported legacy install is wiped, never migrated
|
||||||
|
try {
|
||||||
|
await resetUnsupportedLegacyWalletState();
|
||||||
|
} catch (error) {
|
||||||
|
onResetError(error);
|
||||||
|
}
|
||||||
|
if (!canInit() || savedWalletMaterialIsEncrypted()) return;
|
||||||
|
await activateSavedMaterial(getPlainWalletMaterial());
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
create,
|
create,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
deriveWalletLinkingKey,
|
deriveWalletMaterial,
|
||||||
ensureSavedKeyOwner,
|
ensureSavedWalletMaterialOwner,
|
||||||
isValidSeedPhrase,
|
isValidSeedPhrase,
|
||||||
linkingPubKeyHex,
|
saveWalletMaterial,
|
||||||
saveLinkingKey,
|
savedWalletMaterialExists,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||||
|
import { disableBiometricUnlock } from '@/capabilities/biometricUnlock';
|
||||||
import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys';
|
import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys';
|
||||||
|
import { clearAllActivity, clearSettings } from '@/lnurlcash/storage';
|
||||||
import {
|
import {
|
||||||
clearPasskeySlotsForOwner,
|
clearPasskeySlotsForOwner,
|
||||||
clearUnownedPasskeySlots,
|
clearUnownedPasskeySlots,
|
||||||
@@ -17,6 +21,11 @@ import {
|
|||||||
migrateLegacyNwcStorage,
|
migrateLegacyNwcStorage,
|
||||||
} from '@/lnurlcash/storage/nwcConnections';
|
} from '@/lnurlcash/storage/nwcConnections';
|
||||||
import { withStorageLock } from '@/lnurlcash/storageLock';
|
import { withStorageLock } from '@/lnurlcash/storageLock';
|
||||||
|
import {
|
||||||
|
LINKING_KEY_STORAGE_KEY,
|
||||||
|
WALLET_MATERIAL_STORAGE_KEY,
|
||||||
|
} from '@/lnurlcash/storage/walletOwnerEvents';
|
||||||
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||||
import {
|
import {
|
||||||
migrateLegacyTrustedMints,
|
migrateLegacyTrustedMints,
|
||||||
removeTrustedMintsForOwner,
|
removeTrustedMintsForOwner,
|
||||||
@@ -75,11 +84,12 @@ export const stopWalletNwcSession = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const migrateProvenLegacyOwner = async (
|
export const migrateProvenLegacyOwner = async (
|
||||||
linkingKey: Uint8Array,
|
material: WalletMaterialV2,
|
||||||
ownerWasMissing: boolean,
|
ownerWasMissing: boolean,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
ensureSavedKeyOwner(linkingKey);
|
ensureSavedWalletMaterialOwner(material);
|
||||||
if (!ownerWasMissing) return;
|
if (!ownerWasMissing) return;
|
||||||
|
const linkingKey = walletMaterialLinkingKey(material);
|
||||||
await migrateLegacyPasskeySlots(linkingKey);
|
await migrateLegacyPasskeySlots(linkingKey);
|
||||||
migrateLegacyNwcStorage(linkingKey);
|
migrateLegacyNwcStorage(linkingKey);
|
||||||
await migrateLegacyTrustedMints(linkingKey);
|
await migrateLegacyTrustedMints(linkingKey);
|
||||||
@@ -103,26 +113,72 @@ export const clearUnownedAuthorizations = async (): Promise<void> => {
|
|||||||
await resetTrustedMintsForReplacement();
|
await resetTrustedMintsForReplacement();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ownerOf = (linkingKey: Uint8Array): string => linkingPubKeyHex(linkingKey);
|
// ---- unsupported legacy (alpha) install reset ----
|
||||||
|
|
||||||
|
// Frozen namespaces the v2 lifecycle never writes: the linking-key-only
|
||||||
|
// record and the pre-funds-document bearer store. Their raw presence (even
|
||||||
|
// corrupt or unparseable) means this device holds an alpha install, which
|
||||||
|
// cannot be migrated - the cash root is unrecoverable from a linking key -
|
||||||
|
// so it is reset, never upgraded.
|
||||||
|
const LEGACY_BEARERS_STORAGE_KEY = 'sattle_bearers';
|
||||||
|
// current owner-bound namespaces that a full reset removes outright; the
|
||||||
|
// key strings are duplicated here because the reset deletes whole
|
||||||
|
// namespaces by name rather than going through owner-scoped APIs
|
||||||
|
const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections';
|
||||||
|
const NWC_ENABLED_STORAGE_KEY = 'sattle_nwc_enabled';
|
||||||
|
|
||||||
|
export const hasUnsupportedLegacyWalletState = (): boolean =>
|
||||||
|
!savedWalletMaterialExists() &&
|
||||||
|
(localStorage.getItem(LINKING_KEY_STORAGE_KEY) !== null ||
|
||||||
|
localStorage.getItem(LEGACY_BEARERS_STORAGE_KEY) !== null);
|
||||||
|
|
||||||
|
// Alpha reset. No migration: every namespace the alpha wallet could have
|
||||||
|
// touched is removed and the device returns to the uninstalled state. The
|
||||||
|
// native biometric secret is deleted FIRST (same ordering as forget): if
|
||||||
|
// that fails, nothing local has been wiped yet, so the next attempt retries
|
||||||
|
// the whole reset instead of leaving a half-wiped install. When a v2
|
||||||
|
// install is present instead, only stray legacy-key residue is removed - the
|
||||||
|
// live wallet (including its bearer store) is untouched.
|
||||||
|
export const resetUnsupportedLegacyWalletState = async (): Promise<void> => {
|
||||||
|
if (savedWalletMaterialExists()) {
|
||||||
|
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasUnsupportedLegacyWalletState()) return;
|
||||||
|
await disableBiometricUnlock();
|
||||||
|
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(LEGACY_BEARERS_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(WALLET_MATERIAL_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(PASSKEY_SLOTS_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(NWC_CONNECTIONS_STORAGE_KEY);
|
||||||
|
localStorage.removeItem(NWC_ENABLED_STORAGE_KEY);
|
||||||
|
clearAllActivity();
|
||||||
|
clearSettings();
|
||||||
|
await resetTrustedMintsForReplacement();
|
||||||
|
};
|
||||||
|
|
||||||
type SeedInstallerOptions = {
|
type SeedInstallerOptions = {
|
||||||
readonly prepareInstallation: (ownerId: string) => Promise<void>;
|
readonly prepareInstallation: (ownerId: string) => Promise<void>;
|
||||||
readonly activate: (linkingKey: Uint8Array) => Promise<void>;
|
readonly activate: (material: WalletMaterialV2) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeedInstaller = (
|
export type SeedInstaller = (
|
||||||
seedPhrase: string,
|
seedPhrase: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
restore?: (linkingKey: Uint8Array) => Promise<void>,
|
restore?: (material: WalletMaterialV2) => Promise<void>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
export const createSeedInstaller =
|
export const createSeedInstaller =
|
||||||
(options: SeedInstallerOptions): SeedInstaller =>
|
(options: SeedInstallerOptions): SeedInstaller =>
|
||||||
async (seedPhrase, password, restore) => {
|
async (seedPhrase, password, restore) => {
|
||||||
if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.');
|
if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.');
|
||||||
const linkingKey = deriveWalletLinkingKey(seedPhrase);
|
const material = deriveWalletMaterial(seedPhrase);
|
||||||
await options.prepareInstallation(ownerOf(linkingKey));
|
await options.prepareInstallation(walletMaterialOwnerId(material));
|
||||||
if (restore) await restore(linkingKey);
|
if (restore) await restore(material);
|
||||||
await saveLinkingKey(linkingKey, password);
|
// a restore engine may have installed a legacy linking-key record from
|
||||||
await options.activate(linkingKey);
|
// the backup payload: the v2 lifecycle never activates it, so it is
|
||||||
|
// residue - removing it here also fences old tabs still watching that key
|
||||||
|
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||||
|
await saveWalletMaterial(material, password);
|
||||||
|
await options.activate(material);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
import { linkingPubKeyHex, saveWalletMaterial, walletMaterialHash } from '@/lnurlcash/keys';
|
||||||
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||||
|
import { deriveWalletMaterial } from '@/lnurlcash/keys';
|
||||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
import { startWalletOwnerMonitor } from './walletOwnerMonitor';
|
import { startWalletOwnerMonitor } from './walletOwnerMonitor';
|
||||||
|
|
||||||
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7));
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9));
|
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||||
|
const MATERIAL: WalletMaterialV2 = {
|
||||||
|
...deriveWalletMaterial(
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||||
|
),
|
||||||
|
linkingKeyHex: bytesToHex(LINKING_KEY),
|
||||||
|
};
|
||||||
|
const OTHER_MATERIAL: WalletMaterialV2 = {
|
||||||
|
...MATERIAL,
|
||||||
|
linkingKeyHex: bytesToHex(OTHER_LINKING_KEY),
|
||||||
|
};
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||||
|
|
||||||
describe('wallet owner monitor', () => {
|
describe('wallet owner monitor', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -13,19 +27,12 @@ describe('wallet owner monitor', () => {
|
|||||||
stubLocalStorage();
|
stubLocalStorage();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('consumes a background transition rejection after an owner replacement', () => {
|
it('consumes a background transition rejection after an owner replacement', async () => {
|
||||||
// Given an unlocked stale owner and a transition promise observed by the queue
|
// Given an unlocked stale owner and a transition promise observed by the queue
|
||||||
const events = new EventTarget();
|
const events = new EventTarget();
|
||||||
vi.stubGlobal('window', events);
|
vi.stubGlobal('window', events);
|
||||||
localStorage.setItem(
|
await saveWalletMaterial(OTHER_MATERIAL);
|
||||||
'sattle_linking_key',
|
expect(walletMaterialHash(OTHER_MATERIAL)).toBeTruthy();
|
||||||
JSON.stringify({
|
|
||||||
enc: false,
|
|
||||||
value: '09'.repeat(32),
|
|
||||||
ownerId: OTHER_OWNER_ID,
|
|
||||||
version: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const transition = Promise.resolve();
|
const transition = Promise.resolve();
|
||||||
const catchRejection = vi.spyOn(transition, 'catch');
|
const catchRejection = vi.spyOn(transition, 'catch');
|
||||||
startWalletOwnerMonitor({
|
startWalletOwnerMonitor({
|
||||||
@@ -34,14 +41,39 @@ describe('wallet owner monitor', () => {
|
|||||||
runTransition: vi.fn().mockReturnValue(transition),
|
runTransition: vi.fn().mockReturnValue(transition),
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the browser reports that the saved owner changed
|
// When the browser reports that the saved material changed
|
||||||
events.dispatchEvent(
|
events.dispatchEvent(
|
||||||
Object.defineProperties(new Event('storage'), {
|
Object.defineProperties(new Event('storage'), {
|
||||||
key: { value: 'sattle_linking_key' },
|
key: { value: 'sattle_wallet_material_v2' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then the fire-and-forget transition has a rejection consumer
|
// Then the fire-and-forget transition has a rejection consumer
|
||||||
expect(catchRejection).toHaveBeenCalledOnce();
|
expect(catchRejection).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores legacy-key wakeups while the saved v2 owner is intact', async () => {
|
||||||
|
// Given an unlocked owner whose v2 record is untouched
|
||||||
|
const events = new EventTarget();
|
||||||
|
vi.stubGlobal('window', events);
|
||||||
|
await saveWalletMaterial(MATERIAL);
|
||||||
|
const deactivate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const stop = startWalletOwnerMonitor({
|
||||||
|
snapshot: () => ({ token: 1, state: 'unlocked', ownerId: OWNER_ID }),
|
||||||
|
deactivate,
|
||||||
|
runTransition: vi.fn((transition: () => Promise<void>) => transition()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a legacy-key storage event arrives (an old tab clearing residue)
|
||||||
|
events.dispatchEvent(
|
||||||
|
Object.defineProperties(new Event('storage'), {
|
||||||
|
key: { value: 'sattle_linking_key' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// Then the session stays active: the v2 owner never changed
|
||||||
|
expect(deactivate).not.toHaveBeenCalled();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
import { savedWalletMaterialOwnerId } from '@/lnurlcash/keys';
|
||||||
import { onSavedKeyStorageChange } from '@/lnurlcash/storage/walletOwnerEvents';
|
import { onSavedKeyStorageChange } from '@/lnurlcash/storage/walletOwnerEvents';
|
||||||
import type { WalletState } from './walletOwnerFence';
|
import type { WalletState } from './walletOwnerFence';
|
||||||
|
|
||||||
@@ -14,13 +14,16 @@ type WalletOwnerMonitor = Readonly<{
|
|||||||
runTransition: (transition: () => Promise<void>) => Promise<void>;
|
runTransition: (transition: () => Promise<void>) => Promise<void>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Wakes on any saved-key storage event (the v2 material record, the legacy
|
||||||
|
// linking-key entry, or a clear) and re-reads the CURRENT v2 owner: payload
|
||||||
|
// and legacy-entry changes are only wakeups, never trusted state
|
||||||
export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => void) =>
|
export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => void) =>
|
||||||
onSavedKeyStorageChange(() => {
|
onSavedKeyStorageChange(() => {
|
||||||
const expected = monitor.snapshot();
|
const expected = monitor.snapshot();
|
||||||
if (
|
if (
|
||||||
expected.state !== 'unlocked' ||
|
expected.state !== 'unlocked' ||
|
||||||
expected.ownerId === null ||
|
expected.ownerId === null ||
|
||||||
savedKeyOwnerId() === expected.ownerId
|
savedWalletMaterialOwnerId() === expected.ownerId
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,7 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi
|
|||||||
current.token !== expected.token ||
|
current.token !== expected.token ||
|
||||||
current.state !== 'unlocked' ||
|
current.state !== 'unlocked' ||
|
||||||
current.ownerId !== expected.ownerId ||
|
current.ownerId !== expected.ownerId ||
|
||||||
savedKeyOwnerId() === expected.ownerId
|
savedWalletMaterialOwnerId() === expected.ownerId
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user