@@ -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
1618console . 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
1928chrome . 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