import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import {beforeEach, describe, expect, it} from 'vitest' import { decryptSavedWalletMaterial, deriveWalletMaterial, encryptSecretParts, ensureSavedWalletMaterialOwner, getPlainWalletMaterial, isValidStoredWalletMaterial, linkingPubKeyHex, restoreWalletMaterialStored, saveWalletMaterial, serializeWalletMaterial, savedWalletMaterialOwnerId, walletMaterialLinkingKey, WalletMaterialProofMismatchError, } from './keys' import {walletMaterialOwnerId} from './storage/storedSecret' import {parseJsonObject, stubLocalStorage} from './test-utils' const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' const PASSWORD = 'correct horse battery staple' 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) } beforeEach(() => { stubLocalStorage() }) describe('baseline: wallet material identity', () => { it('derives identity only from the linking key when the cash root changes', () => { const material = deriveWalletMaterial(MNEMONIC) const other = deriveWalletMaterial(OTHER_MNEMONIC) const alteredRoot = {...material, cashRootHex: other.cashRootHex} expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material)) expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material)) }) }) describe('saved wallet material commitment', () => { it('stores the canonical material commitment on plaintext and encrypted saves', async () => { const material = deriveWalletMaterial(MNEMONIC) const expectedHash = materialHash(serializeWalletMaterial(material)) await saveWalletMaterial(material) const plaintext = readRawMaterialRecord() await saveWalletMaterial(material, PASSWORD) const encrypted = readRawMaterialRecord() expect(plaintext.materialHash).toBe(expectedHash) expect(encrypted.materialHash).toBe(expectedHash) }) it('rejects authenticated encrypted material when its commitment differs', async () => { const material = deriveWalletMaterial(MNEMONIC) await saveWalletMaterial(material, PASSWORD) localStorage.setItem( MATERIAL_STORAGE_KEY, JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}), ) await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( WalletMaterialProofMismatchError, ) }) it('rejects plaintext material when its commitment differs', async () => { const material = deriveWalletMaterial(MNEMONIC) await saveWalletMaterial(material) const before = JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}) localStorage.setItem(MATERIAL_STORAGE_KEY, before) expect(getPlainWalletMaterial()).toBeNull() expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) }) it('refuses to stamp same-owner material with an altered cash root', async () => { const material = deriveWalletMaterial(MNEMONIC) const other = deriveWalletMaterial(OTHER_MNEMONIC) await saveWalletMaterial(material, PASSWORD) expect(() => ensureSavedWalletMaterialOwner({...material, cashRootHex: other.cashRootHex}), ).toThrow(WalletMaterialProofMismatchError) }) it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => { const material = deriveWalletMaterial(MNEMONIC) const serialized = serializeWalletMaterial(material) const record = { enc: false, value: serialized, materialHash: materialHash(serialized), ownerId: walletMaterialOwnerId(material), version: 2, } expect(isValidStoredWalletMaterial(record)).toBe(true) expect(isValidStoredWalletMaterial({...record, materialHash: record.materialHash.toUpperCase()})).toBe( false, ) expect( isValidStoredWalletMaterial({ enc: record.enc, value: record.value, ownerId: record.ownerId, version: record.version, }), ).toBe(false) }) it('rejects malformed authenticated material after its correct commitment matches', async () => { const material = deriveWalletMaterial(MNEMONIC) const malformed = JSON.stringify({...material, cashRootHex: '00'.repeat(64)}) const parts = await encryptSecretParts(malformed, PASSWORD) localStorage.setItem( MATERIAL_STORAGE_KEY, JSON.stringify({ enc: true, ...parts, materialHash: materialHash(malformed), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }), ) await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow( 'valid v2 wallet material', ) }) it('preserves a verified restored commitment while discarding its owner claim', () => { const material = deriveWalletMaterial(MNEMONIC) const serialized = serializeWalletMaterial(material) restoreWalletMaterialStored({ enc: false, value: serialized, materialHash: materialHash(serialized), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), version: 2, }) expect(readRawMaterialRecord()).toEqual({ enc: false, value: serialized, materialHash: materialHash(serialized), }) expect(getPlainWalletMaterial()).toEqual(material) }) it('proves an encrypted restored commitment before owner stamping', async () => { const material = deriveWalletMaterial(MNEMONIC) const serialized = serializeWalletMaterial(material) const parts = await encryptSecretParts(serialized, PASSWORD) restoreWalletMaterialStored({ enc: true, ...parts, materialHash: materialHash(serialized), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), version: 2, }) expect(savedWalletMaterialOwnerId()).toBeNull() const proven = await decryptSavedWalletMaterial(PASSWORD) ensureSavedWalletMaterialOwner(proven) expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material)) }) it('rejects an unproven restored hash that contradicts encrypted material', async () => { const material = deriveWalletMaterial(MNEMONIC) const serialized = serializeWalletMaterial(material) const parts = await encryptSecretParts(serialized, PASSWORD) restoreWalletMaterialStored({ enc: true, ...parts, materialHash: '00'.repeat(32), ownerId: walletMaterialOwnerId(material), version: 2, }) await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( WalletMaterialProofMismatchError, ) expect(savedWalletMaterialOwnerId()).toBeNull() }) })