From d109a41158c1528788e9b833b9f443ae94a881dd Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:12 +0000 Subject: [PATCH 01/24] fix: remove duplicate fetch/XHR interception from content script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both src/injected.js (content script context) and src/nip98-interceptor.js (page context) were independently overriding window.fetch and XMLHttpRequest.prototype.send. This caused double interception of every request, double auth header injection, and double 401 retry loops. The content script's interception was also coupled to a window.__podkey_setAuthFn callback that was never properly wired โ€” getAuthHeaderFn started as null and relied on an async setup race. This commit removes all fetch/XHR interception code from injected.js and leaves NIP-98 request interception solely to nip98-interceptor.js, which already has its own clean implementation using CustomEvent-based message passing to the content script. The content script now only handles what it should: relaying podkey-request/podkey-response events for NIP-07 and podkey-nip98-request/response events for NIP-98. Co-Authored-By: claude-flow --- src/injected.js | 321 ++---------------------------------------------- 1 file changed, 11 insertions(+), 310 deletions(-) diff --git a/src/injected.js b/src/injected.js index b77f08b..06c7a75 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'); @@ -312,9 +59,12 @@ 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 +// Listen for requests from the injected script (NIP-07 relay) window.addEventListener('podkey-request', async (event) => { const { id, type, ...data } = event.detail; @@ -370,53 +120,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'); From f7f3ff3ded3f46a38aed685535f308bb65d26271 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:14 +0000 Subject: [PATCH 02/24] security: move private key from chrome.storage.local to chrome.storage.session chrome.storage.local persists data to disk as unencrypted JSON at a well-known path (~/.config/google-chrome/.../Local Extension Settings//). Any process with filesystem access -- malware, other extensions with nativeMessaging, backup tools -- can read the raw private key hex. chrome.storage.session is MV3-only, in-memory storage that is: - Never written to disk - Cleared when the service worker terminates - Scoped to the extension's origin Changes: - storeKeypair() now writes private key to session storage, public key to local storage (public key is not secret and is needed by the popup to display identity without unlocking) - getKeypair() reads private key from session, public key from local - deleteKeypair() clears from both storages (including legacy local copy) - Added getStoredPublicKey() for popup identity display - popup/popup.js handleExport() now reads from session storage first, with a fallback to local for legacy installs - Added legacy private key cleanup: storeKeypair removes any old chrome.storage.local copy of the private key Trade-off: Users must re-import their key after browser restart (session storage is cleared). This is an acceptable UX cost for the security gain of never persisting the private key to disk. Co-Authored-By: claude-flow --- popup/popup.js | 13 ++++++---- src/storage.js | 65 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/popup/popup.js b/popup/popup.js index 80af088..4819b80 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -219,10 +219,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; } @@ -282,9 +287,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/storage.js b/src/storage.js index 0996eb7..057b3c3 100644 --- a/src/storage.js +++ b/src/storage.js @@ -1,6 +1,11 @@ /** * Podkey - Secure storage for Nostr keys - * Uses Chrome storage API with encryption + * + * Private keys are stored in chrome.storage.session (in-memory only, never + * persisted to disk, cleared when the service worker terminates). + * + * Public keys remain in chrome.storage.local so the popup can display the + * user's pubkey/DID without requiring the private key to be unlocked. */ const STORAGE_KEYS = { @@ -13,7 +18,9 @@ const STORAGE_KEYS = { }; /** - * Store private key securely + * Store keypair securely. + * Private key goes to session storage (in-memory only). + * Public key goes to local storage (persisted, but not secret). * @param {string} privateKey - 64-char hex private key * @param {string} publicKey - 64-char hex public key */ @@ -23,36 +30,46 @@ export async function storeKeypair(privateKey, publicKey) { throw new Error('Keys must be 64-char hex'); } + // Private key: session storage only (in-memory, never written to disk) + await chrome.storage.session.set({ + [STORAGE_KEYS.PRIVATE_KEY]: privateKey + }); + + // Public key: local storage (needs to survive service worker restarts + // so the popup can show the user's identity without the private key) await chrome.storage.local.set({ - [STORAGE_KEYS.PRIVATE_KEY]: privateKey, [STORAGE_KEYS.PUBLIC_KEY]: publicKey }); - console.log('[Podkey] Keypair stored securely'); + // Remove any legacy private key from local storage left by older versions + await chrome.storage.local.remove([STORAGE_KEYS.PRIVATE_KEY]); + + console.log('[Podkey] Keypair stored (private key in session storage only)'); } /** - * Get stored keypair + * Get stored keypair. + * Private key comes from session storage, public key from local storage. + * Returns null if either key is missing (e.g. service worker restarted and + * session storage was cleared -- user will need to re-import). * @returns {Promise<{privateKey: string, publicKey: string} | null>} */ export async function getKeypair() { - const result = await chrome.storage.local.get([ - STORAGE_KEYS.PRIVATE_KEY, - STORAGE_KEYS.PUBLIC_KEY - ]); + const { [STORAGE_KEYS.PRIVATE_KEY]: privateKey } = + await chrome.storage.session.get([STORAGE_KEYS.PRIVATE_KEY]); + + const { [STORAGE_KEYS.PUBLIC_KEY]: publicKey } = + await chrome.storage.local.get([STORAGE_KEYS.PUBLIC_KEY]); - if (!result[STORAGE_KEYS.PRIVATE_KEY] || !result[STORAGE_KEYS.PUBLIC_KEY]) { + if (!privateKey || !publicKey) { return null; } - return { - privateKey: result[STORAGE_KEYS.PRIVATE_KEY], - publicKey: result[STORAGE_KEYS.PUBLIC_KEY] - }; + return { privateKey, publicKey }; } /** - * Check if keypair exists + * Check if a usable keypair exists (private key in session + public key on disk). * @returns {Promise} */ export async function hasKeypair() { @@ -61,16 +78,28 @@ export async function hasKeypair() { } /** - * Delete stored keypair (use with caution!) + * Check if a public key exists on disk (may not have a private key in session). + * Useful for the popup to show identity even when the session has expired. + * @returns {Promise} The public key hex, or null + */ +export async function getStoredPublicKey() { + const { [STORAGE_KEYS.PUBLIC_KEY]: publicKey } = + await chrome.storage.local.get([STORAGE_KEYS.PUBLIC_KEY]); + return publicKey || null; +} + +/** + * Delete stored keypair from both session and local storage. * @returns {Promise} */ export async function deleteKeypair() { + await chrome.storage.session.remove([STORAGE_KEYS.PRIVATE_KEY]); await chrome.storage.local.remove([ - STORAGE_KEYS.PRIVATE_KEY, + STORAGE_KEYS.PRIVATE_KEY, // clean up any legacy local copy STORAGE_KEYS.PUBLIC_KEY ]); - console.log('[Podkey] Keypair deleted'); + console.log('[Podkey] Keypair deleted from all storage'); } /** From d9667dabd53eef415cbc6a91d1c8131db326a75b Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:29 +0000 Subject: [PATCH 03/24] security: fix Solid server auto-trust hostname matching Replace String.includes() with exact hostname matching via URL parsing. The previous substring check allowed malicious domains containing the trusted substring to pass (e.g. evil-solid.social.attacker.com matched solid.social). Now uses new URL().hostname with exact match or subdomain check (hostname.endsWith('.' + trusted)). Also removes the never-matching '/.well-known/solid' entry since origins don't contain paths. Co-Authored-By: claude-flow --- src/background.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/background.js b/src/background.js index 84481e2..3e55567 100644 --- a/src/background.js +++ b/src/background.js @@ -330,16 +330,23 @@ 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) { From 69eaaca78d847afdbdcf1250a8919d15271b1f68 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:32 +0000 Subject: [PATCH 04/24] security: remove NIP-98 signed event cache to prevent replay attacks Remove the nip98Cache Map and all caching logic. The cache reused signed events for identical URL+method+bodyHash within 60 seconds, causing the same event ID to be sent multiple times. Servers with replay protection reject duplicate event IDs, and the predictable cache keys create an additional attack surface. NIP-98 event creation is computationally cheap (~1ms with noble/secp256k1), so caching provides no meaningful benefit. Co-Authored-By: claude-flow --- src/background.js | 57 +++++++++++++++++------------------------------ 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/src/background.js b/src/background.js index 84481e2..638a5e5 100644 --- a/src/background.js +++ b/src/background.js @@ -17,9 +17,7 @@ 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 +// NIP-98 events are always created fresh to avoid replay issues // Track retry state to prevent infinite loops: key = requestId, value = true const retryState = new Map(); @@ -392,43 +390,28 @@ async function createNip98AuthHeader (url, method, body = null) { } } - // 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 duplicates) + const event = { + kind: 27235, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', url], + ['method', method] + ] + }; - const keypair = await getKeypair(); - signedEvent = await signEvent(event, keypair.privateKey); + if (bodyHash) { + event.tags.push(['payload', bodyHash]); + } - // 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)); - console.log('[Podkey] Public key (did:nostr):', `did:nostr:${keypair.publicKey}`); - } + 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) + '...'); From af55262fc95bb2809b03565cddf711c5b7c4bef4 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:34 +0000 Subject: [PATCH 05/24] security: whitelist message types in content script relay Add an ALLOWED_TYPES whitelist to validate the type field from page events before forwarding to the background script. Previously, the type field came directly from the page with no validation, and the spread operator forwarded arbitrary fields, allowing a malicious page to invoke internal extension message handlers or inject unexpected data. Now only known NIP message types are forwarded, and only safe fields per message type are included in the forwarded message. Co-Authored-By: claude-flow --- src/injected.js | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/injected.js b/src/injected.js index b77f08b..5711a67 100644 --- a/src/injected.js +++ b/src/injected.js @@ -314,17 +314,47 @@ script.onload = function () { }; (document.head || document.documentElement).appendChild(script); +// Allowed message types that can be forwarded to the background script +const ALLOWED_TYPES = new Set([ + 'GET_PUBLIC_KEY', + 'SIGN_EVENT', + 'GET_RELAYS', + 'NIP04_ENCRYPT', + 'NIP04_DECRYPT', + 'NIP44_ENCRYPT', + 'NIP44_DECRYPT' +]); + // Listen for requests from the injected script 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 === 'NIP04_ENCRYPT' || type === 'NIP04_DECRYPT' || + type === 'NIP44_ENCRYPT' || type === 'NIP44_DECRYPT')) { + if (data.peer) safeData.peer = String(data.peer); + 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 }); From 5534b20ff3d86a9dc005dce69bf846ed10ddb2b5 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:22:10 +0000 Subject: [PATCH 06/24] security: implement signing approval popup to replace auto-approve The previous showPermissionPrompt() unconditionally returned true, meaning ANY website could silently sign Nostr events as the user without any confirmation. This is a complete bypass of the extension's security model -- equivalent to giving every website full access to the user's private key. This commit replaces the auto-approve stub with a proper popup-based approval flow: - New popup/approve.html: Approval dialog showing the requesting origin, action description, and event preview data - New popup/approve.js: Handles approve/deny button clicks and sends the decision back to the background service worker via chrome.runtime.sendMessage - Modified src/background.js: - showPermissionPrompt() now opens a chrome.windows.create() popup and returns a Promise that resolves when the user clicks approve or deny - Pending approvals tracked via Map - 60-second auto-deny timeout prevents abandoned prompts from blocking indefinitely - APPROVE_SIGNING message type handled before the main message router to avoid deadlock - Event preview data (kind, truncated content, tag count) passed to the approval popup for informed consent This follows the same pattern used by established NIP-07 extensions (nos2x, Alby) where every signing request requires explicit user approval unless the origin has been previously trusted. Co-Authored-By: claude-flow --- popup/approve.html | 114 +++++++++++++++++++++++++++++++++++++++++++++ popup/approve.js | 55 ++++++++++++++++++++++ src/background.js | 96 ++++++++++++++++++++++++++------------ 3 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 popup/approve.html create mode 100644 popup/approve.js diff --git a/popup/approve.html b/popup/approve.html new file mode 100644 index 0000000..f5db261 --- /dev/null +++ b/popup/approve.html @@ -0,0 +1,114 @@ + + + + + PodKey - Signing Request + + + + +
+

Signing Request

+
+
+
+
+ + +
+
+
+ + + diff --git a/popup/approve.js b/popup/approve.js new file mode 100644 index 0000000..b17db5b --- /dev/null +++ b/popup/approve.js @@ -0,0 +1,55 @@ +/** + * Podkey - Signing Approval Popup + * Presents signing requests to the user for explicit approval/denial. + */ + +const params = new URLSearchParams(window.location.search); +const requestId = params.get('id'); +const origin = params.get('origin') || 'Unknown'; +const action = params.get('action') || 'sign'; +const preview = params.get('preview') || ''; + +// Populate UI +document.getElementById('origin').textContent = origin; +document.getElementById('action').textContent = `wants to: ${action}`; + +if (preview) { + try { + // Try to pretty-print JSON previews + const parsed = JSON.parse(preview); + document.getElementById('preview').textContent = JSON.stringify(parsed, null, 2); + } catch { + document.getElementById('preview').textContent = preview; + } +} else { + document.getElementById('preview').style.display = 'none'; +} + +// Approve button +document.getElementById('approve').addEventListener('click', () => { + chrome.runtime.sendMessage({ + type: 'APPROVE_SIGNING', + requestId, + approved: true + }); + window.close(); +}); + +// Deny button +document.getElementById('deny').addEventListener('click', () => { + chrome.runtime.sendMessage({ + type: 'APPROVE_SIGNING', + requestId, + approved: false + }); + window.close(); +}); + +// Also deny on window close (user closes the popup without clicking) +window.addEventListener('beforeunload', () => { + chrome.runtime.sendMessage({ + type: 'APPROVE_SIGNING', + requestId, + approved: false + }); +}); diff --git a/src/background.js b/src/background.js index 84481e2..5ba0621 100644 --- a/src/background.js +++ b/src/background.js @@ -36,8 +36,21 @@ 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); @@ -110,7 +123,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'); @@ -148,12 +161,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'); @@ -256,30 +271,51 @@ 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); + + // Clean up timeout when resolved normally + const originalResolve = resolve; + pendingApprovals.set(requestId, (approved) => { + clearTimeout(timeout); + originalResolve(approved); + }); + + const params = new URLSearchParams({ + id: requestId, + origin: origin || 'Unknown', + action: action || 'sign', + preview: eventPreview || '' + }); + + chrome.windows.create({ + url: `popup/approve.html?${params.toString()}`, + type: 'popup', + width: 420, + height: 380, + focused: true + }); + }); } /** From 84829ec85c8c19bf48a765e0f255e15c58ae98a2 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 19:33:56 +0000 Subject: [PATCH 07/24] feat: NIP-44 (v2) encrypt/decrypt for window.nostr Add window.nostr.nip44.{encrypt,decrypt} to the injected NIP-07 provider so apps that use NIP-17 / NIP-59 gift-wrapped direct messages (e.g. the DreamLab nostr-rust-forum) can encrypt/decrypt DMs through the Podkey extension. Implementation mirrors the existing signing/nip04 message path exactly: page provider (nostr-provider.js) posts a podkey-request CustomEvent -> content script (injected.js) relays via chrome.runtime.sendMessage -> background service worker (background.js) routes NIP44_ENCRYPT/NIP44_DECRYPT/ NIP44_GET_CONVERSATION_KEY to handlers that resolve the keypair the same way GET_PUBLIC_KEY/SIGN_EVENT do. The raw private key never leaves the background; only the base64 payload / plaintext / conversation key crosses to the page. NIP-44 v2 (src/nip44.js) uses the vetted @noble stack the extension already depends on for nip04-era crypto: - @noble/secp256k1 : ECDH shared secret (x-coordinate) - @noble/hashes : hkdf (extract/expand), hmac, sha256 - @noble/ciphers : chacha20 (added dependency) conversation key = hkdf_extract(salt="nip44-v2", ikm=ECDH.x); per-message keys = hkdf_expand(conv_key, info=nonce, 76) -> chacha_key|chacha_nonce|hmac_key; payload = base64(0x02|nonce|ct|mac), mac = hmac_sha256(hmac_key, nonce|ct). Padding and unpadding per spec. Tests (test/nip44.test.js, node --test) cover symmetric conversation keys, round-trips (unicode/long/random-nonce), background-path helpers, HMAC tamper detection, and the official NIP-44 spec vectors (deterministic conversation key + encrypt/decrypt). All 34 tests pass; npm run build bundles cleanly. Co-Authored-By: jjohare --- CHANGELOG.md | 12 ++ README.md | 14 +++ package-lock.json | 19 ++- package.json | 3 +- src/background.js | 99 +++++++++++++++ src/nip44.js | 273 ++++++++++++++++++++++++++++++++++++++++++ src/nostr-provider.js | 34 ++++++ test/nip44.test.js | 166 +++++++++++++++++++++++++ 8 files changed, 616 insertions(+), 4 deletions(-) create mode 100644 src/nip44.js create mode 100644 test/nip44.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f273f7..42d26f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Podkey will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **NIP-44 (v2) encryption** โ€” `window.nostr.nip44.encrypt(pubkey, plaintext)` + and `window.nostr.nip44.decrypt(pubkey, ciphertext)`, enabling NIP-17 / NIP-59 + gift-wrapped direct messages. Crypto runs in the background service worker + (the private key never reaches the page), reusing the existing message-passing + path. Implemented with `@noble/ciphers` (chacha20) + `@noble/hashes` + (hkdf/hmac/sha256) + `@noble/secp256k1` (ECDH). Verified against the official + NIP-44 spec test vectors. + ## [0.0.7] - 2024-12-XX ### Changed diff --git a/README.md b/README.md index b12cc78..eb19d56 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ - โœ… **Key Generation** - Secure cryptographic key generation using `@noble/secp256k1` - โœ… **Key Import** - Import existing 64-char hex private keys - โœ… **Event Signing** - Sign Nostr events with Schnorr signatures +- โœ… **NIP-44 Encryption** - v2 `encrypt`/`decrypt` for NIP-17/NIP-59 gift-wrapped DMs (key never leaves the background) - โœ… **Trust Management** - Per-origin permissions with auto-approval - โœ… **Beautiful UI** - Soft gradients, smooth animations - โœ… **64-char Hex Keys** - Proper did:nostr format compatibility @@ -261,6 +262,19 @@ const signed = await window.nostr.signEvent({ }) ``` +### `window.nostr.nip44.encrypt(pubkey, plaintext)` / `window.nostr.nip44.decrypt(pubkey, ciphertext)` + +NIP-44 (v2) encryption, used by NIP-17 / NIP-59 gift-wrapped direct messages. +The private key never leaves the background service worker โ€” encryption is +performed there and only the resulting base64 payload (or decrypted plaintext) +crosses to the page, mirroring the existing signing message path. + +```javascript +const peer = '<64-char hex pubkey>' +const payload = await window.nostr.nip44.encrypt(peer, 'hello') +const plaintext = await window.nostr.nip44.decrypt(peer, payload) +``` + ### `window.nostr.getRelays()` Returns relay configuration (coming soon). diff --git a/package-lock.json b/package-lock.json index 8fe28a2..c9ffaeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "podkey", - "version": "0.0.2", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "podkey", - "version": "0.0.2", - "license": "MIT", + "version": "0.0.7", + "license": "AGPL-3.0", "dependencies": { + "@noble/ciphers": "^2.2.0", "@noble/hashes": "^1.3.3", "@noble/secp256k1": "^3.0.0" }, @@ -563,6 +564,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", diff --git a/package.json b/package.json index 3e04ce5..39ffcd3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "homepage": "https://github.com/JavaScriptSolidServer/podkey#readme", "dependencies": { + "@noble/ciphers": "^2.2.0", "@noble/hashes": "^1.3.3", "@noble/secp256k1": "^3.0.0" }, @@ -44,4 +45,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/src/background.js b/src/background.js index 84481e2..a1f01d0 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, @@ -83,6 +89,15 @@ async function handleMessage (message, sender) { case 'NIP04_DECRYPT': throw new Error('NIP-04 encryption not yet implemented'); + case 'NIP44_ENCRYPT': + return await handleNip44Encrypt(message.pubkey, message.plaintext, origin); + + case 'NIP44_DECRYPT': + return await handleNip44Decrypt(message.pubkey, message.ciphertext, origin); + + case 'NIP44_GET_CONVERSATION_KEY': + return await handleNip44GetConversationKey(message.pubkey, origin); + case 'CREATE_NIP98_AUTH_HEADER': return await createNip98AuthHeader( message.url, @@ -181,6 +196,90 @@ 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 */ 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/nostr-provider.js b/src/nostr-provider.js index 98125c4..7a7f593 100644 --- a/src/nostr-provider.js +++ b/src/nostr-provider.js @@ -87,6 +87,40 @@ ciphertext }); } + }, + + /** + * Encrypt / decrypt (NIP-44 v2) + * Used by NIP-17 / NIP-59 (gift-wrapped) direct messages. The private key + * never leaves the background service worker โ€” encryption is performed + * there and only the resulting payload/plaintext crosses to the page. + */ + nip44: { + /** + * @param {string} pubkey - 64-char hex peer public key + * @param {string} plaintext - message to encrypt + * @returns {Promise} base64 NIP-44 v2 payload + */ + encrypt: async (pubkey, plaintext) => { + return sendMessageToExtension({ + type: 'NIP44_ENCRYPT', + pubkey, + plaintext + }); + }, + + /** + * @param {string} pubkey - 64-char hex peer public key + * @param {string} ciphertext - base64 NIP-44 v2 payload + * @returns {Promise} decrypted plaintext + */ + decrypt: async (pubkey, ciphertext) => { + return sendMessageToExtension({ + type: 'NIP44_DECRYPT', + pubkey, + ciphertext + }); + } } }; diff --git a/test/nip44.test.js b/test/nip44.test.js new file mode 100644 index 0000000..fdb3ba0 --- /dev/null +++ b/test/nip44.test.js @@ -0,0 +1,166 @@ +/** + * Tests for Podkey NIP-44 (v2) encryption. + * + * Mirrors the structure of crypto.test.js and adds official NIP-44 spec + * test vectors to prove the implementation matches the standard. + * Vectors: https://github.com/paulmillr/nip44/blob/main/nip44.vectors.json + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + getConversationKey, + encrypt, + decrypt, + encryptWithKeys, + decryptWithKeys, + bytesToHex, + hexToBytes +} from '../src/nip44.js'; +import { generateKeypair, getPublicKey } from '../src/crypto.js'; + +describe('NIP-44 (v2)', () => { + describe('getConversationKey', () => { + it('is symmetric: convKey(a, B) === convKey(b, A)', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + + const fromAlice = getConversationKey(alice.privateKey, bob.publicKey); + const fromBob = getConversationKey(bob.privateKey, alice.publicKey); + + assert.strictEqual(bytesToHex(fromAlice), bytesToHex(fromBob), + 'Conversation key must be identical from both sides'); + assert.strictEqual(fromAlice.length, 32, 'Conversation key must be 32 bytes'); + }); + + it('matches the official NIP-44 spec test vector', () => { + // From nip44.vectors.json -> v2.valid.get_conversation_key[0] + const sec1 = '315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268'; + const pub2 = 'c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133'; + const expected = '3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1'; + + const convKey = getConversationKey(sec1, pub2); + assert.strictEqual(bytesToHex(convKey), expected, + 'Conversation key must match the NIP-44 spec vector'); + }); + + it('rejects malformed keys', () => { + assert.throws(() => getConversationKey('zz', 'a'.repeat(64)), /Invalid private key/); + assert.throws(() => getConversationKey('a'.repeat(64), 'short'), /Invalid public key/); + }); + }); + + describe('official NIP-44 spec encrypt/decrypt vector', () => { + // From nip44.vectors.json -> v2.valid.encrypt_decrypt[0] + const conversationKey = hexToBytes('c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d'); + const nonce = hexToBytes('0000000000000000000000000000000000000000000000000000000000000001'); + const plaintext = 'a'; + const payload = 'AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb'; + + it('encrypt with the spec nonce produces the spec payload', () => { + assert.strictEqual(encrypt(plaintext, conversationKey, nonce), payload); + }); + + it('decrypt of the spec payload yields the spec plaintext', () => { + assert.strictEqual(decrypt(payload, conversationKey), plaintext); + }); + }); + + describe('encrypt/decrypt round-trip', () => { + it('round-trips a simple message', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const plaintext = 'Hello from the DreamLab forum ๐Ÿ‘‹'; + + const convKeyAlice = getConversationKey(alice.privateKey, bob.publicKey); + const convKeyBob = getConversationKey(bob.privateKey, alice.publicKey); + + const payload = encrypt(plaintext, convKeyAlice); + assert.match(payload, /^[A-Za-z0-9+/]+=*$/, 'Payload should be base64'); + + const decrypted = decrypt(payload, convKeyBob); + assert.strictEqual(decrypted, plaintext, 'Decrypted text must match original'); + }); + + it('produces a v2 payload (first byte 0x02)', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const convKey = getConversationKey(alice.privateKey, bob.publicKey); + + const payload = encrypt('version check', convKey); + const raw = Uint8Array.from(atob(payload), c => c.charCodeAt(0)); + assert.strictEqual(raw[0], 2, 'First byte of the payload must be version 0x02'); + }); + + it('produces different payloads for the same plaintext (random nonce)', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const convKey = getConversationKey(alice.privateKey, bob.publicKey); + + const a = encrypt('same message', convKey); + const b = encrypt('same message', convKey); + assert.notStrictEqual(a, b, 'Nonce randomization must yield distinct payloads'); + assert.strictEqual(decrypt(a, convKey), decrypt(b, convKey)); + }); + + it('round-trips unicode and emoji', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const convKey = getConversationKey(alice.privateKey, bob.publicKey); + const plaintext = 'รผnรฎcรถdรฉ ๆต‹่ฏ• ๐Ÿ” โ€” NIP-44'; + + assert.strictEqual(decrypt(encrypt(plaintext, convKey), convKey), plaintext); + }); + + it('round-trips a long message (padding buckets)', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const convKey = getConversationKey(alice.privateKey, bob.publicKey); + const plaintext = 'x'.repeat(5000); + + assert.strictEqual(decrypt(encrypt(plaintext, convKey), convKey), plaintext); + }); + }); + + describe('encryptWithKeys/decryptWithKeys (background path helpers)', () => { + it('round-trips using raw key material', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const plaintext = 'gift-wrapped DM payload'; + + const payload = encryptWithKeys(alice.privateKey, bob.publicKey, plaintext); + const decrypted = decryptWithKeys(bob.privateKey, alice.publicKey, payload); + + assert.strictEqual(decrypted, plaintext); + }); + + it('derived pubkeys match generated pubkeys', async () => { + const alice = await generateKeypair(); + assert.strictEqual(getPublicKey(alice.privateKey), alice.publicKey); + }); + }); + + describe('tamper detection (HMAC)', () => { + it('rejects a payload with a flipped ciphertext byte', async () => { + const alice = await generateKeypair(); + const bob = await generateKeypair(); + const convKey = getConversationKey(alice.privateKey, bob.publicKey); + + const payload = encrypt('authentic message', convKey); + const raw = Uint8Array.from(atob(payload), c => c.charCodeAt(0)); + raw[40] ^= 0xff; // flip a byte inside the ciphertext region + let binary = ''; + for (const b of raw) binary += String.fromCharCode(b); + const tampered = btoa(binary); + + assert.throws(() => decrypt(tampered, convKey), /Invalid MAC/); + }); + + it('rejects an unknown version byte', () => { + const convKey = hexToBytes('00'.repeat(32)); + // version 0x01 (NIP-04-style marker is unsupported by NIP-44 v2) + const bogus = btoa(String.fromCharCode(1) + 'x'.repeat(96)); + assert.throws(() => decrypt(bogus, convKey), /Unknown encryption version/); + }); + }); +}); From 11c018cbe2533e6eee1b914c18e239aec16ddfd7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 19:50:56 +0000 Subject: [PATCH 08/24] test: update storage mock for session/local split The encrypted-key-storage change (#12) moved the private key to chrome.storage.session; the test mock only stubbed chrome.storage.local, so storeKeypair/getKeypair/hasKeypair/deleteKeypair threw on the missing session area. Back each area with its own store, matching production. Co-Authored-By: jjohare --- test/storage.test.js | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/test/storage.test.js b/test/storage.test.js index 5483eff..b40dc28 100644 --- a/test/storage.test.js +++ b/test/storage.test.js @@ -6,37 +6,45 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; -// Mock chrome.storage for testing -const mockStorage = { - data: {}, - local: { +// Mock chrome.storage for testing. The extension uses two areas: session +// (in-memory private key) and local (persisted public key + settings), so the +// mock backs each area with its own store. +function makeArea (store) { + return { get: async (keys) => { const result = {}; if (Array.isArray(keys)) { keys.forEach(key => { - result[key] = mockStorage.data[key]; + result[key] = store[key]; }); } else if (keys) { Object.keys(keys).forEach(key => { - result[key] = mockStorage.data[key] || keys[key]; + result[key] = store[key] || keys[key]; }); } else { - return { ...mockStorage.data }; + return { ...store }; } return result; }, set: async (items) => { - Object.assign(mockStorage.data, items); + Object.assign(store, items); }, remove: async (keys) => { if (Array.isArray(keys)) { - keys.forEach(key => delete mockStorage.data[key]); + keys.forEach(key => delete store[key]); } else { - delete mockStorage.data[keys]; + delete store[keys]; } } - } + }; +} + +const mockStorage = { + localData: {}, + sessionData: {} }; +mockStorage.local = makeArea(mockStorage.localData); +mockStorage.session = makeArea(mockStorage.sessionData); // Set up global chrome mock global.chrome = { storage: mockStorage }; @@ -56,7 +64,10 @@ const { describe('Storage Functions', () => { // Clear storage before each test const clearStorage = () => { - mockStorage.data = {}; + mockStorage.localData = {}; + mockStorage.sessionData = {}; + mockStorage.local = makeArea(mockStorage.localData); + mockStorage.session = makeArea(mockStorage.sessionData); }; describe('storeKeypair / getKeypair', () => { From adf19127fad85c543a9aa44fe6ada22a515f07ed Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 19:57:51 +0000 Subject: [PATCH 09/24] fix(crypto): self-verify schnorr signature in signEvent before returning A key-holder must never emit a signature it has not verified. signEvent now checks schnorr.verify(sig, eventId, pubkey) and throws on mismatch, catching a faulty signing path (bad RNG, library regression) before the bad signature leaves the service worker. This also exercises the previously unused verify primitive on the hot signing path. Co-Authored-By: jjohare --- src/crypto.js | 7 +++++++ 1 file changed, 7 insertions(+) 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, From 19b545ded6b5ba843a048434890ef878e7e7455d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 19:58:17 +0000 Subject: [PATCH 10/24] fix(provider): stop advertising unimplemented nip04 and getRelays window.nostr.nip04 delegated to a background handler that throws "not implemented", and getRelays returned {}. Feature-detection (window.nostr.nip04 / .getRelays) was therefore lying to callers. NIP-04 is a distinct, deprecated scheme we do not implement (Podkey ships NIP-44 v2 only), and Podkey holds no relay list, so the honest signal is the absence of both methods. The dead background cases are removed alongside the NIP-98 work. Co-Authored-By: jjohare --- src/nostr-provider.js | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/src/nostr-provider.js b/src/nostr-provider.js index 7a7f593..6cab005 100644 --- a/src/nostr-provider.js +++ b/src/nostr-provider.js @@ -57,37 +57,10 @@ }); }, - /** - * Get relays (optional NIP-07 extension) - * @returns {Promise} Relay configuration - */ - async getRelays () { - return sendMessageToExtension({ type: 'GET_RELAYS' }); - }, - - /** - * Encrypt (NIP-04) - * @param {string} pubkey - Recipient public key - * @param {string} plaintext - Message to encrypt - * @returns {Promise} Encrypted message - */ - nip04: { - encrypt: async (pubkey, plaintext) => { - return sendMessageToExtension({ - type: 'NIP04_ENCRYPT', - pubkey, - plaintext - }); - }, - - decrypt: async (pubkey, ciphertext) => { - return sendMessageToExtension({ - type: 'NIP04_DECRYPT', - pubkey, - ciphertext - }); - } - }, + // NIP-04 is intentionally not provided: it is a deprecated, unauthenticated + // scheme and Podkey only ships NIP-44 (v2). Advertising window.nostr.nip04 + // would make feature-detection lie. NIP-07 getRelays is also omitted because + // Podkey holds no relay list โ€” a missing method is the honest signal. /** * Encrypt / decrypt (NIP-44 v2) From ae8a1f337839f9143041e313d7155d88a3dfdb54 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 19:58:48 +0000 Subject: [PATCH 11/24] fix(nip98): fresh-token nonce, page-side body hash, redirect-aware retry; drop dead code; gate secret logs NIP-98 hardening: - Add a random 'nonce' tag to every kind-27235 event. created_at has 1s resolution, so two identical requests in the same second previously produced a byte-identical event id (a replayable token even after the cache removal). - Compute the 'payload' body hash in the page context (nip98-interceptor.js), where FormData / URLSearchParams / Blob / typed-array bodies are still intact, and send only the hex digest. The raw body no longer crosses the message channel (it could not be structured-cloned faithfully), which also fixes the missing payload hash for URLSearchParams/binary bodies. background.js consumes the precomputed hash, falling back to hashing string/ArrayBuffer/Blob locally. - On the 401 retry, re-sign against response.url when the original request was redirected, so the 'u' tag matches the endpoint that issued the challenge. Dead code: - Remove unused retryState map and formatEventForPrompt (the approval popup inlines its own preview). - Remove the NIP04_ENCRYPT/DECRYPT and GET_RELAYS message cases now that the provider no longer advertises them. Secret-log hygiene: - Introduce a DEBUG flag (default off) in background.js, popup.js and nip98-interceptor.js and gate every console statement that prints a public key, DID, signed event, Authorization header, or message result behind it. Error logs and benign lifecycle logs are kept. Co-Authored-By: jjohare --- popup/popup.js | 10 +++- src/background.js | 124 +++++++++++++-------------------------- src/injected.js | 9 ++- src/nip98-interceptor.js | 71 +++++++++++++++------- 4 files changed, 104 insertions(+), 110 deletions(-) diff --git a/popup/popup.js b/popup/popup.js index cae5a79..10d6d90 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'); @@ -116,7 +120,7 @@ async function handleGenerate() { 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({ @@ -152,7 +156,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 = ''; diff --git a/src/background.js b/src/background.js index 86f0a85..1060353 100644 --- a/src/background.js +++ b/src/background.js @@ -21,12 +21,11 @@ import { import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; -console.log('[Podkey] Background service worker started'); +// 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; -// NIP-98 events are always created fresh to avoid replay issues - -// 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 () => { @@ -57,7 +56,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 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 => { @@ -74,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': @@ -92,14 +93,6 @@ async function handleMessage (message, sender) { case 'GET_KEYPAIR_STATUS': return await handleGetKeypairStatus(); - case 'GET_RELAYS': - // TODO: Implement relay management - return {}; - - case 'NIP04_ENCRYPT': - case 'NIP04_DECRYPT': - throw new Error('NIP-04 encryption not yet implemented'); - case 'NIP44_ENCRYPT': return await handleNip44Encrypt(message.pubkey, message.plaintext, origin); @@ -113,7 +106,8 @@ async function handleMessage (message, sender) { return await createNip98AuthHeader( message.url, message.method, - message.body + message.body, + message.bodyHash ); default: @@ -196,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 @@ -298,23 +292,14 @@ async function handleNip44GetConversationKey (peerPubkey, origin) { */ 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; @@ -340,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, @@ -415,29 +400,6 @@ async function showPermissionPrompt (origin, action, eventPreview) { }); } -/** - * 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(', ')}]`); - }); - } - - if (event.content) { - const preview = event.content.substring(0, 100); - lines.push(`Content: ${preview}${event.content.length > 100 ? '...' : ''}`); - } - - return lines.join('\n'); -} - - /** * Encode signed event to Authorization header value * @param {object} signedEvent - Signed Nostr event @@ -482,7 +444,7 @@ function isLikelySolidServer (origin) { ); } -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; @@ -491,73 +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()))); } } // Always create a fresh signed event (no caching -- reusing signed events - // causes replay issues and servers with replay protection reject duplicates) + // 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] + ['method', method], + ['nonce', bytesToHex(crypto.getRandomValues(new Uint8Array(16)))] ] }; - if (bodyHash) { - event.tags.push(['payload', bodyHash]); + if (resolvedBodyHash) { + event.tags.push(['payload', resolvedBodyHash]); } 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)); - console.log('[Podkey] Public key (did:nostr):', `did:nostr:${keypair.publicKey}`); + 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; @@ -569,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/injected.js b/src/injected.js index 25b8485..3eba392 100644 --- a/src/injected.js +++ b/src/injected.js @@ -19,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) { diff --git a/src/nip98-interceptor.js b/src/nip98-interceptor.js index 029a1cc..e0ffdb1 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) { @@ -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'); })(); From 2f740cb1a4fdf5d9c97972093b2d5ba8cb2480f6 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 20:08:16 +0000 Subject: [PATCH 12/24] chore(lint): add minimal ESLint config for browser-extension ESM eslint 8.57.1 is pinned in package-lock, so this uses the .eslintrc.json (eslintrc) format. Extends eslint:recommended with browser, webextensions (chrome.*), serviceworker (self), and es2022 environments; test/ and scripts/ get the node environment via overrides. Security/correctness rules are errors: no-eval, no-implied-eval, no-undef. no-unused-vars is a warning (argsIgnorePattern ^_) so pre-existing unused args/imports in src and test surface in CI logs without blocking the gate, keeping defaults sensible rather than a pedantic wall. Ignores node_modules, dist, and the gitignored **/*.bundle.js build output. Co-Authored-By: jjohare --- .eslintrc.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .eslintrc.json diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3e45219 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,41 @@ +{ + "root": true, + "env": { + "browser": true, + "webextensions": true, + "serviceworker": true, + "es2022": true + }, + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "no-eval": "error", + "no-implied-eval": "error", + "no-undef": "error", + "no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] + }, + "ignorePatterns": [ + "node_modules/", + "dist/", + "**/*.bundle.js" + ], + "overrides": [ + { + "files": ["test/**/*.js", "scripts/**/*.js"], + "env": { + "node": true, + "browser": false + } + } + ] +} From 5b6056eaed686a7f342e2cf1bc094ecec85cba47 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 20:08:20 +0000 Subject: [PATCH 13/24] ci: add build/test/lint gate for PRs and pushes to main New CI workflow runs on pull_request and push to main: a single ubuntu-latest job on Node 20 with npm cache, running npm ci -> build -> test -> lint. Minimal contents:read permissions; gh-pages.yml is left untouched. Co-Authored-By: jjohare --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f74ecc1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build-test-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Lint + run: npm run lint From 4bc9514d07b2e680a56776b9895f571a17b9194f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 20:19:25 +0000 Subject: [PATCH 14/24] feat(popup): calm, trustworthy redesign of the main popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the purple/blue gradient theme with a calm slate palette and a single trust-blue accent (semantic green/red reserved for safe/destructive actions). Add a token-based light/dark theme via prefers-color-scheme, generous spacing, rounded cards, subtle shadows, accessible focus rings, and inline SVG icons in place of emoji. - popup.css: full rewrite as a shared design system (CSS custom properties for surfaces, text, borders, accent, semantic colours, shape) consumed by both popup.html and approve.html. - popup.html: restructured identity view โ€” npub/DID in monospace inset cards, a "Session" lock pill making the session-only key legible at a glance, a settings toggle with a description, and a tidy trusted-sites list. - popup.js: copy/generate now update a label span instead of overwriting button contents (preserves icons); loadTrustedSites rebuilds via the DOM API (replaceChildren/createElement) so no innerHTML is used on a surface that renders web-origin strings. All message protocols and DEBUG gating unchanged. Co-Authored-By: jjohare --- popup/popup.css | 640 ++++++++++++++++++++++++++++++++--------------- popup/popup.html | 130 +++++++--- popup/popup.js | 29 ++- 3 files changed, 560 insertions(+), 239 deletions(-) diff --git a/popup/popup.css b/popup/popup.css index 6db5dfe..3408f7d 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -1,8 +1,100 @@ /** * Podkey - Popup Styles - * Beautiful soft light theme with gradients + * Calm, modern, trustworthy. Slate neutrals with a single trust-blue accent; + * semantic green/red reserved for safe/destructive actions only. + * Dark mode via prefers-color-scheme. No frameworks, no dependencies. */ +:root { + /* Surfaces */ + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f1f3f6; + --surface-inset: #f8fafc; + + /* Text */ + --text: #1e293b; + --text-muted: #64748b; + --text-faint: #94a3b8; + + /* Borders */ + --border: #e2e8f0; + --border-strong: #cbd5e1; + + /* Accent (trust blue) */ + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-soft: #eff6ff; + --accent-ring: rgba(37, 99, 235, 0.25); + + /* Semantic */ + --safe: #16a34a; + --safe-hover: #15803d; + --safe-soft: #f0fdf4; + --safe-border: #bbf7d0; + + --danger: #dc2626; + --danger-hover: #b91c1c; + --danger-soft: #fef2f2; + --danger-border: #fecaca; + + --warn: #b45309; + --warn-soft: #fffbeb; + --warn-border: #fde68a; + + /* Shape */ + --radius: 12px; + --radius-sm: 8px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow: 0 4px 16px rgba(15, 23, 42, 0.08); + --shadow-lg: 0 10px 30px rgba(15, 23, 42, 0.12); + + --mono: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', + monospace; + --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', + Arial, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e131b; + --surface: #161c26; + --surface-2: #1c232f; + --surface-inset: #0f151d; + + --text: #e6eaf0; + --text-muted: #9aa6b6; + --text-faint: #6b7888; + + --border: #2a3340; + --border-strong: #3a4452; + + --accent: #5b8def; + --accent-hover: #74a0f4; + --accent-soft: #16223a; + --accent-ring: rgba(91, 141, 239, 0.35); + + --safe: #4ade80; + --safe-hover: #6ee79a; + --safe-soft: #122418; + --safe-border: #1f4d30; + + --danger: #f87171; + --danger-hover: #fca5a5; + --danger-soft: #2a1414; + --danger-border: #5a2626; + + --warn: #fbbf24; + --warn-soft: #2a2310; + --warn-border: #5a4a1a; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow: 0 4px 16px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.55); + } +} + * { box-sizing: border-box; margin: 0; @@ -10,205 +102,321 @@ } body { - width: 420px; - min-height: 500px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - background: linear-gradient(135deg, #fef5ff 0%, #f0f9ff 50%, #fef3c7 100%); - color: #334155; - padding: 0; + width: 380px; + min-height: 480px; + font-family: var(--sans); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } .screen { - padding: 20px; + padding: 18px 18px 14px; } -/* Header */ +/* ---- Header ---- */ .header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; +} + +.header.center { + flex-direction: column; text-align: center; - margin-bottom: 24px; - padding-bottom: 20px; - border-bottom: 2px solid rgba(168, 85, 247, 0.2); + gap: 8px; + margin-bottom: 20px; } -.logo { - font-size: 64px; - margin-bottom: 12px; - filter: drop-shadow(0 4px 8px rgba(168, 85, 247, 0.15)); +.brand-mark { + width: 36px; + height: 36px; + flex: none; + display: grid; + place-items: center; + border-radius: 10px; + background: var(--accent-soft); + color: var(--accent); + border: 1px solid var(--border); } -.logo-small { - font-size: 32px; - display: inline-block; - margin-right: 8px; +.brand-mark svg { + width: 20px; + height: 20px; +} + +.brand-mark.lg { + width: 56px; + height: 56px; + border-radius: 16px; + margin-bottom: 2px; +} + +.brand-mark.lg svg { + width: 30px; + height: 30px; } h1 { - font-size: 24px; + font-size: 18px; font-weight: 700; - background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: 6px; + letter-spacing: -0.01em; + color: var(--text); } h2 { - font-size: 20px; - color: #6b21a8; - margin-bottom: 8px; + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text); } .subtitle { - font-size: 13px; - color: #9333ea; - font-weight: 500; + font-size: 12.5px; + color: var(--text-muted); + line-height: 1.45; + max-width: 30ch; +} + +.header.center .subtitle { + margin: 0 auto; } -/* Sections */ +/* ---- Sections / cards ---- */ .section { - background: rgba(255, 255, 255, 0.7); - backdrop-filter: blur(10px); - border: 1px solid rgba(168, 85, 247, 0.15); - border-radius: 16px; - padding: 16px; - margin-bottom: 16px; - box-shadow: 0 4px 12px rgba(168, 85, 247, 0.08); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; + box-shadow: var(--shadow-sm); } .section-title { - font-size: 14px; - font-weight: 600; - color: #7c3aed; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); margin-bottom: 12px; } -/* Buttons */ +/* ---- Buttons ---- */ .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; width: 100%; - padding: 14px; - border: none; - border-radius: 12px; - font-size: 15px; + padding: 11px 14px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 14px; font-weight: 600; cursor: pointer; - transition: all 0.2s ease; + transition: background 0.15s ease, border-color 0.15s ease, + transform 0.05s ease, box-shadow 0.15s ease; margin-bottom: 10px; } +.btn:last-child { + margin-bottom: 0; +} + +.btn svg { + width: 16px; + height: 16px; + flex: none; +} + +.btn:active { + transform: translateY(1px); +} + +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-ring); +} + .btn-primary { - background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%); - color: white; - box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25); + background: var(--accent); + color: #fff; + box-shadow: var(--shadow-sm); } .btn-primary:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(124, 58, 237, 0.35); + background: var(--accent-hover); } .btn-secondary { - background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%); - color: #6b21a8; - border: 1px solid #c7d2fe; + background: var(--surface); + color: var(--text); + border-color: var(--border-strong); } .btn-secondary:hover { - background: linear-gradient(135deg, #c7d2fe 0%, #bfdbfe 100%); - transform: translateY(-1px); + background: var(--surface-2); +} + +.btn[disabled] { + opacity: 0.55; + cursor: not-allowed; +} + +.btn[disabled]:active { + transform: none; +} + +/* ---- Identity ---- */ +.identity-section .section-title { + margin-bottom: 10px; +} + +.identity-field { + margin-bottom: 12px; +} + +.identity-field:last-of-type { + margin-bottom: 14px; +} + +.identity-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + margin-bottom: 5px; +} + +.identity-value { + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.55; + color: var(--text); + background: var(--surface-inset); + border: 1px solid var(--border); + padding: 9px 11px; + border-radius: var(--radius-sm); + word-break: break-all; } .btn-copy { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; width: 100%; - padding: 10px; - margin-top: 12px; - background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 100%); - border: 1px solid #fbbf24; - border-radius: 8px; - color: #92400e; - font-size: 13px; + padding: 9px; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: 12.5px; font-weight: 600; cursor: pointer; - transition: all 0.2s; + transition: background 0.15s ease, color 0.15s ease; } -.btn-copy:hover { - background: linear-gradient(135deg, #fde68a 0%, #fbbf24 100%); - transform: translateY(-1px); +.btn-copy svg { + width: 14px; + height: 14px; } -/* Identity Card */ -.identity-card { - background: linear-gradient(135deg, #fef3c7 0%, #dbeafe 100%); - border: 1px solid #bfdbfe; - border-radius: 12px; - padding: 16px; +.btn-copy:hover { + background: var(--surface-2); } -.identity-label { - font-size: 11px; - font-weight: 700; - color: #7c2d12; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-top: 12px; - margin-bottom: 6px; +.btn-copy.copied { + color: var(--safe); + border-color: var(--safe-border); + background: var(--safe-soft); } -.identity-label:first-child { - margin-top: 0; +/* ---- Session/lock pill ---- */ +.lock-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + text-transform: none; + letter-spacing: 0; + background: var(--safe-soft); + color: var(--safe); + border: 1px solid var(--safe-border); } -.identity-value { - font-family: 'Monaco', 'Menlo', 'Courier New', monospace; - font-size: 11px; - color: #475569; - background: rgba(255, 255, 255, 0.8); - padding: 8px 10px; - border-radius: 6px; - word-break: break-all; - line-height: 1.5; +.lock-pill svg { + width: 11px; + height: 11px; } -/* Input Fields */ +/* ---- Inputs ---- */ label { display: block; - font-size: 13px; + font-size: 12.5px; font-weight: 600; - color: #6b21a8; - margin-bottom: 8px; + color: var(--text); + margin-bottom: 7px; } textarea { width: 100%; - padding: 12px; - border: 2px solid #e9d5ff; - border-radius: 8px; - font-family: 'Monaco', 'Menlo', monospace; + padding: 11px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-family: var(--mono); font-size: 12px; + line-height: 1.5; resize: vertical; - background: rgba(255, 255, 255, 0.9); - color: #334155; - transition: border-color 0.2s; + background: var(--surface-inset); + color: var(--text); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +textarea::placeholder { + color: var(--text-faint); } textarea:focus { outline: none; - border-color: #a78bfa; - box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.1); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); } .hint { - font-size: 11px; - color: #dc2626; - margin-top: 8px; - margin-bottom: 16px; - padding: 8px 12px; - background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); - border: 1px solid #fca5a5; - border-radius: 6px; -} - -/* Actions */ + display: flex; + align-items: flex-start; + gap: 7px; + font-size: 11.5px; + line-height: 1.45; + color: var(--warn); + margin: 10px 0 0; + padding: 9px 11px; + background: var(--warn-soft); + border: 1px solid var(--warn-border); + border-radius: var(--radius-sm); +} + +.hint svg { + width: 14px; + height: 14px; + flex: none; + margin-top: 1px; +} + +/* ---- Actions row ---- */ .actions { display: flex; gap: 10px; @@ -219,23 +427,38 @@ textarea:focus { margin-bottom: 0; } -/* Settings */ +/* ---- Settings ---- */ .settings-item { display: flex; align-items: center; justify-content: space-between; - padding: 12px 0; - color: #6b21a8; - font-weight: 500; - font-size: 14px; + gap: 12px; } -/* Toggle Switch */ +.settings-text { + min-width: 0; +} + +.settings-label { + font-size: 13.5px; + font-weight: 600; + color: var(--text); +} + +.settings-desc { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; + margin-top: 2px; +} + +/* Toggle */ .toggle { position: relative; display: inline-block; - width: 50px; - height: 26px; + width: 42px; + height: 24px; + flex: none; } .toggle input { @@ -246,147 +469,174 @@ textarea:focus { .slider { position: absolute; + inset: 0; cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: #cbd5e1; - transition: 0.3s; - border-radius: 26px; + background: var(--border-strong); + transition: background 0.2s ease; + border-radius: 999px; } .slider:before { position: absolute; - content: ""; - height: 20px; - width: 20px; + content: ''; + height: 18px; + width: 18px; left: 3px; bottom: 3px; - background: white; - transition: 0.3s; + background: #fff; + transition: transform 0.2s ease; border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-sm); } input:checked + .slider { - background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%); + background: var(--accent); } input:checked + .slider:before { - transform: translateX(24px); + transform: translateX(18px); +} + +input:focus-visible + .slider { + box-shadow: 0 0 0 3px var(--accent-ring); } -/* Trusted Sites */ +/* ---- Trusted sites ---- */ .trusted-list { - max-height: 180px; + max-height: 168px; overflow-y: auto; + margin: -2px; + padding: 2px; } .trusted-item { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px; - margin: 6px 0; - background: linear-gradient(135deg, #fef3c7 0%, #e0f2fe 100%); - border: 1px solid #bfdbfe; - border-radius: 8px; - font-size: 12px; + gap: 10px; + padding: 9px 11px; + margin-bottom: 7px; + background: var(--surface-inset); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.trusted-item:last-child { + margin-bottom: 0; } .trusted-origin { - font-family: monospace; - color: #475569; + font-family: var(--mono); + font-size: 11.5px; + color: var(--text); flex: 1; - margin-right: 8px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .btn-remove { + flex: none; padding: 4px 10px; - background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%); - border: 1px solid #f87171; - border-radius: 6px; - color: #991b1b; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: inherit; font-size: 11px; font-weight: 600; cursor: pointer; - transition: all 0.2s; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; } .btn-remove:hover { - background: linear-gradient(135deg, #fca5a5 0%, #f87171 100%); - transform: translateY(-1px); + background: var(--danger-soft); + color: var(--danger); + border-color: var(--danger-border); +} + +.btn-remove:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-ring); } .empty-state { text-align: center; - padding: 32px 16px; - color: #a78bfa; - font-size: 13px; - font-style: italic; + padding: 22px 16px; + color: var(--text-faint); + font-size: 12.5px; } -/* Info Box */ +/* ---- Info box ---- */ .info-box { - background: linear-gradient(135deg, #f0fdf4 0%, #dbeafe 100%); - border: 1px solid #86efac; - border-radius: 12px; - padding: 16px; - margin-top: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin-top: 4px; + box-shadow: var(--shadow-sm); } -.info-box p { - font-size: 12px; - line-height: 1.6; - color: #166534; - margin-bottom: 8px; +.info-box-title { + font-size: 12.5px; + font-weight: 700; + color: var(--text); + margin-bottom: 6px; } -.info-box p:last-child { - margin-bottom: 0; +.info-box p { + font-size: 12px; + line-height: 1.55; + color: var(--text-muted); } .info-box strong { - color: #15803d; + color: var(--text); font-weight: 600; } -/* Footer */ +/* ---- Footer ---- */ .footer { text-align: center; - padding: 16px; + padding: 12px 0 2px; font-size: 12px; - color: #9333ea; + color: var(--text-muted); } .footer a { - color: #7c3aed; + color: var(--accent); text-decoration: none; font-weight: 600; - transition: color 0.2s; } .footer a:hover { - color: #6b21a8; text-decoration: underline; } -/* Scrollbar Styling */ -.trusted-list::-webkit-scrollbar { - width: 6px; +.footer .sep { + margin: 0 6px; + color: var(--text-faint); +} + +/* ---- Scrollbars ---- */ +.trusted-list::-webkit-scrollbar, +.event-preview::-webkit-scrollbar { + width: 8px; } -.trusted-list::-webkit-scrollbar-track { - background: rgba(168, 85, 247, 0.05); - border-radius: 3px; +.trusted-list::-webkit-scrollbar-track, +.event-preview::-webkit-scrollbar-track { + background: transparent; } -.trusted-list::-webkit-scrollbar-thumb { - background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%); - border-radius: 3px; +.trusted-list::-webkit-scrollbar-thumb, +.event-preview::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; } -.trusted-list::-webkit-scrollbar-thumb:hover { - background: linear-gradient(135deg, #7c3aed 0%, #6b21a8 100%); +.trusted-list::-webkit-scrollbar-thumb:hover, +.event-preview::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); } diff --git a/popup/popup.html b/popup/popup.html index 5ae2fed..0ff22d5 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -3,14 +3,21 @@ - Podkey - Your Nostr Identity + Podkey โ€” Your Nostr Identity