feat: wallet operations engine and encrypted storage with tests

This commit is contained in:
2026-08-19 21:06:17 +02:00
parent 6f966d5a2d
commit 1c9e7165e4
19 changed files with 2698 additions and 7 deletions
+94
View File
@@ -0,0 +1,94 @@
// The encrypted activity log: one entry per important wallet action,
// AES-GCM under the same bearer key, append-only, capped so a wallet used
// for years doesn't grow localStorage without limit.
import type {EncryptedRecordParts} from '../keys'
import {encryptRecord, decryptRecord} from '../keys'
import {withStorageLock} from '../storageLock'
// `message` is the full human-readable sentence rather than structured
// fields the UI reassembles, so the log stays simple to read and to extend
// with new kinds later.
export type ActivityKind =
| 'mint'
| 'split'
| 'combine'
| 'melt'
| 'transfer'
| 'receive'
| 'spent'
| 'deleted'
export type ActivityEvent = {
id: string
kind: ActivityKind
message: string
createdAt: number
}
export type EncryptedActivityRecord = {id: string} & EncryptedRecordParts
const ACTIVITY_STORAGE_KEY = 'sattle_activity'
// bounds how far back the log ever reaches - the oldest entries simply
// roll off once this many are kept
export const MAX_ACTIVITY_ENTRIES = 500
export const newActivityId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
export const readEncryptedActivity = (): EncryptedActivityRecord[] => {
const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY)
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => {
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records))
}
// same tolerance as loadBearers - an entry that fails to decrypt with this
// key (written by a different seed) is skipped, not destroyed
export const loadActivity = async (
aesKey: CryptoKey
): Promise<ActivityEvent[]> => {
const events: ActivityEvent[] = []
for (const record of readEncryptedActivity()) {
try {
const event = await decryptRecord<Omit<ActivityEvent, 'id'>>(
aesKey,
record
)
events.push({...event, id: record.id})
} catch {
// undecryptable with this key - leave it in place
}
}
return events.sort((a, b) => b.createdAt - a.createdAt)
}
// append-only (the log never edits or removes a single entry, only clears
// outright - see clearAllActivity) - records are stored oldest-first so
// trimming to the cap is just dropping off the front
export const persistActivityEvent = async (
aesKey: CryptoKey,
event: ActivityEvent
): Promise<void> => {
const {id, ...plain} = event
const parts = await encryptRecord(aesKey, plain)
await withStorageLock(ACTIVITY_STORAGE_KEY, () => {
const records = readEncryptedActivity()
records.push({id, ...parts})
writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES))
})
}
export const clearAllActivity = (): void => {
localStorage.removeItem(ACTIVITY_STORAGE_KEY)
}
+154
View File
@@ -0,0 +1,154 @@
// Backup files: everything exactly as it sits in localStorage - bearer
// ciphertexts always, the linking-key record only when it is itself
// password-encrypted. A plaintext linking key never leaves the device in a
// backup; the seed phrase is the recovery path for it instead. Trusted
// mints are plain (not secret - a mintPubkey is public), included as-is.
import type {StoredSecret} from '../keys'
import {
getSavedLinkingKeyStored,
savedKeyExists,
savedKeyIsEncrypted,
restoreLinkingKeyStored,
isValidStoredSecret
} from '../keys'
import type {TrustedMint} from '../trustedMints'
import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
import type {EncryptedBearerRecord} from './bearers'
import {readEncryptedBearers, writeEncryptedBearers} from './bearers'
export type BackupFile = {
type: 'sattle-backup'
version: 1
createdAt: number
linkingKey?: StoredSecret
bearers: EncryptedBearerRecord[]
trustedMints?: TrustedMint[]
}
export const buildBackup = (): BackupFile => {
const backup: BackupFile = {
type: 'sattle-backup',
version: 1,
createdAt: Date.now(),
bearers: readEncryptedBearers(),
trustedMints: readTrustedMints()
}
const storedKey = getSavedLinkingKeyStored()
if (savedKeyIsEncrypted() && storedKey) {
backup.linkingKey = storedKey
}
return backup
}
export type RestoreResult = {
added: number
skipped: number
linkingKeyRestored: boolean
// true when the backup carried a linking key but this device already had
// one, so it was deliberately NOT installed (see below) - distinct from
// "no key in this backup at all". The bearer records above still merged
// in regardless, but they were encrypted under the backup's own seed, not
// whatever wallet is active on this device - unless that's the exact same
// seed, they won't decrypt here, and the caller should say so rather than
// let that read as a silent no-op.
linkingKeySkipped: boolean
trustedMintsAdded: number
}
// restore-time bounds - a crafted or corrupt file must not be able to fill
// localStorage with junk records that never decrypt (quota exhaustion turns
// every later write into a failure, which can strand a just-rotated note),
// nor hang the tab in JSON.parse. A real backup holds a handful of notes,
// each well under a kilobyte encrypted, so these are generous
export const MAX_BACKUP_FILE_BYTES = 10 * 1024 * 1024
const MAX_BACKUP_RECORDS = 10_000
const MAX_BACKUP_FIELD_LENGTH = 64 * 1024
const isBackupFile = (data: unknown): data is BackupFile => {
if (typeof data !== 'object' || data === null) return false
const backup = data as Record<string, unknown>
return (
backup.type === 'sattle-backup' &&
backup.version === 1 &&
Array.isArray(backup.bearers)
)
}
// merges a backup into localStorage: bearer records are added by id
// (already present ids are left as-is - union, never overwrite), the
// backup's linking key is only installed when this device has none yet -
// never overwriting an existing wallet. That guard is deliberate (a
// stale/wrong backup must never clobber a wallet already holding funds),
// but it means restore order matters: a device that already has ANY wallet
// silently keeps its own key, and this backup's bearers merge into storage
// without ever becoming visible, since they don't decrypt under a
// different key. See linkingKeySkipped above. The note-level dedupe (same
// note arriving under a different record id, spent-wins) happens after
// decrypt, in bearers.ts's mergeBearers.
export const applyBackup = (data: unknown): RestoreResult => {
if (!isBackupFile(data)) {
throw new Error('Not a valid sattle backup file.')
}
const backup = data
const existing = readEncryptedBearers()
const existingIds = new Set(existing.map(r => r.id))
if (backup.bearers.length > MAX_BACKUP_RECORDS) {
throw new Error(
`Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`
)
}
let added = 0
let skipped = 0
for (const record of backup.bearers) {
if (
typeof record?.id !== 'string' ||
typeof record?.iv !== 'string' ||
typeof record?.ciphertext !== 'string' ||
record.id.length > MAX_BACKUP_FIELD_LENGTH ||
record.iv.length > MAX_BACKUP_FIELD_LENGTH ||
record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH
) {
skipped++
continue
}
if (existingIds.has(record.id)) {
skipped++
continue
}
existing.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext})
existingIds.add(record.id)
added++
}
try {
writeEncryptedBearers(existing)
} catch {
throw new Error(
'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.'
)
}
let linkingKeyRestored = false
let linkingKeySkipped = false
// an invalid key record reads as "no key in this backup", never as skipped
if (backup.linkingKey && isValidStoredSecret(backup.linkingKey)) {
if (savedKeyExists()) {
linkingKeySkipped = true
} else {
restoreLinkingKeyStored(backup.linkingKey)
linkingKeyRestored = true
}
}
const trustedMintsAdded = Array.isArray(backup.trustedMints)
? mergeTrustedMints(backup.trustedMints)
: 0
return {
added,
skipped,
linkingKeyRestored,
linkingKeySkipped,
trustedMintsAdded
}
}
+117
View File
@@ -0,0 +1,117 @@
// Encrypted bearer-note persistence: each note is an AES-GCM ciphertext
// record under a key derived from the linking key (see keys.ts), so a note
// URL - which IS the money - never touches disk in plaintext.
import type {EncryptedRecordParts} from '../keys'
import {encryptRecord, decryptRecord} from '../keys'
import type {Bearer} from '../types'
import {noteK1, serverOf} from 'lnurlcash-kit'
import {withStorageLock} from '../storageLock'
// the wallet's default note order (newest first) with manually dragged
// notes taking priority once they have an explicit rank
export const compareBearerOrder = (a: Bearer, b: Bearer): number =>
(a.sortIndex ?? -a.createdAt) - (b.sortIndex ?? -b.createdAt)
export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts
const BEARERS_STORAGE_KEY = 'sattle_bearers'
export const newBearerId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
const raw = localStorage.getItem(BEARERS_STORAGE_KEY)
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export const writeEncryptedBearers = (
records: EncryptedBearerRecord[]
): void => {
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
}
// decrypts everything currently stored - a record that fails to decrypt
// (e.g. written by a different seed's key) is skipped, not destroyed: it
// stays in localStorage untouched and simply doesn't show up
export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
const bearers: Bearer[] = []
for (const record of readEncryptedBearers()) {
try {
const bearer = await decryptRecord<Omit<Bearer, 'id'>>(aesKey, record)
bearers.push({...bearer, id: record.id})
} catch {
// undecryptable with this key - leave it in place
}
}
return bearers.sort((a, b) => b.createdAt - a.createdAt)
}
export const persistBearer = async (
aesKey: CryptoKey,
bearer: Bearer
): Promise<void> => {
const {id, ...plain} = bearer
const parts = await encryptRecord(aesKey, plain)
await withStorageLock(BEARERS_STORAGE_KEY, () => {
const records = readEncryptedBearers().filter(r => r.id !== id)
records.push({id, ...parts})
writeEncryptedBearers(records)
})
}
export const deleteBearerRecord = async (id: string): Promise<void> => {
await withStorageLock(BEARERS_STORAGE_KEY, () => {
writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id))
})
}
// wipes every bearer record from this device outright - unlike forgetting
// just the linking key, this is not recoverable by restoring the same seed:
// the ciphertexts themselves are gone, so only a previously downloaded
// backup file can bring them back
export const clearAllBearers = (): void => {
localStorage.removeItem(BEARERS_STORAGE_KEY)
}
// Merge two decrypted bearer lists into one, keyed by note identity
// (issuing server + k1 secret), falling back to record id for notes whose
// k1 is absent (a paired-device mirror carries none). Union semantics with
// spent-wins: when both lists hold the same note, the copy locked as spent
// always survives over a still-spendable one - a spent note that "comes
// back" after a restore is how double-spends are born. Among copies in the
// same spent state, the newer updatedAt wins. This is the merge a restore
// (backup file now, nostr later) applies after its records decrypt, and it
// is what makes multi-device restores converge instead of duplicate.
export const mergeBearers = (
current: Bearer[],
incoming: Bearer[]
): Bearer[] => {
const keyOf = (b: Bearer): string => {
const k1 = noteK1(b.url)
return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`
}
const merged = new Map<string, Bearer>()
for (const bearer of [...current, ...incoming]) {
const key = keyOf(bearer)
const existing = merged.get(key)
if (!existing) {
merged.set(key, bearer)
continue
}
if (bearer.spent !== existing.spent) {
merged.set(key, bearer.spent ? bearer : existing)
continue
}
merged.set(key, bearer.updatedAt >= existing.updatedAt ? bearer : existing)
}
return [...merged.values()].sort((a, b) => b.createdAt - a.createdAt)
}
+33
View File
@@ -0,0 +1,33 @@
// Wallet settings - plaintext, nothing secret (a default mint choice, a
// fiat display unit). Flat optional fields rather than a versioned
// envelope: absent keys just mean "never set".
export type WalletSettings = {
defaultMint?: string
}
const SETTINGS_STORAGE_KEY = 'sattle_settings'
export const loadSettings = (): WalletSettings => {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY)
if (!raw) return {}
try {
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return {}
const s = parsed as Record<string, unknown>
return {
defaultMint:
typeof s.defaultMint === 'string' ? s.defaultMint : undefined
}
} catch {
return {}
}
}
export const persistSettings = (settings: WalletSettings): void => {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings))
}
export const clearSettings = (): void => {
localStorage.removeItem(SETTINGS_STORAGE_KEY)
}