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 c353fdb..84481e2 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'); @@ -74,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}`); } @@ -287,3 +303,145 @@ function formatEventForPrompt (event) { return lines.join('\n'); } + + +/** + * 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); +} + + +/** + * 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 + */ +/** + * 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 + const origin = new URL(url).origin; + 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 (!autoSign) { + console.log('[Podkey] Auto-sign disabled, skipping NIP-98 auth'); + return null; + } + + // 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 + const cacheKey = `${url}:${method}:${bodyHash}`; + const cached = nip98Cache.get(cacheKey); + + let signedEvent; + if (cached && cached.expires > Date.now()) { + signedEvent = cached.event; + console.log('[Podkey] Using cached NIP-98 auth event'); + } else { + // 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 + nip98Cache.set(cacheKey, { + event: signedEvent, + expires: Date.now() + CACHE_TTL + }); + + 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}`); + } + + 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; + } +} + +// 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. + +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..d429b5e 100644 --- a/src/injected.js +++ b/src/injected.js @@ -3,6 +3,205 @@ * 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 - 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(); + const method = options?.method || 'GET'; + console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); + + // If we have the auth function, use it + 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); + 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 - 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 + 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 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'); @@ -67,7 +266,51 @@ window.addEventListener('podkey-request', async (event) => { } }); -console.log('[Podkey] Content script loaded'); +// 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 { + 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: 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); + 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'); // Error handling for script injection script.onerror = function () { 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'); +})();