Skip to content

Commit 460b393

Browse files
Implement automatic NIP-98 HTTP authentication
- Add webRequest listeners to intercept HTTP requests - Automatically add NIP-98 Authorization headers for trusted origins - Create NIP-98 auth events with proper event structure (kind 27235) - Hash request bodies and include in payload tag - Cache signed events to avoid re-signing identical requests - Detect 401 responses (retry logic limited by Chrome API) - Respect user preferences (auto-sign, trusted origins) Closes #1
1 parent c9939e6 commit 460b393

1 file changed

Lines changed: 260 additions & 0 deletions

File tree

src/background.js

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,18 @@ import {
1212
addTrustedOrigin,
1313
getAutoSign
1414
} from './storage.js';
15+
import { sha256 } from '@noble/hashes/sha256';
16+
import { bytesToHex } from '@noble/hashes/utils';
1517

1618
console.log('[Podkey] Background service worker started');
1719

20+
// NIP-98 auth event cache: key = `${url}:${method}:${bodyHash}`, value = { event, expires }
21+
const nip98Cache = new Map();
22+
const CACHE_TTL = 60000; // 60 seconds
23+
24+
// Track retry state to prevent infinite loops: key = requestId, value = true
25+
const retryState = new Map();
26+
1827
// Initialize extension
1928
chrome.runtime.onInstalled.addListener(async () => {
2029
console.log('[Podkey] Extension installed');
@@ -287,3 +296,254 @@ function formatEventForPrompt (event) {
287296

288297
return lines.join('\n');
289298
}
299+
300+
/**
301+
* Create NIP-98 authentication event for an HTTP request
302+
* @param {object} requestDetails - Chrome webRequest details
303+
* @returns {Promise<object>} Unsigned NIP-98 event
304+
*/
305+
async function createNip98AuthEvent (requestDetails) {
306+
const event = {
307+
kind: 27235,
308+
content: '',
309+
created_at: Math.floor(Date.now() / 1000),
310+
tags: [
311+
['u', requestDetails.url], // Full URL including query params
312+
['method', requestDetails.method]
313+
]
314+
};
315+
316+
// If request has body, add payload tag with SHA-256 hash
317+
if (requestDetails.requestBody) {
318+
const bodyHash = await hashRequestBody(requestDetails.requestBody);
319+
event.tags.push(['payload', bodyHash]);
320+
}
321+
322+
return event;
323+
}
324+
325+
/**
326+
* Hash request body for NIP-98 payload tag
327+
* @param {object} requestBody - Chrome webRequest requestBody
328+
* @returns {Promise<string>} SHA-256 hash as hex string
329+
*/
330+
async function hashRequestBody (requestBody) {
331+
let bodyBytes;
332+
333+
if (requestBody.raw) {
334+
// ArrayBuffer[] format from Chrome
335+
const chunks = requestBody.raw;
336+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.bytes.byteLength, 0);
337+
const combined = new Uint8Array(totalLength);
338+
let offset = 0;
339+
for (const chunk of chunks) {
340+
combined.set(new Uint8Array(chunk.bytes), offset);
341+
offset += chunk.bytes.byteLength;
342+
}
343+
bodyBytes = combined;
344+
} else if (requestBody.formData) {
345+
// FormData - convert to string representation
346+
const formDataStr = JSON.stringify(requestBody.formData);
347+
bodyBytes = new TextEncoder().encode(formDataStr);
348+
} else {
349+
// Fallback: treat as empty
350+
bodyBytes = new Uint8Array(0);
351+
}
352+
353+
const hash = sha256(bodyBytes);
354+
return bytesToHex(hash);
355+
}
356+
357+
/**
358+
* Encode signed event to Authorization header value
359+
* @param {object} signedEvent - Signed Nostr event
360+
* @returns {string} Base64-encoded event for Authorization header
361+
*/
362+
function encodeNip98Header (signedEvent) {
363+
const eventJson = JSON.stringify(signedEvent);
364+
// Use btoa for base64 encoding (available in service workers)
365+
return btoa(eventJson);
366+
}
367+
368+
/**
369+
* Check if request should have NIP-98 auth added
370+
* @param {object} requestDetails - Chrome webRequest details
371+
* @returns {Promise<boolean>}
372+
*/
373+
async function shouldAddNip98Auth (requestDetails) {
374+
// Check if keypair exists
375+
const keyExists = await hasKeypair();
376+
if (!keyExists) {
377+
return false;
378+
}
379+
380+
// Check if origin is trusted
381+
const origin = new URL(requestDetails.url).origin;
382+
const trusted = await isTrustedOrigin(origin);
383+
if (!trusted) {
384+
return false;
385+
}
386+
387+
// Check if auto-sign is enabled
388+
const autoSign = await getAutoSign();
389+
if (!autoSign) {
390+
return false;
391+
}
392+
393+
// Don't add auth if request already has Authorization header (unless it's a retry)
394+
const hasAuth = requestDetails.requestHeaders?.some(
395+
h => h.name.toLowerCase() === 'authorization'
396+
);
397+
if (hasAuth && !retryState.has(requestDetails.requestId)) {
398+
return false;
399+
}
400+
401+
return true;
402+
}
403+
404+
/**
405+
* Intercept requests and add NIP-98 auth if needed
406+
* @param {object} details - Chrome webRequest details
407+
* @returns {object|undefined} Modified request headers or undefined
408+
*/
409+
async function interceptRequest (details) {
410+
try {
411+
// Only process XMLHttpRequest and fetch requests
412+
if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) {
413+
return;
414+
}
415+
416+
const shouldAuth = await shouldAddNip98Auth(details);
417+
if (!shouldAuth) {
418+
return;
419+
}
420+
421+
// Check cache first
422+
const bodyHash = details.requestBody ? await hashRequestBody(details.requestBody) : '';
423+
const cacheKey = `${details.url}:${details.method}:${bodyHash}`;
424+
const cached = nip98Cache.get(cacheKey);
425+
426+
let signedEvent;
427+
if (cached && cached.expires > Date.now()) {
428+
// Use cached event
429+
signedEvent = cached.event;
430+
console.log('[Podkey] Using cached NIP-98 auth event');
431+
} else {
432+
// Create and sign new event
433+
const event = await createNip98AuthEvent(details);
434+
const keypair = await getKeypair();
435+
signedEvent = await signEvent(event, keypair.privateKey);
436+
437+
// Cache the signed event
438+
nip98Cache.set(cacheKey, {
439+
event: signedEvent,
440+
expires: Date.now() + CACHE_TTL
441+
});
442+
443+
console.log('[Podkey] Created and signed NIP-98 auth event for', details.url);
444+
}
445+
446+
// Encode to Authorization header
447+
const authHeader = encodeNip98Header(signedEvent);
448+
449+
// Add or replace Authorization header
450+
const headers = details.requestHeaders || [];
451+
const authIndex = headers.findIndex(h => h.name.toLowerCase() === 'authorization');
452+
453+
if (authIndex >= 0) {
454+
headers[authIndex].value = `Nostr ${authHeader}`;
455+
} else {
456+
headers.push({
457+
name: 'Authorization',
458+
value: `Nostr ${authHeader}`
459+
});
460+
}
461+
462+
return { requestHeaders: headers };
463+
} catch (error) {
464+
console.error('[Podkey] Error intercepting request:', error);
465+
// Don't block the request if auth fails
466+
return;
467+
}
468+
}
469+
470+
/**
471+
* Handle 401 response - create auth and retry
472+
* @param {object} details - Chrome webRequest details
473+
* @returns {object|undefined} Modified response or undefined
474+
*/
475+
async function handle401Response (details) {
476+
// Only retry on 401 Unauthorized
477+
if (details.statusCode !== 401) {
478+
return;
479+
}
480+
481+
// Prevent infinite retry loops
482+
if (retryState.has(details.requestId)) {
483+
console.log('[Podkey] Already retried this request, skipping');
484+
return;
485+
}
486+
487+
try {
488+
// Check if we should auto-auth this origin
489+
const origin = new URL(details.url).origin;
490+
const trusted = await isTrustedOrigin(origin);
491+
const autoSign = await getAutoSign();
492+
493+
if (!trusted || !autoSign) {
494+
console.log('[Podkey] Origin not trusted or auto-sign disabled, not retrying');
495+
return;
496+
}
497+
498+
// Mark as retrying
499+
retryState.set(details.requestId, true);
500+
501+
// Create NIP-98 auth event
502+
const event = await createNip98AuthEvent({
503+
url: details.url,
504+
method: details.method,
505+
requestBody: details.requestBody
506+
});
507+
508+
const keypair = await getKeypair();
509+
const signedEvent = await signEvent(event, keypair.privateKey);
510+
const authHeader = encodeNip98Header(signedEvent);
511+
512+
console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url);
513+
514+
// Retry the request with auth header
515+
// Note: Chrome webRequest API doesn't support retrying directly
516+
// The page/script needs to retry, but we can log the auth header
517+
// For now, we'll rely on the onBeforeSendHeaders interceptor for the retry
518+
// This is a limitation - we'd need to use fetch() API to actually retry
519+
520+
// Clean up retry state after a delay
521+
setTimeout(() => {
522+
retryState.delete(details.requestId);
523+
}, 5000);
524+
} catch (error) {
525+
console.error('[Podkey] Error handling 401 response:', error);
526+
retryState.delete(details.requestId);
527+
}
528+
}
529+
530+
// Set up webRequest listeners for NIP-98 auto-auth
531+
chrome.webRequest.onBeforeSendHeaders.addListener(
532+
interceptRequest,
533+
{
534+
urls: ['<all_urls>'],
535+
types: ['xmlhttprequest', 'main_frame', 'sub_frame']
536+
},
537+
['requestHeaders', 'blocking']
538+
);
539+
540+
chrome.webRequest.onHeadersReceived.addListener(
541+
handle401Response,
542+
{
543+
urls: ['<all_urls>'],
544+
types: ['xmlhttprequest', 'main_frame', 'sub_frame']
545+
},
546+
['responseHeaders']
547+
);
548+
549+
console.log('[Podkey] NIP-98 auto-auth listeners registered');

0 commit comments

Comments
 (0)