Skip to content

Commit 57192fe

Browse files
committed
feat: implement @nse-dev/browser with full test suite (23 tests)
SubtleCrypto AES-256-GCM key wrapping, IndexedDB storage, memory storage for testing. Two modes: auto-generated wrapping key (SubtleCrypto) or explicit master key (server-compatible). Uses nostr-crypto-utils for Schnorr signing. Sign/verify round-trips, kind 0 support, wrong-key rejection, blob security checks.
1 parent e54f04f commit 57192fe

9 files changed

Lines changed: 3021 additions & 34 deletions

File tree

platforms/browser/package-lock.json

Lines changed: 2281 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

platforms/browser/package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@nse-dev/browser",
3+
"version": "0.1.0",
4+
"description": "Browser Nostr Secure Enclave — WebAuthn + SubtleCrypto key wrapping for secp256k1",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"import": "./dist/index.js",
11+
"types": "./dist/index.d.ts"
12+
}
13+
},
14+
"files": ["dist"],
15+
"scripts": {
16+
"build": "tsc",
17+
"test": "vitest run",
18+
"test:watch": "vitest"
19+
},
20+
"keywords": ["nostr", "secure-enclave", "browser", "webauthn", "subtlecrypto"],
21+
"license": "MIT",
22+
"repository": {
23+
"type": "git",
24+
"url": "https://github.com/HumanjavaEnterprises/nse-dev.web.landingpage.src",
25+
"directory": "platforms/browser"
26+
},
27+
"dependencies": {
28+
"nostr-crypto-utils": "^0.7.0",
29+
"@noble/curves": "^2.0.1",
30+
"@noble/hashes": "^2.0.1"
31+
},
32+
"devDependencies": {
33+
"typescript": "^5.7.0",
34+
"vitest": "^4.0.0"
35+
}
36+
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { NSEBrowser } from './browser.js';
3+
import { NSEMemoryStorage } from './storage-memory.js';
4+
import { NSEError, NSEErrorCode } from '../../core/src/index.js';
5+
import { verifySignature } from 'nostr-crypto-utils';
6+
import { bytesToHex } from './crypto.js';
7+
8+
describe('@nse-dev/browser — NSEBrowser', () => {
9+
let nse: NSEBrowser;
10+
let storage: NSEMemoryStorage;
11+
12+
beforeEach(() => {
13+
storage = new NSEMemoryStorage();
14+
nse = new NSEBrowser({ storage });
15+
});
16+
17+
describe('constructor', () => {
18+
it('accepts no master key (SubtleCrypto mode)', () => {
19+
expect(() => new NSEBrowser({ storage })).not.toThrow();
20+
});
21+
22+
it('accepts a valid master key', () => {
23+
const key = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
24+
expect(() => new NSEBrowser({ storage, masterKey: key })).not.toThrow();
25+
});
26+
27+
it('rejects invalid master key length', () => {
28+
expect(() => new NSEBrowser({ storage, masterKey: 'tooshort' })).toThrow(NSEError);
29+
});
30+
});
31+
32+
describe('generate()', () => {
33+
it('generates a keypair and returns key info', async () => {
34+
const info = await nse.generate();
35+
36+
expect(info.pubkey).toHaveLength(64);
37+
expect(info.npub).toMatch(/^npub1/);
38+
expect(info.created_at).toBeGreaterThan(0);
39+
expect(info.hardware_backed).toBe(false);
40+
});
41+
42+
it('stores encrypted blob + wrapping key in storage', async () => {
43+
await nse.generate();
44+
45+
const blob = await storage.get('nse:blob');
46+
expect(blob).not.toBeNull();
47+
48+
const parsed = JSON.parse(blob!);
49+
expect(parsed.version).toBe(1);
50+
expect(parsed.hardware_backed).toBe(false);
51+
52+
// Wrapping key should also be stored (SubtleCrypto mode)
53+
const wrappingKey = await storage.get('nse:wrapping-key');
54+
expect(wrappingKey).not.toBeNull();
55+
});
56+
57+
it('throws KEY_EXISTS if key already exists', async () => {
58+
await nse.generate();
59+
await expect(nse.generate()).rejects.toThrow(NSEError);
60+
61+
try {
62+
await nse.generate();
63+
} catch (e) {
64+
expect((e as NSEError).code).toBe(NSEErrorCode.KEY_EXISTS);
65+
}
66+
});
67+
});
68+
69+
describe('exists()', () => {
70+
it('returns false when no key', async () => {
71+
expect(await nse.exists()).toBe(false);
72+
});
73+
74+
it('returns true after generate', async () => {
75+
await nse.generate();
76+
expect(await nse.exists()).toBe(true);
77+
});
78+
});
79+
80+
describe('getPublicKey() / getNpub()', () => {
81+
it('returns pubkey from stored blob', async () => {
82+
const info = await nse.generate();
83+
expect(await nse.getPublicKey()).toBe(info.pubkey);
84+
});
85+
86+
it('returns npub from stored blob', async () => {
87+
const info = await nse.generate();
88+
expect(await nse.getNpub()).toBe(info.npub);
89+
});
90+
91+
it('throws KEY_NOT_FOUND when no key', async () => {
92+
await expect(nse.getPublicKey()).rejects.toThrow(NSEError);
93+
});
94+
});
95+
96+
describe('sign()', () => {
97+
it('signs a Nostr event with valid Schnorr signature', async () => {
98+
const info = await nse.generate();
99+
100+
const signed = await nse.sign({
101+
kind: 1,
102+
content: 'hello from NSE browser',
103+
tags: [],
104+
created_at: Math.floor(Date.now() / 1000),
105+
});
106+
107+
expect(signed.id).toHaveLength(64);
108+
expect(signed.pubkey).toBe(info.pubkey);
109+
expect(signed.sig).toHaveLength(128);
110+
});
111+
112+
it('produces a signature that verifies', async () => {
113+
await nse.generate();
114+
115+
const signed = await nse.sign({
116+
kind: 1,
117+
content: 'verify me in browser',
118+
tags: [['t', 'nse']],
119+
created_at: Math.floor(Date.now() / 1000),
120+
});
121+
122+
const valid = await verifySignature(signed);
123+
expect(valid).toBe(true);
124+
});
125+
126+
it('signs kind 0 metadata events correctly', async () => {
127+
await nse.generate();
128+
129+
const signed = await nse.sign({
130+
kind: 0,
131+
content: JSON.stringify({ name: 'NSE Browser Test' }),
132+
tags: [],
133+
created_at: Math.floor(Date.now() / 1000),
134+
});
135+
136+
expect(signed.kind).toBe(0);
137+
const valid = await verifySignature(signed);
138+
expect(valid).toBe(true);
139+
});
140+
141+
it('throws KEY_NOT_FOUND when no key', async () => {
142+
await expect(nse.sign({
143+
kind: 1, content: 'no key', tags: [], created_at: 0,
144+
})).rejects.toThrow(NSEError);
145+
});
146+
});
147+
148+
describe('destroy()', () => {
149+
it('removes key and wrapping key from storage', async () => {
150+
await nse.generate();
151+
expect(await nse.exists()).toBe(true);
152+
153+
await nse.destroy();
154+
expect(await nse.exists()).toBe(false);
155+
156+
// Wrapping key also removed
157+
expect(await storage.get('nse:wrapping-key')).toBeNull();
158+
});
159+
160+
it('allows re-generate after destroy', async () => {
161+
await nse.generate();
162+
await nse.destroy();
163+
const info = await nse.generate();
164+
expect(info.pubkey).toHaveLength(64);
165+
});
166+
});
167+
168+
describe('master key mode', () => {
169+
it('works with explicit master key (server-compatible)', async () => {
170+
const masterKey = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
171+
const nseWithKey = new NSEBrowser({ storage: new NSEMemoryStorage(), masterKey });
172+
173+
const info = await nseWithKey.generate();
174+
expect(info.pubkey).toHaveLength(64);
175+
176+
const signed = await nseWithKey.sign({
177+
kind: 1,
178+
content: 'master key mode',
179+
tags: [],
180+
created_at: Math.floor(Date.now() / 1000),
181+
});
182+
183+
const valid = await verifySignature(signed);
184+
expect(valid).toBe(true);
185+
});
186+
187+
it('does not store wrapping key when master key provided', async () => {
188+
const masterKey = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
189+
const memStorage = new NSEMemoryStorage();
190+
const nseWithKey = new NSEBrowser({ storage: memStorage, masterKey });
191+
192+
await nseWithKey.generate();
193+
expect(await memStorage.get('nse:wrapping-key')).toBeNull();
194+
});
195+
196+
it('fails to decrypt with wrong master key', async () => {
197+
const key1 = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
198+
const key2 = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
199+
const memStorage = new NSEMemoryStorage();
200+
201+
const nse1 = new NSEBrowser({ storage: memStorage, masterKey: key1 });
202+
await nse1.generate();
203+
204+
const nse2 = new NSEBrowser({ storage: memStorage, masterKey: key2 });
205+
try {
206+
await nse2.sign({ kind: 1, content: 'wrong key', tags: [], created_at: 0 });
207+
expect.unreachable('should have thrown');
208+
} catch (e) {
209+
expect((e as NSEError).code).toBe(NSEErrorCode.DECRYPTION_FAILED);
210+
}
211+
});
212+
});
213+
214+
describe('sign/verify round-trip', () => {
215+
it('generate → sign 5 events → verify all', async () => {
216+
const info = await nse.generate();
217+
218+
for (let i = 0; i < 5; i++) {
219+
const signed = await nse.sign({
220+
kind: 1,
221+
content: `browser message ${i}`,
222+
tags: [['nonce', String(i)]],
223+
created_at: Math.floor(Date.now() / 1000) + i,
224+
});
225+
226+
expect(signed.pubkey).toBe(info.pubkey);
227+
const valid = await verifySignature(signed);
228+
expect(valid).toBe(true);
229+
}
230+
});
231+
});
232+
233+
describe('encrypted blob security', () => {
234+
it('ciphertext is not the raw private key', async () => {
235+
await nse.generate();
236+
237+
const raw = await storage.get('nse:blob');
238+
const blob = JSON.parse(raw!);
239+
240+
// Ciphertext includes AES-GCM auth tag, so it's longer than 64 chars
241+
expect(blob.ciphertext.length).toBeGreaterThan(64);
242+
});
243+
244+
it('different generates produce different ciphertexts', async () => {
245+
await nse.generate();
246+
const blob1 = await storage.get('nse:blob');
247+
await nse.destroy();
248+
249+
await nse.generate();
250+
const blob2 = await storage.get('nse:blob');
251+
252+
// Different keys + different IVs = different ciphertexts
253+
expect(JSON.parse(blob1!).ciphertext).not.toBe(JSON.parse(blob2!).ciphertext);
254+
});
255+
});
256+
});

0 commit comments

Comments
 (0)