Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
19 changes: 16 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -44,4 +45,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}
99 changes: 99 additions & 0 deletions src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>} 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<string>} 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<string>} 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
*/
Expand Down
Loading