Repository navigation
Expand file tree
/
Copy pathbackground.js
More file actions
354 lines (310 loc) · 14.2 KB
/
Copy pathbackground.js
File metadata and controls
354 lines (310 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
/*
* This source code is licensed under the GNU General Public License v3.0 (GPL-3.0).
* See the full license text: https://github.com/SenseiDeElite/agecord/blob/main/LICENSE
*/
// background.js — Agecord service worker
// Key flow:
// popup → background: identity blob + contacts key.
// background → content: ML-DSA-87 seed + age identity line.
// popup ↔ background: contacts JSON ↔ ciphertext.
// Message decryption runs in content script (file-crypto-worker.js);
// chrome.storage.session stores transient unlock state securely.
'use strict';
import { init as _rustcryptoInit, xchacha20poly1305_encrypt }
from './lib/rustcrypto-wasm.min.js';
// init() is idempotent. Fire it immediately so WASM is ready before any message
// arrives, but service workers forbid top-level await — store the Promise instead.
const _wasmReady = _rustcryptoInit();
let _identity = null;
let _contactsKeyBytes = null; // raw Uint8Array, derived by popup's crypto-worker
let _contacts = {};
let _ageRecipient = null;
// Incremented by handleUnlock/handleRelock. Invalidates in-flight async restores.
let _epoch = 0;
// ─── Base64 helpers ───────────────────────────────────────────────────────────
const toB64 = bytes => bytes.toBase64();
const fromB64 = b64 => Uint8Array.fromBase64(b64);
// ─── XChaCha20-Poly1305 helpers ───────────────────────────────────────────────
// XChaCha20-Poly1305 envelope:
// [version][24-byte nonce][ciphertext+tag]
// Stored as base64 in chrome.storage.local (contactsEnc).
// Note: crypto-worker.js uses a different envelope with embedded salt.
// Here salt is stored separately (contactsSaltB64). Keep formats distinct.
// Identity blob:
// line 0: AGE-SECRET-KEY-1… (X25519)
// line 1: mldsa87seed:<b64> (32-byte seed)
// ML-DSA-87 signing key is rebuilt from seed per call.
function getMldsaSeed(identityBlob) {
const line = identityBlob.split('\n')[1] ?? '';
if (!line.startsWith('mldsa87seed:')) throw new Error('No ML-DSA-87 seed in identity blob');
return fromB64(line.slice('mldsa87seed:'.length));
}
// ─── Contacts encryption ──────────────────────────────────────────────────────
const ENVELOPE_VER = 0x01;
const ENVELOPE_HDR_LEN = 1;
function encryptContacts(jsonStr) {
if (!_contactsKeyBytes) throw new Error('Contacts key not available — extension locked.');
const plaintext = new TextEncoder().encode(jsonStr);
const noncePlusCt = xchacha20poly1305_encrypt(_contactsKeyBytes, plaintext);
const envelope = new Uint8Array(ENVELOPE_HDR_LEN + noncePlusCt.length);
envelope[0] = ENVELOPE_VER;
envelope.set(noncePlusCt, ENVELOPE_HDR_LEN);
return toB64(envelope);
}
// ─── Identity state ───────────────────────────────────────────────────────────
// Keys held in chrome.storage.session; shared by ensureIdentity's read and
// RELOCK's clear so the two can't silently drift out of sync.
// age_contacts_key mirrors _contactsKeyBytes and is written atomically with
// identity so service-worker wake cannot restore them independently.
const SESSION_KEYS = ['age_unlocked', 'age_identity', 'age_contacts', 'age_recipient', 'age_contacts_key'];
// Restores identity, contacts, and key from session storage.
// Discards stale results superseded by RELOCK or a newer UNLOCK.
async function ensureIdentity() {
if (_identity) return true;
const myEpoch = _epoch;
try {
const d = await chrome.storage.session.get(SESSION_KEYS);
if (_epoch !== myEpoch) return _identity !== null; // superseded mid-read
if (!d.age_unlocked || !d.age_identity) return false;
_identity = d.age_identity;
if (d.age_contacts && typeof d.age_contacts === 'object') _contacts = d.age_contacts;
if (d.age_recipient) _ageRecipient = d.age_recipient;
if (d.age_contacts_key) {
_contactsKeyBytes?.fill(0);
_contactsKeyBytes = fromB64(d.age_contacts_key);
}
return true;
} catch (e) {
console.error('[age] ensureIdentity: session read failed:', e?.message);
return false;
}
}
// Shared by the tab-push path (sendUnlockToTab) and the pull-response path
// (handleRequestUnlock) so the two payload shapes can't drift apart.
// Returns null when locked.
// Include identityLine directly to avoid a second request during session restoration.
function buildUnlockPayload() {
if (!_identity) return null;
const identityLine = _identity.split('\n')[0];
if (!identityLine) {
// Empty identityLine means malformed identity, not a locked state.
console.error('[age] buildUnlockPayload: identityLine is empty — identity blob is malformed');
}
return {
mldsaSeedB64: toB64(getMldsaSeed(_identity)),
identityLine,
contacts: _contacts,
ageRecipient: _ageRecipient,
};
}
// Best-effort push to an open, initialized, idle tab. A single failed
// attempt is sufficient; init-time REQUEST_UNLOCK handles missed pushes.
async function sendUnlockToTab(tabId) {
const payload = buildUnlockPayload();
if (!payload) return;
try {
await chrome.tabs.sendMessage(tabId, { type: 'UNLOCK', ...payload });
} catch (e) {
console.info(`[age] sendUnlockToTab: tab ${tabId} not reachable, will self-heal via its own pull:`, e?.message);
}
}
// Returns all currently-open Discord tabs.
const getDiscordTabs = () => chrome.tabs.query({ url: 'https://discord.com/*' });
// Fire-and-forget broadcast for RELOCK and CONTACTS_UPDATED.
// No retry needed; payloads are not timing-sensitive.
// Rejections mean no content listener; expected and logged.
async function broadcastToTabs(payload) {
const tabs = await getDiscordTabs();
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, payload)
.catch(e => console.info(`[age] broadcastToTabs: tab ${tab.id} did not receive ${payload.type}:`, e?.message));
}
}
// ─── Message handlers ──────────────────────────────────────────────────────────
function handleUnlock(msg, _sender, sendResponse) {
(async () => {
try {
// State is set directly here (not via ensureIdentity) because the
// popup is the authoritative source at unlock time — no session read needed.
_epoch++; // supersede any in-flight ensureIdentity()/RELOCK race
_identity = msg.identity;
_contacts = msg.contacts ?? _contacts;
_ageRecipient = msg.ageRecipient ?? _ageRecipient;
if (msg.contactsKeyB64) {
_contactsKeyBytes?.fill(0);
_contactsKeyBytes = fromB64(msg.contactsKeyB64);
}
// Atomic session write; roll back if _epoch changes during the write to prevent
// RELOCK races from resurrecting age_unlocked state.
const myEpoch = _epoch;
await chrome.storage.session.set({
age_unlocked: true,
age_identity: _identity,
age_contacts: _contacts,
age_recipient: _ageRecipient,
age_contacts_key: _contactsKeyBytes ? toB64(_contactsKeyBytes) : null,
});
if (_epoch !== myEpoch) {
// Superseded during the write; remove the stale identity from storage.
await chrome.storage.session.remove(SESSION_KEYS)
.catch(e => console.error('[age] UNLOCK post-RELOCK cleanup failed:', e?.message));
sendResponse({ ok: false, error: 'superseded' });
return;
}
// Best-effort push to every open Discord tab. Only load-bearing for
// tabs that are already fully loaded and idle — a tab that's still
// loading will get this same state itself via its own boot-time
// REQUEST_UNLOCK pull, whether or not this push reaches it in time.
const tabs = await getDiscordTabs();
await Promise.all(tabs.map(tab => sendUnlockToTab(tab.id)));
sendResponse({ ok: true });
} catch (e) {
console.error('[age] UNLOCK error:', e?.message);
sendResponse({ ok: false, error: e?.message ?? String(e) });
}
})();
return true;
}
function handlePing(_msg, _sender, sendResponse) {
(async () => {
await ensureIdentity();
sendResponse({ ok: true, hasContactsKey: _contactsKeyBytes !== null, hasIdentity: _identity !== null });
})();
return true;
}
function handleRelock(_msg, _sender, sendResponse) {
_epoch++; // supersede any in-flight ensureIdentity() read from an UNLOCK/PING/etc.
_identity = null;
_contactsKeyBytes?.fill(0);
_contactsKeyBytes = null;
_contacts = {}; _ageRecipient = null;
// Clear session storage before async work so concurrent ensureIdentity() sees
// age_unlocked=false, aborts, and can't restore stale identity after relock.
// The epoch bump above additionally covers reads that already passed that
// check and are waiting on their own .get() to resolve.
chrome.storage.session.remove(SESSION_KEYS)
.catch(e => console.error('[age] RELOCK session clear failed:', e?.message));
broadcastToTabs({ type: 'RELOCK' })
.catch(e => console.error('[age] RELOCK broadcast failed:', e?.message));
sendResponse({ ok: true });
return false;
}
function handleReloadDiscordTabs(_msg, _sender, sendResponse) {
(async () => {
try {
const tabs = await getDiscordTabs();
for (const t of tabs) chrome.tabs.reload(t.id);
} catch (e) {
console.error('[age] RELOAD_DISCORD_TABS failed:', e?.message);
}
})();
sendResponse({ ok: true });
return false;
}
function handleContactsUpdated(msg, _sender, sendResponse) {
if (msg.contacts) _contacts = msg.contacts;
if (msg.ageRecipient) _ageRecipient = msg.ageRecipient;
broadcastToTabs({ type: 'CONTACTS_UPDATED', contacts: _contacts, ageRecipient: _ageRecipient });
// Sync session storage while unlocked; re-check _epoch after set() to prevent
// a RELOCK race from resurrecting age_contacts.
if (_identity) {
const myEpoch = _epoch;
(async () => {
try {
await chrome.storage.session.set({ age_contacts: _contacts, age_recipient: _ageRecipient });
if (_epoch !== myEpoch) {
await chrome.storage.session.remove(['age_contacts', 'age_recipient']);
}
} catch (e) {
console.error('[age] CONTACTS_UPDATED session sync failed:', e?.message);
}
})();
}
sendResponse({ ok: true });
return false;
}
function handleEncryptContacts(msg, _sender, sendResponse) {
(async () => {
try {
await _wasmReady;
await ensureIdentity(); // restore contacts key too if the SW just woke up
sendResponse({ ok: true, ciphertextB64: encryptContacts(msg.json) });
} catch (e) {
console.error('[age] ENCRYPT_CONTACTS error:', e?.message);
sendResponse({ ok: false, error: e?.message ?? String(e) });
}
})();
return true;
}
// Pull-based counterpart to sendUnlockToTab. Called at content-script init;
// responds on the request/response channel, avoiding a second sendMessage
// and any listener-readiness race.
function handleRequestUnlock(_msg, _sender, sendResponse) {
(async () => {
try {
const hasIdentity = await ensureIdentity();
if (!hasIdentity) { sendResponse({ ok: false }); return; }
const payload = buildUnlockPayload();
if (!payload) { sendResponse({ ok: false }); return; }
sendResponse({ ok: true, ...payload });
} catch (e) {
console.error('[age] REQUEST_UNLOCK error:', e?.message);
sendResponse({ ok: false, error: e?.message ?? String(e) });
}
})();
return true;
}
const handlers = {
__proto__: null, // no inherited keys (toString, constructor, …) as handler names
UNLOCK: handleUnlock,
PING: handlePing,
RELOCK: handleRelock,
RELOAD_DISCORD_TABS: handleReloadDiscordTabs,
CONTACTS_UPDATED: handleContactsUpdated,
ENCRYPT_CONTACTS: handleEncryptContacts,
REQUEST_UNLOCK: handleRequestUnlock,
};
// ─── Sender policy ────────────────────────────────────────────────────────────
// runtime.onMessage is limited to this extension's contexts; external senders
// use onMessageExternal. Enforce explicit sender policies because content
// scripts on discord.com process untrusted input.
// Default-deny: each type requires a handler and a SENDER_POLICY entry.
// Null-prototype tables prevent inherited keys such as "toString" and "__proto__".
const POPUP_URL = chrome.runtime.getURL('popup/popup.html');
const DISCORD_ORIGIN = 'https://discord.com';
// Exact page match, ignoring query/hash. popup/import-helper.html is excluded.
const isPopup = (sender) =>
typeof sender?.url === 'string' && sender.url.split(/[?#]/)[0] === POPUP_URL;
// Top-frame content script on discord.com. Check sender.origin
// instead of the path: Discord is a SPA and routes can change
// before boot-time pulls. The manifest restricts injection to /channels/*;
// this check excludes extension pages, child frames, and lookalike hosts.
// chrome-extension: and moz-extension: origins cannot match.
const isDiscordTopFrame = (sender) =>
!!sender?.tab && sender.frameId === 0 && sender.origin === DISCORD_ORIGIN;
const SENDER_POLICY = {
__proto__: null,
UNLOCK: isPopup,
RELOCK: isPopup,
ENCRYPT_CONTACTS: isPopup,
CONTACTS_UPDATED: isPopup,
PING: isPopup,
RELOAD_DISCORD_TABS: isPopup,
REQUEST_UNLOCK: isDiscordTopFrame,
};
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const type = msg?.type;
if (typeof type !== 'string') return false;
const handler = handlers[type];
if (!handler) return false;
const allowed = SENDER_POLICY[type];
if (!allowed) {
console.error(`[age] ${type} has a handler but no SENDER_POLICY entry; rejected`);
return false;
}
if (!allowed(sender)) {
console.warn(`[age] ${type} rejected: unauthorized sender`);
return false;
}
return handler(msg, sender, sendResponse);
});