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/); + }); + }); +});