Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions distros/safari/api-keys/api-keys.build.js

Large diffs are not rendered by default.

77 changes: 68 additions & 9 deletions distros/safari/background.build.js

Large diffs are not rendered by default.

43 changes: 34 additions & 9 deletions distros/safari/sidepanel.build.js

Large diffs are not rendered by default.

43 changes: 34 additions & 9 deletions distros/safari/vault/vault.build.js

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions src/utilities/bunker-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import { RelayConnection } from './nip46.js';

const log = msg => console.log('BunkerServer: ', msg);

// Replay protection. We remember recently-seen request event ids and reject
// duplicates, and reject events whose created_at is outside this window. An
// attacker who captured an authenticated client's encrypted sign_event could
// otherwise re-publish it to the relay and have it re-signed.
const MAX_SEEN_EVENTS = 500;
const REPLAY_WINDOW_SECONDS = 300; // ±5 min tolerates client clock skew

export class BunkerServer {
/**
* @param {Object} opts
Expand All @@ -41,6 +48,10 @@ export class BunkerServer {
this.subId = `bunker-srv-${crypto.randomUUID().slice(0, 8)}`;
this.active = false;

// Replay protection state (see _isFreshEvent).
this._seenEventIds = new Set();
this._seenOrder = [];

// Will be set by start()
this._getPrivKey = null;
}
Expand Down Expand Up @@ -94,6 +105,8 @@ export class BunkerServer {
}
this.relays = [];
this.authenticatedClients.clear();
this._seenEventIds.clear();
this._seenOrder = [];
this.active = false;
this._getPrivKey = null;
log('Bunker server stopped');
Expand All @@ -107,12 +120,45 @@ export class BunkerServer {
return `bunker://${this.userPubkey}?${relayParams}&secret=${this.secret}`;
}

/**
* Replay/freshness gate for an incoming request event. Rejects events whose
* created_at is outside REPLAY_WINDOW_SECONDS and request ids we've already
* processed. `nowSec` is injectable for testing.
* @returns {boolean} true if the event is fresh and should be processed.
*/
_isFreshEvent(event, nowSec) {
const now = nowSec != null ? nowSec : Math.floor(Date.now() / 1000);
const createdAt = event.created_at || 0;
if (Math.abs(now - createdAt) > REPLAY_WINDOW_SECONDS) {
log(`Rejected out-of-window event (created_at=${createdAt}, now=${now})`);
return false;
}
const id = event.id;
// Without an id we can't dedup; the window check above still applies.
if (!id) return true;
if (this._seenEventIds.has(id)) {
log(`Rejected replayed event ${id.slice(0, 8)}...`);
return false;
}
this._seenEventIds.add(id);
this._seenOrder.push(id);
if (this._seenOrder.length > MAX_SEEN_EVENTS) {
const old = this._seenOrder.shift();
this._seenEventIds.delete(old);
}
return true;
}

/**
* Handle an incoming NIP-46 request event.
*/
async _handleRequest(event) {
const clientPubkey = event.pubkey;

// Replay protection — drop duplicates and stale/future-dated events
// before doing any key work.
if (!this._isFreshEvent(event)) return;

let privKey;
try {
privKey = await this._getPrivKey();
Expand Down
68 changes: 57 additions & 11 deletions src/utilities/sync-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,48 @@ async function buildSyncPayload() {
// Push to sync
// ---------------------------------------------------------------------------

/**
* Write a payload to storage.sync, surviving quota / item-cap rejections.
*
* storage.sync.set is all-or-nothing: if the aggregate exceeds the quota or the
* MAX_ITEMS cap, Chrome rejects the ENTIRE batch. To avoid silently syncing
* nothing (not even P1 profiles), on rejection we drop the lowest-importance
* priority group still present (highest priority number) and retry — never
* dropping P1 profiles, so identity always syncs. Throws only if even the
* minimal payload is rejected.
*
* @param {Object} payload - data keys → values (WITHOUT the sync-meta key)
* @param {Array<{priority:number, keys:string[]}>} placedEntries
* @param {(payload:Object)=>Promise<void>} setFn - performs the actual set
* @param {number} nowMs - timestamp for sync metadata
* @returns {Promise<string[]>} the data keys actually written (excluding meta)
*/
export async function setSyncRespectingQuota(payload, placedEntries, setFn, nowMs) {
const attempt = { ...payload };
let activeKeys = placedEntries.flatMap(e => e.keys);
// Drop order: lowest importance (highest priority number) first; never P1.
const dropOrder = [...new Set(placedEntries.map(e => e.priority))]
.filter(p => p > PRIORITY.P1_PROFILES)
.sort((a, b) => b - a);

while (true) {
attempt[SYNC_META_KEY] = JSON.stringify({ lastWrittenAt: nowMs, keys: activeKeys });
try {
await setFn(attempt);
return activeKeys;
} catch (e) {
const dropPrio = dropOrder.shift();
if (dropPrio == null) throw e; // nothing droppable left (P1 + meta too big)
const dropKeys = new Set(
placedEntries.filter(en => en.priority === dropPrio).flatMap(en => en.keys)
);
for (const k of dropKeys) delete attempt[k];
activeKeys = activeKeys.filter(k => !dropKeys.has(k));
console.warn(`[SyncManager] sync.set rejected; dropped priority ${dropPrio} (${dropKeys.size} keys) and retrying`);
}
}
}

async function pushToSync() {
if (!api.storage.sync) return;

Expand All @@ -177,6 +219,7 @@ async function pushToSync() {
let usedItems = 0;
const syncPayload = {};
const allSyncKeys = [];
const placedEntries = []; // { priority, keys } — used for quota fallback
let budgetExhausted = false;

for (const entry of entries) {
Expand All @@ -198,29 +241,32 @@ async function pushToSync() {
}
}

const entryKeys = [];
for (const c of chunks) {
syncPayload[c.key] = c.value;
allSyncKeys.push(c.key);
entryKeys.push(c.key);
}
placedEntries.push({ priority: entry.priority, keys: entryKeys });
usedBytes += entrySize;
usedItems += chunks.length;
}

// Add sync metadata
const meta = {
lastWrittenAt: Date.now(),
keys: allSyncKeys,
};
syncPayload[SYNC_META_KEY] = JSON.stringify(meta);

// Write to sync storage
await api.storage.sync.set(syncPayload);
// Write to sync storage. The helper adds sync metadata and, if the batch
// is rejected for quota/item overflow, drops the lowest-priority entries
// and retries so high-priority data (P1 profiles) still syncs.
const writtenKeys = await setSyncRespectingQuota(
syncPayload,
placedEntries,
(p) => api.storage.sync.set(p),
Date.now(),
);

// Clean orphaned chunks: read existing sync keys and remove any not in our payload
try {
const existing = await api.storage.sync.get(null);
const orphanKeys = Object.keys(existing).filter(k =>
k !== SYNC_META_KEY && !allSyncKeys.includes(k)
k !== SYNC_META_KEY && !writtenKeys.includes(k)
);
if (orphanKeys.length > 0) {
await api.storage.sync.remove(orphanKeys);
Expand All @@ -229,7 +275,7 @@ async function pushToSync() {
// Non-critical cleanup
}

console.log(`[SyncManager] Pushed ${allSyncKeys.length} entries (${usedBytes} bytes) to sync storage`);
console.log(`[SyncManager] Pushed ${writtenKeys.length} entries to sync storage`);
} catch (e) {
console.error('[SyncManager] pushToSync error:', e);
// Local storage is unaffected — graceful degradation
Expand Down
70 changes: 70 additions & 0 deletions test/bunker-replay.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Bunker (NIP-46) replay-protection regression tests
*
* The bunker server signs requests from authenticated clients. Without replay
* protection, a captured authenticated request event could be re-published to
* the relay and re-signed. _isFreshEvent() rejects duplicate request ids and
* events whose created_at is outside the freshness window.
*
* bunker-server.js imports nip46.js → browser-polyfill, which throws unless an
* extension API namespace exists. WebSocket is only used inside start(), not at
* import, so we can construct a server and test _isFreshEvent without starting.
*/

import { describe, it, expect, beforeEach } from 'vitest';

globalThis.chrome = {
runtime: { onMessage: { addListener() {} } },
storage: {
local: { get() {}, set() {} },
sync: { get() {}, set() {} },
onChanged: { addListener() {} },
},
};

const { BunkerServer } = await import('../src/utilities/bunker-server.js');

const NOW = 1_700_000_000; // seconds
const makeServer = () => new BunkerServer({ relayUrls: ['wss://r'], userPubkey: 'pub', secret: 'sek' });

describe('BunkerServer replay protection', () => {
let srv;
beforeEach(() => { srv = makeServer(); });

it('accepts a fresh, unseen event', () => {
expect(srv._isFreshEvent({ id: 'a', created_at: NOW }, NOW)).toBe(true);
});

it('rejects a replayed (duplicate id) event', () => {
expect(srv._isFreshEvent({ id: 'a', created_at: NOW }, NOW)).toBe(true);
expect(srv._isFreshEvent({ id: 'a', created_at: NOW }, NOW)).toBe(false);
});

it('rejects events older than the freshness window', () => {
expect(srv._isFreshEvent({ id: 'b', created_at: NOW - 301 }, NOW)).toBe(false);
});

it('rejects future-dated events beyond the window', () => {
expect(srv._isFreshEvent({ id: 'c', created_at: NOW + 301 }, NOW)).toBe(false);
});

it('accepts events at the window boundary', () => {
expect(srv._isFreshEvent({ id: 'd', created_at: NOW - 300 }, NOW)).toBe(true);
expect(srv._isFreshEvent({ id: 'e', created_at: NOW + 300 }, NOW)).toBe(true);
});

it('bounds the seen-id set so it cannot grow without limit', () => {
for (let i = 0; i < 600; i++) srv._isFreshEvent({ id: 'k' + i, created_at: NOW }, NOW);
expect(srv._seenEventIds.size).toBeLessThanOrEqual(500);
// The oldest id was evicted, so it is accepted again (acceptable: the
// created_at window is the cross-eviction backstop).
expect(srv._isFreshEvent({ id: 'k0', created_at: NOW }, NOW)).toBe(true);
});

it('stop() clears replay state', () => {
srv._isFreshEvent({ id: 'a', created_at: NOW }, NOW);
srv.stop();
expect(srv._seenEventIds.size).toBe(0);
expect(srv._seenOrder).toHaveLength(0);
});
});
83 changes: 83 additions & 0 deletions test/sync-quota.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Sync quota-fallback regression tests
*
* storage.sync.set is all-or-nothing: an over-quota batch is rejected whole, so
* the old code could silently sync NOTHING (not even profiles). setSyncRespectingQuota
* drops the lowest-priority entries and retries, guaranteeing P1 still syncs.
*
* sync-manager imports browser-polyfill, which throws unless an extension API
* namespace exists — install a minimal chrome stub, then import.
*/

import { describe, it, expect } from 'vitest';

globalThis.chrome = {
runtime: { onMessage: { addListener() {} } },
storage: {
local: { get() {}, set() {} },
sync: { get() {}, set() {} },
onChanged: { addListener() {} },
},
};

const { setSyncRespectingQuota } = await import('../src/utilities/sync-manager.js');

const NOW = 1_700_000_000_000;
const META = '_sync_meta';

const placed = () => [
{ priority: 1, keys: ['profiles', 'profileIndex'] },
{ priority: 2, keys: ['autoLockMinutes'] },
{ priority: 3, keys: ['apiKeyVault'] },
{ priority: 4, keys: ['vaultDoc:a', 'vaultDoc:b'] },
];
const payload = () => ({
profiles: 'p', profileIndex: 'i', autoLockMinutes: '15',
apiKeyVault: 'k', 'vaultDoc:a': 'A', 'vaultDoc:b': 'B',
});

describe('setSyncRespectingQuota', () => {
it('writes everything (plus meta) when the first set succeeds', async () => {
const calls = [];
const setFn = async (p) => { calls.push({ ...p }); };
const written = await setSyncRespectingQuota(payload(), placed(), setFn, NOW);

expect(calls).toHaveLength(1);
expect(calls[0][META]).toBeDefined();
expect(written).toEqual(['profiles', 'profileIndex', 'autoLockMinutes', 'apiKeyVault', 'vaultDoc:a', 'vaultDoc:b']);
});

it('drops P4 then P3 on repeated quota rejection, keeping P1/P2', async () => {
let fails = 2; // reject the first two attempts
const seenKeys = [];
const setFn = async (p) => {
seenKeys.push(Object.keys(p).filter(k => k !== META));
if (fails-- > 0) throw new Error('QUOTA_BYTES quota exceeded');
};
const written = await setSyncRespectingQuota(payload(), placed(), setFn, NOW);

expect(seenKeys).toHaveLength(3); // all → drop P4 → drop P3 → ok
expect(written).toEqual(['profiles', 'profileIndex', 'autoLockMinutes']);
expect(written).not.toContain('apiKeyVault');
expect(written).not.toContain('vaultDoc:a');
});

it('never drops P1; throws if even the minimal payload is rejected', async () => {
const setFn = async () => { throw new Error('quota'); };
await expect(setSyncRespectingQuota(payload(), placed(), setFn, NOW)).rejects.toThrow();
});

it('meta key list reflects only the keys actually written after drops', async () => {
let fails = 1;
let lastMeta = null;
const setFn = async (p) => {
if (fails-- > 0) throw new Error('quota');
lastMeta = JSON.parse(p[META]);
};
const written = await setSyncRespectingQuota(payload(), placed(), setFn, NOW);

expect(lastMeta.keys).toEqual(written);
expect(lastMeta.keys).not.toContain('vaultDoc:a'); // P4 dropped
expect(lastMeta.lastWrittenAt).toBe(NOW);
});
});