import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import {describe, expect, it} from 'vitest' import { decryptSavedWalletMaterial, deriveWalletMaterial, encryptSecretParts, ensureSavedWalletMaterialOwner, getPlainWalletMaterial, isValidStoredWalletMaterial, linkingPubKeyHex, restoreWalletMaterialStored, savedWalletMaterialExists, savedWalletMaterialIsEncrypted, savedWalletMaterialOwnerId, saveWalletMaterial, serializeWalletMaterial, walletMaterialLinkingKey, } from './keys' import {parseJsonObject, stubLocalStorage} from './test-utils' const FIXED_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const PASSWORD = 'correct horse battery staple' const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' const LEGACY_STORAGE_KEY = 'sattle_linking_key' const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' const CASH_ROOT_HEX = 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized))) const readRawMaterialRecord = (): Record => { const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) if (raw === null) throw new Error('expected a saved wallet-material record') return parseJsonObject(raw) } describe('WalletMaterialV2 persistence', () => { it('round-trips canonical plaintext material in the new namespace', async () => { // Given complete wallet material and an unrelated legacy record stubLocalStorage() localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({enc: false, value: '07'.repeat(32)})) const material = deriveWalletMaterial(FIXED_MNEMONIC) // When the material is saved without a password await saveWalletMaterial(material) // Then only the new namespace carries the exact complete material expect(savedWalletMaterialExists()).toBe(true) expect(savedWalletMaterialIsEncrypted()).toBe(false) expect(getPlainWalletMaterial()).toEqual(material) expect(readRawMaterialRecord()).toEqual({ enc: false, value: serializeWalletMaterial(material), materialHash: materialHash(serializeWalletMaterial(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }) expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull() expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC) expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"') }) it('round-trips canonical password-encrypted material without plaintext fields', async () => { // Given complete wallet material stubLocalStorage() const material = deriveWalletMaterial(FIXED_MNEMONIC) // When the material is password protected await saveWalletMaterial(material, PASSWORD) // Then only authenticated decryption returns both parts expect(savedWalletMaterialIsEncrypted()).toBe(true) expect(getPlainWalletMaterial()).toBeNull() expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material) await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow() const raw = JSON.stringify(readRawMaterialRecord()) expect(raw).not.toContain(LINKING_KEY_HEX) expect(raw).not.toContain(CASH_ROOT_HEX) expect(raw).not.toContain(FIXED_MNEMONIC) }) it('strips restored owner claims until the material proves its real owner', () => { // Given plaintext material carrying a foreign owner claim stubLocalStorage() const material = deriveWalletMaterial(FIXED_MNEMONIC) const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9)) // When the record is restored and subsequently proven by plaintext unlock restoreWalletMaterialStored({ enc: false, value: serializeWalletMaterial(material), materialHash: materialHash(serializeWalletMaterial(material)), ownerId: foreignOwner, version: 2, }) const beforeProof = readRawMaterialRecord() const unlocked = getPlainWalletMaterial() if (unlocked === null) throw new Error('expected restored plaintext material') ensureSavedWalletMaterialOwner(unlocked) // Then the claim was absent before proof and only the derived owner was stamped expect(beforeProof).toEqual({ enc: false, value: serializeWalletMaterial(material), materialHash: materialHash(serializeWalletMaterial(material)), }) expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material))) }) it('rejects a plaintext owner mismatch without returning or stamping material', () => { // Given canonical material stamped with a foreign current owner stubLocalStorage() const material = deriveWalletMaterial(FIXED_MNEMONIC) localStorage.setItem( MATERIAL_STORAGE_KEY, JSON.stringify({ enc: false, value: serializeWalletMaterial(material), materialHash: materialHash(serializeWalletMaterial(material)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), version: 2, }), ) const before = localStorage.getItem(MATERIAL_STORAGE_KEY) // When it is read or offered for proof const unlocked = getPlainWalletMaterial() const stamp = () => ensureSavedWalletMaterialOwner(material) // Then neither operation trusts or rewrites the foreign record expect(unlocked).toBeNull() expect(stamp).toThrow('different wallet') expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) }) it('rejects an encrypted owner mismatch after authentication', async () => { // Given valid encrypted material whose owner marker was replaced stubLocalStorage() const material = deriveWalletMaterial(FIXED_MNEMONIC) await saveWalletMaterial(material, PASSWORD) const foreign = { ...readRawMaterialRecord(), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), } localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign)) const before = localStorage.getItem(MATERIAL_STORAGE_KEY) // When the authenticated material contradicts the current owner marker const unlock = decryptSavedWalletMaterial(PASSWORD) // Then no material is returned or marker changed await expect(unlock).rejects.toThrow('different wallet') expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) }) it.each([ ['legacy key-only plaintext', LINKING_KEY_HEX, 2], ['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1], [ 'malformed material plaintext', JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00'}), 2, ], ])('rejects %s records at the storage boundary', (_name, value, version) => { // Given an invalid record under the v2 material namespace stubLocalStorage() const material = deriveWalletMaterial(FIXED_MNEMONIC) const record = { enc: false, value, materialHash: materialHash(value), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version, } localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record)) // When storage helpers parse it // Then the record is unusable as wallet material expect(isValidStoredWalletMaterial(record)).toBe(false) expect(savedWalletMaterialExists()).toBe(false) expect(getPlainWalletMaterial()).toBeNull() }) it('rejects authenticated ciphertext containing malformed material', async () => { // Given valid AES-GCM parts whose plaintext is not a v2 material envelope stubLocalStorage() const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD) const material = deriveWalletMaterial(FIXED_MNEMONIC) localStorage.setItem( MATERIAL_STORAGE_KEY, JSON.stringify({ enc: true, ...parts, materialHash: materialHash(LINKING_KEY_HEX), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }), ) // When the password successfully authenticates the malformed plaintext const unlock = decryptSavedWalletMaterial(PASSWORD) // Then it still fails closed as invalid material await expect(unlock).rejects.toThrow('valid v2 wallet material') }) })