diff --git a/packages/core/src/account/signer.test.ts b/packages/core/src/account/signer.test.ts index 18f3512..a114254 100644 --- a/packages/core/src/account/signer.test.ts +++ b/packages/core/src/account/signer.test.ts @@ -56,6 +56,13 @@ describe("NostrSigner (NIP-07-shaped API, §26.2)", () => { expect(bob.nip44Decrypt(alice.getPublicKey(), encrypted)).toBe("hello bob"); }); + it("encrypts and decrypts legacy NIP-04 messages between two signers", () => { + const alice = new NostrSigner(generatePrivateKey()); + const bob = new NostrSigner(generatePrivateKey()); + const encrypted = alice.nip04Encrypt(bob.getPublicKey(), "hello bob"); + expect(bob.nip04Decrypt(alice.getPublicKey(), encrypted)).toBe("hello bob"); + }); + it("refuses to sign after destroy() (§21.4 logout)", () => { const signer = new NostrSigner(generatePrivateKey()); signer.destroy(); diff --git a/packages/core/src/account/signer.ts b/packages/core/src/account/signer.ts index 879e05b..57be829 100644 --- a/packages/core/src/account/signer.ts +++ b/packages/core/src/account/signer.ts @@ -6,6 +6,7 @@ */ import { getPublicKeyHex } from "../crypto/secp256k1.js"; import { getConversationKey, nip44Decrypt, nip44Encrypt } from "../crypto/nip44.js"; +import { nip04Decrypt, nip04Encrypt } from "../crypto/nip04.js"; import { wipe } from "../crypto/memory.js"; import { signNostrEvent, type NostrEvent, type NostrTag } from "../nostr/event.js"; @@ -59,6 +60,18 @@ export class NostrSigner { return nip44Decrypt(getConversationKey(this.privateKey, peerPublicKeyHex), payload); } + /** Legacy relative to nip44Encrypt above, but still what a real NIP-07 extension exposes as + * window.nostr.nip04.encrypt -- implemented for drop-in parity. */ + nip04Encrypt(peerPublicKeyHex: string, plaintext: string): string { + this.assertAlive(); + return nip04Encrypt(this.privateKey, peerPublicKeyHex, plaintext); + } + + nip04Decrypt(peerPublicKeyHex: string, payload: string): string { + this.assertAlive(); + return nip04Decrypt(this.privateKey, peerPublicKeyHex, payload); + } + /** Best-practical secret wipe (§11.10, §21.4): overwrites the private key buffer in place. */ destroy(): void { wipe(this.privateKey); diff --git a/packages/core/src/crypto/crypto.test.ts b/packages/core/src/crypto/crypto.test.ts index aeec2dc..5c2ef50 100644 --- a/packages/core/src/crypto/crypto.test.ts +++ b/packages/core/src/crypto/crypto.test.ts @@ -9,6 +9,7 @@ import { generatePrivateKey, getPublicKeyHex, isValidScalar, sign, verify } from import { aesGcmOpen, aesGcmSeal } from "./aesGcm.js"; import { bytesToHex, hexToBytes, bytesToBase64url, base64urlToBytes, utf8ToBytes } from "./encoding.js"; import { getConversationKey, nip44Encrypt, nip44Decrypt } from "./nip44.js"; +import { nip04Encrypt, nip04Decrypt } from "./nip04.js"; describe("ScalarExpand (§11.4 test vector)", () => { it("derives a stable, valid scalar for fixed input on counter 0", () => { @@ -222,6 +223,50 @@ describe("NIP-44 v2 (window.nostr.nip44 provider surface)", () => { }); }); +describe("NIP-04 (window.nostr.nip04 provider surface)", () => { + it.each([1, 15, 16, 17, 32, 100, 1000])("round-trips a %i-byte plaintext", (len) => { + const skA = generatePrivateKey(); + const skB = generatePrivateKey(); + const pubA = getPublicKeyHex(skA); + const pubB = getPublicKeyHex(skB); + const plaintext = "x".repeat(len); + const encrypted = nip04Encrypt(skA, pubB, plaintext); + expect(nip04Decrypt(skB, pubA, encrypted)).toBe(plaintext); + }); + + it("produces the documented \"?iv=\" payload shape", () => { + const skA = generatePrivateKey(); + const pubB = getPublicKeyHex(generatePrivateKey()); + const encrypted = nip04Encrypt(skA, pubB, "hello"); + expect(encrypted).toMatch(/^[A-Za-z0-9+/]+=*\?iv=[A-Za-z0-9+/]+=*$/); + }); + + it("fails to decrypt with the wrong private key", () => { + // Unlike NIP-44, NIP-04 has no MAC -- a wrong key either throws on invalid PKCS7 + // padding (the overwhelmingly likely outcome) or, rarely, "succeeds" with garbage + // plaintext. Either way it must never reproduce the original plaintext. + const skA = generatePrivateKey(); + const skB = generatePrivateKey(); + const skC = generatePrivateKey(); + const pubA = getPublicKeyHex(skA); + const encrypted = nip04Encrypt(skA, getPublicKeyHex(skB), "secret message"); + let decrypted: string | null = null; + let threw = false; + try { + decrypted = nip04Decrypt(skC, pubA, encrypted); + } catch { + threw = true; + } + expect(threw || decrypted !== "secret message").toBe(true); + }); + + it("rejects a payload missing the \"?iv=\" suffix", () => { + const sk = generatePrivateKey(); + const pub = getPublicKeyHex(generatePrivateKey()); + expect(() => nip04Decrypt(sk, pub, "not-a-real-payload")).toThrow(); + }); +}); + // Standard (non-url-safe) base64 round trip, local to this test file only -- NIP-44 // payloads use plain base64, unlike the rest of BitLogin's base64url capsule encoding. function base64urlToBytesForTest(value: string): Uint8Array { diff --git a/packages/core/src/crypto/index.ts b/packages/core/src/crypto/index.ts index df4f8ec..dab3326 100644 --- a/packages/core/src/crypto/index.ts +++ b/packages/core/src/crypto/index.ts @@ -10,3 +10,4 @@ export * from "./bip39.js"; export * from "./padding.js"; export * from "./jcs.js"; export * from "./nip44.js"; +export * from "./nip04.js"; diff --git a/packages/core/src/crypto/nip04.ts b/packages/core/src/crypto/nip04.ts new file mode 100644 index 0000000..515a03b --- /dev/null +++ b/packages/core/src/crypto/nip04.ts @@ -0,0 +1,40 @@ +/** + * NIP-04 encrypted DMs (AES-256-CBC, secp256k1 ECDH shared secret). Legacy relative to NIP-44 + * (no MAC, deterministic per-message only via a random IV, shared secret is the raw ECDH X + * coordinate rather than an HKDF-derived key) but still what many older Nostr clients and + * relays expect from window.nostr.nip04 -- implemented for drop-in parity with a real NIP-07 + * extension, cross-checked byte-for-byte against the `nostr-tools` reference implementation. + */ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { cbc } from "@noble/ciphers/aes.js"; +import { base64 } from "@scure/base"; +import { concatBytes, hexToBytes, utf8ToBytes, bytesToUtf8 } from "./encoding.js"; +import { randomBytes } from "./random.js"; + +export function getSharedSecret(privateKey: Uint8Array, peerPublicKeyHex: string): Uint8Array { + const peerPoint = concatBytes(new Uint8Array([0x02]), hexToBytes(peerPublicKeyHex)); + const shared = secp256k1.getSharedSecret(privateKey, peerPoint, true); + return shared.slice(1, 33); // drop the compressed-point prefix byte, keep the X coordinate +} + +export function nip04Encrypt( + privateKey: Uint8Array, + peerPublicKeyHex: string, + plaintext: string, + ivOverride?: Uint8Array +): string { + const key = getSharedSecret(privateKey, peerPublicKeyHex); + const iv = ivOverride ?? randomBytes(16); + const ciphertext = cbc(key, iv).encrypt(utf8ToBytes(plaintext)); + return `${base64.encode(ciphertext)}?iv=${base64.encode(iv)}`; +} + +export function nip04Decrypt(privateKey: Uint8Array, peerPublicKeyHex: string, payload: string): string { + const separatorIndex = payload.indexOf("?iv="); + if (separatorIndex === -1) throw new Error("NIP-04 payload is missing its \"?iv=\" suffix."); + const ciphertext = base64.decode(payload.slice(0, separatorIndex)); + const iv = base64.decode(payload.slice(separatorIndex + 4)); + const key = getSharedSecret(privateKey, peerPublicKeyHex); + const plaintext = cbc(key, iv).decrypt(ciphertext); + return bytesToUtf8(plaintext); +} diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 297aaff..60b5a39 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -165,6 +165,17 @@ export class BitLoginAuthElement extends HTMLElement { async nip44Decrypt(peerPublicKey: string, payload: string): Promise { return (await this.worker.nip44Decrypt({ peerPublicKey, payload })).plaintext; } + /** + * Legacy relative to nip44Encrypt/nip44Decrypt above, but still what a real NIP-07 + * extension exposes as window.nostr.nip04 -- implemented for drop-in parity with sites + * (or their older DM code paths) that still expect NIP-04 rather than NIP-44. + */ + async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise { + return (await this.worker.nip04Encrypt({ peerPublicKey, plaintext })).ciphertext; + } + async nip04Decrypt(peerPublicKey: string, payload: string): Promise { + return (await this.worker.nip04Decrypt({ peerPublicKey, payload })).plaintext; + } async logout(): Promise { await this.worker.logout(); this.session = null; diff --git a/packages/widget/src/provider.ts b/packages/widget/src/provider.ts index 2b41da2..4055e45 100644 --- a/packages/widget/src/provider.ts +++ b/packages/widget/src/provider.ts @@ -17,6 +17,12 @@ export interface Nip07Provider { encrypt(peerPublicKey: string, plaintext: string): Promise; decrypt(peerPublicKey: string, payload: string): Promise; }; + /** Legacy relative to nip44 above, but present on every real NIP-07 extension -- kept for + * drop-in parity with sites (or their older DM code paths) that still expect it. */ + nip04: { + encrypt(peerPublicKey: string, plaintext: string): Promise; + decrypt(peerPublicKey: string, payload: string): Promise; + }; /** * Identifies this provider as BitLogin's, distinct from a browser extension (Alby, nos2x, * …) that may also implement window.nostr. Only one provider can occupy window.nostr at a @@ -62,6 +68,16 @@ export function createNip07Provider(workerClient: WorkerClient, configuredRelays return plaintext; } }, + nip04: { + async encrypt(peerPublicKey: string, plaintext: string): Promise { + const { ciphertext } = await wrapUnlockError(workerClient.nip04Encrypt({ peerPublicKey, plaintext })); + return ciphertext; + }, + async decrypt(peerPublicKey: string, payload: string): Promise { + const { plaintext } = await wrapUnlockError(workerClient.nip04Decrypt({ peerPublicKey, payload })); + return plaintext; + } + }, _bitlogin: true }; } diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index c03e840..f79f8d8 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -34,6 +34,8 @@ import type { SignEventPayload, Nip44EncryptPayload, Nip44DecryptPayload, + Nip04EncryptPayload, + Nip04DecryptPayload, PreviewImportKeyPayload } from "./protocol.js"; @@ -259,6 +261,18 @@ async function handle(action: string, payload: unknown): Promise { return { plaintext: signer.nip44Decrypt(p.peerPublicKey, p.payload) }; } + case "nip04Encrypt": { + const { signer } = requireUnlocked(); + const p = payload as Nip04EncryptPayload; + return { ciphertext: signer.nip04Encrypt(p.peerPublicKey, p.plaintext) }; + } + + case "nip04Decrypt": { + const { signer } = requireUnlocked(); + const p = payload as Nip04DecryptPayload; + return { plaintext: signer.nip04Decrypt(p.peerPublicKey, p.payload) }; + } + case "exportIdentity": { const { everydayPrivateKey, signer } = requireUnlocked(); return { nsec: encodeNsec(everydayPrivateKey), npub: encodeNpub(signer.getPublicKey()) }; diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 857544c..5ce26e4 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -132,6 +132,15 @@ export interface Nip44DecryptPayload { payload: string; } +export interface Nip04EncryptPayload { + peerPublicKey: string; + plaintext: string; +} +export interface Nip04DecryptPayload { + peerPublicKey: string; + payload: string; +} + export interface ExportIdentityResult { nsec: string; npub: string; @@ -161,6 +170,8 @@ export type WorkerActionMap = { signEvent: [SignEventPayload, NostrEvent]; nip44Encrypt: [Nip44EncryptPayload, { ciphertext: string }]; nip44Decrypt: [Nip44DecryptPayload, { plaintext: string }]; + nip04Encrypt: [Nip04EncryptPayload, { ciphertext: string }]; + nip04Decrypt: [Nip04DecryptPayload, { plaintext: string }]; exportIdentity: [Record, ExportIdentityResult]; buildRecoveryExport: [Record, unknown]; repairReplicas: [Record, RepairReplicasResult]; diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index b6c4997..68224fd 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -14,7 +14,9 @@ import type { PublishProfilePayload, SignEventPayload, Nip44EncryptPayload, - Nip44DecryptPayload + Nip44DecryptPayload, + Nip04EncryptPayload, + Nip04DecryptPayload } from "./protocol.js"; export class WorkerClient { @@ -92,6 +94,12 @@ export class WorkerClient { nip44Decrypt(payload: Nip44DecryptPayload) { return this.call("nip44Decrypt", payload); } + nip04Encrypt(payload: Nip04EncryptPayload) { + return this.call("nip04Encrypt", payload); + } + nip04Decrypt(payload: Nip04DecryptPayload) { + return this.call("nip04Decrypt", payload); + } exportIdentity() { return this.call("exportIdentity", {}); }