Skip to content

Implement Automatic NIP-98 HTTP Authentication#2

Merged
melvincarvalho merged 5 commits into
mainfrom
feature/nip98-auto-auth
Jan 5, 2026
Merged

Implement Automatic NIP-98 HTTP Authentication#2
melvincarvalho merged 5 commits into
mainfrom
feature/nip98-auto-auth

Conversation

@melvincarvalho
Copy link
Copy Markdown
Contributor

Overview

This PR implements automatic NIP-98 HTTP authentication as described in #1. The extension now automatically intercepts HTTP requests and adds NIP-98 Authorization headers for trusted origins when auto-sign is enabled.

Changes

  • ✅ Added chrome.webRequest listeners for request interception
  • ✅ Implemented NIP-98 auth event creation (kind 27235)
  • ✅ Added request body hashing for POST/PUT requests
  • ✅ Implemented caching to avoid re-signing identical requests
  • ✅ Added 401 response detection (note: Chrome API limitations prevent automatic retry)
  • ✅ Respects user preferences (auto-sign, trusted origins)

Implementation Details

Request Interception

  • Uses onBeforeSendHeaders to intercept outgoing requests
  • Only processes requests from trusted origins with auto-sign enabled
  • Adds Authorization: Nostr <base64-event> header

NIP-98 Event Creation

  • Creates events with kind 27235
  • Includes u tag with full URL (including query params)
  • Includes method tag with HTTP method
  • Includes payload tag with SHA-256 hash for requests with body

Caching

  • Caches signed events per (URL, method, bodyHash) tuple
  • Cache TTL: 60 seconds
  • Prevents unnecessary re-signing of identical requests

401 Detection

  • Detects 401 responses via onHeadersReceived
  • Logs auth event creation for debugging
  • Note: Chrome webRequest API doesn't support automatic retry, so pages need to handle retries

Testing

  • Build succeeds
  • Manual testing with Solid server needed
  • Unit tests for new functions (to be added)

Limitations

  1. 401 Retry: Chrome's webRequest API doesn't support automatic request retry. The extension can detect 401s and create auth events, but pages/scripts need to handle the actual retry.

  2. Request Body Access: Some request body formats may not be fully accessible via webRequest API (e.g., streaming bodies).

Related

Closes #1

- 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements automatic NIP-98 HTTP authentication for the Podkey browser extension. The implementation adds request interception capabilities to automatically sign HTTP requests with Nostr authentication events for trusted origins when auto-sign is enabled.

Key changes:

  • Added webRequest listeners to intercept outgoing HTTP requests and inject NIP-98 Authorization headers
  • Implemented NIP-98 authentication event creation (kind 27235) with URL, method, and payload tags
  • Added caching mechanism to avoid re-signing identical requests within a 60-second TTL
  • Attempted 401 response detection and retry logic (though with significant limitations)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/background.js Outdated
async function interceptRequest (details) {
try {
// Only process XMLHttpRequest and fetch requests
if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) {
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type filter includes 'main_frame' and 'sub_frame', which refers to navigation requests (page loads). NIP-98 HTTP authentication is designed for API requests, not full page navigation. Including these types could cause issues:

  1. Page navigation requests typically don't need NIP-98 auth (they use cookies/sessions)
  2. Adding auth headers to page loads could interfere with normal navigation
  3. Request bodies for navigation requests may not be accessible

Consider removing 'main_frame' and 'sub_frame' from the type filter and only process 'xmlhttprequest' and 'fetch' types, which are actual API/AJAX requests.

Suggested change
if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) {
if (!['xmlhttprequest', 'fetch'].includes(details.type)) {

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
Comment on lines +540 to +545
chrome.webRequest.onHeadersReceived.addListener(
handle401Response,
{
urls: ['<all_urls>'],
types: ['xmlhttprequest', 'main_frame', 'sub_frame']
},
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type filters are inconsistent between the two listeners. The onBeforeSendHeaders listener filters for 'xmlhttprequest', 'main_frame', 'sub_frame' in both the shouldAddNip98Auth check and the listener registration. However, the onHeadersReceived listener uses the same types but without corresponding filtering logic.

Additionally, as mentioned in another comment, 'main_frame' and 'sub_frame' should likely not be included for NIP-98 authentication as these are navigation requests, not API calls. Consider using only 'xmlhttprequest' and potentially 'fetch' for AJAX/API requests.

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
expires: Date.now() + CACHE_TTL
});

console.log('[Podkey] Created and signed NIP-98 auth event for', details.url);
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function logs the full URL in console.log statements, which could expose sensitive information in query parameters (tokens, session IDs, personal data, etc.).

Consider sanitizing URLs before logging, or reducing the verbosity of these logs. For security-conscious extensions, avoid logging full URLs that might contain sensitive query parameters.

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
urls: ['<all_urls>'],
types: ['xmlhttprequest', 'main_frame', 'sub_frame']
},
['responseHeaders']
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The listener includes 'responseHeaders' in the extraInfoSpec array, but this is unnecessary for detecting 401 status codes. The statusCode is available in the details object without needing access to response headers.

Remove 'responseHeaders' from the extraInfoSpec array as it's not needed and may cause unnecessary overhead or permission issues.

Suggested change
['responseHeaders']
[]

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
const signedEvent = await signEvent(event, keypair.privateKey);
const authHeader = encodeNip98Header(signedEvent);

console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url);
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to another comment, this log statement exposes the full URL which may contain sensitive information in query parameters.

Consider sanitizing URLs in logs or reducing log verbosity for production builds.

Suggested change
console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url);
console.log('[Podkey] 401 detected, created NIP-98 auth for retry for origin:', origin);

Copilot uses AI. Check for mistakes.
Comment thread src/background.js
Comment on lines +25 to +26
const retryState = new Map();

Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retryState Map uses requestId as the key, but Chrome reuses requestIds across different navigation sessions. This could cause legitimate requests to be incorrectly identified as retries, preventing them from getting authentication headers.

Additionally, there's no cleanup mechanism for this Map (similar to the cache issue), which will cause memory leaks. Consider:

  1. Including additional identifiers (URL, timestamp) in the retry key
  2. Implementing proper cleanup for stale retry state entries
  3. Using a time-based approach instead of relying on requestId alone
Suggested change
const retryState = new Map();
const retryState = new Map();
const RETRY_STATE_TTL = 60000; // 60 seconds
// Periodically clear retry state to avoid memory leaks and stale entries
setInterval(() => {
retryState.clear();
}, RETRY_STATE_TTL);

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
urls: ['<all_urls>'],
types: ['xmlhttprequest', 'main_frame', 'sub_frame']
},
['requestHeaders', 'blocking']
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manifest.json is missing the "webRequestBlocking" permission which is required for synchronous blocking webRequest listeners in Manifest V3. The onBeforeSendHeaders listener uses the 'blocking' flag, but this won't work without the proper permission.

Add "webRequestBlocking" to the permissions array in manifest.json. Without this permission, the blocking listener will fail silently and authentication headers won't be added to requests.

Suggested change
['requestHeaders', 'blocking']
['requestHeaders']

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
Comment on lines +502 to +505
const event = await createNip98AuthEvent({
url: details.url,
method: details.method,
requestBody: details.requestBody
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function attempts to access requestBody from the details object in onHeadersReceived, but requestBody is only available in onBeforeRequest listener, not in onHeadersReceived. This means the payload hash will always be empty for retries, causing authentication to fail for POST/PUT requests.

To fix this, you would need to either:

  1. Cache the request body hash when the request is first seen in onBeforeSendHeaders
  2. Accept that 401 retry doesn't work with request bodies (document this limitation)
  3. Remove the 401 retry functionality entirely as noted in other comments
Suggested change
const event = await createNip98AuthEvent({
url: details.url,
method: details.method,
requestBody: details.requestBody
// Note: requestBody is not available in onHeadersReceived; retries for POST/PUT
// will not include a body hash. This is a known limitation of the webRequest API.
const event = await createNip98AuthEvent({
url: details.url,
method: details.method

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
Comment on lines +409 to +467
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;
}
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async function is registered as a blocking listener, but async functions in blocking webRequest listeners don't properly block the request in Manifest V3. The function returns a Promise, but Chrome's webRequest API in MV3 doesn't wait for async functions to resolve.

The listener needs to be synchronous, or you need to use chrome.declarativeNetRequest instead. As currently written, the request will proceed before the authentication header is added, defeating the purpose of the NIP-98 authentication.

This is a fundamental architectural issue with using async/await in blocking webRequest listeners in Manifest V3.

Suggested change
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;
}
function interceptRequest (details) {
// NOTE: In Manifest V3, webRequest blocking listeners must be synchronous.
// This wrapper returns immediately, and the async work runs in the background.
(async () => {
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}`
});
}
// In MV3, returning from this async IIFE does not affect the in-flight request.
// The listener itself (this function) has already returned.
// Any future architecture that requires true blocking should use declarativeNetRequest.
} catch (error) {
console.error('[Podkey] Error intercepting request:', error);
// Don't block the request if auth fails
return;
}
})();

Copilot uses AI. Check for mistakes.
Comment thread src/background.js Outdated
const signedEvent = await signEvent(event, keypair.privateKey);
const authHeader = encodeNip98Header(signedEvent);

console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url);
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable authHeader.

Suggested change
console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url);
console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url, 'header:', authHeader);

Copilot uses AI. Check for mistakes.
- 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
@melvincarvalho melvincarvalho merged commit abe31b2 into main Jan 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants