mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
feat: fence wallet mutations by lifecycle owner
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { deriveBearerAesKey, linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import { loadBearers, readEncryptedBearers } from '@/lnurlcash/storage';
|
||||
import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import type { NewBearer } from '@/lnurlcash/types';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9));
|
||||
|
||||
const note = (secret: string): NewBearer => ({
|
||||
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
});
|
||||
|
||||
// a second tab installing its own wallet: the saved-key record flips to the
|
||||
// successor owner. Deliberately NO storage event is dispatched - the fence
|
||||
// must not depend on the wakeup arriving first.
|
||||
const replacePersistedOwner = (): void => {
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({
|
||||
enc: false,
|
||||
value: '09'.repeat(32),
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
type LockRequest = {
|
||||
readonly callback: () => unknown;
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
// mirrors BROWSER LockManager semantics: a callback failure rejects the
|
||||
// request and frees the lock name (Node 24's LockManager wedges instead,
|
||||
// which is why these tests drive their own fake)
|
||||
class DeferredLocks {
|
||||
readonly requests: LockRequest[] = [];
|
||||
|
||||
readonly request = (_name: string, callback: () => unknown): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
this.requests.push({ callback, resolve, reject });
|
||||
});
|
||||
|
||||
async releaseNext(): Promise<void> {
|
||||
const request = this.requests.shift();
|
||||
if (!request) throw new Error('Expected a queued lock request.');
|
||||
try {
|
||||
request.resolve(await request.callback());
|
||||
} catch (error) {
|
||||
request.reject(error instanceof Error ? error : new Error(String(error), { cause: error }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
stubLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('stale-owner fencing of fund commits', () => {
|
||||
it('fences a changeset commit after a silent owner replacement', async () => {
|
||||
// Given an unlocked wallet with one bearer and no pending storage event
|
||||
vi.stubGlobal('navigator', {});
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [existing] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
|
||||
// When another tab replaced the wallet and this (still unlocked) tab
|
||||
// tries to commit a fund change
|
||||
replacePersistedOwner();
|
||||
await expect(
|
||||
wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence),
|
||||
).rejects.toBeInstanceOf(WalletOwnerMismatchError);
|
||||
|
||||
// Then nothing moved: not in storage, not in the reactive list
|
||||
expect(readEncryptedBearers()).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(1);
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fences a single-record spent mark after a silent owner replacement', async () => {
|
||||
// Given an unlocked wallet whose owner was silently replaced
|
||||
vi.stubGlobal('navigator', {});
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [existing] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
replacePersistedOwner();
|
||||
|
||||
// When the stale tab marks the note spent
|
||||
await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf(
|
||||
WalletOwnerMismatchError,
|
||||
);
|
||||
|
||||
// Then the record is untouched in both worlds
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||
expect((await loadBearers(key))[0]?.spent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('revalidates the owner inside the commit lock, after encryption', async () => {
|
||||
// Given a changeset commit whose storage write is held at the lock
|
||||
vi.stubGlobal('navigator', {});
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [existing] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
|
||||
// When the commit passed the entry fence, finished encryption, and is
|
||||
// parked at the lock - and only NOW another tab replaces the owner
|
||||
const locks = new DeferredLocks();
|
||||
vi.stubGlobal('navigator', { locks });
|
||||
const committing = wallet.applyChangeset(
|
||||
{ add: [note('bb')], markSpent: [existing.id] },
|
||||
ownerFence,
|
||||
);
|
||||
await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0));
|
||||
replacePersistedOwner();
|
||||
await locks.releaseNext();
|
||||
|
||||
// Then the commit fails closed instead of writing stale-owner ciphertext
|
||||
await expect(committing).rejects.toBeInstanceOf(WalletOwnerMismatchError);
|
||||
expect(readEncryptedBearers()).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(1);
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a fence captured by an earlier lifecycle of the same owner', async () => {
|
||||
// Given an operation accepted before the encrypted wallet locked and
|
||||
// unlocked again under the same persisted owner
|
||||
vi.stubGlobal('navigator', {});
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create('password');
|
||||
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence());
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
const staleFence = wallet.captureOwnerFence();
|
||||
await wallet.lock();
|
||||
await wallet.unlock('password');
|
||||
|
||||
// When the old lifecycle tries to commit into the new runtime
|
||||
await expect(
|
||||
wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, staleFence),
|
||||
).rejects.toBeInstanceOf(WalletOwnerMismatchError);
|
||||
|
||||
// Then exact owner equality alone cannot authorize the stale operation
|
||||
expect(readEncryptedBearers()).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(1);
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('revalidates a spent update inside its persistence lock', async () => {
|
||||
vi.stubGlobal('navigator', {});
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence());
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const locks = new DeferredLocks();
|
||||
vi.stubGlobal('navigator', { locks });
|
||||
|
||||
const updating = wallet.markSpent(existing.id, ownerFence);
|
||||
await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0));
|
||||
replacePersistedOwner();
|
||||
await locks.releaseNext();
|
||||
|
||||
await expect(updating).rejects.toBeInstanceOf(WalletOwnerMismatchError);
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||
expect((await loadBearers(key))[0]?.spent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('revalidates a deletion inside its persistence lock', async () => {
|
||||
vi.stubGlobal('navigator', {});
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence());
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const locks = new DeferredLocks();
|
||||
vi.stubGlobal('navigator', { locks });
|
||||
|
||||
const removing = wallet.removeNote(existing.id, ownerFence);
|
||||
await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0));
|
||||
replacePersistedOwner();
|
||||
await locks.releaseNext();
|
||||
|
||||
await expect(removing).rejects.toBeInstanceOf(WalletOwnerMismatchError);
|
||||
expect(readEncryptedBearers()).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { assertSavedKeyOwner, WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner';
|
||||
|
||||
export type WalletState = 'none' | 'locked' | 'unlocked';
|
||||
export type WalletOwnerFence = () => void;
|
||||
|
||||
type WalletOwnerFenceOptions = Readonly<{
|
||||
state: () => WalletState;
|
||||
ownerId: () => string | null;
|
||||
lifecycleToken: () => number;
|
||||
accepting: () => boolean;
|
||||
}>;
|
||||
|
||||
export const createWalletOwnerFence = (options: WalletOwnerFenceOptions) => {
|
||||
const assertCurrentOwner = (): void => {
|
||||
const ownerId = options.ownerId();
|
||||
if (options.state() !== 'unlocked' || ownerId === null) {
|
||||
throw new WalletOwnerMismatchError();
|
||||
}
|
||||
assertSavedKeyOwner(ownerId);
|
||||
};
|
||||
|
||||
const capture = (): WalletOwnerFence => {
|
||||
if (!options.accepting()) throw new WalletOwnerMismatchError();
|
||||
assertCurrentOwner();
|
||||
const token = options.lifecycleToken();
|
||||
const ownerId = options.ownerId();
|
||||
if (ownerId === null) throw new WalletOwnerMismatchError();
|
||||
return () => {
|
||||
if (options.lifecycleToken() !== token || options.ownerId() !== ownerId) {
|
||||
throw new WalletOwnerMismatchError();
|
||||
}
|
||||
assertCurrentOwner();
|
||||
};
|
||||
};
|
||||
|
||||
return { assertCurrentOwner, capture };
|
||||
};
|
||||
Reference in New Issue
Block a user