Skip to content

Commit 3fd5d8c

Browse files
committed
merge(test): expand suite 56->133 over secured behaviour
2 parents b51760d + 11562f1 commit 3fd5d8c

7 files changed

Lines changed: 1092 additions & 0 deletions

test/body-hash.test.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Tests for bodyToHashHex — the page-side NIP-98 `payload` body hashing in
3+
* nip98-interceptor.js. The raw request body never crosses to the background
4+
* service worker; the page computes its SHA-256 here (the only context where
5+
* FormData / URLSearchParams / Blob survive intact) and ships only the digest.
6+
*
7+
* bodyToHashHex is a private function inside the interceptor's page-context
8+
* IIFE (no ESM export — classic scripts can't import). To exercise the real
9+
* source it is loaded and run inside a vm sandbox with a minimal page (window,
10+
* fetch, XHR), and the bodyHash the interceptor emits on the
11+
* podkey-nip98-request event is captured. This drives the genuine code path
12+
* (window.fetch wrapper -> getNip98AuthHeader -> bodyToHashHex) rather than a
13+
* reimplementation.
14+
*/
15+
16+
import { describe, it, before } from 'node:test';
17+
import assert from 'node:assert/strict';
18+
import { readFileSync } from 'node:fs';
19+
import { createHash, webcrypto } from 'node:crypto';
20+
import vm from 'node:vm';
21+
22+
const sha256hex = (input) => createHash('sha256').update(input).digest('hex');
23+
24+
/** Build a fresh sandboxed page with the interceptor installed. */
25+
function makeInterceptedPage () {
26+
const src = readFileSync(new URL('../src/nip98-interceptor.js', import.meta.url), 'utf8');
27+
const bus = new EventTarget();
28+
const win = {
29+
__podkey_nip98_intercepted: false,
30+
addEventListener: (...a) => bus.addEventListener(...a),
31+
removeEventListener: (...a) => bus.removeEventListener(...a),
32+
dispatchEvent: (e) => bus.dispatchEvent(e),
33+
// originalFetch: a stub the wrapper calls after deciding on auth.
34+
fetch: async () => ({ status: 200, redirected: false, url: '' })
35+
};
36+
37+
// Echo every nip98 request back immediately so the fetch wrapper resolves,
38+
// recording the detail (which carries bodyHash) for assertions.
39+
const requests = [];
40+
bus.addEventListener('podkey-nip98-request', (e) => {
41+
requests.push(e.detail);
42+
bus.dispatchEvent(new CustomEvent('podkey-nip98-response', { detail: { id: e.detail.id, result: null } }));
43+
});
44+
45+
// Minimal XHR so the interceptor can patch its prototype without throwing.
46+
function XHR () {}
47+
XHR.prototype = { open () {}, send () {}, setRequestHeader () {} };
48+
49+
const sandbox = {
50+
window: win,
51+
XMLHttpRequest: XHR,
52+
crypto: webcrypto,
53+
CustomEvent, Headers, Request, TextEncoder,
54+
Blob, URLSearchParams, ArrayBuffer, Uint8Array, Uint16Array, Array, Object,
55+
console, setTimeout, clearTimeout
56+
};
57+
vm.createContext(sandbox);
58+
vm.runInContext(src, sandbox);
59+
60+
return {
61+
/** Issue a fetch through the wrapped fetch and return the captured bodyHash. */
62+
async fetchAndGetHash (init) {
63+
requests.length = 0;
64+
await win.fetch('https://pod.test/r', init);
65+
assert.equal(requests.length, 1, 'interceptor should emit exactly one nip98 request');
66+
return requests[0].bodyHash;
67+
},
68+
/** Issue a fetch and return whether any nip98 request was emitted. */
69+
async fetchEmitsRequest (input, init) {
70+
requests.length = 0;
71+
await win.fetch(input, init);
72+
return requests.length > 0;
73+
}
74+
};
75+
}
76+
77+
describe('bodyToHashHex (NIP-98 page-side body hashing)', () => {
78+
let page;
79+
before(() => { page = makeInterceptedPage(); });
80+
81+
it('hashes a string body to its sha-256 hex', async () => {
82+
const body = '{"hello":"world"}';
83+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body }), sha256hex(body));
84+
});
85+
86+
it('hashes a URLSearchParams body via its serialized form', async () => {
87+
const params = new URLSearchParams({ a: '1', b: 'two' });
88+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: params }), sha256hex(params.toString()));
89+
});
90+
91+
it('hashes a Blob body by its bytes', async () => {
92+
const text = 'blob-bytes-😀';
93+
const blob = new Blob([text]);
94+
const expected = sha256hex(Buffer.from(text, 'utf8'));
95+
assert.equal(await page.fetchAndGetHash({ method: 'PUT', body: blob }), expected);
96+
});
97+
98+
it('hashes an ArrayBuffer body', async () => {
99+
const bytes = new Uint8Array([1, 2, 3, 4, 250, 255]);
100+
const expected = sha256hex(Buffer.from(bytes));
101+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: bytes.buffer }), expected);
102+
});
103+
104+
it('hashes a typed-array (Uint8Array) body', async () => {
105+
const bytes = new Uint8Array([10, 20, 30]);
106+
const expected = sha256hex(Buffer.from(bytes));
107+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: bytes }), expected);
108+
});
109+
110+
it('respects a typed-array view offset (does not hash the whole buffer)', async () => {
111+
const full = new Uint8Array([0, 0, 1, 2, 3, 0]);
112+
const view = full.subarray(2, 5); // [1,2,3]
113+
const expected = sha256hex(Buffer.from([1, 2, 3]));
114+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: view }), expected);
115+
});
116+
117+
it('returns "" for no body (GET)', async () => {
118+
assert.equal(await page.fetchAndGetHash({ method: 'GET' }), '');
119+
});
120+
121+
it('returns "" for an empty-string body', async () => {
122+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: '' }), '');
123+
});
124+
125+
it('returns "" for a FormData body (no canonical NIP-98 multipart hash)', async () => {
126+
// Documented decision: NIP-98 defines no canonical hash for multipart bodies,
127+
// so FormData is intentionally not hashed (no payload tag is added). A
128+
// FormData instance matches none of bodyToHashHex's recognised body types
129+
// (string / URLSearchParams / Blob / ArrayBuffer / typed-array) and hits the
130+
// `return ''` fallthrough.
131+
const fd = new FormData();
132+
fd.append('field', 'value');
133+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: fd }), '');
134+
});
135+
});
136+
137+
describe('interceptor honors a page-set Authorization (no NIP-98 injection)', () => {
138+
let page;
139+
before(() => { page = makeInterceptedPage(); });
140+
141+
it('does not emit a nip98 request when init.headers already has Authorization', async () => {
142+
const emitted = await page.fetchEmitsRequest('https://pod.test/r', {
143+
method: 'GET', headers: { Authorization: 'DPoP page-token' }
144+
});
145+
assert.equal(emitted, false, 'page-set DPoP must not be overwritten by NIP-98');
146+
});
147+
148+
it('emits a nip98 request when no Authorization is present', async () => {
149+
const emitted = await page.fetchEmitsRequest('https://pod.test/r', { method: 'GET' });
150+
assert.equal(emitted, true);
151+
});
152+
});

test/consent-approval.test.js

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* Tests for the per-origin consent gate / signing-approval flow.
3+
*
4+
* background.js exports nothing and wires its handlers onto chrome.runtime at
5+
* import time, so these tests drive it exactly the way the real extension does:
6+
* mock `chrome`, import the module to capture the onMessage listener, then send
7+
* the same messages the content script and approve popup would send.
8+
*
9+
* Covered (consent contract, security/signing-approval-ui #20):
10+
* - approve resolves the pending request with the result
11+
* - deny rejects the request ("User denied ...")
12+
* - the 60s timeout auto-denies
13+
* - APPROVE_SIGNING with approved:false (popup beforeunload = deny) rejects
14+
* - trusted-origin + autoSign auto-path: kind 27235 Solid auth signs with NO
15+
* prompt; any other kind still prompts even when trusted+autoSign
16+
*/
17+
18+
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
19+
import assert from 'node:assert/strict';
20+
21+
// --- chrome mock (session + local areas, like storage.test.js) ----------------
22+
function makeArea (store) {
23+
return {
24+
get: async (keys) => {
25+
const result = {};
26+
if (Array.isArray(keys)) {
27+
keys.forEach((k) => { result[k] = store[k]; });
28+
} else if (keys) {
29+
Object.keys(keys).forEach((k) => { result[k] = store[k] ?? keys[k]; });
30+
} else {
31+
return { ...store };
32+
}
33+
return result;
34+
},
35+
set: async (items) => { Object.assign(store, items); },
36+
remove: async (keys) => {
37+
(Array.isArray(keys) ? keys : [keys]).forEach((k) => delete store[k]);
38+
}
39+
};
40+
}
41+
42+
const stores = { local: {}, session: {} };
43+
const windowsCreated = [];
44+
const captured = { onMessage: null };
45+
46+
global.chrome = {
47+
runtime: {
48+
onInstalled: { addListener: () => {} },
49+
onMessage: { addListener: (fn) => { captured.onMessage = fn; } },
50+
lastError: null
51+
},
52+
windows: {
53+
create: (opts) => { windowsCreated.push(opts); return Promise.resolve({ id: windowsCreated.length }); }
54+
},
55+
storage: {
56+
local: makeArea(stores.local),
57+
session: makeArea(stores.session)
58+
}
59+
};
60+
61+
// Import background once (captures the onMessage listener). Storage helpers are
62+
// imported separately for seeding; they share the same chrome mock instance.
63+
await import('../src/background.js');
64+
const { generateKeypair } = await import('../src/crypto.js');
65+
const { storeKeypair, addTrustedOrigin, setAutoSign } = await import('../src/storage.js');
66+
67+
const onMessage = captured.onMessage;
68+
69+
/**
70+
* Drain the microtask queue. handleMessage awaits several storage reads before
71+
* it reaches showPermissionPrompt / chrome.windows.create; one setImmediate
72+
* turn flushes that chain deterministically (and is not affected by the faked
73+
* setTimeout used in the timeout tests).
74+
*/
75+
const flush = () => new Promise((resolve) => setImmediate(resolve));
76+
77+
/** Send a message to the background and return a promise of sendResponse(). */
78+
function send (message) {
79+
return new Promise((resolve) => { onMessage(message, {}, resolve); });
80+
}
81+
82+
/** Read the requestId out of the most recently opened approval popup URL. */
83+
function lastRequestId () {
84+
const last = windowsCreated[windowsCreated.length - 1];
85+
// url is `popup/approve.html?id=...&origin=...` — parse against a dummy base
86+
return new URL('https://x/' + last.url).searchParams.get('id');
87+
}
88+
89+
/** Resolve a pending prompt by sending the popup's APPROVE_SIGNING message. */
90+
function respond (requestId, approved) {
91+
onMessage({ type: 'APPROVE_SIGNING', requestId, approved }, {}, () => {});
92+
}
93+
94+
const SOLID_EVENT = { kind: 27235, created_at: 1700000000, tags: [['u', 'https://pod.test/']], content: '' };
95+
const NOTE_EVENT = { kind: 1, created_at: 1700000000, tags: [], content: 'gm' };
96+
97+
describe('consent gate / approval flow', () => {
98+
let keypair;
99+
100+
beforeEach(async () => {
101+
// Reset storage and the record of opened windows before every test.
102+
for (const k of Object.keys(stores.local)) delete stores.local[k];
103+
for (const k of Object.keys(stores.session)) delete stores.session[k];
104+
windowsCreated.length = 0;
105+
keypair = await generateKeypair();
106+
await storeKeypair(keypair.privateKey, keypair.publicKey);
107+
});
108+
109+
afterEach(() => { mock.timers.reset(); });
110+
111+
it('opens an approval popup for an untrusted origin (does not auto-resolve)', async () => {
112+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://untrusted.test' });
113+
// Let the handler reach showPermissionPrompt / chrome.windows.create.
114+
await flush();
115+
assert.equal(windowsCreated.length, 1, 'exactly one approval popup should open');
116+
const url = windowsCreated[0].url;
117+
assert.match(url, /^popup\/approve\.html\?/);
118+
const params = new URL('https://x/' + url).searchParams;
119+
assert.equal(params.get('origin'), 'https://untrusted.test');
120+
assert.equal(params.get('action'), 'read your public key');
121+
assert.ok(params.get('id'), 'a requestId must be present');
122+
// Clean up the dangling promise so the test runner doesn't hang.
123+
respond(params.get('id'), true);
124+
await pending;
125+
});
126+
127+
it('approve resolves the pending request with the public key', async () => {
128+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
129+
await flush();
130+
respond(lastRequestId(), true);
131+
const result = await pending;
132+
assert.equal(result, keypair.publicKey);
133+
});
134+
135+
it('deny rejects the request with "User denied permission"', async () => {
136+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
137+
await flush();
138+
respond(lastRequestId(), false);
139+
const result = await pending;
140+
assert.deepEqual(result, { error: 'User denied permission' });
141+
});
142+
143+
it('APPROVE_SIGNING approved:false (popup beforeunload = deny) rejects signing', async () => {
144+
const pending = send({ type: 'SIGN_EVENT', event: NOTE_EVENT, origin: 'https://app.test' });
145+
await flush();
146+
// The approve popup sends {approved:false} on window beforeunload (closing
147+
// the window without choosing = deny). Same wire message, approved:false.
148+
respond(lastRequestId(), false);
149+
const result = await pending;
150+
assert.deepEqual(result, { error: 'User denied signing' });
151+
});
152+
153+
it('the 60s timeout auto-denies when the popup never responds', async () => {
154+
mock.timers.enable({ apis: ['setTimeout'] });
155+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://slow.test' });
156+
await flush();
157+
assert.equal(windowsCreated.length, 1, 'popup opened, awaiting a decision');
158+
// No APPROVE_SIGNING arrives. Advance just past the 60s auto-deny.
159+
mock.timers.tick(60001);
160+
const result = await pending;
161+
assert.deepEqual(result, { error: 'User denied permission' });
162+
});
163+
164+
it('does NOT auto-deny before 60s elapse', async () => {
165+
mock.timers.enable({ apis: ['setTimeout'] });
166+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
167+
await flush();
168+
mock.timers.tick(59999); // one ms short of the deadline
169+
// Still pending — a late approval must win the race.
170+
respond(lastRequestId(), true);
171+
const result = await pending;
172+
assert.equal(result, keypair.publicKey);
173+
});
174+
175+
it('a late APPROVE_SIGNING after timeout is ignored (entry already deleted)', async () => {
176+
mock.timers.enable({ apis: ['setTimeout'] });
177+
const pending = send({ type: 'GET_PUBLIC_KEY', origin: 'https://app.test' });
178+
await flush();
179+
const id = lastRequestId();
180+
mock.timers.tick(60001);
181+
const result = await pending;
182+
assert.deepEqual(result, { error: 'User denied permission' });
183+
// Late response for the same id must be a harmless no-op (no throw, no
184+
// second resolution). pendingApprovals no longer holds the id.
185+
assert.doesNotThrow(() => respond(id, true));
186+
});
187+
});
188+
189+
describe('trusted-origin auto-path (autoSign)', () => {
190+
let keypair;
191+
192+
beforeEach(async () => {
193+
for (const k of Object.keys(stores.local)) delete stores.local[k];
194+
for (const k of Object.keys(stores.session)) delete stores.session[k];
195+
windowsCreated.length = 0;
196+
keypair = await generateKeypair();
197+
await storeKeypair(keypair.privateKey, keypair.publicKey);
198+
});
199+
200+
it('signs a kind-27235 Solid event with NO prompt when trusted + autoSign', async () => {
201+
await addTrustedOrigin('https://pod.test');
202+
await setAutoSign(true);
203+
const result = await send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://pod.test' });
204+
assert.equal(windowsCreated.length, 0, 'auto-sign path must not open a popup');
205+
assert.equal(result.kind, 27235);
206+
assert.equal(result.pubkey, keypair.publicKey);
207+
assert.equal(result.sig.length, 128);
208+
});
209+
210+
it('still PROMPTS for a non-Solid kind even when trusted + autoSign', async () => {
211+
await addTrustedOrigin('https://pod.test');
212+
await setAutoSign(true);
213+
const pending = send({ type: 'SIGN_EVENT', event: NOTE_EVENT, origin: 'https://pod.test' });
214+
await flush();
215+
assert.equal(windowsCreated.length, 1, 'non-Solid kind must still require approval');
216+
respond(lastRequestId(), true);
217+
const result = await pending;
218+
assert.equal(result.kind, 1);
219+
});
220+
221+
it('PROMPTS for a Solid event when autoSign is OFF (even if trusted)', async () => {
222+
await addTrustedOrigin('https://pod.test');
223+
await setAutoSign(false);
224+
const pending = send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://pod.test' });
225+
await flush();
226+
assert.equal(windowsCreated.length, 1, 'autoSign off must require explicit approval');
227+
respond(lastRequestId(), true);
228+
const result = await pending;
229+
assert.equal(result.kind, 27235);
230+
});
231+
232+
it('approving an untrusted signing request trusts the origin (no re-prompt)', async () => {
233+
await setAutoSign(true);
234+
// First request: untrusted -> prompt -> approve.
235+
const first = send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://new.test' });
236+
await flush();
237+
assert.equal(windowsCreated.length, 1);
238+
respond(lastRequestId(), true);
239+
await first;
240+
// Second Solid request from the now-trusted origin: auto-signs, no popup.
241+
const second = await send({ type: 'SIGN_EVENT', event: SOLID_EVENT, origin: 'https://new.test' });
242+
assert.equal(windowsCreated.length, 1, 'origin became trusted; no second popup');
243+
assert.equal(second.kind, 27235);
244+
});
245+
});

0 commit comments

Comments
 (0)