mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: wallet operations engine and encrypted storage with tests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user