Skip to content

Commit 677b9df

Browse files
committed
test: signEvent self-verify guard + honest provider surface
sign-event-verify.test.js (7): a correctly-signed event passes and round-trips through verifySignature; the self-verify guard throws (never returns a bad signature) when the shared @noble/secp256k1 schnorr.sign singleton is patched to emit a flipped-byte or wrong-message signature; verifySignature rejects mutated content/sig/pubkey. Patches and restores the module singleton without touching src. provider-surface.test.js (15): window.nostr exposes exactly getPublicKey, signEvent and nip44.{encrypt,decrypt} and does NOT expose nip04 or getRelays (truthful feature-detection); signEvent input validation rejects bad shapes before any wire dispatch; the podkey-request CustomEvent wiring forwards the right type/fields (nip44 field is "pubkey", not "peer") and propagates a background error back as a rejection. Runs the page-context IIFE against a minimal EventTarget-backed window. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 6e03bbc commit 677b9df

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

test/provider-surface.test.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Tests for the NIP-07 provider surface (nostr-provider.js).
3+
*
4+
* The provider must advertise ONLY what it implements, so that page-side
5+
* feature-detection is truthful:
6+
* - exposes getPublicKey, signEvent, nip44.{encrypt,decrypt}
7+
* - does NOT expose nip04 (deprecated, unauthenticated; not shipped)
8+
* - does NOT expose getRelays (Podkey holds no relay list)
9+
*
10+
* nostr-provider.js is a page-context IIFE that assigns window.nostr, so the
11+
* test mounts a minimal window (EventTarget-backed) and imports the module to
12+
* run it, then inspects the installed surface and its request wiring.
13+
*/
14+
15+
import { describe, it, before } from 'node:test';
16+
import assert from 'node:assert/strict';
17+
18+
let nostr;
19+
const events = new EventTarget();
20+
21+
before(async () => {
22+
global.window = {
23+
addEventListener: (...a) => events.addEventListener(...a),
24+
removeEventListener: (...a) => events.removeEventListener(...a),
25+
dispatchEvent: (e) => events.dispatchEvent(e)
26+
};
27+
// Running the IIFE installs window.nostr.
28+
await import('../src/nostr-provider.js');
29+
nostr = global.window.nostr;
30+
});
31+
32+
describe('provider surface (honest feature-detection)', () => {
33+
it('exposes exactly getPublicKey, signEvent and nip44', () => {
34+
assert.deepEqual(Object.keys(nostr).sort(), ['getPublicKey', 'nip44', 'signEvent']);
35+
});
36+
37+
it('exposes nip44.encrypt and nip44.decrypt as functions', () => {
38+
assert.equal(typeof nostr.nip44.encrypt, 'function');
39+
assert.equal(typeof nostr.nip44.decrypt, 'function');
40+
});
41+
42+
it('does NOT expose nip04 (deprecated scheme is not shipped)', () => {
43+
assert.equal('nip04' in nostr, false);
44+
assert.equal(nostr.nip04, undefined);
45+
});
46+
47+
it('does NOT expose getRelays (no relay list is held)', () => {
48+
assert.equal('getRelays' in nostr, false);
49+
assert.equal(nostr.getRelays, undefined);
50+
});
51+
52+
it('getPublicKey and signEvent are async functions', () => {
53+
assert.equal(typeof nostr.getPublicKey, 'function');
54+
assert.equal(typeof nostr.signEvent, 'function');
55+
});
56+
});
57+
58+
describe('signEvent input validation (before any signing)', () => {
59+
// These reject synchronously inside the provider, never reaching the wire,
60+
// so no response listener is needed.
61+
it('rejects a non-object event', async () => {
62+
await assert.rejects(() => nostr.signEvent(null), /Event must be an object/);
63+
await assert.rejects(() => nostr.signEvent('nope'), /Event must be an object/);
64+
});
65+
66+
it('rejects a missing/typed kind', async () => {
67+
await assert.rejects(() => nostr.signEvent({ created_at: 1, tags: [], content: '' }), /kind must be a number/);
68+
});
69+
70+
it('rejects a missing created_at', async () => {
71+
await assert.rejects(() => nostr.signEvent({ kind: 1, tags: [], content: '' }), /created_at must be a number/);
72+
});
73+
74+
it('rejects non-array tags', async () => {
75+
await assert.rejects(() => nostr.signEvent({ kind: 1, created_at: 1, tags: 'x', content: '' }), /tags must be an array/);
76+
});
77+
78+
it('rejects non-string content', async () => {
79+
await assert.rejects(() => nostr.signEvent({ kind: 1, created_at: 1, tags: [], content: 5 }), /content must be a string/);
80+
});
81+
});
82+
83+
describe('provider request wiring (podkey-request CustomEvent)', () => {
84+
/**
85+
* Capture the next podkey-request the provider emits, reply to it with the
86+
* matching id, and return the request detail for assertions.
87+
*/
88+
function captureRequest (reply) {
89+
return new Promise((resolve) => {
90+
const handler = (e) => {
91+
global.window.removeEventListener('podkey-request', handler);
92+
const detail = e.detail;
93+
// Respond on the next tick so the provider's listener is registered.
94+
queueMicrotask(() => {
95+
global.window.dispatchEvent(new CustomEvent('podkey-response', {
96+
detail: { id: detail.id, result: reply(detail) }
97+
}));
98+
});
99+
resolve(detail);
100+
};
101+
global.window.addEventListener('podkey-request', handler);
102+
});
103+
}
104+
105+
it('getPublicKey dispatches a GET_PUBLIC_KEY request and resolves the response', async () => {
106+
const reqP = captureRequest(() => 'deadbeef'.repeat(8));
107+
const resP = nostr.getPublicKey();
108+
const req = await reqP;
109+
assert.equal(req.type, 'GET_PUBLIC_KEY');
110+
assert.ok(req.id, 'request must carry a correlation id');
111+
assert.equal(await resP, 'deadbeef'.repeat(8));
112+
});
113+
114+
it('signEvent forwards the validated event under SIGN_EVENT', async () => {
115+
const event = { kind: 1, created_at: 1700000000, tags: [['t', 'x']], content: 'gm' };
116+
const reqP = captureRequest((d) => ({ ...d.event, id: 'a'.repeat(64), pubkey: 'b'.repeat(64), sig: 'c'.repeat(128) }));
117+
const resP = nostr.signEvent(event);
118+
const req = await reqP;
119+
assert.equal(req.type, 'SIGN_EVENT');
120+
assert.deepEqual(req.event, event);
121+
const signed = await resP;
122+
assert.equal(signed.sig.length, 128);
123+
});
124+
125+
it('nip44.encrypt dispatches NIP44_ENCRYPT with pubkey + plaintext (field is "pubkey")', async () => {
126+
const reqP = captureRequest(() => 'BASE64PAYLOAD');
127+
const resP = nostr.nip44.encrypt('f'.repeat(64), 'secret');
128+
const req = await reqP;
129+
assert.equal(req.type, 'NIP44_ENCRYPT');
130+
assert.equal(req.pubkey, 'f'.repeat(64));
131+
assert.equal(req.plaintext, 'secret');
132+
assert.equal('peer' in req, false, 'the reconciled field name is "pubkey", not "peer"');
133+
assert.equal(await resP, 'BASE64PAYLOAD');
134+
});
135+
136+
it('nip44.decrypt dispatches NIP44_DECRYPT with pubkey + ciphertext', async () => {
137+
const reqP = captureRequest(() => 'plaintext-out');
138+
const resP = nostr.nip44.decrypt('f'.repeat(64), 'BASE64PAYLOAD');
139+
const req = await reqP;
140+
assert.equal(req.type, 'NIP44_DECRYPT');
141+
assert.equal(req.pubkey, 'f'.repeat(64));
142+
assert.equal(req.ciphertext, 'BASE64PAYLOAD');
143+
assert.equal(await resP, 'plaintext-out');
144+
});
145+
146+
it('propagates a background error back to the caller as a rejection', async () => {
147+
const handler = (e) => {
148+
global.window.removeEventListener('podkey-request', handler);
149+
queueMicrotask(() => {
150+
global.window.dispatchEvent(new CustomEvent('podkey-response', {
151+
detail: { id: e.detail.id, error: 'User denied permission' }
152+
}));
153+
});
154+
};
155+
global.window.addEventListener('podkey-request', handler);
156+
await assert.rejects(() => nostr.getPublicKey(), /User denied permission/);
157+
});
158+
});

test/sign-event-verify.test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Tests for the schnorr self-verification guard in signEvent (crypto.js).
3+
*
4+
* A key-holder must never emit a signature it cannot itself verify: a faulty
5+
* signing path (bad RNG, a library regression) producing a structurally-valid
6+
* but cryptographically-wrong signature has to throw, not return a bad token.
7+
*
8+
* The positive path is tested directly. The throw path is forced by patching
9+
* the shared @noble/secp256k1 schnorr.sign singleton (the same module instance
10+
* crypto.js imports) so it returns a corrupted signature — without touching src.
11+
*/
12+
13+
import { describe, it, afterEach } from 'node:test';
14+
import assert from 'node:assert/strict';
15+
import { schnorr } from '@noble/secp256k1';
16+
import { generateKeypair, signEvent, verifySignature, getEventHash } from '../src/crypto.js';
17+
18+
const baseEvent = () => ({ kind: 1, created_at: 1700000000, tags: [], content: 'self-verify' });
19+
20+
describe('signEvent self-verification', () => {
21+
const realSign = schnorr.sign;
22+
afterEach(() => { schnorr.sign = realSign; }); // always restore the singleton
23+
24+
it('a correctly-signed event passes and round-trips through verifySignature', async () => {
25+
const kp = await generateKeypair();
26+
const signed = await signEvent(baseEvent(), kp.privateKey);
27+
28+
assert.equal(signed.sig.length, 128);
29+
assert.equal(signed.pubkey, kp.publicKey);
30+
assert.equal(signed.id, getEventHash(signed), 'id must be the NIP-01 event hash');
31+
assert.equal(await verifySignature(signed), true);
32+
});
33+
34+
it('throws when signing yields a signature that does not verify (flipped byte)', async () => {
35+
const kp = await generateKeypair();
36+
// Emit a 64-byte signature that is structurally valid but wrong: passes the
37+
// length check, fails schnorr.verify. The guard must catch it.
38+
schnorr.sign = (msg, sk) => {
39+
const sig = realSign(msg, sk);
40+
sig[0] ^= 0xff;
41+
return sig;
42+
};
43+
await assert.rejects(
44+
() => signEvent(baseEvent(), kp.privateKey),
45+
/Signature self-verification failed/
46+
);
47+
});
48+
49+
it('throws when the signature is over the wrong message id', async () => {
50+
const kp = await generateKeypair();
51+
// Produce a real schnorr signature, but over a different 32-byte message
52+
// than the event id. It is a valid signature for *something* — just not for
53+
// this event — so length passes and self-verify against the true id fails.
54+
const wrongMsg = new Uint8Array(32).fill(7);
55+
schnorr.sign = (_msg, sk) => realSign(wrongMsg, sk);
56+
await assert.rejects(
57+
() => signEvent(baseEvent(), kp.privateKey),
58+
/Signature self-verification failed/
59+
);
60+
});
61+
62+
it('does NOT leak the bad signature when it throws', async () => {
63+
const kp = await generateKeypair();
64+
let emitted;
65+
schnorr.sign = (msg, sk) => {
66+
const sig = realSign(msg, sk);
67+
sig[10] ^= 0x01;
68+
emitted = sig;
69+
return sig;
70+
};
71+
await assert.rejects(() => signEvent(baseEvent(), kp.privateKey));
72+
assert.ok(emitted, 'the patched signer ran');
73+
// The point of the guard: a bad signature is produced internally but never
74+
// returned to a caller. (The rejection above already proves no value is
75+
// returned; this asserts the failure happened *after* signing, at verify.)
76+
});
77+
});
78+
79+
describe('verifySignature (guard primitive) rejects mutations', () => {
80+
it('rejects a mutated content (id no longer matches)', async () => {
81+
const kp = await generateKeypair();
82+
const signed = await signEvent(baseEvent(), kp.privateKey);
83+
const tampered = { ...signed, content: 'tampered' };
84+
// The signature is over the original id; verifying against the original id
85+
// still passes, but a consumer recomputing the id would reject it.
86+
assert.notEqual(getEventHash(tampered), signed.id, 'mutated content changes the id');
87+
assert.equal(await verifySignature({ ...tampered, id: getEventHash(tampered) }), false);
88+
});
89+
90+
it('rejects a flipped signature byte', async () => {
91+
const kp = await generateKeypair();
92+
const signed = await signEvent(baseEvent(), kp.privateKey);
93+
const badSig = (parseInt(signed.sig[0], 16) ^ 0x8).toString(16) + signed.sig.slice(1);
94+
assert.equal(await verifySignature({ ...signed, sig: badSig }), false);
95+
});
96+
97+
it('rejects a signature verified against a different pubkey', async () => {
98+
const kp = await generateKeypair();
99+
const other = await generateKeypair();
100+
const signed = await signEvent(baseEvent(), kp.privateKey);
101+
assert.equal(await verifySignature({ ...signed, pubkey: other.publicKey }), false);
102+
});
103+
});

0 commit comments

Comments
 (0)