did:nostr and Solid authentication for the decentralized web
@@ -18,21 +25,31 @@
Welcome to Podkey
-
-
What is Podkey?
+
What is Podkey?
A browser extension for did:nostr and
- Solid authentication. Store your cryptographic
- identity and authenticate seamlessly to Solid pods using did:nostr
- identities.
+ Solid authentication. It holds your cryptographic
+ identity and signs in to Solid pods using your did:nostr identity —
+ your keys never leave this device.
@@ -40,22 +57,40 @@
Welcome to Podkey
-
Import Private Key
-
Enter your 64-character hex private key
+
+
+
+
+
Import private key
+
Enter your 64-character hex private key
+
-
+
-
⚠️ Never share your private key with anyone!
+
+
+ Never share your private key with anyone.
+
- Import
- Cancel
+ Cancel
+ Import
@@ -63,48 +98,73 @@
Import Private Key
-
🔑
+
+
+
Podkey
-
Your Identity
-
-
Public Key
-
Loading...
+
+ Your identity
+
+
+ Session
+
+
-
DID
-
Loading...
+
+
Public key (npub hex)
+
Loading…
+
- 📋 Copy Public Key
+
+
DID
+
Loading…
+
+
+
+ Copy public key
+
-
Settings
+
Settings
- Auto-sign for Solid
+
+
Auto-sign for Solid
+
Silently authenticate to trusted Solid servers.
+
-
Trusted Sites
+
Trusted sites
No trusted sites yet
diff --git a/popup/popup.js b/popup/popup.js
index e6bd89b..8bdf0db 100644
--- a/popup/popup.js
+++ b/popup/popup.js
@@ -2,6 +2,10 @@
* Podkey - Popup UI Logic
*/
+// Set DEBUG=true to log identity material (public key / DID) for local
+// debugging. Off by default so the popup never prints the user's pubkey.
+const DEBUG = false;
+
// UI State
let currentScreen = 'setup';
@@ -20,7 +24,7 @@ async function checkKeypairStatus() {
console.log('[Podkey Popup] Checking keypair status...');
try {
const response = await chrome.runtime.sendMessage({ type: 'GET_KEYPAIR_STATUS' });
- console.log('[Podkey Popup] Keypair status response:', response);
+ if (DEBUG) console.log('[Podkey Popup] Keypair status response:', response);
if (response.exists) {
console.log('[Podkey Popup] Keypair exists, showing main screen');
@@ -72,8 +76,9 @@ async function showMainScreen(status) {
// Load trusted sites
await loadTrustedSites();
- // Load auto-sign setting
- const { podkey_auto_sign: autoSign = true } = await chrome.storage.local.get(['podkey_auto_sign']);
+ // Load auto-sign setting (defaults OFF — must match storage.getAutoSign,
+ // which keeps silent trusted-origin Solid / NIP-98 signing strictly opt-in).
+ const { podkey_auto_sign: autoSign = false } = await chrome.storage.local.get(['podkey_auto_sign']);
document.getElementById('autoSignToggle').checked = autoSign;
}
@@ -109,14 +114,16 @@ function setupEventListeners() {
* Handle generate new keypair
*/
async function handleGenerate() {
+ const btn = document.getElementById('generateBtn');
+ const labelEl = btn.querySelector('.btn-label');
+ const original = labelEl ? labelEl.textContent : btn.textContent;
try {
- const btn = document.getElementById('generateBtn');
- btn.textContent = 'Generating...';
+ if (labelEl) labelEl.textContent = 'Generating…';
btn.disabled = true;
const response = await chrome.runtime.sendMessage({ type: 'GENERATE_KEYPAIR' });
- console.log('[Podkey] Keypair generated:', response.publicKey);
+ if (DEBUG) console.log('[Podkey] Keypair generated:', response.publicKey);
// Show main screen
await showMainScreen({
@@ -126,8 +133,8 @@ async function handleGenerate() {
});
} catch (error) {
alert('Error generating keypair: ' + error.message);
- document.getElementById('generateBtn').textContent = '✨ Generate New Key';
- document.getElementById('generateBtn').disabled = false;
+ if (labelEl) labelEl.textContent = original;
+ btn.disabled = false;
}
}
@@ -152,7 +159,7 @@ async function handleImport() {
privateKey
});
- console.log('[Podkey] Keypair imported:', response.publicKey);
+ if (DEBUG) console.log('[Podkey] Keypair imported:', response.publicKey);
// Clear input
document.getElementById('privateKeyInput').value = '';
@@ -180,11 +187,14 @@ async function handleCopy() {
await navigator.clipboard.writeText(publicKey);
const btn = document.getElementById('copyBtn');
- const originalText = btn.textContent;
- btn.textContent = '✅ Copied!';
+ const labelEl = btn.querySelector('.btn-copy-label');
+ const originalText = labelEl.textContent;
+ labelEl.textContent = 'Copied';
+ btn.classList.add('copied');
setTimeout(() => {
- btn.textContent = originalText;
+ labelEl.textContent = originalText;
+ btn.classList.remove('copied');
}, 2000);
} catch (error) {
alert('Failed to copy: ' + error.message);
@@ -219,10 +229,15 @@ async function handleExport() {
if (!confirmed) return;
try {
- const { podkey_private_key: privateKey } = await chrome.storage.local.get(['podkey_private_key']);
+ // Private key is now stored in session storage (in-memory only).
+ // Try session storage first, fall back to local for legacy installs.
+ let { podkey_private_key: privateKey } = await chrome.storage.session.get(['podkey_private_key']);
+ if (!privateKey) {
+ ({ podkey_private_key: privateKey } = await chrome.storage.local.get(['podkey_private_key']));
+ }
if (!privateKey) {
- alert('No private key found');
+ alert('No private key found. The key may have been cleared when the browser restarted. Please re-import your key.');
return;
}
@@ -242,12 +257,18 @@ async function loadTrustedSites() {
const listEl = document.getElementById('trustedList');
const origins = Object.keys(trusted);
+ // Rebuild the list with the DOM API only — no innerHTML on this surface,
+ // since origin strings originate from web pages.
+ listEl.replaceChildren();
+
if (origins.length === 0) {
- listEl.innerHTML = '
No trusted sites yet
';
+ const empty = document.createElement('div');
+ empty.className = 'empty-state';
+ empty.textContent = 'No trusted sites yet';
+ listEl.appendChild(empty);
return;
}
- listEl.innerHTML = '';
origins.sort().forEach(origin => {
const div = document.createElement('div');
div.className = 'trusted-item';
@@ -287,9 +308,9 @@ async function removeTrustedSite(origin) {
console.log('[Podkey] Removed trusted site:', origin);
}
-// Listen for storage changes
+// Listen for storage changes (local for trusted sites / pubkey, session for private key)
chrome.storage.onChanged.addListener((changes, areaName) => {
- if (areaName === 'local' && currentScreen === 'main') {
+ if ((areaName === 'local' || areaName === 'session') && currentScreen === 'main') {
loadTrustedSites();
}
});
diff --git a/src/background.js b/src/background.js
index 84481e2..4cb7216 100644
--- a/src/background.js
+++ b/src/background.js
@@ -4,6 +4,12 @@
*/
import { generateKeypair, signEvent, getPublicKey } from './crypto.js';
+import {
+ getConversationKey,
+ encrypt as nip44Encrypt,
+ decrypt as nip44Decrypt,
+ bytesToHex as nip44BytesToHex
+} from './nip44.js';
import {
storeKeypair,
getKeypair,
@@ -15,14 +21,11 @@ import {
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
+// Set DEBUG=true to emit verbose diagnostics. Off by default so the extension
+// never logs public keys, DIDs, NIP-98 events, or Authorization headers.
+const DEBUG = false;
-// Track retry state to prevent infinite loops: key = requestId, value = true
-const retryState = new Map();
+if (DEBUG) console.log('[Podkey] Background service worker started');
// Initialize extension
chrome.runtime.onInstalled.addListener(async () => {
@@ -36,11 +39,26 @@ chrome.runtime.onInstalled.addListener(async () => {
}
});
+// Pending signing approval promises: requestId -> resolve function
+const pendingApprovals = new Map();
+
// Handle messages from content scripts and popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ // Handle signing approval responses from the approve popup
+ if (message.type === 'APPROVE_SIGNING') {
+ const resolve = pendingApprovals.get(message.requestId);
+ if (resolve) {
+ pendingApprovals.delete(message.requestId);
+ resolve(message.approved === true);
+ }
+ return; // synchronous, no sendResponse needed
+ }
+
handleMessage(message, sender)
.then(result => {
- console.log('[Podkey] Message handled successfully:', message.type, result);
+ // Never log `result`: it may be a public key, signed event, or
+ // Authorization header (token). Log only the message type under DEBUG.
+ if (DEBUG) console.log('[Podkey] Message handled:', message.type);
sendResponse(result);
})
.catch(error => {
@@ -57,7 +75,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
async function handleMessage (message, sender) {
const { type, origin } = message;
- console.log('[Podkey] Message received:', type, 'from', origin || 'popup');
+ if (DEBUG) console.log('[Podkey] Message received:', type, 'from', origin || 'popup');
switch (type) {
case 'GET_PUBLIC_KEY':
@@ -75,19 +93,21 @@ async function handleMessage (message, sender) {
case 'GET_KEYPAIR_STATUS':
return await handleGetKeypairStatus();
- case 'GET_RELAYS':
- // TODO: Implement relay management
- return {};
+ case 'NIP44_ENCRYPT':
+ return await handleNip44Encrypt(message.pubkey, message.plaintext, origin);
+
+ case 'NIP44_DECRYPT':
+ return await handleNip44Decrypt(message.pubkey, message.ciphertext, origin);
- case 'NIP04_ENCRYPT':
- case 'NIP04_DECRYPT':
- throw new Error('NIP-04 encryption not yet implemented');
+ case 'NIP44_GET_CONVERSATION_KEY':
+ return await handleNip44GetConversationKey(message.pubkey, origin);
case 'CREATE_NIP98_AUTH_HEADER':
return await createNip98AuthHeader(
message.url,
message.method,
- message.body
+ message.body,
+ message.bodyHash
);
default:
@@ -98,7 +118,7 @@ async function handleMessage (message, sender) {
/**
* Get public key with user permission
*/
-async function handleGetPublicKey (origin, sender) {
+async function handleGetPublicKey (origin, _sender) {
// Check if keypair exists
const keyExists = await hasKeypair();
if (!keyExists) {
@@ -110,7 +130,7 @@ async function handleGetPublicKey (origin, sender) {
if (!trusted) {
// Show permission prompt
- const allowed = await showPermissionPrompt(origin, 'share your public key');
+ const allowed = await showPermissionPrompt(origin, 'read your public key');
if (!allowed) {
throw new Error('User denied permission');
@@ -131,7 +151,7 @@ async function handleGetPublicKey (origin, sender) {
/**
* Sign event with user permission
*/
-async function handleSignEvent (event, origin, sender) {
+async function handleSignEvent (event, origin, _sender) {
// Check if keypair exists
const keyExists = await hasKeypair();
if (!keyExists) {
@@ -148,12 +168,14 @@ async function handleSignEvent (event, origin, sender) {
let shouldSign = trusted && autoSign && isSolidAuth;
if (!shouldSign) {
- // Show signing prompt
- const eventPreview = formatEventForPrompt(event);
- const allowed = await showPermissionPrompt(
- origin,
- `sign this ${isSolidAuth ? 'Solid authentication' : 'event'}:\n\n${eventPreview}`
- );
+ // Show signing prompt with event preview
+ const actionLabel = isSolidAuth ? 'sign a Solid authentication event' : `sign an event (kind ${event.kind})`;
+ const previewData = JSON.stringify({
+ kind: event.kind,
+ content: (event.content || '').substring(0, 200),
+ tags: (event.tags || []).length
+ });
+ const allowed = await showPermissionPrompt(origin, actionLabel, previewData);
if (!allowed) {
throw new Error('User denied signing');
@@ -168,7 +190,7 @@ async function handleSignEvent (event, origin, sender) {
// Sign the event
const signedEvent = await signEvent(event, keypair.privateKey);
- console.log('[Podkey] Event signed:', signedEvent.id.substring(0, 16) + '...');
+ if (DEBUG) console.log('[Podkey] Event signed:', signedEvent.id.substring(0, 16) + '...');
// Ensure event structure is correct (tags should be array, content should be string)
// Preserve original event structure but ensure required fields are correct types
@@ -181,28 +203,103 @@ async function handleSignEvent (event, origin, sender) {
};
}
+/**
+ * Resolve the user's keypair for an encryption request, gating on origin
+ * trust exactly the way GET_PUBLIC_KEY / SIGN_EVENT do. The raw private key
+ * never leaves the background service worker — only ciphertext/plaintext or a
+ * conversation key is returned to the page.
+ *
+ * @param {string} origin - requesting page origin
+ * @param {string} action - human-readable action for the permission prompt
+ * @returns {Promise<{privateKey: string, publicKey: string}>}
+ */
+async function resolveKeypairForEncryption (origin, action) {
+ const keyExists = await hasKeypair();
+ if (!keyExists) {
+ throw new Error('No keypair found. Please generate or import a key first.');
+ }
+
+ const trusted = await isTrustedOrigin(origin);
+ if (!trusted) {
+ const allowed = await showPermissionPrompt(origin, action);
+ if (!allowed) {
+ throw new Error('User denied permission');
+ }
+ await addTrustedOrigin(origin);
+ }
+
+ const keypair = await getKeypair();
+ if (!keypair || typeof keypair.privateKey !== 'string') {
+ throw new Error('Invalid keypair format');
+ }
+ return keypair;
+}
+
+/**
+ * NIP-44 (v2) encrypt: encrypt plaintext for a peer pubkey.
+ * @param {string} peerPubkey - 64-char hex peer public key
+ * @param {string} plaintext - message to encrypt
+ * @param {string} origin - requesting page origin
+ * @returns {Promise} base64 NIP-44 payload
+ */
+async function handleNip44Encrypt (peerPubkey, plaintext, origin) {
+ if (typeof peerPubkey !== 'string' || typeof plaintext !== 'string') {
+ throw new Error('nip44.encrypt requires (pubkey, plaintext) strings');
+ }
+
+ const keypair = await resolveKeypairForEncryption(origin, 'encrypt a message (NIP-44)');
+ const conversationKey = getConversationKey(keypair.privateKey, peerPubkey);
+ return nip44Encrypt(plaintext, conversationKey);
+}
+
+/**
+ * NIP-44 (v2) decrypt: decrypt a base64 payload from a peer pubkey.
+ * @param {string} peerPubkey - 64-char hex peer public key
+ * @param {string} ciphertext - base64 NIP-44 payload
+ * @param {string} origin - requesting page origin
+ * @returns {Promise} decrypted plaintext
+ */
+async function handleNip44Decrypt (peerPubkey, ciphertext, origin) {
+ if (typeof peerPubkey !== 'string' || typeof ciphertext !== 'string') {
+ throw new Error('nip44.decrypt requires (pubkey, ciphertext) strings');
+ }
+
+ const keypair = await resolveKeypairForEncryption(origin, 'decrypt a message (NIP-44)');
+ const conversationKey = getConversationKey(keypair.privateKey, peerPubkey);
+ return nip44Decrypt(ciphertext, conversationKey);
+}
+
+/**
+ * NIP-44 (v2) conversation key derivation (hex). Some apps call this sub-API to
+ * cache the key client-side; we still derive it in the background so the raw
+ * private key stays here.
+ * @param {string} peerPubkey - 64-char hex peer public key
+ * @param {string} origin - requesting page origin
+ * @returns {Promise} 64-char hex conversation key
+ */
+async function handleNip44GetConversationKey (peerPubkey, origin) {
+ if (typeof peerPubkey !== 'string') {
+ throw new Error('nip44.getConversationKey requires a pubkey string');
+ }
+
+ const keypair = await resolveKeypairForEncryption(origin, 'derive a NIP-44 conversation key');
+ const conversationKey = getConversationKey(keypair.privateKey, peerPubkey);
+ return nip44BytesToHex(conversationKey);
+}
+
/**
* Generate new keypair
*/
async function handleGenerateKeypair () {
try {
- console.log('[Podkey] Starting keypair generation...');
const keypair = await generateKeypair();
- console.log('[Podkey] Keypair generated:', {
- privateKeyLength: keypair.privateKey.length,
- publicKeyLength: keypair.publicKey.length
- });
-
await storeKeypair(keypair.privateKey, keypair.publicKey);
- console.log('[Podkey] Keypair stored');
+ if (DEBUG) console.log('[Podkey] Keypair generated and stored');
- const result = {
+ return {
publicKey: keypair.publicKey,
did: `did:nostr:${keypair.publicKey}`
};
-
- console.log('[Podkey] Returning result:', result);
- return result;
} catch (error) {
console.error('[Podkey] Error generating keypair:', error);
throw error;
@@ -228,7 +325,7 @@ async function handleImportKeypair (privateKey) {
// Store keypair
await storeKeypair(privateKey, publicKey);
- console.log('[Podkey] Keypair imported');
+ if (DEBUG) console.log('[Podkey] Keypair imported');
return {
publicKey,
@@ -256,55 +353,53 @@ async function handleGetKeypairStatus () {
}
/**
- * Show permission prompt to user
- * Note: Service workers can't use confirm(), so we auto-approve for now
- * TODO: Implement proper UI using chrome.notifications or action badge
+ * Show permission prompt to user via a popup window.
+ * Opens popup/approve.html and waits for the user to approve or deny.
+ * Auto-denies after 60 seconds if no response.
+ * @param {string} origin - The requesting origin
+ * @param {string} action - Human-readable description of the action
+ * @param {string} [eventPreview] - Optional preview of the event data
+ * @returns {Promise} True if user approved
*/
-async function showPermissionPrompt (origin, action) {
- // For now, auto-approve requests (service workers can't use confirm())
- // In production, this should show a notification or update the badge
- console.log(`[Podkey] Auto-approving: ${origin} wants to ${action}`);
-
- // TODO: Show notification using chrome.notifications API
- // For now, return true to auto-approve
- return true;
-
- // Future implementation:
- // return new Promise((resolve) => {
- // chrome.notifications.create({
- // type: 'basic',
- // iconUrl: 'icons/128x128.png',
- // title: 'Podkey Permission Request',
- // message: `${origin} wants to ${action}`
- // }, (notificationId) => {
- // // Handle user response via notification buttons
- // });
- // });
-}
+async function showPermissionPrompt (origin, action, eventPreview) {
+ const requestId = crypto.randomUUID();
+
+ return new Promise((resolve) => {
+ pendingApprovals.set(requestId, resolve);
+
+ // Auto-deny after 60 seconds if no response
+ const timeout = setTimeout(() => {
+ if (pendingApprovals.has(requestId)) {
+ pendingApprovals.delete(requestId);
+ console.log(`[Podkey] Signing request ${requestId} timed out, auto-denying`);
+ resolve(false);
+ }
+ }, 60000);
-/**
- * Format event for display in prompt
- */
-function formatEventForPrompt (event) {
- const lines = [];
- lines.push(`Kind: ${event.kind}`);
-
- if (event.tags && event.tags.length > 0) {
- lines.push(`Tags: ${event.tags.length}`);
- event.tags.slice(0, 3).forEach(tag => {
- lines.push(` [${tag.join(', ')}]`);
+ // Clean up timeout when resolved normally
+ const originalResolve = resolve;
+ pendingApprovals.set(requestId, (approved) => {
+ clearTimeout(timeout);
+ originalResolve(approved);
});
- }
- if (event.content) {
- const preview = event.content.substring(0, 100);
- lines.push(`Content: ${preview}${event.content.length > 100 ? '...' : ''}`);
- }
+ const params = new URLSearchParams({
+ id: requestId,
+ origin: origin || 'Unknown',
+ action: action || 'sign',
+ preview: eventPreview || ''
+ });
- return lines.join('\n');
+ chrome.windows.create({
+ url: `popup/approve.html?${params.toString()}`,
+ type: 'popup',
+ width: 420,
+ height: 380,
+ focused: true
+ });
+ });
}
-
/**
* Encode signed event to Authorization header value
* @param {object} signedEvent - Signed Nostr event
@@ -330,19 +425,26 @@ function encodeNip98Header (signedEvent) {
* @returns {boolean}
*/
function isLikelySolidServer (origin) {
- // Common Solid server indicators
- const solidIndicators = [
+ let hostname;
+ try {
+ hostname = new URL(origin).hostname;
+ } catch {
+ return false;
+ }
+
+ const trustedHosts = [
'solid.social',
'solidcommunity.net',
'inrupt.net',
- 'solidweb.org',
- '/.well-known/solid'
+ 'solidweb.org'
];
- return solidIndicators.some(indicator => origin.includes(indicator));
+ return trustedHosts.some(trusted =>
+ hostname === trusted || hostname.endsWith('.' + trusted)
+ );
}
-async function createNip98AuthHeader (url, method, body = null) {
+async function createNip98AuthHeader (url, method, body = null, bodyHash = null) {
try {
// Check if we should add auth
const origin = new URL(url).origin;
@@ -351,88 +453,69 @@ async function createNip98AuthHeader (url, method, body = null) {
const keyExists = await hasKeypair();
const isSolid = isLikelySolidServer(origin);
- console.log('[Podkey] NIP-98 auth check:', {
- url,
- origin,
- keyExists,
- trusted,
- autoSign,
- isSolid
- });
+ if (DEBUG) 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');
+ if (DEBUG) 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);
+ if (DEBUG) console.log('[Podkey] Auto-trusting Solid server:', origin);
await addTrustedOrigin(origin);
} else if (!trusted) {
- console.log('[Podkey] Origin not trusted, skipping NIP-98 auth');
+ if (DEBUG) console.log('[Podkey] Origin not trusted, skipping NIP-98 auth');
return null;
}
if (!autoSign) {
- console.log('[Podkey] Auto-sign disabled, skipping NIP-98 auth');
+ if (DEBUG) console.log('[Podkey] Auto-sign disabled, skipping NIP-98 auth');
return null;
}
- // Hash body if present
- let bodyHash = '';
- if (body) {
+ // Prefer a body hash computed in the page context (the only place where
+ // FormData / URLSearchParams / streamed bodies survive intact). Fall back
+ // to hashing here for body types that cross the message channel losslessly.
+ let resolvedBodyHash = typeof bodyHash === 'string' ? bodyHash : '';
+ if (!resolvedBodyHash && body) {
if (typeof body === 'string') {
- bodyHash = bytesToHex(sha256(new TextEncoder().encode(body)));
+ resolvedBodyHash = bytesToHex(sha256(new TextEncoder().encode(body)));
} else if (body instanceof ArrayBuffer) {
- bodyHash = bytesToHex(sha256(new Uint8Array(body)));
+ resolvedBodyHash = bytesToHex(sha256(new Uint8Array(body)));
} else if (body instanceof Blob) {
- const arrayBuffer = await body.arrayBuffer();
- bodyHash = bytesToHex(sha256(new Uint8Array(arrayBuffer)));
+ resolvedBodyHash = bytesToHex(sha256(new Uint8Array(await body.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]);
- }
+ // Always create a fresh signed event (no caching -- reusing signed events
+ // causes replay issues and servers with replay protection reject them).
+ // created_at has 1-second resolution, so a random nonce tag guarantees a
+ // distinct event id for repeated identical requests within the same second.
+ const event = {
+ kind: 27235,
+ content: '',
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['u', url],
+ ['method', method],
+ ['nonce', bytesToHex(crypto.getRandomValues(new Uint8Array(16)))]
+ ]
+ };
- const keypair = await getKeypair();
- signedEvent = await signEvent(event, keypair.privateKey);
+ if (resolvedBodyHash) {
+ event.tags.push(['payload', resolvedBodyHash]);
+ }
- // Cache
- nip98Cache.set(cacheKey, {
- event: signedEvent,
- expires: Date.now() + CACHE_TTL
- });
+ const keypair = await getKeypair();
+ const signedEvent = await signEvent(event, keypair.privateKey);
- console.log('[Podkey] Created and signed NIP-98 auth event for', url);
- console.log('[Podkey] NIP-98 event:', JSON.stringify(signedEvent, null, 2));
+ if (DEBUG) {
+ console.log('[Podkey] NIP-98 event:', JSON.stringify(signedEvent));
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;
+ return `Nostr ${encodeNip98Header(signedEvent)}`;
} catch (error) {
console.error('[Podkey] Error creating NIP-98 auth header:', error);
return null;
@@ -444,4 +527,4 @@ async function createNip98AuthHeader (url, method, body = null) {
// 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)');
+if (DEBUG) console.log('[Podkey] NIP-98 auto-auth: Using JavaScript-level interception (see injected.js)');
diff --git a/src/crypto.js b/src/crypto.js
index 181f4ce..297fe24 100644
--- a/src/crypto.js
+++ b/src/crypto.js
@@ -80,6 +80,13 @@ export async function signEvent (event, privateKeyHex) {
throw new Error('Invalid signature length');
}
+ // Self-verify before returning: a signature that does not verify against
+ // the event id and pubkey indicates a faulty signing path (bad RNG, library
+ // regression) and must never be emitted from a key-holder.
+ if (!schnorr.verify(signatureBytes, eventIdBytes, hexToBytes(pubkey))) {
+ throw new Error('Signature self-verification failed');
+ }
+
// Ensure all fields are strings (Nostr spec requires this)
return {
...event,
diff --git a/src/injected.js b/src/injected.js
index b77f08b..3eba392 100644
--- a/src/injected.js
+++ b/src/injected.js
@@ -1,266 +1,13 @@
/**
* Podkey - Content Script
- * Bridges between injected window.nostr and background script
+ * Bridges between injected window.nostr and background script.
+ *
+ * Fetch/XHR interception for NIP-98 auth headers is handled exclusively
+ * by src/nip98-interceptor.js (injected into the page context below).
+ * This content script only relays messages between the page and the
+ * extension background via CustomEvents.
*/
-// 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;
- const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
-
- // Duplicate of src/auth-header-utils.js — keep in sync. The canonical
- // module is imported by unit tests; this copy runs in content-script
- // context where classic scripts can't use ESM imports. Build-time
- // bundling to drop the duplication is tracked in #7.
- function hasAuthorizationHeader (headers) {
- if (!headers) return false;
- if (typeof Headers !== 'undefined' && headers instanceof Headers) {
- return headers.has('authorization');
- }
- if (Array.isArray(headers)) {
- return headers.some((entry) =>
- Array.isArray(entry) && typeof entry[0] === 'string' &&
- entry[0].toLowerCase() === 'authorization'
- );
- }
- if (typeof headers === 'object') {
- for (const key of Object.keys(headers)) {
- if (key.toLowerCase() === 'authorization') return true;
- }
- }
- return false;
- }
-
- // Per fetch spec, init.headers overrides Request.headers entirely — so
- // init.headers is authoritative when present. Only consult input.headers
- // when init omits the headers key.
- function fetchCallHasAuthorization (input, init) {
- if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) {
- return hasAuthorizationHeader(init.headers);
- }
- if (typeof Request !== 'undefined' && input instanceof Request) {
- return hasAuthorizationHeader(input.headers);
- }
- return false;
- }
-
- function setAuthorizationOnOptions (options, value) {
- options.headers = options.headers || {};
- if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
- options.headers.set('Authorization', value);
- } else if (Array.isArray(options.headers)) {
- const normalized = new Headers(options.headers);
- normalized.set('Authorization', value);
- options.headers = normalized;
- } else {
- for (const key of Object.keys(options.headers)) {
- if (key.toLowerCase() === 'authorization') delete options.headers[key];
- }
- options.headers['Authorization'] = value;
- }
- return options.headers;
- }
-
- function normalizeFetchCall (input, init) {
- if (typeof Request !== 'undefined' && input instanceof Request) {
- return {
- url: input.url,
- method: init?.method || input.method || 'GET',
- body: init?.body
- };
- }
- return {
- url: typeof input === 'string' ? input : String(input),
- method: init?.method || 'GET',
- body: init?.body
- };
- }
-
- // 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 = {}) {
- // Default-param `= {}` only applies for `undefined`; `fetch(url, null)`
- // passes null through. Coerce once so the rest of the wrapper can
- // assume an object.
- options = options || {};
- // Normalize once — handles fetch(url, init) and fetch(new Request(...))
- // so downstream signing sees the real URL/method, not "[object Request]".
- const { url: urlString, method, body } = normalizeFetchCall(url, options);
- console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
-
- // Respect an Authorization header the page already set — on either
- // options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting
- // would re-identify the request as Podkey's NIP-98 and break the page's
- // own auth. If it fails with 401, the retry path below still injects
- // NIP-98. See issue #5.
- const pageSetAuth = fetchCallHasAuthorization(url, options);
-
- // If we have the auth function, use it
- if (getAuthHeaderFn && !pageSetAuth) {
- console.log('[Podkey] ✅ Auth function ready, adding header...');
- const promise = (async () => {
- try {
- const authHeader = await getAuthHeaderFn(urlString, method, body);
- if (authHeader) {
- setAuthorizationOnOptions(options, authHeader);
- console.log('[Podkey] ✅ Added NIP-98 auth header');
- } 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(urlString, method, body);
- if (authHeader) {
- const retryOptions = { ...options };
- setAuthorizationOnOptions(retryOptions, 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;
- });
- }
-
- // Fall-through branch: we're here because either the page set its own
- // Authorization (and we deliberately skipped initial injection) or the
- // auth function isn't wired up yet. Send the request as-is; on 401,
- // retry with NIP-98 — either immediately if ready, or after waiting
- // for setup (with a hard deadline so we don't hang forever).
- if (pageSetAuth) {
- console.log('[Podkey] ⏭️ Page already set Authorization — skipping initial injection');
- } else {
- console.log('[Podkey] ⚠️ Auth function not ready yet, making request...');
- }
-
- const requestPromise = originalFetch.call(this, url, options);
-
- const AUTH_READY_DEADLINE_MS = 5000;
-
- // 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 for auth function to be ready, bounded by deadline. Every
- // async step below has an error handler so the outer promise is
- // guaranteed to settle (otherwise the caller would hang).
- return new Promise((resolve) => {
- const deadline = Date.now() + AUTH_READY_DEADLINE_MS;
- const checkAuth = () => {
- if (getAuthHeaderFn) {
- console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...');
- getAuthHeaderFn(urlString, method, body)
- .then(authHeader => {
- if (authHeader) {
- const retryOptions = { ...options };
- setAuthorizationOnOptions(retryOptions, authHeader);
- originalFetch.call(this, url, retryOptions)
- .then(resolve)
- .catch(err => {
- console.error('[Podkey] Retry fetch failed:', err);
- resolve(response);
- });
- } else {
- resolve(response);
- }
- })
- .catch(err => {
- console.error('[Podkey] Error getting auth header for retry:', err);
- resolve(response);
- });
- } else if (Date.now() >= deadline) {
- console.log('[Podkey] ⚠️ Auth function still not ready after deadline, giving up');
- 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;
- this._podkeyHasPageAuth = false;
- return originalXHROpen.apply(this, [method, url, ...args]);
- };
-
- // Track a page-set Authorization on XHR so send() can respect it.
- XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
- if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
- this._podkeyHasPageAuth = true;
- }
- return originalXHRSetRequestHeader.apply(this, arguments);
- };
-
- XMLHttpRequest.prototype.send = function (body) {
- // setRequestHeader must run before send(); defer originalXHRSend until
- // the header is applied (or skipped), else the async setRequestHeader
- // would fire after the request is already in-flight and throw
- // InvalidStateError.
- const runSend = () => originalXHRSend.apply(this, [body]);
- if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) {
- getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body)
- .then(authHeader => {
- if (authHeader) {
- originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
- }
- })
- .catch(e => {
- console.error('[Podkey] Error in XHR interceptor:', e);
- })
- .finally(runSend);
- } else {
- runSend();
- }
- };
-
- // 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');
@@ -272,16 +19,19 @@ interceptorScript.onerror = function () {
};
(document.head || document.documentElement).appendChild(interceptorScript);
-// Listen for NIP-98 auth requests from page context
+// Listen for NIP-98 auth requests from page context. The request body never
+// crosses this boundary -- the page context computes its SHA-256 (the only
+// place FormData / URLSearchParams / streamed bodies survive intact) and sends
+// only the hex digest for the NIP-98 `payload` tag.
window.addEventListener('podkey-nip98-request', async (event) => {
- const { id, url, method, body } = event.detail;
+ const { id, url, method, bodyHash } = event.detail;
try {
const response = await chrome.runtime.sendMessage({
type: 'CREATE_NIP98_AUTH_HEADER',
url,
method,
- body
+ bodyHash
});
if (chrome.runtime.lastError) {
@@ -312,19 +62,48 @@ script.src = chrome.runtime.getURL('src/nostr-provider.js');
script.onload = function () {
this.remove();
};
+script.onerror = function () {
+ console.error('[Podkey] Failed to load nostr-provider.js');
+};
(document.head || document.documentElement).appendChild(script);
-// Listen for requests from the injected script
+// Allowed message types that can be forwarded to the background script
+const ALLOWED_TYPES = new Set([
+ 'GET_PUBLIC_KEY',
+ 'SIGN_EVENT',
+ 'NIP44_ENCRYPT',
+ 'NIP44_DECRYPT'
+]);
+
+// Listen for requests from the injected script (NIP-07 relay)
window.addEventListener('podkey-request', async (event) => {
const { id, type, ...data } = event.detail;
console.log('[Podkey] Received request:', type, 'from page');
+ if (!ALLOWED_TYPES.has(type)) {
+ console.warn('[Podkey] Rejected unknown request type:', type);
+ window.dispatchEvent(new CustomEvent('podkey-response', {
+ detail: { id, error: 'Unknown request type' }
+ }));
+ return;
+ }
+
+ // Only forward known safe fields per message type
+ const safeData = {};
+ if (type === 'SIGN_EVENT' && data.event) {
+ safeData.event = data.event;
+ } else if (type === 'NIP44_ENCRYPT' || type === 'NIP44_DECRYPT') {
+ if (data.pubkey) safeData.pubkey = String(data.pubkey);
+ if (data.plaintext !== undefined) safeData.plaintext = String(data.plaintext || '');
+ if (data.ciphertext !== undefined) safeData.ciphertext = String(data.ciphertext);
+ }
+
try {
- // Forward to background script
+ // Forward to background script with only validated fields
const response = await chrome.runtime.sendMessage({
type,
- ...data,
+ ...safeData,
origin: window.location.origin
});
@@ -370,53 +149,4 @@ window.addEventListener('podkey-request', async (event) => {
}
});
-// 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 () {
- console.error('[Podkey] Failed to load nostr-provider.js');
-};
+console.log('[Podkey] Content script loaded');
diff --git a/src/nip44.js b/src/nip44.js
new file mode 100644
index 0000000..7bb3c15
--- /dev/null
+++ b/src/nip44.js
@@ -0,0 +1,273 @@
+/**
+ * Podkey - NIP-44 (v2) encryption / decryption
+ *
+ * Implements the current NIP-44 versioned encryption scheme (version 0x02):
+ * conversation key = hkdf_extract(IKM = ECDH(priv, pub).x, salt = "nip44-v2")
+ * per-message keys = hkdf_expand(conversation_key, info = nonce, L = 76)
+ * -> chacha_key (32) ‖ chacha_nonce (12) ‖ hmac_key (32)
+ * ciphertext = chacha20(chacha_key, chacha_nonce, pad(plaintext))
+ * mac = hmac_sha256(hmac_key, aad = nonce ‖ ciphertext)
+ * payload = base64( version(0x02) ‖ nonce(32) ‖ ciphertext ‖ mac(32) )
+ *
+ * Uses the same vetted @noble primitives the rest of the extension depends on:
+ * - @noble/secp256k1 : ECDH shared secret
+ * - @noble/hashes : hkdf, hmac, sha256
+ * - @noble/ciphers : chacha20 (raw, unauthenticated, IETF nonce)
+ *
+ * Spec: https://github.com/nostr-protocol/nips/blob/master/44.md
+ */
+
+import { getSharedSecret } from '@noble/secp256k1';
+import { hkdf, extract as hkdfExtract, expand as hkdfExpand } from '@noble/hashes/hkdf';
+import { hmac } from '@noble/hashes/hmac';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes, concatBytes, utf8ToBytes } from '@noble/hashes/utils';
+import { chacha20 } from '@noble/ciphers/chacha.js';
+
+const VERSION = 2;
+const SALT = utf8ToBytes('nip44-v2');
+const MIN_PLAINTEXT_SIZE = 1; // 1 byte (NIP-44 forbids empty plaintext)
+const MAX_PLAINTEXT_SIZE = 65535; // 64KB - 1
+
+/**
+ * Compute the NIP-44 conversation key for a (private, public) pair.
+ * conversation_key = hkdf_extract(IKM = sharedX, salt = "nip44-v2")
+ *
+ * @param {string} privateKeyHex - 64-char hex private key (the user's)
+ * @param {string} peerPublicKeyHex - 64-char hex x-only public key (the peer's)
+ * @returns {Uint8Array} 32-byte conversation key
+ */
+export function getConversationKey (privateKeyHex, peerPublicKeyHex) {
+ validateHexKey(privateKeyHex, 'private');
+ validateHexKey(peerPublicKeyHex, 'public');
+
+ // ECDH: prefix the x-only Nostr pubkey with 0x02 to get a compressed point.
+ // getSharedSecret returns a 33-byte compressed point; bytes [1..33] are the
+ // shared X coordinate used as the HKDF input keying material.
+ const shared = getSharedSecret(hexToBytes(privateKeyHex), hexToBytes('02' + peerPublicKeyHex));
+ const sharedX = shared.subarray(1, 33);
+
+ return hkdfExtract(sha256, sharedX, SALT);
+}
+
+/**
+ * Derive the per-message keys from the conversation key and nonce.
+ * @param {Uint8Array} conversationKey - 32-byte conversation key
+ * @param {Uint8Array} nonce - 32-byte random nonce
+ * @returns {{ chachaKey: Uint8Array, chachaNonce: Uint8Array, hmacKey: Uint8Array }}
+ */
+function getMessageKeys (conversationKey, nonce) {
+ if (conversationKey.length !== 32) {
+ throw new Error('Invalid conversation key length');
+ }
+ if (nonce.length !== 32) {
+ throw new Error('Invalid nonce length');
+ }
+
+ const keys = hkdfExpand(sha256, conversationKey, nonce, 76);
+ return {
+ chachaKey: keys.subarray(0, 32),
+ chachaNonce: keys.subarray(32, 44),
+ hmacKey: keys.subarray(44, 76)
+ };
+}
+
+/**
+ * Calculate the padded length for a given plaintext length (power-of-two
+ * bucketing, minimum 32 bytes) per the NIP-44 padding scheme.
+ * @param {number} len - unpadded plaintext length in bytes
+ * @returns {number} padded length
+ */
+function calcPaddedLen (len) {
+ if (!Number.isInteger(len) || len < 1) {
+ throw new Error('Expected positive integer length');
+ }
+ if (len <= 32) return 32;
+ const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1);
+ const chunk = nextPower <= 256 ? 32 : nextPower / 8;
+ return chunk * (Math.floor((len - 1) / chunk) + 1);
+}
+
+/**
+ * Pad plaintext: u16 big-endian length prefix ‖ utf8(plaintext) ‖ zero padding.
+ * @param {string} plaintext
+ * @returns {Uint8Array}
+ */
+function pad (plaintext) {
+ const unpadded = utf8ToBytes(plaintext);
+ const unpaddedLen = unpadded.length;
+ if (unpaddedLen < MIN_PLAINTEXT_SIZE || unpaddedLen > MAX_PLAINTEXT_SIZE) {
+ throw new Error('Invalid plaintext length');
+ }
+
+ const prefix = new Uint8Array(2);
+ new DataView(prefix.buffer).setUint16(0, unpaddedLen, false); // big-endian
+
+ const padded = new Uint8Array(calcPaddedLen(unpaddedLen));
+ padded.set(unpadded);
+
+ return concatBytes(prefix, padded);
+}
+
+/**
+ * Unpad a decrypted buffer back to the original plaintext string.
+ * @param {Uint8Array} padded - prefix ‖ plaintext ‖ zero padding
+ * @returns {string}
+ */
+function unpad (padded) {
+ const unpaddedLen = new DataView(padded.buffer, padded.byteOffset, 2).getUint16(0, false);
+ const unpadded = padded.subarray(2, 2 + unpaddedLen);
+
+ if (
+ unpaddedLen < MIN_PLAINTEXT_SIZE ||
+ unpaddedLen > MAX_PLAINTEXT_SIZE ||
+ unpadded.length !== unpaddedLen ||
+ padded.length !== 2 + calcPaddedLen(unpaddedLen)
+ ) {
+ throw new Error('Invalid padding');
+ }
+
+ return new TextDecoder().decode(unpadded);
+}
+
+/**
+ * HMAC-SHA256 with the nonce as associated data, per NIP-44:
+ * mac = hmac_sha256(key, aad ‖ message) where aad = nonce
+ * @param {Uint8Array} key - 32-byte hmac key
+ * @param {Uint8Array} ciphertext
+ * @param {Uint8Array} aad - 32-byte nonce
+ * @returns {Uint8Array} 32-byte mac
+ */
+function hmacAad (key, ciphertext, aad) {
+ if (aad.length !== 32) {
+ throw new Error('AAD (nonce) must be 32 bytes');
+ }
+ return hmac(sha256, key, concatBytes(aad, ciphertext));
+}
+
+/**
+ * Constant-time comparison of two byte arrays.
+ * @param {Uint8Array} a
+ * @param {Uint8Array} b
+ * @returns {boolean}
+ */
+function equalBytes (a, b) {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
+ return diff === 0;
+}
+
+/**
+ * Encrypt plaintext with NIP-44 v2.
+ * @param {string} plaintext - message to encrypt
+ * @param {Uint8Array} conversationKey - 32-byte conversation key
+ * @param {Uint8Array} [nonce] - optional 32-byte nonce (random if omitted)
+ * @returns {string} base64 NIP-44 payload
+ */
+export function encrypt (plaintext, conversationKey, nonce = randomBytes(32)) {
+ const { chachaKey, chachaNonce, hmacKey } = getMessageKeys(conversationKey, nonce);
+ const padded = pad(plaintext);
+ const ciphertext = chacha20(chachaKey, chachaNonce, padded);
+ const mac = hmacAad(hmacKey, ciphertext, nonce);
+
+ const payload = concatBytes(new Uint8Array([VERSION]), nonce, ciphertext, mac);
+ return base64Encode(payload);
+}
+
+/**
+ * Decrypt a NIP-44 v2 base64 payload.
+ * @param {string} payload - base64 NIP-44 payload
+ * @param {Uint8Array} conversationKey - 32-byte conversation key
+ * @returns {string} decrypted plaintext
+ */
+export function decrypt (payload, conversationKey) {
+ if (typeof payload !== 'string' || payload.length === 0) {
+ throw new Error('Invalid payload');
+ }
+ // Reject NIP-44 "encoded as #..." marker payloads explicitly.
+ if (payload[0] === '#') {
+ throw new Error('Unsupported encryption version');
+ }
+
+ const data = base64Decode(payload);
+ const version = data[0];
+ if (version !== VERSION) {
+ throw new Error(`Unknown encryption version: ${version}`);
+ }
+
+ // version(1) ‖ nonce(32) ‖ ciphertext(>=32) ‖ mac(32)
+ if (data.length < 1 + 32 + 32 + 32) {
+ throw new Error('Invalid payload length');
+ }
+
+ const nonce = data.subarray(1, 33);
+ const ciphertext = data.subarray(33, data.length - 32);
+ const mac = data.subarray(data.length - 32);
+
+ const { chachaKey, chachaNonce, hmacKey } = getMessageKeys(conversationKey, nonce);
+ const calculatedMac = hmacAad(hmacKey, ciphertext, nonce);
+ if (!equalBytes(calculatedMac, mac)) {
+ throw new Error('Invalid MAC');
+ }
+
+ const padded = chacha20(chachaKey, chachaNonce, ciphertext);
+ return unpad(padded);
+}
+
+/**
+ * Encrypt using raw key material (convenience for the background handler):
+ * derives the conversation key from (privateKeyHex, peerPublicKeyHex) first.
+ * @param {string} privateKeyHex
+ * @param {string} peerPublicKeyHex
+ * @param {string} plaintext
+ * @returns {string} base64 NIP-44 payload
+ */
+export function encryptWithKeys (privateKeyHex, peerPublicKeyHex, plaintext) {
+ const conversationKey = getConversationKey(privateKeyHex, peerPublicKeyHex);
+ return encrypt(plaintext, conversationKey);
+}
+
+/**
+ * Decrypt using raw key material (convenience for the background handler).
+ * @param {string} privateKeyHex
+ * @param {string} peerPublicKeyHex
+ * @param {string} payload - base64 NIP-44 payload
+ * @returns {string} decrypted plaintext
+ */
+export function decryptWithKeys (privateKeyHex, peerPublicKeyHex, payload) {
+ const conversationKey = getConversationKey(privateKeyHex, peerPublicKeyHex);
+ return decrypt(payload, conversationKey);
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function randomBytes (n) {
+ const bytes = new Uint8Array(n);
+ crypto.getRandomValues(bytes);
+ return bytes;
+}
+
+function validateHexKey (key, kind) {
+ if (typeof key !== 'string' || !/^[0-9a-fA-F]{64}$/.test(key)) {
+ throw new Error(`Invalid ${kind} key: must be 64-char hex`);
+ }
+}
+
+function base64Encode (bytes) {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ return btoa(binary);
+}
+
+function base64Decode (str) {
+ const binary = atob(str);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+// Re-export hashing helpers used by callers/tests for convenience.
+export { bytesToHex, hexToBytes, hkdf };
diff --git a/src/nip98-interceptor.js b/src/nip98-interceptor.js
index 029a1cc..52bd5b8 100644
--- a/src/nip98-interceptor.js
+++ b/src/nip98-interceptor.js
@@ -6,6 +6,10 @@
(function () {
'use strict';
+ // Set to true for verbose request-flow logging. Off by default; this script
+ // never logs the Authorization header value or any token.
+ const DEBUG = false;
+
// Only inject once
if (window.__podkey_nip98_intercepted) {
return;
@@ -86,10 +90,36 @@
};
}
+ // Compute the NIP-98 `payload` body hash here in the page context, where the
+ // body object is still intact (FormData / URLSearchParams / Blob cannot be
+ // structured-cloned to the background service worker faithfully). Returns the
+ // hex SHA-256, or '' when there is no hashable body. FormData is intentionally
+ // unsupported: NIP-98 does not define a canonical hash for multipart bodies.
+ async function bodyToHashHex (body) {
+ if (body === undefined || body === null || body === '') return '';
+ let bytes;
+ if (typeof body === 'string') {
+ bytes = new TextEncoder().encode(body);
+ } else if (body instanceof URLSearchParams) {
+ bytes = new TextEncoder().encode(body.toString());
+ } else if (body instanceof Blob) {
+ bytes = new Uint8Array(await body.arrayBuffer());
+ } else if (body instanceof ArrayBuffer) {
+ bytes = new Uint8Array(body);
+ } else if (ArrayBuffer.isView(body)) {
+ bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
+ } else {
+ return '';
+ }
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
+ return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
+ }
+
// Helper to get auth header from extension
async function getNip98AuthHeader (url, method, body) {
try {
const urlString = typeof url === 'string' ? url : url.toString();
+ const bodyHash = await bodyToHashHex(body);
// Send message to extension via custom event (content script will forward it)
return new Promise((resolve) => {
@@ -104,13 +134,13 @@
window.addEventListener('podkey-nip98-response', handler);
- // Request auth header
+ // Request auth header (body hash only -- the raw body stays in the page)
window.dispatchEvent(new CustomEvent('podkey-nip98-request', {
detail: {
id: eventId,
url: urlString,
method: method || 'GET',
- body: body
+ bodyHash
}
}));
@@ -135,7 +165,7 @@
// Normalize once — handles fetch(url, init) and fetch(new Request(...))
// so downstream signing sees the real URL/method, not "[object Request]".
const { url: urlString, method, body } = normalizeFetchCall(url, options);
- console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
+ if (DEBUG) console.log('[Podkey] fetch() intercepted:', urlString, method);
// Respect an Authorization header the page already set — on either
// options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting
@@ -149,34 +179,33 @@
const authHeader = await getNip98AuthHeader(urlString, method, body);
if (authHeader) {
setAuthorizationOnOptions(options, authHeader);
- console.log('[Podkey] ✅ Added NIP-98 auth header');
- } else {
- console.log('[Podkey] ⚠️ No auth header (will retry on 401)');
+ if (DEBUG) console.log('[Podkey] Added NIP-98 auth header');
+ } else if (DEBUG) {
+ console.log('[Podkey] No auth header (will retry on 401)');
}
} catch (error) {
console.error('[Podkey] Error adding NIP-98 auth:', error);
}
- } else {
- console.log('[Podkey] ⏭️ Page already set Authorization — skipping injection');
+ } else if (DEBUG) {
+ console.log('[Podkey] Page already set Authorization — skipping injection');
}
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...');
+ if (DEBUG) console.log('[Podkey] 401 detected, retrying with NIP-98 auth...');
try {
- const authHeader = await getNip98AuthHeader(urlString, method, body);
+ // If the original request followed a redirect, the endpoint that
+ // returned 401 is response.url, not the requested url. NIP-98 binds the
+ // signature to the `u` tag, so re-sign against the actual final URL.
+ const retryUrl = response.redirected && response.url ? response.url : urlString;
+ const authHeader = await getNip98AuthHeader(retryUrl, method, body);
if (authHeader) {
const retryOptions = { ...options };
setAuthorizationOnOptions(retryOptions, 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);
- }
+ const retryResponse = await originalFetch.call(this, retryUrl, retryOptions);
+ if (DEBUG) console.log('[Podkey] NIP-98 retry status:', retryResponse.status);
return retryResponse;
}
} catch (error) {
@@ -198,7 +227,7 @@
// Track page-set Authorization on XHR so we don't overwrite it in send().
// setRequestHeader auto-merges values per the XHR spec, but a merged
// "DPoP xxx, Nostr yyy" still confuses servers that branch on scheme.
- XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
+ XMLHttpRequest.prototype.setRequestHeader = function (name, _value) {
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
this._podkeyHasPageAuth = true;
}
@@ -211,16 +240,16 @@
const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body);
if (authHeader) {
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
- console.log('[Podkey] ✅ Added NIP-98 auth to XHR');
+ if (DEBUG) console.log('[Podkey] Added NIP-98 auth to XHR');
}
} catch (error) {
console.error('[Podkey] Error adding NIP-98 auth to XHR:', error);
}
- } else if (this._podkeyHasPageAuth) {
- console.log('[Podkey] ⏭️ Page set XHR Authorization — skipping injection');
+ } else if (this._podkeyHasPageAuth && DEBUG) {
+ console.log('[Podkey] Page set XHR Authorization — skipping injection');
}
return originalXHRSend.apply(this, [body]);
};
- console.log('[Podkey] ✅ NIP-98 interceptor injected into page context');
+ if (DEBUG) console.log('[Podkey] NIP-98 interceptor injected into page context');
})();
diff --git a/src/nostr-provider.js b/src/nostr-provider.js
index 98125c4..6cab005 100644
--- a/src/nostr-provider.js
+++ b/src/nostr-provider.js
@@ -57,32 +57,39 @@
});
},
- /**
- * Get relays (optional NIP-07 extension)
- * @returns {Promise