javascript-solid-server 0.0.71 → 0.0.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +9 -1
- package/SECURITY-AUDIT-2026-01-05.md +382 -0
- package/docs/design/nostr-solid-browser-extension.md +1465 -0
- package/package.json +1 -1
- package/src/auth/solid-oidc.js +50 -1
- package/src/handlers/git.js +33 -6
- package/src/idp/interactions.js +5 -0
- package/src/utils/ssrf.js +9 -4
- package/src/utils/url.js +7 -2
- package/src/wac/parser.js +4 -3
- package/test/wac.test.js +36 -0
- package/SECURITY-AUDIT-2026-01-15.md +0 -514
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
# Nostr-Solid Browser Extension Design
|
|
2
|
+
|
|
3
|
+
## Executive Summary
|
|
4
|
+
|
|
5
|
+
A browser extension that enables seamless authentication to Solid servers using Nostr keys, eliminating the OAuth dance entirely. Users with existing Nostr identities can access protected Solid resources by signing HTTP requests with their Nostr keys.
|
|
6
|
+
|
|
7
|
+
## Problem Statement
|
|
8
|
+
|
|
9
|
+
### Current Solid Authentication UX
|
|
10
|
+
|
|
11
|
+
1. User navigates to protected resource
|
|
12
|
+
2. Server returns 401 Unauthorized
|
|
13
|
+
3. User must initiate OAuth flow (Solid-OIDC)
|
|
14
|
+
4. Redirect to Identity Provider with complex parameters
|
|
15
|
+
5. Authenticate at IdP (username/password, WebAuthn, etc.)
|
|
16
|
+
6. Redirect back with authorization code
|
|
17
|
+
7. Token exchange happens
|
|
18
|
+
8. Finally, access granted
|
|
19
|
+
|
|
20
|
+
**Pain points:**
|
|
21
|
+
- Multiple redirects
|
|
22
|
+
- Requires account creation at an IdP
|
|
23
|
+
- Complex client registration (client_id, redirect_uri)
|
|
24
|
+
- Different credentials for different pods
|
|
25
|
+
- Poor mobile experience
|
|
26
|
+
|
|
27
|
+
### The Nostr SSO Opportunity
|
|
28
|
+
|
|
29
|
+
Nostr users already have:
|
|
30
|
+
- A cryptographic keypair (secp256k1)
|
|
31
|
+
- Key management via browser extensions (nos2x, Alby, nostr-keyx)
|
|
32
|
+
- A universal identity (npub) that works everywhere
|
|
33
|
+
- Experience signing events/messages
|
|
34
|
+
|
|
35
|
+
**With did:nostr authentication:**
|
|
36
|
+
1. User navigates to protected resource
|
|
37
|
+
2. Extension detects 401, signs request with Nostr key
|
|
38
|
+
3. Server verifies signature, grants access
|
|
39
|
+
4. Done. No redirects, no OAuth dance.
|
|
40
|
+
|
|
41
|
+
## Current Nostr Extension Landscape
|
|
42
|
+
|
|
43
|
+
### nos2x (Most Popular)
|
|
44
|
+
- **Capabilities:** `window.nostr.getPublicKey()`, `window.nostr.signEvent()`
|
|
45
|
+
- **Limitations:** Only signs NIP-07 events, not HTTP requests
|
|
46
|
+
- **Users:** ~50,000+
|
|
47
|
+
|
|
48
|
+
### Alby
|
|
49
|
+
- **Capabilities:** NIP-07 + Lightning payments
|
|
50
|
+
- **Limitations:** Same as nos2x for signing
|
|
51
|
+
- **Users:** ~100,000+
|
|
52
|
+
|
|
53
|
+
### nostr-keyx
|
|
54
|
+
- **Capabilities:** NIP-07 with better UX
|
|
55
|
+
- **Limitations:** Same signing limitations
|
|
56
|
+
|
|
57
|
+
### What's Missing
|
|
58
|
+
|
|
59
|
+
None of these extensions can:
|
|
60
|
+
1. Intercept HTTP 401 responses
|
|
61
|
+
2. Sign HTTP request headers (not Nostr events)
|
|
62
|
+
3. Automatically retry requests with authentication
|
|
63
|
+
4. Understand Solid/WebID concepts
|
|
64
|
+
|
|
65
|
+
## Architecture Options
|
|
66
|
+
|
|
67
|
+
### Option A: Standalone Solid-Nostr Extension
|
|
68
|
+
|
|
69
|
+
A new extension specifically for Solid + Nostr authentication.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
┌─────────────────────────────────────────────────────────┐
|
|
73
|
+
│ Browser Extension │
|
|
74
|
+
├─────────────────────────────────────────────────────────┤
|
|
75
|
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
|
76
|
+
│ │ Content │ │ Background │ │ Popup │ │
|
|
77
|
+
│ │ Script │ │ Worker │ │ UI │ │
|
|
78
|
+
│ │ │ │ │ │ │ │
|
|
79
|
+
│ │ - Detect │ │ - Key mgmt │ │ - Settings │ │
|
|
80
|
+
│ │ 401s │ │ - Signing │ │ - Trusted sites │ │
|
|
81
|
+
│ │ - Inject │ │ - Storage │ │ - Key display │ │
|
|
82
|
+
│ │ headers │ │ │ │ │ │
|
|
83
|
+
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
|
84
|
+
└─────────────────────────────────────────────────────────┘
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Pros:**
|
|
88
|
+
- Full control over UX
|
|
89
|
+
- Can optimize for Solid use case
|
|
90
|
+
- No dependency on other extensions
|
|
91
|
+
|
|
92
|
+
**Cons:**
|
|
93
|
+
- Users need another extension
|
|
94
|
+
- Key management duplication
|
|
95
|
+
- Fragmented ecosystem
|
|
96
|
+
|
|
97
|
+
### Option B: Companion to Existing Nostr Extensions
|
|
98
|
+
|
|
99
|
+
Leverage nos2x/Alby for key management, add HTTP signing layer.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
┌─────────────────────────────────────────────────────────┐
|
|
103
|
+
│ Solid-Nostr Auth Extension │
|
|
104
|
+
├─────────────────────────────────────────────────────────┤
|
|
105
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
106
|
+
│ │ HTTP Request Interceptor │ │
|
|
107
|
+
│ │ - webRequest API for 401 detection │ │
|
|
108
|
+
│ │ - Retry logic with auth headers │ │
|
|
109
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
110
|
+
│ │ │
|
|
111
|
+
│ ▼ │
|
|
112
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
113
|
+
│ │ NIP-07 Bridge │ │
|
|
114
|
+
│ │ - window.nostr.getPublicKey() │ │
|
|
115
|
+
│ │ - window.nostr.signEvent() → adapt for HTTP │ │
|
|
116
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
117
|
+
│ │ │
|
|
118
|
+
│ ▼ │
|
|
119
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
120
|
+
│ │ nos2x / Alby / nostr-keyx │ │
|
|
121
|
+
│ │ (existing extension) │ │
|
|
122
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
123
|
+
└─────────────────────────────────────────────────────────┘
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Pros:**
|
|
127
|
+
- Leverages existing key management
|
|
128
|
+
- Users keep their preferred Nostr extension
|
|
129
|
+
- Smaller, focused extension
|
|
130
|
+
|
|
131
|
+
**Cons:**
|
|
132
|
+
- Dependency on NIP-07 extensions
|
|
133
|
+
- Signing HTTP != signing events (adaptation needed)
|
|
134
|
+
|
|
135
|
+
### Option C: NIP Proposal for HTTP Signing
|
|
136
|
+
|
|
137
|
+
Propose a new NIP that standardizes HTTP request signing, then work with nos2x/Alby to implement.
|
|
138
|
+
|
|
139
|
+
**Pros:**
|
|
140
|
+
- Ecosystem-wide solution
|
|
141
|
+
- No new extension needed long-term
|
|
142
|
+
|
|
143
|
+
**Cons:**
|
|
144
|
+
- Slow adoption path
|
|
145
|
+
- Political/coordination challenges
|
|
146
|
+
|
|
147
|
+
### Recommended: Option B (Companion Extension)
|
|
148
|
+
|
|
149
|
+
Best balance of pragmatism and user experience. Users likely already have nos2x or Alby installed.
|
|
150
|
+
|
|
151
|
+
## Detailed Design
|
|
152
|
+
|
|
153
|
+
### Extension Components
|
|
154
|
+
|
|
155
|
+
#### 1. Manifest (manifest.json)
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"manifest_version": 3,
|
|
160
|
+
"name": "Solid Nostr Auth",
|
|
161
|
+
"version": "1.0.0",
|
|
162
|
+
"description": "Authenticate to Solid pods using your Nostr identity",
|
|
163
|
+
"permissions": [
|
|
164
|
+
"storage",
|
|
165
|
+
"webRequest",
|
|
166
|
+
"declarativeNetRequestWithHostAccess"
|
|
167
|
+
],
|
|
168
|
+
"host_permissions": [
|
|
169
|
+
"<all_urls>"
|
|
170
|
+
],
|
|
171
|
+
"background": {
|
|
172
|
+
"service_worker": "background.js"
|
|
173
|
+
},
|
|
174
|
+
"content_scripts": [
|
|
175
|
+
{
|
|
176
|
+
"matches": ["<all_urls>"],
|
|
177
|
+
"js": ["content.js"],
|
|
178
|
+
"run_at": "document_start"
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
"action": {
|
|
182
|
+
"default_popup": "popup.html",
|
|
183
|
+
"default_icon": {
|
|
184
|
+
"16": "icons/icon16.png",
|
|
185
|
+
"48": "icons/icon48.png",
|
|
186
|
+
"128": "icons/icon128.png"
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"icons": {
|
|
190
|
+
"16": "icons/icon16.png",
|
|
191
|
+
"48": "icons/icon48.png",
|
|
192
|
+
"128": "icons/icon128.png"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
#### 2. Background Service Worker (background.js)
|
|
198
|
+
|
|
199
|
+
```javascript
|
|
200
|
+
/**
|
|
201
|
+
* Solid Nostr Auth - Background Service Worker
|
|
202
|
+
*
|
|
203
|
+
* Responsibilities:
|
|
204
|
+
* - Listen for 401 responses from Solid servers
|
|
205
|
+
* - Coordinate with content script to sign requests
|
|
206
|
+
* - Manage trusted sites list
|
|
207
|
+
* - Handle retry logic
|
|
208
|
+
*/
|
|
209
|
+
|
|
210
|
+
// Storage keys
|
|
211
|
+
const STORAGE_KEYS = {
|
|
212
|
+
TRUSTED_SITES: 'trustedSites',
|
|
213
|
+
AUTO_SIGN: 'autoSign',
|
|
214
|
+
PUBKEY_CACHE: 'pubkeyCache'
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Track pending auth requests to avoid loops
|
|
218
|
+
const pendingAuth = new Map();
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Detect if a site is a Solid server by checking response headers
|
|
222
|
+
*/
|
|
223
|
+
function isSolidServer(headers) {
|
|
224
|
+
const dominated = ['solid', 'ms-author-via', 'wac-allow', 'updates-via'];
|
|
225
|
+
return headers.some(h =>
|
|
226
|
+
dominated.some(d => h.name.toLowerCase().includes(d))
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Listen for 401 responses
|
|
232
|
+
*/
|
|
233
|
+
chrome.webRequest.onCompleted.addListener(
|
|
234
|
+
async (details) => {
|
|
235
|
+
// Only handle 401s
|
|
236
|
+
if (details.statusCode !== 401) return;
|
|
237
|
+
|
|
238
|
+
// Avoid infinite loops
|
|
239
|
+
const requestKey = `${details.method}:${details.url}`;
|
|
240
|
+
if (pendingAuth.has(requestKey)) return;
|
|
241
|
+
|
|
242
|
+
// Check if it's a Solid server
|
|
243
|
+
if (!isSolidServer(details.responseHeaders || [])) return;
|
|
244
|
+
|
|
245
|
+
// Check WWW-Authenticate header for Nostr support
|
|
246
|
+
const wwwAuth = details.responseHeaders?.find(
|
|
247
|
+
h => h.name.toLowerCase() === 'www-authenticate'
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Get user settings
|
|
251
|
+
const settings = await chrome.storage.local.get([
|
|
252
|
+
STORAGE_KEYS.TRUSTED_SITES,
|
|
253
|
+
STORAGE_KEYS.AUTO_SIGN
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const origin = new URL(details.url).origin;
|
|
257
|
+
const trustedSites = settings[STORAGE_KEYS.TRUSTED_SITES] || [];
|
|
258
|
+
const autoSign = settings[STORAGE_KEYS.AUTO_SIGN] ?? false;
|
|
259
|
+
|
|
260
|
+
// If auto-sign enabled for trusted sites, or site is trusted
|
|
261
|
+
if (trustedSites.includes(origin) || autoSign) {
|
|
262
|
+
// Mark as pending to avoid loops
|
|
263
|
+
pendingAuth.set(requestKey, Date.now());
|
|
264
|
+
|
|
265
|
+
// Send message to content script to initiate signing
|
|
266
|
+
try {
|
|
267
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
268
|
+
if (tab) {
|
|
269
|
+
chrome.tabs.sendMessage(tab.id, {
|
|
270
|
+
type: 'SIGN_REQUEST',
|
|
271
|
+
url: details.url,
|
|
272
|
+
method: details.method
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error('Failed to send sign request:', err);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Clean up pending after timeout
|
|
280
|
+
setTimeout(() => pendingAuth.delete(requestKey), 30000);
|
|
281
|
+
} else {
|
|
282
|
+
// Prompt user to trust this site
|
|
283
|
+
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
|
|
284
|
+
if (tab) {
|
|
285
|
+
chrome.tabs.sendMessage(tab.id, {
|
|
286
|
+
type: 'PROMPT_TRUST',
|
|
287
|
+
origin,
|
|
288
|
+
url: details.url
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
{ urls: ['<all_urls>'] },
|
|
295
|
+
['responseHeaders']
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Handle messages from content script and popup
|
|
300
|
+
*/
|
|
301
|
+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
302
|
+
switch (message.type) {
|
|
303
|
+
case 'ADD_TRUSTED_SITE':
|
|
304
|
+
addTrustedSite(message.origin).then(sendResponse);
|
|
305
|
+
return true;
|
|
306
|
+
|
|
307
|
+
case 'REMOVE_TRUSTED_SITE':
|
|
308
|
+
removeTrustedSite(message.origin).then(sendResponse);
|
|
309
|
+
return true;
|
|
310
|
+
|
|
311
|
+
case 'GET_TRUSTED_SITES':
|
|
312
|
+
getTrustedSites().then(sendResponse);
|
|
313
|
+
return true;
|
|
314
|
+
|
|
315
|
+
case 'AUTH_COMPLETE':
|
|
316
|
+
// Clean up pending auth
|
|
317
|
+
const key = `${message.method}:${message.url}`;
|
|
318
|
+
pendingAuth.delete(key);
|
|
319
|
+
break;
|
|
320
|
+
|
|
321
|
+
case 'SET_AUTO_SIGN':
|
|
322
|
+
chrome.storage.local.set({ [STORAGE_KEYS.AUTO_SIGN]: message.enabled });
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
async function addTrustedSite(origin) {
|
|
328
|
+
const { trustedSites = [] } = await chrome.storage.local.get(STORAGE_KEYS.TRUSTED_SITES);
|
|
329
|
+
if (!trustedSites.includes(origin)) {
|
|
330
|
+
trustedSites.push(origin);
|
|
331
|
+
await chrome.storage.local.set({ [STORAGE_KEYS.TRUSTED_SITES]: trustedSites });
|
|
332
|
+
}
|
|
333
|
+
return trustedSites;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function removeTrustedSite(origin) {
|
|
337
|
+
const { trustedSites = [] } = await chrome.storage.local.get(STORAGE_KEYS.TRUSTED_SITES);
|
|
338
|
+
const filtered = trustedSites.filter(s => s !== origin);
|
|
339
|
+
await chrome.storage.local.set({ [STORAGE_KEYS.TRUSTED_SITES]: filtered });
|
|
340
|
+
return filtered;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function getTrustedSites() {
|
|
344
|
+
const { trustedSites = [] } = await chrome.storage.local.get(STORAGE_KEYS.TRUSTED_SITES);
|
|
345
|
+
return trustedSites;
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
#### 3. Content Script (content.js)
|
|
350
|
+
|
|
351
|
+
```javascript
|
|
352
|
+
/**
|
|
353
|
+
* Solid Nostr Auth - Content Script
|
|
354
|
+
*
|
|
355
|
+
* Responsibilities:
|
|
356
|
+
* - Bridge to NIP-07 (window.nostr)
|
|
357
|
+
* - Sign HTTP requests using Nostr keys
|
|
358
|
+
* - Inject trust prompts into page
|
|
359
|
+
* - Retry requests with authentication
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
// Check for NIP-07 extension
|
|
363
|
+
let nostrAvailable = false;
|
|
364
|
+
let nostrCheckAttempts = 0;
|
|
365
|
+
|
|
366
|
+
function checkNostr() {
|
|
367
|
+
if (window.nostr) {
|
|
368
|
+
nostrAvailable = true;
|
|
369
|
+
console.log('[Solid Nostr Auth] NIP-07 extension detected');
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
if (nostrCheckAttempts++ < 10) {
|
|
373
|
+
setTimeout(checkNostr, 100);
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
checkNostr();
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Generate HTTP Nostr Authorization header
|
|
382
|
+
*
|
|
383
|
+
* Format: Nostr <base64-encoded-signed-event>
|
|
384
|
+
*
|
|
385
|
+
* The signed event contains:
|
|
386
|
+
* - kind: 27235 (HTTP Auth - proposed)
|
|
387
|
+
* - content: empty
|
|
388
|
+
* - tags: [["u", url], ["method", method]]
|
|
389
|
+
* - created_at: current timestamp
|
|
390
|
+
*/
|
|
391
|
+
async function generateNostrAuthHeader(url, method) {
|
|
392
|
+
if (!window.nostr) {
|
|
393
|
+
throw new Error('NIP-07 extension not available');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const pubkey = await window.nostr.getPublicKey();
|
|
397
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
398
|
+
|
|
399
|
+
// Create event for signing (NIP-98 HTTP Auth style)
|
|
400
|
+
const event = {
|
|
401
|
+
kind: 27235, // HTTP Auth event kind
|
|
402
|
+
created_at: timestamp,
|
|
403
|
+
tags: [
|
|
404
|
+
['u', url],
|
|
405
|
+
['method', method.toUpperCase()]
|
|
406
|
+
],
|
|
407
|
+
content: '',
|
|
408
|
+
pubkey
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Sign the event using NIP-07
|
|
412
|
+
const signedEvent = await window.nostr.signEvent(event);
|
|
413
|
+
|
|
414
|
+
// Encode as base64 for Authorization header
|
|
415
|
+
const encoded = btoa(JSON.stringify(signedEvent));
|
|
416
|
+
|
|
417
|
+
return `Nostr ${encoded}`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Retry a request with Nostr authentication
|
|
422
|
+
*/
|
|
423
|
+
async function retryWithAuth(url, method) {
|
|
424
|
+
try {
|
|
425
|
+
const authHeader = await generateNostrAuthHeader(url, method);
|
|
426
|
+
|
|
427
|
+
const response = await fetch(url, {
|
|
428
|
+
method,
|
|
429
|
+
headers: {
|
|
430
|
+
'Authorization': authHeader
|
|
431
|
+
},
|
|
432
|
+
credentials: 'omit' // Don't send cookies, we're using Nostr auth
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (response.ok) {
|
|
436
|
+
// Reload the page to show authenticated content
|
|
437
|
+
window.location.reload();
|
|
438
|
+
} else {
|
|
439
|
+
console.error('[Solid Nostr Auth] Auth failed:', response.status);
|
|
440
|
+
showNotification('Authentication failed. Check server logs.', 'error');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Notify background script
|
|
444
|
+
chrome.runtime.sendMessage({
|
|
445
|
+
type: 'AUTH_COMPLETE',
|
|
446
|
+
url,
|
|
447
|
+
method,
|
|
448
|
+
success: response.ok
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error('[Solid Nostr Auth] Error:', err);
|
|
453
|
+
showNotification(`Error: ${err.message}`, 'error');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Show trust prompt UI
|
|
459
|
+
*/
|
|
460
|
+
function showTrustPrompt(origin, url) {
|
|
461
|
+
// Remove existing prompt if any
|
|
462
|
+
const existing = document.getElementById('solid-nostr-auth-prompt');
|
|
463
|
+
if (existing) existing.remove();
|
|
464
|
+
|
|
465
|
+
const prompt = document.createElement('div');
|
|
466
|
+
prompt.id = 'solid-nostr-auth-prompt';
|
|
467
|
+
prompt.innerHTML = `
|
|
468
|
+
<style>
|
|
469
|
+
#solid-nostr-auth-prompt {
|
|
470
|
+
position: fixed;
|
|
471
|
+
top: 20px;
|
|
472
|
+
right: 20px;
|
|
473
|
+
z-index: 999999;
|
|
474
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
475
|
+
color: white;
|
|
476
|
+
padding: 20px;
|
|
477
|
+
border-radius: 12px;
|
|
478
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
|
479
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
480
|
+
max-width: 360px;
|
|
481
|
+
animation: slideIn 0.3s ease;
|
|
482
|
+
}
|
|
483
|
+
@keyframes slideIn {
|
|
484
|
+
from { transform: translateX(100%); opacity: 0; }
|
|
485
|
+
to { transform: translateX(0); opacity: 1; }
|
|
486
|
+
}
|
|
487
|
+
#solid-nostr-auth-prompt h3 {
|
|
488
|
+
margin: 0 0 12px 0;
|
|
489
|
+
font-size: 16px;
|
|
490
|
+
display: flex;
|
|
491
|
+
align-items: center;
|
|
492
|
+
gap: 8px;
|
|
493
|
+
}
|
|
494
|
+
#solid-nostr-auth-prompt p {
|
|
495
|
+
margin: 0 0 16px 0;
|
|
496
|
+
font-size: 14px;
|
|
497
|
+
color: #a0a0a0;
|
|
498
|
+
line-height: 1.5;
|
|
499
|
+
}
|
|
500
|
+
#solid-nostr-auth-prompt .origin {
|
|
501
|
+
background: rgba(255,255,255,0.1);
|
|
502
|
+
padding: 8px 12px;
|
|
503
|
+
border-radius: 6px;
|
|
504
|
+
font-family: monospace;
|
|
505
|
+
font-size: 13px;
|
|
506
|
+
margin-bottom: 16px;
|
|
507
|
+
word-break: break-all;
|
|
508
|
+
}
|
|
509
|
+
#solid-nostr-auth-prompt .buttons {
|
|
510
|
+
display: flex;
|
|
511
|
+
gap: 10px;
|
|
512
|
+
}
|
|
513
|
+
#solid-nostr-auth-prompt button {
|
|
514
|
+
flex: 1;
|
|
515
|
+
padding: 10px 16px;
|
|
516
|
+
border: none;
|
|
517
|
+
border-radius: 8px;
|
|
518
|
+
font-size: 14px;
|
|
519
|
+
font-weight: 500;
|
|
520
|
+
cursor: pointer;
|
|
521
|
+
transition: transform 0.1s, box-shadow 0.1s;
|
|
522
|
+
}
|
|
523
|
+
#solid-nostr-auth-prompt button:hover {
|
|
524
|
+
transform: translateY(-1px);
|
|
525
|
+
}
|
|
526
|
+
#solid-nostr-auth-prompt .btn-primary {
|
|
527
|
+
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
|
528
|
+
color: white;
|
|
529
|
+
}
|
|
530
|
+
#solid-nostr-auth-prompt .btn-primary:hover {
|
|
531
|
+
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
|
|
532
|
+
}
|
|
533
|
+
#solid-nostr-auth-prompt .btn-secondary {
|
|
534
|
+
background: rgba(255,255,255,0.1);
|
|
535
|
+
color: white;
|
|
536
|
+
}
|
|
537
|
+
#solid-nostr-auth-prompt .close {
|
|
538
|
+
position: absolute;
|
|
539
|
+
top: 10px;
|
|
540
|
+
right: 10px;
|
|
541
|
+
background: none;
|
|
542
|
+
border: none;
|
|
543
|
+
color: #666;
|
|
544
|
+
cursor: pointer;
|
|
545
|
+
font-size: 18px;
|
|
546
|
+
padding: 4px;
|
|
547
|
+
}
|
|
548
|
+
#solid-nostr-auth-prompt .nostr-icon {
|
|
549
|
+
width: 20px;
|
|
550
|
+
height: 20px;
|
|
551
|
+
}
|
|
552
|
+
</style>
|
|
553
|
+
<button class="close" onclick="this.parentElement.remove()">×</button>
|
|
554
|
+
<h3>
|
|
555
|
+
<svg class="nostr-icon" viewBox="0 0 256 256" fill="currentColor">
|
|
556
|
+
<path d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm0 232c-57.3 0-104-46.7-104-104S70.7 24 128 24s104 46.7 104 104-46.7 104-104 104z"/>
|
|
557
|
+
<circle cx="128" cy="128" r="40"/>
|
|
558
|
+
</svg>
|
|
559
|
+
Sign in with Nostr?
|
|
560
|
+
</h3>
|
|
561
|
+
<p>This Solid server supports Nostr authentication. Would you like to sign in using your Nostr identity?</p>
|
|
562
|
+
<div class="origin">${origin}</div>
|
|
563
|
+
<div class="buttons">
|
|
564
|
+
<button class="btn-secondary" onclick="this.closest('#solid-nostr-auth-prompt').remove()">
|
|
565
|
+
Not now
|
|
566
|
+
</button>
|
|
567
|
+
<button class="btn-primary" id="solid-nostr-trust-btn">
|
|
568
|
+
Sign in
|
|
569
|
+
</button>
|
|
570
|
+
</div>
|
|
571
|
+
`;
|
|
572
|
+
|
|
573
|
+
document.body.appendChild(prompt);
|
|
574
|
+
|
|
575
|
+
// Handle trust button click
|
|
576
|
+
document.getElementById('solid-nostr-trust-btn').addEventListener('click', async () => {
|
|
577
|
+
if (!nostrAvailable) {
|
|
578
|
+
showNotification('Please install a Nostr extension (nos2x, Alby)', 'error');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Add to trusted sites
|
|
583
|
+
await chrome.runtime.sendMessage({
|
|
584
|
+
type: 'ADD_TRUSTED_SITE',
|
|
585
|
+
origin
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Remove prompt
|
|
589
|
+
prompt.remove();
|
|
590
|
+
|
|
591
|
+
// Retry the request with auth
|
|
592
|
+
await retryWithAuth(url, 'GET');
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Show notification toast
|
|
598
|
+
*/
|
|
599
|
+
function showNotification(message, type = 'info') {
|
|
600
|
+
const existing = document.getElementById('solid-nostr-notification');
|
|
601
|
+
if (existing) existing.remove();
|
|
602
|
+
|
|
603
|
+
const colors = {
|
|
604
|
+
info: '#3b82f6',
|
|
605
|
+
success: '#10b981',
|
|
606
|
+
error: '#ef4444'
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const notification = document.createElement('div');
|
|
610
|
+
notification.id = 'solid-nostr-notification';
|
|
611
|
+
notification.style.cssText = `
|
|
612
|
+
position: fixed;
|
|
613
|
+
bottom: 20px;
|
|
614
|
+
right: 20px;
|
|
615
|
+
z-index: 999999;
|
|
616
|
+
background: ${colors[type]};
|
|
617
|
+
color: white;
|
|
618
|
+
padding: 12px 20px;
|
|
619
|
+
border-radius: 8px;
|
|
620
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
621
|
+
font-size: 14px;
|
|
622
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
|
623
|
+
animation: fadeIn 0.3s ease;
|
|
624
|
+
`;
|
|
625
|
+
notification.textContent = message;
|
|
626
|
+
document.body.appendChild(notification);
|
|
627
|
+
|
|
628
|
+
setTimeout(() => notification.remove(), 5000);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Listen for messages from background script
|
|
633
|
+
*/
|
|
634
|
+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
635
|
+
switch (message.type) {
|
|
636
|
+
case 'SIGN_REQUEST':
|
|
637
|
+
retryWithAuth(message.url, message.method);
|
|
638
|
+
break;
|
|
639
|
+
|
|
640
|
+
case 'PROMPT_TRUST':
|
|
641
|
+
showTrustPrompt(message.origin, message.url);
|
|
642
|
+
break;
|
|
643
|
+
|
|
644
|
+
case 'CHECK_NOSTR':
|
|
645
|
+
sendResponse({ available: nostrAvailable });
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Intercept fetch for automatic signing (optional enhancement)
|
|
652
|
+
* This allows JavaScript apps to automatically get signed requests
|
|
653
|
+
*/
|
|
654
|
+
const originalFetch = window.fetch;
|
|
655
|
+
window.fetch = async function(url, options = {}) {
|
|
656
|
+
const response = await originalFetch(url, options);
|
|
657
|
+
|
|
658
|
+
// If 401 and we have Nostr, offer to retry
|
|
659
|
+
if (response.status === 401 && nostrAvailable) {
|
|
660
|
+
// Check if this is a Solid server
|
|
661
|
+
const wacAllow = response.headers.get('WAC-Allow');
|
|
662
|
+
if (wacAllow) {
|
|
663
|
+
// This is a Solid server, could auto-retry with auth
|
|
664
|
+
// For now, just log - could enhance later
|
|
665
|
+
console.log('[Solid Nostr Auth] 401 from Solid server, auth available');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return response;
|
|
670
|
+
};
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
#### 4. Popup UI (popup.html + popup.js)
|
|
674
|
+
|
|
675
|
+
```html
|
|
676
|
+
<!DOCTYPE html>
|
|
677
|
+
<html>
|
|
678
|
+
<head>
|
|
679
|
+
<meta charset="UTF-8">
|
|
680
|
+
<style>
|
|
681
|
+
* {
|
|
682
|
+
box-sizing: border-box;
|
|
683
|
+
margin: 0;
|
|
684
|
+
padding: 0;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
body {
|
|
688
|
+
width: 320px;
|
|
689
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
690
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
691
|
+
color: white;
|
|
692
|
+
padding: 20px;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.header {
|
|
696
|
+
display: flex;
|
|
697
|
+
align-items: center;
|
|
698
|
+
gap: 12px;
|
|
699
|
+
margin-bottom: 20px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.header img {
|
|
703
|
+
width: 32px;
|
|
704
|
+
height: 32px;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.header h1 {
|
|
708
|
+
font-size: 16px;
|
|
709
|
+
font-weight: 600;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.section {
|
|
713
|
+
background: rgba(255,255,255,0.05);
|
|
714
|
+
border-radius: 10px;
|
|
715
|
+
padding: 16px;
|
|
716
|
+
margin-bottom: 16px;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.section h2 {
|
|
720
|
+
font-size: 12px;
|
|
721
|
+
text-transform: uppercase;
|
|
722
|
+
letter-spacing: 0.5px;
|
|
723
|
+
color: #888;
|
|
724
|
+
margin-bottom: 12px;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.identity {
|
|
728
|
+
display: flex;
|
|
729
|
+
align-items: center;
|
|
730
|
+
gap: 12px;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.identity-icon {
|
|
734
|
+
width: 40px;
|
|
735
|
+
height: 40px;
|
|
736
|
+
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
|
737
|
+
border-radius: 50%;
|
|
738
|
+
display: flex;
|
|
739
|
+
align-items: center;
|
|
740
|
+
justify-content: center;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.identity-info {
|
|
744
|
+
flex: 1;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.identity-npub {
|
|
748
|
+
font-family: monospace;
|
|
749
|
+
font-size: 12px;
|
|
750
|
+
color: #888;
|
|
751
|
+
word-break: break-all;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.status {
|
|
755
|
+
display: flex;
|
|
756
|
+
align-items: center;
|
|
757
|
+
gap: 8px;
|
|
758
|
+
font-size: 13px;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.status-dot {
|
|
762
|
+
width: 8px;
|
|
763
|
+
height: 8px;
|
|
764
|
+
border-radius: 50%;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.status-dot.connected {
|
|
768
|
+
background: #10b981;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.status-dot.disconnected {
|
|
772
|
+
background: #ef4444;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.toggle {
|
|
776
|
+
display: flex;
|
|
777
|
+
align-items: center;
|
|
778
|
+
justify-content: space-between;
|
|
779
|
+
padding: 12px 0;
|
|
780
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.toggle:last-child {
|
|
784
|
+
border-bottom: none;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.toggle label {
|
|
788
|
+
font-size: 14px;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.toggle-switch {
|
|
792
|
+
width: 44px;
|
|
793
|
+
height: 24px;
|
|
794
|
+
background: rgba(255,255,255,0.2);
|
|
795
|
+
border-radius: 12px;
|
|
796
|
+
position: relative;
|
|
797
|
+
cursor: pointer;
|
|
798
|
+
transition: background 0.2s;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.toggle-switch.active {
|
|
802
|
+
background: #8b5cf6;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.toggle-switch::after {
|
|
806
|
+
content: '';
|
|
807
|
+
position: absolute;
|
|
808
|
+
top: 2px;
|
|
809
|
+
left: 2px;
|
|
810
|
+
width: 20px;
|
|
811
|
+
height: 20px;
|
|
812
|
+
background: white;
|
|
813
|
+
border-radius: 50%;
|
|
814
|
+
transition: transform 0.2s;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
.toggle-switch.active::after {
|
|
818
|
+
transform: translateX(20px);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.trusted-sites {
|
|
822
|
+
max-height: 150px;
|
|
823
|
+
overflow-y: auto;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.trusted-site {
|
|
827
|
+
display: flex;
|
|
828
|
+
align-items: center;
|
|
829
|
+
justify-content: space-between;
|
|
830
|
+
padding: 8px 0;
|
|
831
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
.trusted-site:last-child {
|
|
835
|
+
border-bottom: none;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
.trusted-site-url {
|
|
839
|
+
font-size: 13px;
|
|
840
|
+
font-family: monospace;
|
|
841
|
+
color: #ccc;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
.trusted-site-remove {
|
|
845
|
+
background: none;
|
|
846
|
+
border: none;
|
|
847
|
+
color: #666;
|
|
848
|
+
cursor: pointer;
|
|
849
|
+
font-size: 16px;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.trusted-site-remove:hover {
|
|
853
|
+
color: #ef4444;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.empty {
|
|
857
|
+
color: #666;
|
|
858
|
+
font-size: 13px;
|
|
859
|
+
text-align: center;
|
|
860
|
+
padding: 20px;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
.footer {
|
|
864
|
+
text-align: center;
|
|
865
|
+
font-size: 11px;
|
|
866
|
+
color: #666;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
.footer a {
|
|
870
|
+
color: #8b5cf6;
|
|
871
|
+
text-decoration: none;
|
|
872
|
+
}
|
|
873
|
+
</style>
|
|
874
|
+
</head>
|
|
875
|
+
<body>
|
|
876
|
+
<div class="header">
|
|
877
|
+
<img src="icons/icon48.png" alt="Solid Nostr Auth">
|
|
878
|
+
<h1>Solid Nostr Auth</h1>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
<div class="section">
|
|
882
|
+
<h2>Your Identity</h2>
|
|
883
|
+
<div id="identity-container">
|
|
884
|
+
<div class="status">
|
|
885
|
+
<span class="status-dot disconnected"></span>
|
|
886
|
+
<span>No Nostr extension detected</span>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
</div>
|
|
890
|
+
|
|
891
|
+
<div class="section">
|
|
892
|
+
<h2>Settings</h2>
|
|
893
|
+
<div class="toggle">
|
|
894
|
+
<label>Auto-sign for trusted sites</label>
|
|
895
|
+
<div class="toggle-switch" id="auto-sign-toggle"></div>
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
|
|
899
|
+
<div class="section">
|
|
900
|
+
<h2>Trusted Sites</h2>
|
|
901
|
+
<div class="trusted-sites" id="trusted-sites">
|
|
902
|
+
<div class="empty">No trusted sites yet</div>
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
|
|
906
|
+
<div class="footer">
|
|
907
|
+
<a href="https://github.com/example/solid-nostr-auth">GitHub</a> ·
|
|
908
|
+
<a href="https://solidproject.org">Solid</a> ·
|
|
909
|
+
<a href="https://nostr.com">Nostr</a>
|
|
910
|
+
</div>
|
|
911
|
+
|
|
912
|
+
<script src="popup.js"></script>
|
|
913
|
+
</body>
|
|
914
|
+
</html>
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
```javascript
|
|
918
|
+
// popup.js
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Solid Nostr Auth - Popup Script
|
|
922
|
+
*/
|
|
923
|
+
|
|
924
|
+
// Elements
|
|
925
|
+
const identityContainer = document.getElementById('identity-container');
|
|
926
|
+
const autoSignToggle = document.getElementById('auto-sign-toggle');
|
|
927
|
+
const trustedSitesContainer = document.getElementById('trusted-sites');
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Initialize popup
|
|
931
|
+
*/
|
|
932
|
+
async function init() {
|
|
933
|
+
// Check for Nostr extension
|
|
934
|
+
await checkNostrExtension();
|
|
935
|
+
|
|
936
|
+
// Load settings
|
|
937
|
+
await loadSettings();
|
|
938
|
+
|
|
939
|
+
// Load trusted sites
|
|
940
|
+
await loadTrustedSites();
|
|
941
|
+
|
|
942
|
+
// Set up event listeners
|
|
943
|
+
setupEventListeners();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Check if NIP-07 extension is available
|
|
948
|
+
*/
|
|
949
|
+
async function checkNostrExtension() {
|
|
950
|
+
try {
|
|
951
|
+
// Query active tab to check for window.nostr
|
|
952
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
953
|
+
|
|
954
|
+
const response = await chrome.tabs.sendMessage(tab.id, { type: 'CHECK_NOSTR' });
|
|
955
|
+
|
|
956
|
+
if (response?.available) {
|
|
957
|
+
// Get public key
|
|
958
|
+
const result = await chrome.scripting.executeScript({
|
|
959
|
+
target: { tabId: tab.id },
|
|
960
|
+
func: async () => {
|
|
961
|
+
if (window.nostr) {
|
|
962
|
+
return await window.nostr.getPublicKey();
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
const pubkey = result[0]?.result;
|
|
969
|
+
|
|
970
|
+
if (pubkey) {
|
|
971
|
+
showIdentity(pubkey);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
} catch (err) {
|
|
976
|
+
console.error('Error checking Nostr:', err);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
showNoNostr();
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Display identity when Nostr is available
|
|
984
|
+
*/
|
|
985
|
+
function showIdentity(pubkey) {
|
|
986
|
+
// Convert to npub
|
|
987
|
+
const npub = hexToNpub(pubkey);
|
|
988
|
+
const shortNpub = npub.slice(0, 12) + '...' + npub.slice(-8);
|
|
989
|
+
|
|
990
|
+
identityContainer.innerHTML = `
|
|
991
|
+
<div class="identity">
|
|
992
|
+
<div class="identity-icon">
|
|
993
|
+
<svg width="20" height="20" viewBox="0 0 256 256" fill="white">
|
|
994
|
+
<circle cx="128" cy="128" r="40"/>
|
|
995
|
+
</svg>
|
|
996
|
+
</div>
|
|
997
|
+
<div class="identity-info">
|
|
998
|
+
<div class="status">
|
|
999
|
+
<span class="status-dot connected"></span>
|
|
1000
|
+
<span>Connected</span>
|
|
1001
|
+
</div>
|
|
1002
|
+
<div class="identity-npub">${shortNpub}</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
</div>
|
|
1005
|
+
`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Show message when no Nostr extension
|
|
1010
|
+
*/
|
|
1011
|
+
function showNoNostr() {
|
|
1012
|
+
identityContainer.innerHTML = `
|
|
1013
|
+
<div class="status">
|
|
1014
|
+
<span class="status-dot disconnected"></span>
|
|
1015
|
+
<span>Install nos2x or Alby extension</span>
|
|
1016
|
+
</div>
|
|
1017
|
+
`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Load settings from storage
|
|
1022
|
+
*/
|
|
1023
|
+
async function loadSettings() {
|
|
1024
|
+
const { autoSign = false } = await chrome.storage.local.get('autoSign');
|
|
1025
|
+
|
|
1026
|
+
if (autoSign) {
|
|
1027
|
+
autoSignToggle.classList.add('active');
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Load trusted sites
|
|
1033
|
+
*/
|
|
1034
|
+
async function loadTrustedSites() {
|
|
1035
|
+
const sites = await chrome.runtime.sendMessage({ type: 'GET_TRUSTED_SITES' });
|
|
1036
|
+
|
|
1037
|
+
if (!sites || sites.length === 0) {
|
|
1038
|
+
trustedSitesContainer.innerHTML = '<div class="empty">No trusted sites yet</div>';
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
trustedSitesContainer.innerHTML = sites.map(site => `
|
|
1043
|
+
<div class="trusted-site" data-site="${site}">
|
|
1044
|
+
<span class="trusted-site-url">${new URL(site).hostname}</span>
|
|
1045
|
+
<button class="trusted-site-remove" title="Remove">×</button>
|
|
1046
|
+
</div>
|
|
1047
|
+
`).join('');
|
|
1048
|
+
|
|
1049
|
+
// Add remove handlers
|
|
1050
|
+
trustedSitesContainer.querySelectorAll('.trusted-site-remove').forEach(btn => {
|
|
1051
|
+
btn.addEventListener('click', async (e) => {
|
|
1052
|
+
const site = e.target.closest('.trusted-site').dataset.site;
|
|
1053
|
+
await chrome.runtime.sendMessage({ type: 'REMOVE_TRUSTED_SITE', origin: site });
|
|
1054
|
+
loadTrustedSites();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Set up event listeners
|
|
1061
|
+
*/
|
|
1062
|
+
function setupEventListeners() {
|
|
1063
|
+
// Auto-sign toggle
|
|
1064
|
+
autoSignToggle.addEventListener('click', async () => {
|
|
1065
|
+
autoSignToggle.classList.toggle('active');
|
|
1066
|
+
const enabled = autoSignToggle.classList.contains('active');
|
|
1067
|
+
await chrome.runtime.sendMessage({ type: 'SET_AUTO_SIGN', enabled });
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Convert hex pubkey to npub (simplified - real impl needs bech32)
|
|
1073
|
+
*/
|
|
1074
|
+
function hexToNpub(hex) {
|
|
1075
|
+
// This is a placeholder - real implementation needs bech32 encoding
|
|
1076
|
+
return 'npub1' + hex.slice(0, 59);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Initialize
|
|
1080
|
+
init();
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
## Server-Side Implementation (JSS)
|
|
1084
|
+
|
|
1085
|
+
The server already supports Nostr authentication via `src/auth/nostr.js`. Key points:
|
|
1086
|
+
|
|
1087
|
+
### Current Implementation
|
|
1088
|
+
|
|
1089
|
+
```javascript
|
|
1090
|
+
// src/auth/nostr.js (existing)
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Verify Nostr HTTP authentication
|
|
1094
|
+
* Authorization: Nostr <base64-event>
|
|
1095
|
+
*/
|
|
1096
|
+
export async function verifyNostrAuth(authHeader, request) {
|
|
1097
|
+
// Extract and decode the signed event
|
|
1098
|
+
const base64Event = authHeader.replace(/^Nostr\s+/i, '');
|
|
1099
|
+
const event = JSON.parse(atob(base64Event));
|
|
1100
|
+
|
|
1101
|
+
// Verify event signature
|
|
1102
|
+
if (!verifyEvent(event)) {
|
|
1103
|
+
return { valid: false, error: 'Invalid signature' };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Check event kind (27235 for HTTP auth)
|
|
1107
|
+
if (event.kind !== 27235) {
|
|
1108
|
+
return { valid: false, error: 'Invalid event kind' };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Verify URL and method match
|
|
1112
|
+
const urlTag = event.tags.find(t => t[0] === 'u');
|
|
1113
|
+
const methodTag = event.tags.find(t => t[0] === 'method');
|
|
1114
|
+
|
|
1115
|
+
// Check timestamp (within 60 seconds)
|
|
1116
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1117
|
+
if (Math.abs(now - event.created_at) > 60) {
|
|
1118
|
+
return { valid: false, error: 'Event expired' };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Convert pubkey to did:nostr
|
|
1122
|
+
const npub = nip19.npubEncode(event.pubkey);
|
|
1123
|
+
const didNostr = `did:nostr:${npub}`;
|
|
1124
|
+
|
|
1125
|
+
return { valid: true, did: didNostr, pubkey: event.pubkey };
|
|
1126
|
+
}
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
### did:nostr → WebID Resolution
|
|
1130
|
+
|
|
1131
|
+
```javascript
|
|
1132
|
+
// src/auth/did-nostr.js (existing)
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Resolve did:nostr to WebID
|
|
1136
|
+
*
|
|
1137
|
+
* Resolution order:
|
|
1138
|
+
* 1. Check local pod registry
|
|
1139
|
+
* 2. Check NIP-05 identifier for WebID
|
|
1140
|
+
* 3. Construct default WebID from server
|
|
1141
|
+
*/
|
|
1142
|
+
export async function resolveDidNostrToWebId(didNostr, serverBaseUrl) {
|
|
1143
|
+
const npub = didNostr.replace('did:nostr:', '');
|
|
1144
|
+
|
|
1145
|
+
// Check local registry first
|
|
1146
|
+
const localWebId = await checkLocalRegistry(npub);
|
|
1147
|
+
if (localWebId) return localWebId;
|
|
1148
|
+
|
|
1149
|
+
// Try NIP-05 resolution
|
|
1150
|
+
const nip05WebId = await resolveViaNip05(npub);
|
|
1151
|
+
if (nip05WebId) return nip05WebId;
|
|
1152
|
+
|
|
1153
|
+
// Default: server-local WebID
|
|
1154
|
+
return `${serverBaseUrl}/nostr/${npub}#me`;
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
## Security Considerations
|
|
1159
|
+
|
|
1160
|
+
### 1. Replay Attack Prevention
|
|
1161
|
+
|
|
1162
|
+
```javascript
|
|
1163
|
+
// Event must include timestamp and be within 60 seconds
|
|
1164
|
+
if (Math.abs(now - event.created_at) > 60) {
|
|
1165
|
+
return { valid: false, error: 'Event expired' };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Event must include exact URL being accessed
|
|
1169
|
+
const urlTag = event.tags.find(t => t[0] === 'u');
|
|
1170
|
+
if (urlTag[1] !== request.url) {
|
|
1171
|
+
return { valid: false, error: 'URL mismatch' };
|
|
1172
|
+
}
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### 2. Method Binding
|
|
1176
|
+
|
|
1177
|
+
```javascript
|
|
1178
|
+
// Event must specify HTTP method
|
|
1179
|
+
const methodTag = event.tags.find(t => t[0] === 'method');
|
|
1180
|
+
if (methodTag[1] !== request.method) {
|
|
1181
|
+
return { valid: false, error: 'Method mismatch' };
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
### 3. Trusted Sites List
|
|
1186
|
+
|
|
1187
|
+
- Users explicitly approve each origin
|
|
1188
|
+
- Sites can be removed at any time
|
|
1189
|
+
- Optional auto-sign for convenience
|
|
1190
|
+
|
|
1191
|
+
### 4. No Private Key Exposure
|
|
1192
|
+
|
|
1193
|
+
- Extension never touches private keys
|
|
1194
|
+
- All signing via NIP-07 (nos2x, Alby)
|
|
1195
|
+
- Keys stay in secure extension storage
|
|
1196
|
+
|
|
1197
|
+
### 5. Content Security
|
|
1198
|
+
|
|
1199
|
+
- Extension uses minimal permissions
|
|
1200
|
+
- Content script isolated from page
|
|
1201
|
+
- No eval() or dynamic code execution
|
|
1202
|
+
|
|
1203
|
+
## User Experience Flow
|
|
1204
|
+
|
|
1205
|
+
### First-Time User
|
|
1206
|
+
|
|
1207
|
+
```
|
|
1208
|
+
1. User installs "Solid Nostr Auth" extension
|
|
1209
|
+
└─> Extension detects existing nos2x/Alby
|
|
1210
|
+
|
|
1211
|
+
2. User navigates to https://alice.example.com/private/
|
|
1212
|
+
└─> Server returns 401
|
|
1213
|
+
|
|
1214
|
+
3. Extension shows trust prompt:
|
|
1215
|
+
┌────────────────────────────────────┐
|
|
1216
|
+
│ 🔐 Sign in with Nostr? │
|
|
1217
|
+
│ │
|
|
1218
|
+
│ This Solid server supports Nostr │
|
|
1219
|
+
│ authentication. │
|
|
1220
|
+
│ │
|
|
1221
|
+
│ ┌──────────────────────────────┐ │
|
|
1222
|
+
│ │ alice.example.com │ │
|
|
1223
|
+
│ └──────────────────────────────┘ │
|
|
1224
|
+
│ │
|
|
1225
|
+
│ [Not now] [Sign in] │
|
|
1226
|
+
└────────────────────────────────────┘
|
|
1227
|
+
|
|
1228
|
+
4. User clicks "Sign in"
|
|
1229
|
+
└─> nos2x prompts to sign event
|
|
1230
|
+
└─> Extension retries request with auth
|
|
1231
|
+
└─> Page reloads with content
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
### Returning User (Trusted Site)
|
|
1235
|
+
|
|
1236
|
+
```
|
|
1237
|
+
1. User navigates to https://alice.example.com/private/
|
|
1238
|
+
└─> Server returns 401
|
|
1239
|
+
|
|
1240
|
+
2. Extension auto-signs (if enabled) or shows small notification
|
|
1241
|
+
└─> nos2x may prompt based on its settings
|
|
1242
|
+
|
|
1243
|
+
3. Request retried with auth
|
|
1244
|
+
└─> Page loads immediately
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
## Integration with SolidOS/Mashlib
|
|
1248
|
+
|
|
1249
|
+
### Option 1: Extension-Only (Recommended for MVP)
|
|
1250
|
+
|
|
1251
|
+
Mashlib doesn't need changes. The extension handles auth before mashlib loads.
|
|
1252
|
+
|
|
1253
|
+
```
|
|
1254
|
+
Browser navigates to protected resource
|
|
1255
|
+
└─> 401 returned (with mashlib HTML)
|
|
1256
|
+
└─> Extension intercepts, signs, retries
|
|
1257
|
+
└─> 200 returned, mashlib loads with authenticated context
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
### Option 2: Mashlib Integration (Future)
|
|
1261
|
+
|
|
1262
|
+
Add Nostr as authentication option in mashlib's login UI.
|
|
1263
|
+
|
|
1264
|
+
```javascript
|
|
1265
|
+
// In mashlib/src/login/
|
|
1266
|
+
class NostrAuthStrategy {
|
|
1267
|
+
async login() {
|
|
1268
|
+
if (!window.nostr) {
|
|
1269
|
+
throw new Error('Install a Nostr extension');
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const pubkey = await window.nostr.getPublicKey();
|
|
1273
|
+
const npub = nip19.npubEncode(pubkey);
|
|
1274
|
+
|
|
1275
|
+
// Store WebID for this session
|
|
1276
|
+
const webId = await this.resolveWebId(npub);
|
|
1277
|
+
this.setAuthenticated(webId);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
## NIP Proposal: HTTP Authentication (NIP-98)
|
|
1283
|
+
|
|
1284
|
+
For ecosystem alignment, we should formalize this as a NIP:
|
|
1285
|
+
|
|
1286
|
+
```markdown
|
|
1287
|
+
NIP-98
|
|
1288
|
+
======
|
|
1289
|
+
|
|
1290
|
+
HTTP Auth
|
|
1291
|
+
---------
|
|
1292
|
+
|
|
1293
|
+
This NIP defines how Nostr keys can authenticate HTTP requests.
|
|
1294
|
+
|
|
1295
|
+
## Event Format
|
|
1296
|
+
|
|
1297
|
+
Kind: 27235
|
|
1298
|
+
|
|
1299
|
+
Tags:
|
|
1300
|
+
- `u` - Full URL being accessed (required)
|
|
1301
|
+
- `method` - HTTP method (required)
|
|
1302
|
+
- `payload` - SHA-256 hash of request body (optional, for POST/PUT)
|
|
1303
|
+
|
|
1304
|
+
Content: Empty string
|
|
1305
|
+
|
|
1306
|
+
## Authorization Header
|
|
1307
|
+
|
|
1308
|
+
Format: `Authorization: Nostr <base64-encoded-event>`
|
|
1309
|
+
|
|
1310
|
+
## Example
|
|
1311
|
+
|
|
1312
|
+
```json
|
|
1313
|
+
{
|
|
1314
|
+
"kind": 27235,
|
|
1315
|
+
"created_at": 1704067200,
|
|
1316
|
+
"tags": [
|
|
1317
|
+
["u", "https://example.com/private/data.json"],
|
|
1318
|
+
["method", "GET"]
|
|
1319
|
+
],
|
|
1320
|
+
"content": "",
|
|
1321
|
+
"pubkey": "...",
|
|
1322
|
+
"id": "...",
|
|
1323
|
+
"sig": "..."
|
|
1324
|
+
}
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
## Verification
|
|
1328
|
+
|
|
1329
|
+
1. Decode base64 event from Authorization header
|
|
1330
|
+
2. Verify event signature
|
|
1331
|
+
3. Check kind is 27235
|
|
1332
|
+
4. Verify `u` tag matches request URL
|
|
1333
|
+
5. Verify `method` tag matches HTTP method
|
|
1334
|
+
6. Check `created_at` is within 60 seconds
|
|
1335
|
+
7. For POST/PUT, verify `payload` hash if present
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
## Implementation Phases
|
|
1339
|
+
|
|
1340
|
+
### Phase 1: MVP (1-2 weeks)
|
|
1341
|
+
- [ ] Basic extension structure
|
|
1342
|
+
- [ ] NIP-07 integration (use existing keys)
|
|
1343
|
+
- [ ] 401 detection and signing
|
|
1344
|
+
- [ ] Trust prompt UI
|
|
1345
|
+
- [ ] Popup with settings
|
|
1346
|
+
|
|
1347
|
+
### Phase 2: Polish (1 week)
|
|
1348
|
+
- [ ] Auto-sign for trusted sites
|
|
1349
|
+
- [ ] Better error handling
|
|
1350
|
+
- [ ] Notification system
|
|
1351
|
+
- [ ] Icon states (authenticated vs not)
|
|
1352
|
+
|
|
1353
|
+
### Phase 3: Ecosystem (2-3 weeks)
|
|
1354
|
+
- [ ] Submit NIP-98 proposal
|
|
1355
|
+
- [ ] Firefox extension port
|
|
1356
|
+
- [ ] Work with nos2x/Alby on native support
|
|
1357
|
+
- [ ] Mashlib integration PR
|
|
1358
|
+
|
|
1359
|
+
### Phase 4: Advanced (Future)
|
|
1360
|
+
- [ ] Multiple key support
|
|
1361
|
+
- [ ] Per-site key selection
|
|
1362
|
+
- [ ] Key backup/recovery hints
|
|
1363
|
+
- [ ] Integration with DID resolvers
|
|
1364
|
+
|
|
1365
|
+
## Testing Strategy
|
|
1366
|
+
|
|
1367
|
+
### Manual Testing
|
|
1368
|
+
1. Install extension + nos2x
|
|
1369
|
+
2. Navigate to JSS server with protected resource
|
|
1370
|
+
3. Verify trust prompt appears
|
|
1371
|
+
4. Verify signing works
|
|
1372
|
+
5. Verify page loads with content
|
|
1373
|
+
|
|
1374
|
+
### Automated Testing
|
|
1375
|
+
```javascript
|
|
1376
|
+
// test/extension.test.js
|
|
1377
|
+
describe('Solid Nostr Auth Extension', () => {
|
|
1378
|
+
it('should detect 401 from Solid server', async () => {
|
|
1379
|
+
// Use puppeteer/playwright with extension loaded
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('should generate valid auth header', async () => {
|
|
1383
|
+
const header = await generateNostrAuthHeader(
|
|
1384
|
+
'https://example.com/test',
|
|
1385
|
+
'GET'
|
|
1386
|
+
);
|
|
1387
|
+
expect(header).toMatch(/^Nostr /);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('should verify auth on server', async () => {
|
|
1391
|
+
// End-to-end test with running JSS instance
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
## Comparison with Alternatives
|
|
1397
|
+
|
|
1398
|
+
| Feature | Solid-OIDC | did:nostr + Extension | HTTP Signatures |
|
|
1399
|
+
|---------|------------|----------------------|-----------------|
|
|
1400
|
+
| Redirects | 2+ | 0 | 0 |
|
|
1401
|
+
| Account needed | Yes (IdP) | No | Yes (key reg) |
|
|
1402
|
+
| Mobile UX | Poor | Good* | Good |
|
|
1403
|
+
| Browser support | All | Chrome/Firefox | All |
|
|
1404
|
+
| Key portability | Limited | Excellent | Limited |
|
|
1405
|
+
| Setup complexity | High | Low | Medium |
|
|
1406
|
+
|
|
1407
|
+
*Requires mobile Nostr app with NIP-07 support
|
|
1408
|
+
|
|
1409
|
+
## Open Questions
|
|
1410
|
+
|
|
1411
|
+
1. **Should we support unsigned requests for public resources?**
|
|
1412
|
+
- Current: Yes, auth only added for 401s
|
|
1413
|
+
- Alternative: Always sign (proves identity even for public)
|
|
1414
|
+
|
|
1415
|
+
2. **How to handle key rotation?**
|
|
1416
|
+
- Current: Not addressed
|
|
1417
|
+
- Option: Support multiple pubkeys per WebID
|
|
1418
|
+
|
|
1419
|
+
3. **Should the extension manage its own keys?**
|
|
1420
|
+
- Current: No, rely on nos2x/Alby
|
|
1421
|
+
- Trade-off: Convenience vs security
|
|
1422
|
+
|
|
1423
|
+
4. **How to surface Nostr identity in Solid apps?**
|
|
1424
|
+
- WebID profile could include Nostr pubkey
|
|
1425
|
+
- Apps could show npub alongside WebID
|
|
1426
|
+
|
|
1427
|
+
## Appendix: Related Standards
|
|
1428
|
+
|
|
1429
|
+
- [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md): Browser Extension Signing
|
|
1430
|
+
- [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md): DNS-based Nostr Identity
|
|
1431
|
+
- [DID:nostr](https://github.com/pnp/did-nostr): DID Method for Nostr
|
|
1432
|
+
- [Solid-OIDC](https://solidproject.org/TR/oidc): Current Solid Auth Spec
|
|
1433
|
+
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures): IETF Draft
|
|
1434
|
+
|
|
1435
|
+
## Repository Structure
|
|
1436
|
+
|
|
1437
|
+
```
|
|
1438
|
+
solid-nostr-auth/
|
|
1439
|
+
├── manifest.json
|
|
1440
|
+
├── background.js
|
|
1441
|
+
├── content.js
|
|
1442
|
+
├── popup.html
|
|
1443
|
+
├── popup.js
|
|
1444
|
+
├── icons/
|
|
1445
|
+
│ ├── icon16.png
|
|
1446
|
+
│ ├── icon48.png
|
|
1447
|
+
│ └── icon128.png
|
|
1448
|
+
├── lib/
|
|
1449
|
+
│ └── nostr-tools.min.js (for bech32/nip19)
|
|
1450
|
+
├── test/
|
|
1451
|
+
│ └── extension.test.js
|
|
1452
|
+
├── README.md
|
|
1453
|
+
└── LICENSE
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
## Conclusion
|
|
1457
|
+
|
|
1458
|
+
A browser extension bridging Nostr keys to Solid authentication provides:
|
|
1459
|
+
|
|
1460
|
+
1. **Immediate UX improvement**: No OAuth dance, no redirects
|
|
1461
|
+
2. **Leverages existing infrastructure**: nos2x, Alby already installed
|
|
1462
|
+
3. **True SSO**: Same identity across all Solid pods
|
|
1463
|
+
4. **Progressive enhancement**: Falls back to Solid-OIDC gracefully
|
|
1464
|
+
|
|
1465
|
+
The extension is lightweight (~50KB), focused, and can be built incrementally while working toward ecosystem-wide adoption through NIP standardization.
|