mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: validate auxiliary wallet storage
This commit is contained in:
@@ -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)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user