Merge pull request #789 from PR0M3TH3AN/codex/fix-cors-issues-with-nip-46-connection

Improve NIP-46 remote signer cipher negotiation
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-22 08:42:52 -04:00
commit 37a7ecfaf9
2 files changed
+256 -77

No files matched your search

+180 -42
View File
@@ -199,6 +199,11 @@ const NIP46_SIGN_EVENT_TIMEOUT_MS = 20_000;
const NIP46_MAX_RETRIES = 1;
const NIP46_HANDSHAKE_TIMEOUT_MS = 60_000;
const NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS = 5;
const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([
"nip44.v2",
"nip44",
"nip04",
]);
function getNip46Storage() {
if (typeof localStorage !== "undefined" && localStorage) {
@@ -269,6 +274,13 @@ function sanitizeStoredNip46Session(candidate) {
clientPublicKey,
remotePubkey,
relays,
encryption: normalizeNip46EncryptionAlgorithm(
typeof candidate.encryption === "string"
? candidate.encryption
: typeof candidate.algorithm === "string"
? candidate.algorithm
: "",
),
secret:
typeof candidate.secret === "string" && candidate.secret.trim()
? candidate.secret.trim()
@@ -3484,6 +3496,39 @@ function normalizeNostrPubkey(candidate) {
return trimmed.toLowerCase();
}
function normalizeNip46EncryptionAlgorithm(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return "";
}
if (NIP46_ENCRYPTION_ALGORITHMS.includes(trimmed)) {
return trimmed;
}
const aliasMap = new Map([
["nip44v2", "nip44.v2"],
["nip44-v2", "nip44.v2"],
["nip44_v2", "nip44.v2"],
["nip44-v1", "nip44"],
["nip44v1", "nip44"],
["nip44_v1", "nip44"],
["nip-44", "nip44"],
["nip-04", "nip04"],
["nip04.v1", "nip04"],
]);
if (aliasMap.has(trimmed)) {
return aliasMap.get(trimmed) || "";
}
return "";
}
function resolveNip46Relays(relays, fallbackRelays = []) {
const primary = sanitizeRelayList(Array.isArray(relays) ? relays : []);
if (primary.length) {
@@ -3865,31 +3910,60 @@ function normalizeNip46CiphertextPayload(payload) {
return Array.from(candidates);
}
export function createNip46Cipher(tools, privateKey, remotePubkey) {
function resolveAvailableNip46Ciphers(
tools,
privateKey,
remotePubkey,
{ preferredAlgorithm } = {},
) {
if (!tools) {
throw new Error("NostrTools helpers are unavailable for remote signing.");
return [];
}
const nip44v2GetConversationKey = resolveNip44V2ConversationKeyGetter(tools);
const normalizedPreferred = normalizeNip46EncryptionAlgorithm(preferredAlgorithm);
const available = [];
const registerCipher = (algorithm, factory) => {
try {
const cipher = factory();
if (
cipher &&
typeof cipher.encrypt === "function" &&
typeof cipher.decrypt === "function"
) {
available.push({ ...cipher, algorithm });
}
} catch (error) {
// Ignore individual algorithm failures so other fallbacks can run.
devLogger.debug?.(
"[nostr] Skipping unavailable NIP-46 cipher algorithm",
algorithm,
error,
);
}
};
const nip44v2GetConversationKey = resolveNip44V2ConversationKeyGetter(tools);
if (
tools?.nip44?.v2?.encrypt &&
tools?.nip44?.v2?.decrypt &&
typeof nip44v2GetConversationKey === "function"
) {
const conversationKey = nip44v2GetConversationKey(privateKey, remotePubkey);
registerCipher("nip44.v2", () => {
const conversationKey = nip44v2GetConversationKey(privateKey, remotePubkey);
if (!conversationKey) {
throw new Error("Failed to derive a nip44 conversation key for remote signing.");
}
if (!conversationKey) {
throw new Error("Failed to derive a nip44 conversation key for remote signing.");
}
return {
encrypt: (plaintext, nonce) =>
typeof nonce === "string"
? tools.nip44.v2.encrypt(plaintext, conversationKey, nonce)
: tools.nip44.v2.encrypt(plaintext, conversationKey),
decrypt: (ciphertext) => tools.nip44.v2.decrypt(ciphertext, conversationKey),
};
return {
encrypt: (plaintext, nonce) =>
typeof nonce === "string"
? tools.nip44.v2.encrypt(plaintext, conversationKey, nonce)
: tools.nip44.v2.encrypt(plaintext, conversationKey),
decrypt: (ciphertext) => tools.nip44.v2.decrypt(ciphertext, conversationKey),
};
});
}
const nip44GetConversationKey = resolveLegacyNip44ConversationKeyGetter(tools);
@@ -3899,32 +3973,60 @@ export function createNip46Cipher(tools, privateKey, remotePubkey) {
tools?.nip44?.decrypt &&
typeof nip44GetConversationKey === "function"
) {
const conversationKey = nip44GetConversationKey(privateKey, remotePubkey);
registerCipher("nip44", () => {
const conversationKey = nip44GetConversationKey(privateKey, remotePubkey);
if (!conversationKey) {
throw new Error("Failed to derive a nip44 conversation key for remote signing.");
}
if (!conversationKey) {
throw new Error("Failed to derive a nip44 conversation key for remote signing.");
}
return {
encrypt: (plaintext, nonce) =>
typeof nonce === "string"
? tools.nip44.encrypt(plaintext, conversationKey, nonce)
: tools.nip44.encrypt(plaintext, conversationKey),
decrypt: (ciphertext) => tools.nip44.decrypt(ciphertext, conversationKey),
};
return {
encrypt: (plaintext, nonce) =>
typeof nonce === "string"
? tools.nip44.encrypt(plaintext, conversationKey, nonce)
: tools.nip44.encrypt(plaintext, conversationKey),
decrypt: (ciphertext) => tools.nip44.decrypt(ciphertext, conversationKey),
};
});
}
if (tools?.nip04?.encrypt && tools?.nip04?.decrypt) {
const privateKeyHex = typeof privateKey === "string" ? privateKey : "";
const remotePubkeyHex = typeof remotePubkey === "string" ? remotePubkey : "";
registerCipher("nip04", () => {
const privateKeyHex = typeof privateKey === "string" ? privateKey : "";
const remotePubkeyHex = typeof remotePubkey === "string" ? remotePubkey : "";
return {
encrypt: (plaintext) => tools.nip04.encrypt(privateKeyHex, remotePubkeyHex, plaintext),
decrypt: (ciphertext) => tools.nip04.decrypt(privateKeyHex, remotePubkeyHex, ciphertext),
};
if (!privateKeyHex || !remotePubkeyHex) {
throw new Error("Missing keys for nip04 encryption.");
}
return {
encrypt: (plaintext) =>
tools.nip04.encrypt(privateKeyHex, remotePubkeyHex, plaintext),
decrypt: (ciphertext) =>
tools.nip04.decrypt(privateKeyHex, remotePubkeyHex, ciphertext),
};
});
}
throw new Error("Remote signer encryption helpers are unavailable.");
if (normalizedPreferred) {
const index = available.findIndex((entry) => entry.algorithm === normalizedPreferred);
if (index > 0) {
const [preferred] = available.splice(index, 1);
available.unshift(preferred);
}
}
return available;
}
export function createNip46Cipher(tools, privateKey, remotePubkey, options = {}) {
const available = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey, options);
if (!available.length) {
throw new Error("Remote signer encryption helpers are unavailable.");
}
return available[0];
}
async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) {
@@ -3933,15 +4035,26 @@ async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext)
throw new Error("NostrTools helpers are unavailable for NIP-46 payload decryption.");
}
const { decrypt } = createNip46Cipher(tools, privateKey, remotePubkey);
const ciphers = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey);
if (!ciphers.length) {
throw new Error("Remote signer encryption helpers are unavailable for NIP-46 payload decryption.");
}
const normalizedCandidates = normalizeNip46CiphertextPayload(ciphertext);
const candidates = normalizedCandidates.length
? normalizedCandidates
: typeof ciphertext === "string"
? [ciphertext]
: [];
const tried = [];
for (const candidate of normalizedCandidates) {
try {
return decrypt(candidate);
} catch (error) {
tried.push({ candidate, error });
for (const cipher of ciphers) {
for (const candidate of candidates) {
try {
const plaintext = cipher.decrypt(candidate);
return { plaintext, algorithm: cipher.algorithm };
} catch (error) {
tried.push({ candidate, algorithm: cipher.algorithm, error });
}
}
}
@@ -3957,6 +4070,7 @@ class Nip46RpcClient {
clientPublicKey,
remotePubkey,
relays,
encryption,
secret,
permissions,
metadata,
@@ -3969,6 +4083,7 @@ class Nip46RpcClient {
this.clientPublicKey = normalizeNostrPubkey(clientPublicKey);
this.remotePubkey = normalizeNostrPubkey(remotePubkey);
this.relays = resolveNip46Relays(relays, nostrClient?.relays || []);
this.encryptionAlgorithm = normalizeNip46EncryptionAlgorithm(encryption);
this.secret = typeof secret === "string" ? secret.trim() : "";
this.permissions = typeof permissions === "string" ? permissions.trim() : "";
this.metadata = metadata && typeof metadata === "object" ? { ...metadata } : {};
@@ -4029,11 +4144,16 @@ class Nip46RpcClient {
throw new Error("NostrTools helpers are unavailable for remote signing.");
}
this.cipher = createNip46Cipher(
const cipher = createNip46Cipher(
tools,
this.clientPrivateKey,
this.remotePubkey,
{ preferredAlgorithm: this.encryptionAlgorithm },
);
this.cipher = cipher;
if (!this.encryptionAlgorithm && cipher?.algorithm) {
this.encryptionAlgorithm = cipher.algorithm;
}
return this.cipher;
}
@@ -4990,6 +5110,7 @@ export class NostrClient {
clientPublicKey: stored.clientPublicKey || "",
relays: Array.isArray(stored.relays) ? [...stored.relays] : [],
metadata: stored.metadata || {},
encryption: stored.encryption || "",
userPubkey,
userNpub: encodeHexToNpub(userPubkey),
};
@@ -5030,6 +5151,10 @@ export class NostrClient {
const relays = Array.isArray(status.relays)
? status.relays.slice()
: (this.nip46Client?.relays || stored.relays || []).slice();
const encryption =
(typeof status.encryption === "string" && status.encryption.trim()
? normalizeNip46EncryptionAlgorithm(status.encryption)
: "") || this.nip46Client?.encryptionAlgorithm || stored.encryption || "";
const snapshot = {
state: nextState,
@@ -5037,6 +5162,7 @@ export class NostrClient {
userPubkey,
relays,
metadata,
encryption,
label:
status.label ||
metadata?.name ||
@@ -5326,11 +5452,14 @@ export class NostrClient {
}
Promise.resolve()
.then(() => decryptNip46PayloadWithKeys(clientPrivateKey, remotePubkey, event.content))
.then((payload) => {
.then(() =>
decryptNip46PayloadWithKeys(clientPrivateKey, remotePubkey, event.content),
)
.then((payloadResult) => {
const plaintext = payloadResult?.plaintext ?? "";
let parsed;
try {
parsed = JSON.parse(payload);
parsed = JSON.parse(plaintext);
} catch (error) {
devLogger.warn("[nostr] Failed to parse remote signer handshake payload:", error);
return;
@@ -5385,6 +5514,7 @@ export class NostrClient {
resolve({
remotePubkey,
response: parsed,
algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
});
})
.catch((error) => {
@@ -5463,6 +5593,7 @@ export class NostrClient {
let clientPrivateKey = "";
let clientPublicKey = "";
let remotePubkey = normalizeNostrPubkey(parsed.remotePubkey);
let handshakeAlgorithm = "";
if (parsed.type === "client") {
clientPrivateKey =
@@ -5540,6 +5671,9 @@ export class NostrClient {
}
remotePubkey = normalizeNostrPubkey(handshakeResult?.remotePubkey);
handshakeAlgorithm = normalizeNip46EncryptionAlgorithm(
handshakeResult?.algorithm,
);
if (!remotePubkey) {
const error = new Error("Remote signer did not return a valid public key.");
error.code = "missing-remote-pubkey";
@@ -5580,6 +5714,7 @@ export class NostrClient {
secret,
permissions,
metadata,
encryption: handshakeAlgorithm,
});
try {
@@ -5634,6 +5769,7 @@ export class NostrClient {
clientPublicKey,
remotePubkey,
relays,
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
secret,
permissions,
metadata,
@@ -5707,6 +5843,7 @@ export class NostrClient {
clientPublicKey: stored.clientPublicKey,
remotePubkey: stored.remotePubkey,
relays,
encryption: stored.encryption,
secret: stored.secret,
permissions: stored.permissions,
metadata: stored.metadata,
@@ -5721,6 +5858,7 @@ export class NostrClient {
writeStoredNip46Session({
...stored,
encryption: client.encryptionAlgorithm || stored.encryption || "",
userPubkey,
lastConnectedAt: Date.now(),
});
+76 -35
View File
@@ -74,13 +74,18 @@ test("decryptNip46PayloadWithKeys handles nip44.v2 ciphertext", async () => {
const { __testExports } = await import("../js/nostr.js");
const { decryptNip46PayloadWithKeys } = __testExports;
const roundTrip = await decryptNip46PayloadWithKeys(
const { plaintext, algorithm } = await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
ciphertext,
);
assert.equal(roundTrip, serialized, "helper should decrypt nip44.v2 ciphertext");
assert.equal(plaintext, serialized, "helper should decrypt nip44.v2 ciphertext");
assert.equal(
algorithm,
"nip44.v2",
"helper should report nip44.v2 as the active algorithm",
);
});
test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async () => {
@@ -107,30 +112,42 @@ test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async
const { __testExports } = await import("../js/nostr.js");
const { decryptNip46PayloadWithKeys } = __testExports;
const roundTrip = await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
structuredPayload,
);
const { plaintext: structuredPlaintext, algorithm } =
await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
structuredPayload,
);
assert.equal(
roundTrip,
structuredPlaintext,
serialized,
"helper should decrypt ciphertext encoded as an object with nonce",
);
const jsonPayload = JSON.stringify(structuredPayload);
const jsonRoundTrip = await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
jsonPayload,
assert.equal(
algorithm,
"nip44.v2",
"structured payload should report nip44.v2 algorithm",
);
const jsonPayload = JSON.stringify(structuredPayload);
const { plaintext: jsonPlaintext, algorithm: jsonAlgorithm } =
await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
jsonPayload,
);
assert.equal(
jsonRoundTrip,
jsonPlaintext,
serialized,
"helper should also decrypt JSON-serialized handshake payloads",
);
assert.equal(
jsonAlgorithm,
"nip44.v2",
"JSON payload should report nip44.v2 algorithm",
);
});
test("decryptNip46PayloadWithKeys decodes buffer-based handshake payloads", async () => {
@@ -154,46 +171,64 @@ test("decryptNip46PayloadWithKeys decodes buffer-based handshake payloads", asyn
const { __testExports } = await import("../js/nostr.js");
const { decryptNip46PayloadWithKeys } = __testExports;
const bufferRoundTrip = await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
bufferPayload,
);
const { plaintext: bufferPlaintext, algorithm } =
await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
bufferPayload,
);
assert.equal(
bufferRoundTrip,
bufferPlaintext,
serialized,
"helper should decrypt handshake payloads that serialize buffers",
);
const jsonBufferPayload = JSON.stringify(bufferPayload);
const jsonBufferRoundTrip = await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
jsonBufferPayload,
assert.equal(
algorithm,
"nip44.v2",
"buffer payload should report nip44.v2 algorithm",
);
const jsonBufferPayload = JSON.stringify(bufferPayload);
const { plaintext: jsonBufferPlaintext, algorithm: jsonBufferAlgorithm } =
await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
jsonBufferPayload,
);
assert.equal(
jsonBufferRoundTrip,
jsonBufferPlaintext,
serialized,
"helper should decrypt JSON-encoded buffer handshake payloads",
);
assert.equal(
jsonBufferAlgorithm,
"nip44.v2",
"JSON buffer payload should report nip44.v2 algorithm",
);
const objectBufferPayload = {
ciphertext: ciphertextBuffer,
};
const objectBufferRoundTrip = await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
objectBufferPayload,
);
const { plaintext: objectBufferPlaintext, algorithm: objectBufferAlgorithm } =
await decryptNip46PayloadWithKeys(
clientSecret,
remotePubkey,
objectBufferPayload,
);
assert.equal(
objectBufferRoundTrip,
objectBufferPlaintext,
serialized,
"helper should decrypt objects containing buffer encoded ciphertext",
);
assert.equal(
objectBufferAlgorithm,
"nip44.v2",
"object buffer payload should report nip44.v2 algorithm",
);
});
test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", async () => {
@@ -228,7 +263,13 @@ test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", asy
"normalizer should include nip04-style ciphertext",
);
const { decrypt } = createNip46Cipher(patchedTools, clientSecret, remotePubkey);
const cipher = createNip46Cipher(patchedTools, clientSecret, remotePubkey);
assert.equal(
cipher.algorithm,
"nip04",
"nip04-only toolchain should resolve the nip04 algorithm",
);
const { decrypt } = cipher;
let decrypted = "";
for (const candidate of candidates) {
try {