fix: validate auxiliary wallet storage

This commit is contained in:
2026-08-22 16:55:00 +02:00
parent 586d12b666
commit 0862a10b01
2 changed files with 71 additions and 42 deletions
+68 -36
View File
@@ -2,9 +2,10 @@
// AES-GCM under the same bearer key, append-only, capped so a wallet used // AES-GCM under the same bearer key, append-only, capped so a wallet used
// for years doesn't grow localStorage without limit. // for years doesn't grow localStorage without limit.
import type { EncryptedRecordParts } from '../keys'; import type {EncryptedRecordParts} from '../keys'
import { encryptRecord, decryptRecord } from '../keys'; import {encryptRecord, decryptRecord} from '../keys'
import { withStorageLock } from '../storageLock'; import {isJsonObject} from '../jsonParsing'
import {withStorageLock} from '../storageLock'
// `message` is the full human-readable sentence rather than structured // `message` is the full human-readable sentence rather than structured
// fields the UI reassembles, so the log stays simple to read and to extend // fields the UI reassembles, so the log stays simple to read and to extend
@@ -19,56 +20,87 @@ export type ActivityKind =
| 'spent' | 'spent'
| 'deleted' | 'deleted'
// a payment or mint initiated by a Nostr Wallet Connect client (M5) // a payment or mint initiated by a Nostr Wallet Connect client (M5)
| 'nwc'; | 'nwc'
export type ActivityEvent = { export type ActivityEvent = {
id: string; id: string
kind: ActivityKind; kind: ActivityKind
message: string; message: string
createdAt: number; createdAt: number
}; }
export type EncryptedActivityRecord = { id: string } & EncryptedRecordParts; export type EncryptedActivityRecord = {id: string} & EncryptedRecordParts
const ACTIVITY_STORAGE_KEY = 'sattle_activity'; const isActivityKind = (value: unknown): value is ActivityKind => {
switch (value) {
case 'mint':
case 'split':
case 'combine':
case 'melt':
case 'transfer':
case 'receive':
case 'spent':
case 'deleted':
case 'nwc':
return true
default:
return false
}
}
const isEncryptedActivityRecord = (value: unknown): value is EncryptedActivityRecord =>
isJsonObject(value) &&
typeof value.id === 'string' &&
typeof value.iv === 'string' &&
typeof value.ciphertext === 'string'
const isStoredActivity = (value: unknown): value is Omit<ActivityEvent, 'id'> =>
isJsonObject(value) &&
isActivityKind(value.kind) &&
typeof value.message === 'string' &&
typeof value.createdAt === 'number'
const ACTIVITY_STORAGE_KEY = 'sattle_activity'
// bounds how far back the log ever reaches - the oldest entries simply // bounds how far back the log ever reaches - the oldest entries simply
// roll off once this many are kept // roll off once this many are kept
export const MAX_ACTIVITY_ENTRIES = 500; export const MAX_ACTIVITY_ENTRIES = 500
export const newActivityId = (): string => export const newActivityId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8))) Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join(''); .join('')
export const readEncryptedActivity = (): EncryptedActivityRecord[] => { export const readEncryptedActivity = (): EncryptedActivityRecord[] => {
const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY); const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY)
if (!raw) return []; if (!raw) return []
try { try {
const parsed: unknown = JSON.parse(raw); const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed.filter(isEncryptedActivityRecord) : []
} catch { } catch {
return []; return []
}
} }
};
const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => { const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => {
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)); localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records))
}; }
// same tolerance as loadBearers - an entry that fails to decrypt with this // same tolerance as loadBearers - an entry that fails to decrypt with this
// key (written by a different seed) is skipped, not destroyed // key (written by a different seed) is skipped, not destroyed
export const loadActivity = async (aesKey: CryptoKey): Promise<ActivityEvent[]> => { export const loadActivity = async (aesKey: CryptoKey): Promise<ActivityEvent[]> => {
const events: ActivityEvent[] = []; const events: ActivityEvent[] = []
for (const record of readEncryptedActivity()) { for (const record of readEncryptedActivity()) {
try { try {
const event = await decryptRecord<Omit<ActivityEvent, 'id'>>(aesKey, record); const event = await decryptRecord(aesKey, record)
events.push({ ...event, id: record.id }); if (!isStoredActivity(event)) throw new Error('Malformed encrypted activity record.')
} catch { events.push({...event, id: record.id})
} catch (error) {
// undecryptable with this key - leave it in place // undecryptable with this key - leave it in place
if (!(error instanceof Error)) throw error
} }
} }
return events.sort((a, b) => b.createdAt - a.createdAt); return events.sort((a, b) => b.createdAt - a.createdAt)
}; }
// append-only (the log never edits or removes a single entry, only clears // append-only (the log never edits or removes a single entry, only clears
// outright - see clearAllActivity) - records are stored oldest-first so // outright - see clearAllActivity) - records are stored oldest-first so
@@ -77,15 +109,15 @@ export const persistActivityEvent = async (
aesKey: CryptoKey, aesKey: CryptoKey,
event: ActivityEvent, event: ActivityEvent,
): Promise<void> => { ): Promise<void> => {
const { id, ...plain } = event; const {id, ...plain} = event
const parts = await encryptRecord(aesKey, plain); const parts = await encryptRecord(aesKey, plain)
await withStorageLock(ACTIVITY_STORAGE_KEY, () => { await withStorageLock(ACTIVITY_STORAGE_KEY, () => {
const records = readEncryptedActivity(); const records = readEncryptedActivity()
records.push({ id, ...parts }); records.push({id, ...parts})
writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)); writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES))
}); })
}; }
export const clearAllActivity = (): void => { export const clearAllActivity = (): void => {
localStorage.removeItem(ACTIVITY_STORAGE_KEY); localStorage.removeItem(ACTIVITY_STORAGE_KEY)
}; }
+3 -6
View File
@@ -20,15 +20,12 @@ export const loadSettings = (): WalletSettings => {
if (typeof parsed !== 'object' || parsed === null) return {} if (typeof parsed !== 'object' || parsed === null) return {}
const s = parsed as Record<string, unknown> const s = parsed as Record<string, unknown>
return { return {
defaultMint: defaultMint: typeof s.defaultMint === 'string' ? s.defaultMint : undefined,
typeof s.defaultMint === 'string' ? s.defaultMint : undefined,
nostrBackupEnabled: nostrBackupEnabled:
typeof s.nostrBackupEnabled === 'boolean' typeof s.nostrBackupEnabled === 'boolean' ? s.nostrBackupEnabled : undefined,
? s.nostrBackupEnabled
: undefined,
nostrBackupRelays: Array.isArray(s.nostrBackupRelays) nostrBackupRelays: Array.isArray(s.nostrBackupRelays)
? s.nostrBackupRelays.filter((r): r is string => typeof r === 'string') ? s.nostrBackupRelays.filter((r): r is string => typeof r === 'string')
: undefined : undefined,
} }
} catch { } catch {
return {} return {}