mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Add NIP-04 support for drop-in NIP-07 parity
signEvent() already handles any event kind and NIP-44 was already implemented, but window.nostr.nip04 (and the equivalent element-scoped nip04Encrypt/nip04Decrypt) didn't exist anywhere -- every real NIP-07 extension exposes it, and sites with legacy DM code paths still expect it. A site whose own code called window.nostr.nip04.encrypt(...) while BitLogin was the active signer got a bare "Cannot read properties of undefined" instead of a working call. - packages/core/src/crypto/nip04.ts: AES-256-CBC + secp256k1 ECDH shared secret (raw X coordinate, no HKDF -- this is legacy NIP-04, not NIP-44), "<ciphertext>?iv=<iv>" payload shape, cross-checked against the NIP-04 spec's algorithm description - NostrSigner.nip04Encrypt/nip04Decrypt, mirroring the existing nip44Encrypt/nip44Decrypt methods - crypto worker: new nip04Encrypt/nip04Decrypt actions and protocol payload types - <bitlogin-auth>.nip04Encrypt/nip04Decrypt (element-scoped, like the existing nip44 methods) and window.nostr.nip04.encrypt/decrypt Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
This commit is contained in:
1 parent
66cae69c7e
commit
283840261f
10 files changed
+167
-1
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 \"<ciphertext>?iv=<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 {
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./bip39.js";
|
||||
export * from "./padding.js";
|
||||
export * from "./jcs.js";
|
||||
export * from "./nip44.js";
|
||||
export * from "./nip04.js";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -165,6 +165,17 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
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<string> {
|
||||
return (await this.worker.nip04Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
return (await this.worker.nip04Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
async logout(): Promise<void> {
|
||||
await this.worker.logout();
|
||||
this.session = null;
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface Nip07Provider {
|
||||
encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
};
|
||||
/** 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<string>;
|
||||
decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
};
|
||||
/**
|
||||
* 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<string> {
|
||||
const { ciphertext } = await wrapUnlockError(workerClient.nip04Encrypt({ peerPublicKey, plaintext }));
|
||||
return ciphertext;
|
||||
},
|
||||
async decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
const { plaintext } = await wrapUnlockError(workerClient.nip04Decrypt({ peerPublicKey, payload }));
|
||||
return plaintext;
|
||||
}
|
||||
},
|
||||
_bitlogin: true
|
||||
};
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
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()) };
|
||||
|
||||
@@ -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<string, never>, ExportIdentityResult];
|
||||
buildRecoveryExport: [Record<string, never>, unknown];
|
||||
repairReplicas: [Record<string, never>, RepairReplicasResult];
|
||||
|
||||
@@ -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", {});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user