Fix direct message encryption fallback and add regression test

This commit is contained in:
thePR0M3TH3AN committed 2025-10-21 21:57:13 -04:00
1 parent 2a3fc276c1
commit d8dcdf2b40
3 files changed
+206 -8

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ Automated regression coverage now exercises these flows on the unified design-sy
- Sign in with a new nsec or mnemonic, opt into remembering it, and verify the passphrase prompt blocks submission until both passphrase fields match.
- Reload the page, select "Unlock saved key", enter the passphrase, and confirm the account unlocks without re-entering the secret.
- Publish a video while logged in with the direct-key provider and confirm the resulting event is signed (relays accept it) and the UI reports success.
- While still signed in via the direct-key provider, send a direct message to a second account and confirm it decrypts successfully on the recipient before release.
- Log out and ensure the session clears decrypted key material; stored keys should require the passphrase to unlock again.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
+71 -8
View File
@@ -8959,10 +8959,6 @@ export class NostrClient {
return { ok: false, error: "sign-event-unavailable" };
}
if (typeof signer.nip04Encrypt !== "function") {
return { ok: false, error: "nip04-unavailable" };
}
if (shouldRequestExtensionPermissions(signer)) {
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
@@ -9002,11 +8998,78 @@ export class NostrClient {
return { ok: false, error: "invalid-target" };
}
const encryptionCandidates = [];
if (typeof signer.nip44Encrypt === "function") {
encryptionCandidates.push({
scheme: "nip44",
encrypt: signer.nip44Encrypt,
});
}
if (typeof signer.nip04Encrypt === "function") {
encryptionCandidates.push({
scheme: "nip04",
encrypt: signer.nip04Encrypt,
});
}
const encryptionErrors = [];
let ciphertext = "";
try {
ciphertext = await signer.nip04Encrypt(targetHex, trimmedMessage);
} catch (error) {
return { ok: false, error: "encryption-failed", details: error };
for (const candidate of encryptionCandidates) {
try {
const encrypted = await candidate.encrypt(targetHex, trimmedMessage);
if (typeof encrypted === "string" && encrypted) {
ciphertext = encrypted;
break;
}
} catch (error) {
encryptionErrors.push({ scheme: candidate.scheme, error });
}
}
if (!ciphertext) {
const normalizedActorHex = normalizeActorKey(actorHex);
const sessionActor = this.sessionActor;
const sessionMatchesActor =
sessionActor &&
typeof sessionActor.pubkey === "string" &&
sessionActor.pubkey.toLowerCase() === normalizedActorHex &&
typeof sessionActor.privateKey === "string" &&
sessionActor.privateKey;
if (sessionMatchesActor) {
try {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (tools?.nip04 && typeof tools.nip04.encrypt === "function") {
const encrypted = await tools.nip04.encrypt(
sessionActor.privateKey,
targetHex,
trimmedMessage,
);
if (typeof encrypted === "string" && encrypted) {
ciphertext = encrypted;
}
} else {
encryptionErrors.push({
scheme: "nip04",
error: new Error("nostr-tools nip04 helpers unavailable"),
});
}
} catch (error) {
encryptionErrors.push({ scheme: "nip04", error });
}
}
}
if (!ciphertext) {
const details =
encryptionErrors.length === 1
? encryptionErrors[0].error
: encryptionErrors.map((entry) => ({
scheme: entry.scheme,
error: entry.error,
}));
return { ok: false, error: "encryption-failed", details };
}
const event = {
+134
View File
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import test from "node:test";
const RELAY_URL = "wss://relay.unit.test";
test("sendDirectMessage succeeds with private key signer and no extension", async () => {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousNostrTools = globalThis.NostrTools;
const previousReady = globalThis.nostrToolsReady;
const nostrTools = await import("nostr-tools");
const canonicalTools = { ...nostrTools };
if (typeof canonicalTools.signEvent !== "function") {
canonicalTools.signEvent = (event, privateKey) => {
const finalized = canonicalTools.finalizeEvent(event, privateKey);
return finalized.sig;
};
}
try {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools;
globalThis.NostrTools = canonicalTools;
globalThis.nostrToolsReady = Promise.resolve({
ok: true,
value: canonicalTools,
});
const { nostrClient, getActiveSigner, clearActiveSigner } = await import(
"../js/nostr.js"
);
const previousPool = nostrClient.pool;
const previousRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const previousPubkey = nostrClient.pubkey;
const previousSessionActor = nostrClient.sessionActor;
const previousLockedSessionActor = nostrClient.lockedSessionActor;
const publishInvocations = [];
nostrClient.pool = {
publish(urls, event) {
publishInvocations.push({ urls, event });
return {
on(eventName, handler) {
if (eventName === "ok") {
setTimeout(() => handler(), 0);
}
return true;
},
};
},
};
nostrClient.relays = [RELAY_URL];
const senderPrivateKey = canonicalTools.utils.bytesToHex(
canonicalTools.generateSecretKey(),
);
const senderPubkey = canonicalTools.getPublicKey(senderPrivateKey);
const recipientSecret = canonicalTools.generateSecretKey();
const recipientPrivateKey = canonicalTools.utils.bytesToHex(recipientSecret);
const recipientPubkey = canonicalTools.getPublicKey(recipientSecret);
await nostrClient.registerPrivateKeySigner({
privateKey: senderPrivateKey,
pubkey: senderPubkey,
});
nostrClient.pubkey = senderPubkey;
const signer = getActiveSigner();
const originalSignEvent = signer?.signEvent;
const originalNip04 = signer?.nip04Encrypt;
const originalNip44 = signer?.nip44Encrypt;
if (signer) {
signer.signEvent = (event) => {
const finalized = canonicalTools.finalizeEvent(event, senderPrivateKey);
return { ...event, id: finalized.id, sig: finalized.sig };
};
delete signer.nip04Encrypt;
delete signer.nip44Encrypt;
}
const message = "bitvid direct message smoke test";
const result = await nostrClient.sendDirectMessage(recipientPubkey, message);
assert.deepEqual(result, { ok: true }, "sendDirectMessage should resolve successfully");
assert.equal(publishInvocations.length, 1, "exactly one relay publish should be attempted");
const [{ urls, event }] = publishInvocations;
assert.deepEqual(urls, [RELAY_URL], "publish should target the configured relay");
assert.equal(event.kind, 4, "event kind should be 4 for direct messages");
assert.equal(event.pubkey, senderPubkey, "event should be authored by the sender");
assert.equal(typeof event.content, "string", "ciphertext should be a string");
assert.notEqual(event.content, "", "ciphertext should not be empty");
const decrypted = await canonicalTools.nip04.decrypt(
recipientPrivateKey,
senderPubkey,
event.content,
);
assert.equal(decrypted, message, "recipient should decrypt the original message");
if (signer) {
signer.signEvent = originalSignEvent;
signer.nip04Encrypt = originalNip04;
signer.nip44Encrypt = originalNip44;
}
clearActiveSigner();
nostrClient.pool = previousPool;
nostrClient.relays = previousRelays;
nostrClient.pubkey = previousPubkey;
nostrClient.sessionActor = previousSessionActor;
nostrClient.lockedSessionActor = previousLockedSessionActor;
} finally {
if (previousCanonical === undefined) {
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
} else {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
}
if (previousNostrTools === undefined) {
delete globalThis.NostrTools;
} else {
globalThis.NostrTools = previousNostrTools;
}
if (previousReady === undefined) {
delete globalThis.nostrToolsReady;
} else {
globalThis.nostrToolsReady = previousReady;
}
}
});