From 460b393ab9a99f4a33d609092f0a11b8c9d50647 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 5 Jan 2026 18:18:40 +0100 Subject: [PATCH 1/5] 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 --- src/background.js | 260 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/src/background.js b/src/background.js index c353fdb..3723b30 100644 --- a/src/background.js +++ b/src/background.js @@ -12,9 +12,18 @@ import { addTrustedOrigin, getAutoSign } from './storage.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; console.log('[Podkey] Background service worker started'); +// NIP-98 auth event cache: key = `${url}:${method}:${bodyHash}`, value = { event, expires } +const nip98Cache = new Map(); +const CACHE_TTL = 60000; // 60 seconds + +// Track retry state to prevent infinite loops: key = requestId, value = true +const retryState = new Map(); + // Initialize extension chrome.runtime.onInstalled.addListener(async () => { console.log('[Podkey] Extension installed'); @@ -287,3 +296,254 @@ function formatEventForPrompt (event) { return lines.join('\n'); } + +/** + * Create NIP-98 authentication event for an HTTP request + * @param {object} requestDetails - Chrome webRequest details + * @returns {Promise} Unsigned NIP-98 event + */ +async function createNip98AuthEvent (requestDetails) { + const event = { + kind: 27235, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', requestDetails.url], // Full URL including query params + ['method', requestDetails.method] + ] + }; + + // If request has body, add payload tag with SHA-256 hash + if (requestDetails.requestBody) { + const bodyHash = await hashRequestBody(requestDetails.requestBody); + event.tags.push(['payload', bodyHash]); + } + + return event; +} + +/** + * Hash request body for NIP-98 payload tag + * @param {object} requestBody - Chrome webRequest requestBody + * @returns {Promise} SHA-256 hash as hex string + */ +async function hashRequestBody (requestBody) { + let bodyBytes; + + if (requestBody.raw) { + // ArrayBuffer[] format from Chrome + const chunks = requestBody.raw; + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.bytes.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(new Uint8Array(chunk.bytes), offset); + offset += chunk.bytes.byteLength; + } + bodyBytes = combined; + } else if (requestBody.formData) { + // FormData - convert to string representation + const formDataStr = JSON.stringify(requestBody.formData); + bodyBytes = new TextEncoder().encode(formDataStr); + } else { + // Fallback: treat as empty + bodyBytes = new Uint8Array(0); + } + + const hash = sha256(bodyBytes); + return bytesToHex(hash); +} + +/** + * Encode signed event to Authorization header value + * @param {object} signedEvent - Signed Nostr event + * @returns {string} Base64-encoded event for Authorization header + */ +function encodeNip98Header (signedEvent) { + const eventJson = JSON.stringify(signedEvent); + // Use btoa for base64 encoding (available in service workers) + return btoa(eventJson); +} + +/** + * Check if request should have NIP-98 auth added + * @param {object} requestDetails - Chrome webRequest details + * @returns {Promise} + */ +async function shouldAddNip98Auth (requestDetails) { + // Check if keypair exists + const keyExists = await hasKeypair(); + if (!keyExists) { + return false; + } + + // Check if origin is trusted + const origin = new URL(requestDetails.url).origin; + const trusted = await isTrustedOrigin(origin); + if (!trusted) { + return false; + } + + // Check if auto-sign is enabled + const autoSign = await getAutoSign(); + if (!autoSign) { + return false; + } + + // Don't add auth if request already has Authorization header (unless it's a retry) + const hasAuth = requestDetails.requestHeaders?.some( + h => h.name.toLowerCase() === 'authorization' + ); + if (hasAuth && !retryState.has(requestDetails.requestId)) { + return false; + } + + return true; +} + +/** + * Intercept requests and add NIP-98 auth if needed + * @param {object} details - Chrome webRequest details + * @returns {object|undefined} Modified request headers or undefined + */ +async function interceptRequest (details) { + try { + // Only process XMLHttpRequest and fetch requests + if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { + return; + } + + const shouldAuth = await shouldAddNip98Auth(details); + if (!shouldAuth) { + return; + } + + // Check cache first + const bodyHash = details.requestBody ? await hashRequestBody(details.requestBody) : ''; + const cacheKey = `${details.url}:${details.method}:${bodyHash}`; + const cached = nip98Cache.get(cacheKey); + + let signedEvent; + if (cached && cached.expires > Date.now()) { + // Use cached event + signedEvent = cached.event; + console.log('[Podkey] Using cached NIP-98 auth event'); + } else { + // Create and sign new event + const event = await createNip98AuthEvent(details); + const keypair = await getKeypair(); + signedEvent = await signEvent(event, keypair.privateKey); + + // Cache the signed event + nip98Cache.set(cacheKey, { + event: signedEvent, + expires: Date.now() + CACHE_TTL + }); + + console.log('[Podkey] Created and signed NIP-98 auth event for', details.url); + } + + // Encode to Authorization header + const authHeader = encodeNip98Header(signedEvent); + + // Add or replace Authorization header + const headers = details.requestHeaders || []; + const authIndex = headers.findIndex(h => h.name.toLowerCase() === 'authorization'); + + if (authIndex >= 0) { + headers[authIndex].value = `Nostr ${authHeader}`; + } else { + headers.push({ + name: 'Authorization', + value: `Nostr ${authHeader}` + }); + } + + return { requestHeaders: headers }; + } catch (error) { + console.error('[Podkey] Error intercepting request:', error); + // Don't block the request if auth fails + return; + } +} + +/** + * Handle 401 response - create auth and retry + * @param {object} details - Chrome webRequest details + * @returns {object|undefined} Modified response or undefined + */ +async function handle401Response (details) { + // Only retry on 401 Unauthorized + if (details.statusCode !== 401) { + return; + } + + // Prevent infinite retry loops + if (retryState.has(details.requestId)) { + console.log('[Podkey] Already retried this request, skipping'); + return; + } + + try { + // Check if we should auto-auth this origin + const origin = new URL(details.url).origin; + const trusted = await isTrustedOrigin(origin); + const autoSign = await getAutoSign(); + + if (!trusted || !autoSign) { + console.log('[Podkey] Origin not trusted or auto-sign disabled, not retrying'); + return; + } + + // Mark as retrying + retryState.set(details.requestId, true); + + // Create NIP-98 auth event + const event = await createNip98AuthEvent({ + url: details.url, + method: details.method, + requestBody: details.requestBody + }); + + const keypair = await getKeypair(); + const signedEvent = await signEvent(event, keypair.privateKey); + const authHeader = encodeNip98Header(signedEvent); + + console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url); + + // Retry the request with auth header + // Note: Chrome webRequest API doesn't support retrying directly + // The page/script needs to retry, but we can log the auth header + // For now, we'll rely on the onBeforeSendHeaders interceptor for the retry + // This is a limitation - we'd need to use fetch() API to actually retry + + // Clean up retry state after a delay + setTimeout(() => { + retryState.delete(details.requestId); + }, 5000); + } catch (error) { + console.error('[Podkey] Error handling 401 response:', error); + retryState.delete(details.requestId); + } +} + +// Set up webRequest listeners for NIP-98 auto-auth +chrome.webRequest.onBeforeSendHeaders.addListener( + interceptRequest, + { + urls: [''], + types: ['xmlhttprequest', 'main_frame', 'sub_frame'] + }, + ['requestHeaders', 'blocking'] +); + +chrome.webRequest.onHeadersReceived.addListener( + handle401Response, + { + urls: [''], + types: ['xmlhttprequest', 'main_frame', 'sub_frame'] + }, + ['responseHeaders'] +); + +console.log('[Podkey] NIP-98 auto-auth listeners registered'); From d58bea94033cb09bcc7142f622800b012c83c213 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 5 Jan 2026 18:49:01 +0100 Subject: [PATCH 2/5] Fix: Remove blocking webRequest listeners (Manifest V3 incompatible) - Remove blocking webRequest listeners that require webRequestBlocking permission - Implement JavaScript-level interception via fetch/XMLHttpRequest patching - Add CREATE_NIP98_AUTH_HEADER message handler - Intercept fetch and XMLHttpRequest in content script - Handle 401 responses with automatic retry Fixes Chrome extension error about webRequestBlocking permission --- src/background.js | 267 ++++++++++------------------------------------ src/injected.js | 110 ++++++++++++++++++- 2 files changed, 168 insertions(+), 209 deletions(-) diff --git a/src/background.js b/src/background.js index 3723b30..4cbd490 100644 --- a/src/background.js +++ b/src/background.js @@ -83,6 +83,13 @@ async function handleMessage (message, sender) { case 'NIP04_DECRYPT': throw new Error('NIP-04 encryption not yet implemented'); + case 'CREATE_NIP98_AUTH_HEADER': + return await createNip98AuthHeader( + message.url, + message.method, + message.body + ); + default: throw new Error(`Unknown message type: ${type}`); } @@ -297,62 +304,6 @@ function formatEventForPrompt (event) { return lines.join('\n'); } -/** - * Create NIP-98 authentication event for an HTTP request - * @param {object} requestDetails - Chrome webRequest details - * @returns {Promise} Unsigned NIP-98 event - */ -async function createNip98AuthEvent (requestDetails) { - const event = { - kind: 27235, - content: '', - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['u', requestDetails.url], // Full URL including query params - ['method', requestDetails.method] - ] - }; - - // If request has body, add payload tag with SHA-256 hash - if (requestDetails.requestBody) { - const bodyHash = await hashRequestBody(requestDetails.requestBody); - event.tags.push(['payload', bodyHash]); - } - - return event; -} - -/** - * Hash request body for NIP-98 payload tag - * @param {object} requestBody - Chrome webRequest requestBody - * @returns {Promise} SHA-256 hash as hex string - */ -async function hashRequestBody (requestBody) { - let bodyBytes; - - if (requestBody.raw) { - // ArrayBuffer[] format from Chrome - const chunks = requestBody.raw; - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.bytes.byteLength, 0); - const combined = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - combined.set(new Uint8Array(chunk.bytes), offset); - offset += chunk.bytes.byteLength; - } - bodyBytes = combined; - } else if (requestBody.formData) { - // FormData - convert to string representation - const formDataStr = JSON.stringify(requestBody.formData); - bodyBytes = new TextEncoder().encode(formDataStr); - } else { - // Fallback: treat as empty - bodyBytes = new Uint8Array(0); - } - - const hash = sha256(bodyBytes); - return bytesToHex(hash); -} /** * Encode signed event to Authorization header value @@ -365,185 +316,85 @@ function encodeNip98Header (signedEvent) { return btoa(eventJson); } -/** - * Check if request should have NIP-98 auth added - * @param {object} requestDetails - Chrome webRequest details - * @returns {Promise} - */ -async function shouldAddNip98Auth (requestDetails) { - // Check if keypair exists - const keyExists = await hasKeypair(); - if (!keyExists) { - return false; - } - - // Check if origin is trusted - const origin = new URL(requestDetails.url).origin; - const trusted = await isTrustedOrigin(origin); - if (!trusted) { - return false; - } - - // Check if auto-sign is enabled - const autoSign = await getAutoSign(); - if (!autoSign) { - return false; - } - - // Don't add auth if request already has Authorization header (unless it's a retry) - const hasAuth = requestDetails.requestHeaders?.some( - h => h.name.toLowerCase() === 'authorization' - ); - if (hasAuth && !retryState.has(requestDetails.requestId)) { - return false; - } - - return true; -} /** - * Intercept requests and add NIP-98 auth if needed - * @param {object} details - Chrome webRequest details - * @returns {object|undefined} Modified request headers or undefined + * Create NIP-98 auth header for a request (called from content script) + * @param {string} url - Request URL + * @param {string} method - HTTP method + * @param {string|ArrayBuffer|Blob|null} body - Request body + * @returns {Promise} Authorization header value */ -async function interceptRequest (details) { +async function createNip98AuthHeader (url, method, body = null) { try { - // Only process XMLHttpRequest and fetch requests - if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { - return; + // Check if we should add auth + const origin = new URL(url).origin; + const trusted = await isTrustedOrigin(origin); + const autoSign = await getAutoSign(); + const keyExists = await hasKeypair(); + + if (!keyExists || !trusted || !autoSign) { + return null; } - const shouldAuth = await shouldAddNip98Auth(details); - if (!shouldAuth) { - return; + // Hash body if present + let bodyHash = ''; + if (body) { + if (typeof body === 'string') { + bodyHash = bytesToHex(sha256(new TextEncoder().encode(body))); + } else if (body instanceof ArrayBuffer) { + bodyHash = bytesToHex(sha256(new Uint8Array(body))); + } else if (body instanceof Blob) { + const arrayBuffer = await body.arrayBuffer(); + bodyHash = bytesToHex(sha256(new Uint8Array(arrayBuffer))); + } } - // Check cache first - const bodyHash = details.requestBody ? await hashRequestBody(details.requestBody) : ''; - const cacheKey = `${details.url}:${details.method}:${bodyHash}`; + // Check cache + const cacheKey = `${url}:${method}:${bodyHash}`; const cached = nip98Cache.get(cacheKey); let signedEvent; if (cached && cached.expires > Date.now()) { - // Use cached event signedEvent = cached.event; console.log('[Podkey] Using cached NIP-98 auth event'); } else { - // Create and sign new event - const event = await createNip98AuthEvent(details); + // Create and sign event + const event = { + kind: 27235, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', url], + ['method', method] + ] + }; + + if (bodyHash) { + event.tags.push(['payload', bodyHash]); + } + const keypair = await getKeypair(); signedEvent = await signEvent(event, keypair.privateKey); - // Cache the signed event + // Cache nip98Cache.set(cacheKey, { event: signedEvent, expires: Date.now() + CACHE_TTL }); - console.log('[Podkey] Created and signed NIP-98 auth event for', details.url); + console.log('[Podkey] Created and signed NIP-98 auth event for', url); } - // Encode to Authorization header - const authHeader = encodeNip98Header(signedEvent); - - // Add or replace Authorization header - const headers = details.requestHeaders || []; - const authIndex = headers.findIndex(h => h.name.toLowerCase() === 'authorization'); - - if (authIndex >= 0) { - headers[authIndex].value = `Nostr ${authHeader}`; - } else { - headers.push({ - name: 'Authorization', - value: `Nostr ${authHeader}` - }); - } - - return { requestHeaders: headers }; + return `Nostr ${encodeNip98Header(signedEvent)}`; } catch (error) { - console.error('[Podkey] Error intercepting request:', error); - // Don't block the request if auth fails - return; + console.error('[Podkey] Error creating NIP-98 auth header:', error); + return null; } } -/** - * Handle 401 response - create auth and retry - * @param {object} details - Chrome webRequest details - * @returns {object|undefined} Modified response or undefined - */ -async function handle401Response (details) { - // Only retry on 401 Unauthorized - if (details.statusCode !== 401) { - return; - } - - // Prevent infinite retry loops - if (retryState.has(details.requestId)) { - console.log('[Podkey] Already retried this request, skipping'); - return; - } - - try { - // Check if we should auto-auth this origin - const origin = new URL(details.url).origin; - const trusted = await isTrustedOrigin(origin); - const autoSign = await getAutoSign(); - - if (!trusted || !autoSign) { - console.log('[Podkey] Origin not trusted or auto-sign disabled, not retrying'); - return; - } - - // Mark as retrying - retryState.set(details.requestId, true); - - // Create NIP-98 auth event - const event = await createNip98AuthEvent({ - url: details.url, - method: details.method, - requestBody: details.requestBody - }); - - const keypair = await getKeypair(); - const signedEvent = await signEvent(event, keypair.privateKey); - const authHeader = encodeNip98Header(signedEvent); - - console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url); - - // Retry the request with auth header - // Note: Chrome webRequest API doesn't support retrying directly - // The page/script needs to retry, but we can log the auth header - // For now, we'll rely on the onBeforeSendHeaders interceptor for the retry - // This is a limitation - we'd need to use fetch() API to actually retry - - // Clean up retry state after a delay - setTimeout(() => { - retryState.delete(details.requestId); - }, 5000); - } catch (error) { - console.error('[Podkey] Error handling 401 response:', error); - retryState.delete(details.requestId); - } -} +// Note: Blocking webRequest listeners require webRequestBlocking permission, +// which is deprecated in Manifest V3 and only available for enterprise extensions. +// Instead, we use JavaScript-level interception via content scripts. +// See src/injected.js for fetch/XMLHttpRequest interception. -// Set up webRequest listeners for NIP-98 auto-auth -chrome.webRequest.onBeforeSendHeaders.addListener( - interceptRequest, - { - urls: [''], - types: ['xmlhttprequest', 'main_frame', 'sub_frame'] - }, - ['requestHeaders', 'blocking'] -); - -chrome.webRequest.onHeadersReceived.addListener( - handle401Response, - { - urls: [''], - types: ['xmlhttprequest', 'main_frame', 'sub_frame'] - }, - ['responseHeaders'] -); - -console.log('[Podkey] NIP-98 auto-auth listeners registered'); +console.log('[Podkey] NIP-98 auto-auth: Using JavaScript-level interception (see injected.js)'); diff --git a/src/injected.js b/src/injected.js index c645417..e6ce7c9 100644 --- a/src/injected.js +++ b/src/injected.js @@ -67,7 +67,115 @@ window.addEventListener('podkey-request', async (event) => { } }); -console.log('[Podkey] Content script loaded'); +// NIP-98 auto-auth: Intercept fetch and XMLHttpRequest +(function interceptHttpRequests () { + // Intercept fetch + const originalFetch = window.fetch; + window.fetch = async function (url, options = {}) { + try { + const authHeader = await getNip98AuthHeader(url, options.method || 'GET', options.body); + if (authHeader) { + options.headers = options.headers || {}; + if (options.headers instanceof Headers) { + options.headers.set('Authorization', authHeader); + } else { + options.headers['Authorization'] = authHeader; + } + } + } catch (error) { + console.error('[Podkey] Error adding NIP-98 auth to fetch:', error); + } + + const response = await originalFetch(url, options); + + // Handle 401 responses + if (response.status === 401) { + try { + const authHeader = await getNip98AuthHeader(url, options.method || 'GET', options.body); + if (authHeader) { + // Retry with auth + const retryOptions = { ...options }; + retryOptions.headers = retryOptions.headers || {}; + if (retryOptions.headers instanceof Headers) { + retryOptions.headers.set('Authorization', authHeader); + } else { + retryOptions.headers['Authorization'] = authHeader; + } + return await originalFetch(url, retryOptions); + } + } catch (error) { + console.error('[Podkey] Error retrying fetch with NIP-98 auth:', error); + } + } + + return response; + }; + + // Intercept XMLHttpRequest + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url, ...args) { + this._podkeyMethod = method; + this._podkeyUrl = url; + return originalOpen.apply(this, [method, url, ...args]); + }; + + XMLHttpRequest.prototype.send = async function (body) { + try { + const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body); + if (authHeader) { + this.setRequestHeader('Authorization', authHeader); + } + } catch (error) { + console.error('[Podkey] Error adding NIP-98 auth to XHR:', error); + } + + // Handle 401 responses + this.addEventListener('load', async function () { + if (this.status === 401) { + try { + const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body); + if (authHeader) { + // Retry with auth + const retryXhr = new XMLHttpRequest(); + retryXhr.open(this._podkeyMethod, this._podkeyUrl); + retryXhr.setRequestHeader('Authorization', authHeader); + // Copy other headers if needed + retryXhr.send(body); + // Note: This is a simplified retry - in practice, you'd want to handle the response properly + } + } catch (error) { + console.error('[Podkey] Error retrying XHR with NIP-98 auth:', error); + } + } + }); + + return originalSend.apply(this, [body]); + }; + + async function getNip98AuthHeader (url, method, body) { + try { + const response = await chrome.runtime.sendMessage({ + type: 'CREATE_NIP98_AUTH_HEADER', + url: typeof url === 'string' ? url : url.toString(), + method: method || 'GET', + body: body + }); + + if (chrome.runtime.lastError) { + return null; + } + + return response || null; + } catch (error) { + console.error('[Podkey] Error getting NIP-98 auth header:', error); + return null; + } + } +})(); + +console.log('[Podkey] Content script loaded with NIP-98 auto-auth interception'); // Error handling for script injection script.onerror = function () { From 92570e968ae9b72c67a2b8b2fe94e1e76fda5535 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 5 Jan 2026 19:12:26 +0100 Subject: [PATCH 3/5] Improve NIP-98 auto-auth: auto-trust Solid servers, better 401 retry logging --- src/background.js | 45 ++++++++++++++++++++++++++++++++++++++++++++- src/injected.js | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/background.js b/src/background.js index 4cbd490..9fc28d9 100644 --- a/src/background.js +++ b/src/background.js @@ -324,6 +324,24 @@ function encodeNip98Header (signedEvent) { * @param {string|ArrayBuffer|Blob|null} body - Request body * @returns {Promise} Authorization header value */ +/** + * Check if an origin is likely a Solid server + * @param {string} origin - Origin to check + * @returns {boolean} + */ +function isLikelySolidServer (origin) { + // Common Solid server indicators + const solidIndicators = [ + 'solid.social', + 'solidcommunity.net', + 'inrupt.net', + 'solidweb.org', + '/.well-known/solid' + ]; + + return solidIndicators.some(indicator => origin.includes(indicator)); +} + async function createNip98AuthHeader (url, method, body = null) { try { // Check if we should add auth @@ -331,8 +349,33 @@ async function createNip98AuthHeader (url, method, body = null) { const trusted = await isTrustedOrigin(origin); const autoSign = await getAutoSign(); const keyExists = await hasKeypair(); + const isSolid = isLikelySolidServer(origin); + + console.log('[Podkey] NIP-98 auth check:', { + url, + origin, + keyExists, + trusted, + autoSign, + isSolid + }); + + if (!keyExists) { + console.log('[Podkey] No keypair found, skipping NIP-98 auth'); + return null; + } + + // For Solid servers, auto-trust on first use if auto-sign is enabled + if (!trusted && isSolid && autoSign) { + console.log('[Podkey] Auto-trusting Solid server:', origin); + await addTrustedOrigin(origin); + } else if (!trusted) { + console.log('[Podkey] Origin not trusted, skipping NIP-98 auth'); + return null; + } - if (!keyExists || !trusted || !autoSign) { + if (!autoSign) { + console.log('[Podkey] Auto-sign disabled, skipping NIP-98 auth'); return null; } diff --git a/src/injected.js b/src/injected.js index e6ce7c9..875ec11 100644 --- a/src/injected.js +++ b/src/injected.js @@ -88,20 +88,38 @@ window.addEventListener('podkey-request', async (event) => { const response = await originalFetch(url, options); - // Handle 401 responses + // Handle 401 responses - retry with NIP-98 auth if (response.status === 401) { + console.log('[Podkey] 401 detected, attempting NIP-98 auth retry for:', url); try { + // Clone response to read body if needed, but for retry we'll make a new request const authHeader = await getNip98AuthHeader(url, options.method || 'GET', options.body); if (authHeader) { // Retry with auth const retryOptions = { ...options }; retryOptions.headers = retryOptions.headers || {}; + + // Handle Headers object if (retryOptions.headers instanceof Headers) { retryOptions.headers.set('Authorization', authHeader); - } else { + } else if (retryOptions.headers instanceof Object) { retryOptions.headers['Authorization'] = authHeader; + } else { + retryOptions.headers = { 'Authorization': authHeader }; } - return await originalFetch(url, retryOptions); + + console.log('[Podkey] Retrying request with NIP-98 auth'); + const retryResponse = await originalFetch(url, retryOptions); + + if (retryResponse.status === 200 || retryResponse.status === 201) { + console.log('[Podkey] ✅ NIP-98 auth successful!'); + } else { + console.log('[Podkey] ⚠️ Retry still failed with status:', retryResponse.status); + } + + return retryResponse; + } else { + console.log('[Podkey] No NIP-98 auth header available for retry'); } } catch (error) { console.error('[Podkey] Error retrying fetch with NIP-98 auth:', error); @@ -156,17 +174,27 @@ window.addEventListener('podkey-request', async (event) => { async function getNip98AuthHeader (url, method, body) { try { + const urlString = typeof url === 'string' ? url : url.toString(); + console.log('[Podkey] Requesting NIP-98 auth header for:', urlString, method); + const response = await chrome.runtime.sendMessage({ type: 'CREATE_NIP98_AUTH_HEADER', - url: typeof url === 'string' ? url : url.toString(), + url: urlString, method: method || 'GET', body: body }); if (chrome.runtime.lastError) { + console.error('[Podkey] Error from background script:', chrome.runtime.lastError.message); return null; } + if (response) { + console.log('[Podkey] Got NIP-98 auth header'); + } else { + console.log('[Podkey] No NIP-98 auth header (origin not trusted, auto-sign disabled, or no keypair)'); + } + return response || null; } catch (error) { console.error('[Podkey] Error getting NIP-98 auth header:', error); From fd3bc497d68ac791e3b7cee6b7fb6b5225018069 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 5 Jan 2026 19:18:57 +0100 Subject: [PATCH 4/5] Add detailed logging to fetch interception for debugging --- src/background.js | 2 +- src/injected.js | 236 +++++++++++++++++++++++++--------------------- 2 files changed, 131 insertions(+), 107 deletions(-) diff --git a/src/background.js b/src/background.js index 9fc28d9..86b7be3 100644 --- a/src/background.js +++ b/src/background.js @@ -338,7 +338,7 @@ function isLikelySolidServer (origin) { 'solidweb.org', '/.well-known/solid' ]; - + return solidIndicators.some(indicator => origin.includes(indicator)); } diff --git a/src/injected.js b/src/injected.js index 875ec11..5c06438 100644 --- a/src/injected.js +++ b/src/injected.js @@ -3,6 +3,122 @@ * Bridges between injected window.nostr and background script */ +// CRITICAL: Set up fetch/XHR interception IMMEDIATELY, synchronously +// This must run before ANY other code, including page scripts +(function setupInterceptionImmediately () { + 'use strict'; + + // Store original functions + const originalFetch = window.fetch; + const originalXHROpen = XMLHttpRequest.prototype.open; + const originalXHRSend = XMLHttpRequest.prototype.send; + + // Helper to get auth header (will be async, but we'll handle that) + let getAuthHeaderFn = null; + + // Set up the async auth header function (will be defined below) + function setAuthHeaderFn (fn) { + getAuthHeaderFn = fn; + } + + // Intercept fetch - synchronous wrapper, async implementation + window.fetch = function (url, options = {}) { + const urlString = typeof url === 'string' ? url : url.toString(); + console.log('[Podkey] 🔍 fetch() called:', urlString, options.method || 'GET'); + + // If we have the auth function, use it + if (getAuthHeaderFn) { + console.log('[Podkey] Auth function available, adding header...'); + const promise = (async () => { + try { + const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); + if (authHeader) { + options = options || {}; + options.headers = options.headers || {}; + if (options.headers instanceof Headers) { + options.headers.set('Authorization', authHeader); + console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)'); + } else { + options.headers['Authorization'] = authHeader; + console.log('[Podkey] ✅ Added NIP-98 auth header (object)'); + } + } else { + console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)'); + } + } catch (e) { + console.error('[Podkey] Error in fetch interceptor:', e); + } + return originalFetch.call(this, url, options); + })(); + + // Handle 401 retry + return promise.then(response => { + if (response.status === 401 && getAuthHeaderFn) { + console.log('[Podkey] 🔄 401 detected, retrying with auth...'); + return (async () => { + try { + const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); + if (authHeader) { + const retryOptions = { ...options }; + retryOptions.headers = retryOptions.headers || {}; + if (retryOptions.headers instanceof Headers) { + retryOptions.headers.set('Authorization', authHeader); + } else { + retryOptions.headers['Authorization'] = authHeader; + } + console.log('[Podkey] 🔄 Retrying with NIP-98 auth...'); + const retryResponse = await originalFetch.call(this, url, retryOptions); + if (retryResponse.status === 200 || retryResponse.status === 201) { + console.log('[Podkey] ✅✅ NIP-98 auth retry successful!'); + } else { + console.log('[Podkey] ⚠️ Retry still failed:', retryResponse.status); + } + return retryResponse; + } + } catch (e) { + console.error('[Podkey] Error in 401 retry:', e); + } + return response; + })(); + } + return response; + }); + } + + // Fallback if auth function not ready yet + console.log('[Podkey] ⚠️ Auth function not ready yet, making request without auth'); + return originalFetch.call(this, url, options); + }; + + // Intercept XMLHttpRequest + XMLHttpRequest.prototype.open = function (method, url, ...args) { + this._podkeyMethod = method; + this._podkeyUrl = url; + return originalXHROpen.apply(this, [method, url, ...args]); + }; + + XMLHttpRequest.prototype.send = function (body) { + if (getAuthHeaderFn && this._podkeyUrl) { + (async () => { + try { + const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body); + if (authHeader) { + this.setRequestHeader('Authorization', authHeader); + } + } catch (e) { + console.error('[Podkey] Error in XHR interceptor:', e); + } + })(); + } + return originalXHRSend.apply(this, [body]); + }; + + // Expose setter for the auth function + window.__podkey_setAuthFn = setAuthHeaderFn; + + console.log('[Podkey] ✅ Synchronous interception setup complete'); +})(); + // Inject the nostr provider script into the page const script = document.createElement('script'); script.src = chrome.runtime.getURL('src/nostr-provider.js'); @@ -67,110 +183,10 @@ window.addEventListener('podkey-request', async (event) => { } }); -// NIP-98 auto-auth: Intercept fetch and XMLHttpRequest -(function interceptHttpRequests () { - // Intercept fetch - const originalFetch = window.fetch; - window.fetch = async function (url, options = {}) { - try { - const authHeader = await getNip98AuthHeader(url, options.method || 'GET', options.body); - if (authHeader) { - options.headers = options.headers || {}; - if (options.headers instanceof Headers) { - options.headers.set('Authorization', authHeader); - } else { - options.headers['Authorization'] = authHeader; - } - } - } catch (error) { - console.error('[Podkey] Error adding NIP-98 auth to fetch:', error); - } - - const response = await originalFetch(url, options); - - // Handle 401 responses - retry with NIP-98 auth - if (response.status === 401) { - console.log('[Podkey] 401 detected, attempting NIP-98 auth retry for:', url); - try { - // Clone response to read body if needed, but for retry we'll make a new request - const authHeader = await getNip98AuthHeader(url, options.method || 'GET', options.body); - if (authHeader) { - // Retry with auth - const retryOptions = { ...options }; - retryOptions.headers = retryOptions.headers || {}; - - // Handle Headers object - if (retryOptions.headers instanceof Headers) { - retryOptions.headers.set('Authorization', authHeader); - } else if (retryOptions.headers instanceof Object) { - retryOptions.headers['Authorization'] = authHeader; - } else { - retryOptions.headers = { 'Authorization': authHeader }; - } - - console.log('[Podkey] Retrying request with NIP-98 auth'); - const retryResponse = await originalFetch(url, retryOptions); - - if (retryResponse.status === 200 || retryResponse.status === 201) { - console.log('[Podkey] ✅ NIP-98 auth successful!'); - } else { - console.log('[Podkey] ⚠️ Retry still failed with status:', retryResponse.status); - } - - return retryResponse; - } else { - console.log('[Podkey] No NIP-98 auth header available for retry'); - } - } catch (error) { - console.error('[Podkey] Error retrying fetch with NIP-98 auth:', error); - } - } - - return response; - }; - - // Intercept XMLHttpRequest - const originalOpen = XMLHttpRequest.prototype.open; - const originalSend = XMLHttpRequest.prototype.send; - - XMLHttpRequest.prototype.open = function (method, url, ...args) { - this._podkeyMethod = method; - this._podkeyUrl = url; - return originalOpen.apply(this, [method, url, ...args]); - }; - - XMLHttpRequest.prototype.send = async function (body) { - try { - const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body); - if (authHeader) { - this.setRequestHeader('Authorization', authHeader); - } - } catch (error) { - console.error('[Podkey] Error adding NIP-98 auth to XHR:', error); - } - - // Handle 401 responses - this.addEventListener('load', async function () { - if (this.status === 401) { - try { - const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body); - if (authHeader) { - // Retry with auth - const retryXhr = new XMLHttpRequest(); - retryXhr.open(this._podkeyMethod, this._podkeyUrl); - retryXhr.setRequestHeader('Authorization', authHeader); - // Copy other headers if needed - retryXhr.send(body); - // Note: This is a simplified retry - in practice, you'd want to handle the response properly - } - } catch (error) { - console.error('[Podkey] Error retrying XHR with NIP-98 auth:', error); - } - } - }); - - return originalSend.apply(this, [body]); - }; +// NIP-98 auto-auth: Set up the async auth header function +// This connects to the synchronous interceptor above +(function setupAuthFunction () { + console.log('[Podkey] Setting up NIP-98 auth function...'); async function getNip98AuthHeader (url, method, body) { try { @@ -190,9 +206,9 @@ window.addEventListener('podkey-request', async (event) => { } if (response) { - console.log('[Podkey] Got NIP-98 auth header'); + console.log('[Podkey] ✅ Got NIP-98 auth header'); } else { - console.log('[Podkey] No NIP-98 auth header (origin not trusted, auto-sign disabled, or no keypair)'); + console.log('[Podkey] ❌ No NIP-98 auth header (origin not trusted, auto-sign disabled, or no keypair)'); } return response || null; @@ -201,6 +217,14 @@ window.addEventListener('podkey-request', async (event) => { return null; } } + + // Connect the auth function to the synchronous interceptor + if (window.__podkey_setAuthFn) { + window.__podkey_setAuthFn(getNip98AuthHeader); + console.log('[Podkey] ✅ Auth function connected'); + } else { + console.error('[Podkey] ❌ Could not connect auth function - interceptor not ready'); + } })(); console.log('[Podkey] Content script loaded with NIP-98 auto-auth interception'); From 6721ae4bbe1f4abb71426fd2e1dd96811677e8df Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Mon, 5 Jan 2026 19:27:40 +0100 Subject: [PATCH 5/5] Add detailed logging for NIP-98 auth events to debug 403 errors --- manifest.json | 3 +- src/background.js | 6 +- src/injected.js | 103 +++++++++++++++++++++++++--- src/nip98-interceptor.js | 140 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 src/nip98-interceptor.js diff --git a/manifest.json b/manifest.json index 691ed15..8c38138 100644 --- a/manifest.json +++ b/manifest.json @@ -32,7 +32,8 @@ "web_accessible_resources": [ { "resources": [ - "src/nostr-provider.js" + "src/nostr-provider.js", + "src/nip98-interceptor.js" ], "matches": [ "" diff --git a/src/background.js b/src/background.js index 86b7be3..84481e2 100644 --- a/src/background.js +++ b/src/background.js @@ -426,9 +426,13 @@ async function createNip98AuthHeader (url, method, body = null) { }); console.log('[Podkey] Created and signed NIP-98 auth event for', url); + console.log('[Podkey] NIP-98 event:', JSON.stringify(signedEvent, null, 2)); + console.log('[Podkey] Public key (did:nostr):', `did:nostr:${keypair.publicKey}`); } - return `Nostr ${encodeNip98Header(signedEvent)}`; + const authHeader = `Nostr ${encodeNip98Header(signedEvent)}`; + console.log('[Podkey] Authorization header (first 100 chars):', authHeader.substring(0, 100) + '...'); + return authHeader; } catch (error) { console.error('[Podkey] Error creating NIP-98 auth header:', error); return null; diff --git a/src/injected.js b/src/injected.js index 5c06438..d429b5e 100644 --- a/src/injected.js +++ b/src/injected.js @@ -21,14 +21,16 @@ getAuthHeaderFn = fn; } - // Intercept fetch - synchronous wrapper, async implementation + // Intercept fetch - MUST replace immediately to catch all calls + // This runs synchronously, so it catches fetch even if called immediately window.fetch = function (url, options = {}) { const urlString = typeof url === 'string' ? url : url.toString(); - console.log('[Podkey] 🔍 fetch() called:', urlString, options.method || 'GET'); - + const method = options?.method || 'GET'; + console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); + // If we have the auth function, use it - if (getAuthHeaderFn) { - console.log('[Podkey] Auth function available, adding header...'); + if (authFunctionReady && getAuthHeaderFn) { + console.log('[Podkey] ✅ Auth function ready, adding header...'); const promise = (async () => { try { const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); @@ -50,7 +52,7 @@ } return originalFetch.call(this, url, options); })(); - + // Handle 401 retry return promise.then(response => { if (response.status === 401 && getAuthHeaderFn) { @@ -84,10 +86,46 @@ return response; }); } - - // Fallback if auth function not ready yet - console.log('[Podkey] ⚠️ Auth function not ready yet, making request without auth'); - return originalFetch.call(this, url, options); + + // Fallback if auth function not ready yet - but still try to get auth + console.log('[Podkey] ⚠️ Auth function not ready yet, making request...'); + + // Even if not ready, try to get auth header asynchronously and retry on 401 + const requestPromise = originalFetch.call(this, url, options); + + // If we get a 401 and auth becomes available, retry + return requestPromise.then(response => { + if (response.status === 401) { + console.log('[Podkey] 🔄 Got 401, checking if auth function is ready now...'); + // Wait a bit for auth function to be ready, then retry + return new Promise((resolve) => { + const checkAuth = () => { + if (authFunctionReady && getAuthHeaderFn) { + console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...'); + getAuthHeaderFn(url, method, options?.body).then(authHeader => { + if (authHeader) { + const retryOptions = { ...options }; + retryOptions.headers = retryOptions.headers || {}; + if (retryOptions.headers instanceof Headers) { + retryOptions.headers.set('Authorization', authHeader); + } else { + retryOptions.headers['Authorization'] = authHeader; + } + originalFetch.call(this, url, retryOptions).then(resolve); + } else { + resolve(response); + } + }); + } else { + // Check again in 100ms + setTimeout(checkAuth, 100); + } + }; + checkAuth(); + }); + } + return response; + }); }; // Intercept XMLHttpRequest @@ -119,6 +157,51 @@ console.log('[Podkey] ✅ Synchronous interception setup complete'); })(); +// Inject NIP-98 interceptor FIRST (must run before any page code) +const interceptorScript = document.createElement('script'); +interceptorScript.src = chrome.runtime.getURL('src/nip98-interceptor.js'); +interceptorScript.onload = function () { + this.remove(); +}; +interceptorScript.onerror = function () { + console.error('[Podkey] Failed to load nip98-interceptor.js'); +}; +(document.head || document.documentElement).appendChild(interceptorScript); + +// Listen for NIP-98 auth requests from page context +window.addEventListener('podkey-nip98-request', async (event) => { + const { id, url, method, body } = event.detail; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'CREATE_NIP98_AUTH_HEADER', + url, + method, + body + }); + + if (chrome.runtime.lastError) { + throw new Error(chrome.runtime.lastError.message); + } + + // Send response back to page context + window.dispatchEvent(new CustomEvent('podkey-nip98-response', { + detail: { + id, + result: response || null + } + })); + } catch (error) { + console.error('[Podkey] Error handling NIP-98 request:', error); + window.dispatchEvent(new CustomEvent('podkey-nip98-response', { + detail: { + id, + result: null + } + })); + } +}); + // Inject the nostr provider script into the page const script = document.createElement('script'); script.src = chrome.runtime.getURL('src/nostr-provider.js'); diff --git a/src/nip98-interceptor.js b/src/nip98-interceptor.js new file mode 100644 index 0000000..037cd0a --- /dev/null +++ b/src/nip98-interceptor.js @@ -0,0 +1,140 @@ +/** + * NIP-98 HTTP Auth Interceptor + * Injected into page context to intercept fetch/XHR requests + */ + +(function () { + 'use strict'; + + // Only inject once + if (window.__podkey_nip98_intercepted) { + return; + } + window.__podkey_nip98_intercepted = true; + + // Store original functions + const originalFetch = window.fetch; + const originalXHROpen = XMLHttpRequest.prototype.open; + const originalXHRSend = XMLHttpRequest.prototype.send; + + // Helper to get auth header from extension + async function getNip98AuthHeader (url, method, body) { + try { + const urlString = typeof url === 'string' ? url : url.toString(); + + // Send message to extension via custom event (content script will forward it) + return new Promise((resolve) => { + const eventId = Math.random().toString(36).substring(7); + + const handler = (event) => { + if (event.detail.id === eventId) { + window.removeEventListener('podkey-nip98-response', handler); + resolve(event.detail.result || null); + } + }; + + window.addEventListener('podkey-nip98-response', handler); + + // Request auth header + window.dispatchEvent(new CustomEvent('podkey-nip98-request', { + detail: { + id: eventId, + url: urlString, + method: method || 'GET', + body: body + } + })); + + // Timeout after 2 seconds + setTimeout(() => { + window.removeEventListener('podkey-nip98-response', handler); + resolve(null); + }, 2000); + }); + } catch (error) { + console.error('[Podkey] Error getting NIP-98 auth header:', error); + return null; + } + } + + // Intercept fetch + window.fetch = async function (url, options = {}) { + const urlString = typeof url === 'string' ? url : url.toString(); + const method = options?.method || 'GET'; + console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); + + try { + const authHeader = await getNip98AuthHeader(url, method, options?.body); + if (authHeader) { + options = options || {}; + options.headers = options.headers || {}; + if (options.headers instanceof Headers) { + options.headers.set('Authorization', authHeader); + console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)'); + } else { + options.headers['Authorization'] = authHeader; + console.log('[Podkey] ✅ Added NIP-98 auth header (object)'); + } + } else { + console.log('[Podkey] ⚠️ No auth header (will retry on 401)'); + } + } catch (error) { + console.error('[Podkey] Error adding NIP-98 auth:', error); + } + + const response = await originalFetch.call(this, url, options); + + // Handle 401 retry + if (response.status === 401) { + console.log('[Podkey] 🔄 401 detected, retrying with NIP-98 auth...'); + try { + const authHeader = await getNip98AuthHeader(url, method, options?.body); + if (authHeader) { + const retryOptions = { ...options }; + retryOptions.headers = retryOptions.headers || {}; + if (retryOptions.headers instanceof Headers) { + retryOptions.headers.set('Authorization', authHeader); + } else { + retryOptions.headers['Authorization'] = authHeader; + } + console.log('[Podkey] 🔄 Retrying with NIP-98 auth...'); + const retryResponse = await originalFetch.call(this, url, retryOptions); + if (retryResponse.status === 200 || retryResponse.status === 201) { + console.log('[Podkey] ✅✅ NIP-98 auth retry successful!'); + } else { + console.log('[Podkey] ⚠️ Retry still failed:', retryResponse.status); + } + return retryResponse; + } + } catch (error) { + console.error('[Podkey] Error in 401 retry:', error); + } + } + + return response; + }; + + // Intercept XMLHttpRequest + XMLHttpRequest.prototype.open = function (method, url, ...args) { + this._podkeyMethod = method; + this._podkeyUrl = url; + return originalXHROpen.apply(this, [method, url, ...args]); + }; + + XMLHttpRequest.prototype.send = async function (body) { + if (this._podkeyUrl) { + try { + const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body); + if (authHeader) { + this.setRequestHeader('Authorization', authHeader); + console.log('[Podkey] ✅ Added NIP-98 auth to XHR'); + } + } catch (error) { + console.error('[Podkey] Error adding NIP-98 auth to XHR:', error); + } + } + return originalXHRSend.apply(this, [body]); + }; + + console.log('[Podkey] ✅ NIP-98 interceptor injected into page context'); +})();