Skip to content

feat: NIP-44 (v2) encrypt/decrypt for window.nostr#21

Closed
jjohare wants to merge 1 commit into
JavaScriptSolidServer:mainfrom
jjohare:feat/nip44-support
Closed

feat: NIP-44 (v2) encrypt/decrypt for window.nostr#21
jjohare wants to merge 1 commit into
JavaScriptSolidServer:mainfrom
jjohare:feat/nip44-support

Conversation

@jjohare

@jjohare jjohare commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

What

Adds NIP-44 (v2) encryption to the injected NIP-07 provider:

  • window.nostr.nip44.encrypt(peerPubkeyHex, plaintext) → base64 payload
  • window.nostr.nip44.decrypt(peerPubkeyHex, base64payload) → plaintext

Why (DreamLab forum DM use case)

The DreamLab nostr-rust-forum
needs window.nostr.nip44.{encrypt,decrypt} to decrypt NIP-44 gift-wrapped
(NIP-59) direct messages for users who sign in with Podkey. Podkey currently
injects getPublicKey, signEvent, and nip04, but not nip44, so the
forum shows "Your Nostr extension doesn't support NIP-44 encryption". Adding
nip44 makes DMs (and any NIP-17 / NIP-44 feature) work through the extension.

How — mirrors the existing message path exactly

NIP-07 keeps the private key in the background service worker, never the page.
The new code reuses the same page → content-script → background round-trip
the provider already uses for signing:

  1. src/nostr-provider.js (page): nip44.encrypt/decrypt post a
    podkey-request CustomEvent (NIP44_ENCRYPT / NIP44_DECRYPT) — identical
    shape to the existing nip04 calls.
  2. src/injected.js (content script): unchanged — already relays
    { id, type, ...data } generically to the background via
    chrome.runtime.sendMessage.
  3. src/background.js (service worker): new NIP44_ENCRYPT /
    NIP44_DECRYPT / NIP44_GET_CONVERSATION_KEY cases route to handlers that
    resolve the keypair through a resolveKeypairForEncryption() helper which
    gates on origin trust exactly like GET_PUBLIC_KEY / SIGN_EVENT do.
    The raw private key never crosses to the page — only the base64
    payload / plaintext / conversation-key hex is returned.

NIP-44 v2 implementation

src/nip44.js implements the current spec (version 0x02) using the vetted
@noble stack the extension already depends on
:

  • @noble/secp256k1 — ECDH shared secret (x-coordinate)
  • @noble/hasheshkdf (extract/expand), hmac, sha256
  • @noble/cipherschacha20 (new dependency, same @noble family)
conversation_key = hkdf_extract(salt="nip44-v2", ikm=ECDH(priv, pub).x)
per-message keys = hkdf_expand(conversation_key, info=nonce, 76)
                     -> chacha_key(32) ‖ chacha_nonce(12) ‖ hmac_key(32)
ciphertext       = chacha20(chacha_key, chacha_nonce, pad(plaintext))
mac              = hmac_sha256(hmac_key, nonce ‖ ciphertext)
payload          = base64(version(0x02) ‖ nonce(32) ‖ ciphertext ‖ mac(32))

Tests

test/nip44.test.js (runs under the existing node --test runner) covers:

  • symmetric conversation keys, round-trips (unicode / long / random-nonce),
    background-path helpers, HMAC tamper detection, unknown-version rejection
  • the official NIP-44 spec test vectors (deterministic conversation key +
    deterministic encrypt/decrypt with a fixed nonce)

npm test → all green (incl. the pre-existing suites). npm run build bundles
the service worker cleanly with the new module + chacha20 inlined.

Notes for reviewers

  • nip04 is left untouched (still a stub on main); this PR is NIP-44 only,
    which is what NIP-17/NIP-59 DMs require.
  • README + CHANGELOG updated to advertise the new capability.
  • src/background.bundle.js is git-ignored (build artifact) and not committed.

🤖 Generated by Claude Code

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 <github@thedreamlab.uk>
@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.


Claude Code 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

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.

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.

2 participants