Skip to content

security: implement signing approval popup (replace silent auto-approve)#20

Closed
jjohare wants to merge 1 commit into
JavaScriptSolidServer:mainfrom
jjohare:security/signing-approval-ui
Closed

security: implement signing approval popup (replace silent auto-approve)#20
jjohare wants to merge 1 commit into
JavaScriptSolidServer:mainfrom
jjohare:security/signing-approval-ui

Conversation

@jjohare

@jjohare jjohare commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replace showPermissionPrompt() auto-approve stub with a real popup-based approval flow
  • New popup/approve.html + popup/approve.js: Approval dialog showing origin, action, and event preview
  • 60-second auto-deny timeout prevents abandoned prompts from blocking indefinitely

Security Impact (CRITICAL)

This is the most critical security fix. The current showPermissionPrompt() implementation:

async function showPermissionPrompt(origin, action) {
  console.log(`[Podkey] Auto-approving: ${origin} wants to ${action}`);
  return true; // <-- EVERY request is silently approved
}

Every website can silently sign Nostr events as the user. A malicious page can:

  1. Call window.nostr.signEvent() to sign arbitrary events (posts, DMs, delegation tokens)
  2. Call window.nostr.getPublicKey() to identify the user
  3. The user sees no prompt, no notification, nothing

This completely defeats the purpose of a signing extension. The user's identity is effectively compromised the moment they visit any page with JavaScript.

How the Fix Works

The new flow follows the established pattern used by nos2x, Alby, and other NIP-07 extensions:

  1. Website calls window.nostr.signEvent(event)
  2. Content script forwards to background service worker
  3. showPermissionPrompt() generates a unique requestId and opens popup/approve.html via chrome.windows.create()
  4. The popup displays:
    • The requesting origin (e.g., https://evil-site.com)
    • The action being requested (e.g., "sign an event (kind 1)")
    • A preview of the event data (kind, truncated content, tag count)
    • A countdown bar showing the 60-second timeout
  5. User clicks Approve or Deny
  6. approve.js sends APPROVE_SIGNING message back to background
  7. Background resolves the pending Promise and proceeds accordingly

If the user closes the popup without clicking, the beforeunload handler sends a deny. If 60 seconds pass with no response, the request is auto-denied.

Files Changed

File Change
src/background.js Replace auto-approve with popup-based showPermissionPrompt(); add pendingApprovals Map; handle APPROVE_SIGNING messages; pass event preview data to prompts
popup/approve.html New approval dialog with origin badge, action text, event preview, approve/deny buttons, timeout countdown bar
popup/approve.js Handle URL params, wire up approve/deny buttons, send APPROVE_SIGNING messages, deny on window close

Test plan

  • Load extension, visit a test page that calls window.nostr.getPublicKey() -- verify approval popup appears
  • Click Deny -- verify the calling page receives an error
  • Click Approve -- verify the calling page receives the public key
  • Test window.nostr.signEvent() -- verify popup shows event kind and content preview
  • Close the popup window without clicking a button -- verify request is denied
  • Wait 60 seconds without interacting -- verify request is auto-denied
  • Verify trusted origins still auto-approve for Solid auth events (kind 27235) with auto-sign enabled
  • Verify the popup opens focused and in front of the browser window

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<requestId, resolve>
  - 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 <ruv@ruv.net>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a critical security gap in the signing flow by replacing the background service worker’s silent auto-approval with an explicit user approval popup before releasing a public key or signing events.

Changes:

  • Implement a real showPermissionPrompt() that opens an approval popup and resolves via a pendingApprovals map in the background worker.
  • Add a new approval UI (popup/approve.html + popup/approve.js) that shows origin/action plus a small event preview.
  • Add a 60-second auto-deny timeout in the background to prevent hanging requests.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/background.js Replaces auto-approve stub with popup-based approval flow and request tracking/timeout.
popup/approve.html Adds a dedicated approval dialog UI for signing/permission requests.
popup/approve.js Wires the approval UI to send approve/deny messages back to the background worker.
Comments suppressed due to low confidence (1)

src/background.js:325

  • formatEventForPrompt() is now unused after switching the signing prompt to pass previewData JSON into showPermissionPrompt(). Please remove this dead code (or re-use it to build the popup preview) to avoid confusing future maintenance.
/**
 * Format event for display in prompt
 */
function formatEventForPrompt (event) {
  const lines = [];

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

Comment thread popup/approve.js
Comment thread popup/approve.js
Comment thread popup/approve.html
Comment on lines +2 to +4
<html>
<head>
<meta charset="utf-8">
Comment thread popup/approve.html
<html>
<head>
<meta charset="utf-8">
<title>PodKey - Signing Request</title>
@jjohare

jjohare commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #22 — a single CI-green, adversarially-reviewed PR that consolidates this branch with the other security fixes plus NIP-44 and residual gap-fixes (exact-host trust, NIP-98 nonce, signEvent self-verify, honest provider surface, auto-sign default OFF). Suggest reviewing/merging #22; this can be closed in its favour.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


DreamLab-AI Mega-Sprint seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

jjohare added a commit that referenced this pull request Jun 15, 2026
Consolidated hardening of the Podkey key-holding signer: brings seven in-flight security/feature branches plus residual gap-fills, a polished CSP-clean UI, a CI build/test/lint gate, and a 133-test suite into one reviewable PR. The fix set was independently re-derived from a full audit of the extension and every item below was verified fixed *in the final tree* by an adversarial review pass (not just claimed).

**Supersedes** (folded in here; safe to close in favour of this PR): #12, #14, #17, #18, #19, #20, #21.

## Security posture — before → after

| Area | Before | After |
|---|---|---|
| Consent | Auto-approve everything (`showPermissionPrompt` hard-returned `true`) | **Explicit per-origin consent** popup that blocks on the user's decision; closing or 60s timeout denies |
| Key at rest | `storage.local` (persisted plaintext) | **`storage.session` only** (in-memory, cleared on browser close); raw key never crosses to the page |
| Crypto | A fake `crypto-browser.js` in the tree (`pubkey = sha256(privkey)`) | Deleted; **real `@noble` crypto** only, with `signEvent` schnorr self-verify before return |
| NIP-98 | Signed-event cache → byte-identical id → replayable | **Fresh per-token 16-byte nonce**, redirect-aware `u` tag, page-side body-hash binding |
| Solid-host trust | Substring match (`inrupt.net.evil.com` accepted) | **Exact-host match**, case-insensitive; lookalikes rejected |
| Interception | Double fetch/XHR interception + double 401-retry | **Single interception path**, one retry |
| Message channel | Page could inject arbitrary fields / origin / `privateKey` | Content script **whitelists 4 message types, forwards only safe fields, enforces the real origin** |
| Provider surface | Advertised `nip04`/`getRelays` that throw / return `{}` | **Honest**: `getPublicKey`, `signEvent`, `nip44.{encrypt,decrypt}` only |
| UI | Inline scripts + `innerHTML` XSS sinks | **CSP `script-src 'self'`**; all DOM via `textContent`/`createElement` |
| Auto-sign | Defaulted **ON** (first-run silent trust + NIP-98 mint) | Defaults **OFF** — silent trusted-origin Solid signing and NIP-98 auto-trust are strictly opt-in |

## Notes for review

- **Consent timeout:** `approve.js` actively sends `approved:false` + closes at the 60s mark (one-shot `decisionSent` guard) so the visible countdown is truthful; the background `chrome.windows` 60s timer remains a backstop. If you'd prefer the popup stay passive and let the background own the timeout, that is the single behavioural line to reconsider — everything else on the UI is restyling.
- The page-context `nip98-interceptor.js` still duplicates a couple of `auth-header-utils.js` helpers because page context can't ESM-import the module; intentional, tracked separately.

## Verification

- `npm run build` ✓ · `npm test` → **133 pass / 0 fail** ✓ · `npm run lint` → **0 errors** (now `no-unused-vars: error`) ✓
- New `.github/workflows/ci.yml` runs build → test → lint on every PR and push to `main`.

🤖 Generated by Claude Code
@jjohare

jjohare commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #22 (merged as 384918d), which consolidates this branch's fixes into one reviewed, CI-green change. Closing in favour of that.

@jjohare jjohare closed this Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants