diff --git a/components/profile-modal.html b/components/profile-modal.html index 80471645..be90b6fd 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1047,9 +1047,9 @@
-

Privacy mode (coming soon)

+

NIP-17 privacy delivery

- Pause inbound DM sync and clear cached previews on this device. + Send gift-wrapped DMs to the relays your recipient advertises in kind 10050.

diff --git a/js/app.js b/js/app.js index 9ac52aea..4d7e185c 100644 --- a/js/app.js +++ b/js/app.js @@ -3217,13 +3217,24 @@ class Application { return true; } - handleProfilePrivacyToggle({ enabled, controller } = {}) { - if (enabled) { - this.showStatus("Privacy mode is coming in Phase 3."); + handleProfilePrivacyToggle({ enabled, controller, recipient } = {}) { + const relayHints = Array.isArray(recipient?.relayHints) + ? recipient.relayHints + : []; + + if (enabled && !relayHints.length) { + this.showError("Recipient has not shared NIP-17 relay hints yet."); if (controller?.setPrivacyToggleState) { controller.setPrivacyToggleState(false); } + return; } + + this.showStatus( + enabled + ? "NIP-17 privacy delivery enabled for this recipient." + : "Using standard DM delivery for this recipient.", + ); } updateProfileInDOM(pubkey, profile) { diff --git a/js/nostr/client.js b/js/nostr/client.js index 2cf97f0a..505a1e11 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -4376,6 +4376,10 @@ export class NostrClient { resolvedActorOverride = null; } + if (!resolvedOptions || typeof resolvedOptions !== "object") { + resolvedOptions = {}; + } + const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; const trimmedMessage = typeof message === "string" ? message.trim() : ""; @@ -4474,6 +4478,307 @@ export class NostrClient { } const signerCapabilities = resolveSignerCapabilities(signer); + const useNip17 = Boolean(resolvedOptions.useNip17); + + const resolveNip17RelayList = async (pubkey, relayHints) => { + const direct = sanitizeRelayList(relayHints); + if (direct.length) { + return direct; + } + + const relayCandidates = sanitizeRelayList( + Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + if (!relayCandidates.length || !this.pool) { + return []; + } + + try { + await this.ensurePool(); + const events = await this.pool.list(relayCandidates, [ + { kinds: [10050], authors: [pubkey], limit: 1 }, + ]); + const sorted = Array.isArray(events) + ? events + .filter((entry) => entry && entry.pubkey === pubkey) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)) + : []; + if (!sorted.length) { + return []; + } + + const tags = Array.isArray(sorted[0]?.tags) ? sorted[0].tags : []; + const relayList = tags + .filter((tag) => Array.isArray(tag) && tag[0] === "relay") + .map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")) + .filter(Boolean); + return sanitizeRelayList(relayList); + } catch (error) { + devLogger.warn("[nostr] Failed to load NIP-17 relay list.", error); + return []; + } + }; + + if (useNip17) { + if (!signerCapabilities.nip44 || typeof signer.nip44Encrypt !== "function") { + return { ok: false, error: "nip44-unsupported" }; + } + + const recipientRelays = await resolveNip17RelayList( + targetHex, + resolvedOptions.recipientRelayHints, + ); + + if (!recipientRelays.length) { + return { ok: false, error: "nip17-relays-missing" }; + } + + const senderRelays = await resolveNip17RelayList( + actorHex, + resolvedOptions.senderRelayHints, + ); + const fallbackRelays = sanitizeRelayList( + Array.isArray(this.writeRelays) && this.writeRelays.length + ? this.writeRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + const senderRelayTargets = senderRelays.length ? senderRelays : fallbackRelays; + + const tools = await ensureNostrTools(); + const getPublicKey = + tools && typeof tools.getPublicKey === "function" ? tools.getPublicKey : null; + if (!getPublicKey) { + return { ok: false, error: "nip17-keygen-failed" }; + } + + const randomPastTimestamp = () => { + const now = Math.floor(Date.now() / 1000); + const offset = Math.floor(Math.random() * 172800); + return now - offset; + }; + + const generateEphemeralKeypair = () => { + let privateKey = ""; + try { + if (typeof tools.generateSecretKey === "function") { + const secret = tools.generateSecretKey(); + if (secret instanceof Uint8Array) { + privateKey = bytesToHex(secret); + } + } + if (!privateKey) { + if (typeof tools.generatePrivateKey === "function") { + privateKey = tools.generatePrivateKey(); + } else if (window?.crypto?.getRandomValues) { + const randomBytes = new Uint8Array(32); + window.crypto.getRandomValues(randomBytes); + privateKey = Array.from(randomBytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + } + } catch (error) { + devLogger.warn("[nostr] Failed to generate NIP-17 wrapper key.", error); + privateKey = ""; + } + + const normalizedPrivateKey = + typeof privateKey === "string" ? privateKey.trim() : ""; + if (!normalizedPrivateKey) { + return null; + } + + let pubkey = ""; + try { + pubkey = getPublicKey(normalizedPrivateKey); + } catch (error) { + let retrySuccess = false; + try { + if (HEX64_REGEX.test(normalizedPrivateKey)) { + const bytes = new Uint8Array(normalizedPrivateKey.length / 2); + for (let i = 0; i < normalizedPrivateKey.length; i += 2) { + bytes[i / 2] = parseInt( + normalizedPrivateKey.substring(i, i + 2), + 16, + ); + } + pubkey = getPublicKey(bytes); + retrySuccess = true; + } + } catch (retryError) { + // Fall back to error handling below + } + if (!retrySuccess) { + devLogger.warn( + "[nostr] Failed to derive NIP-17 wrapper pubkey.", + error, + ); + return null; + } + } + + return { privateKey: normalizedPrivateKey, pubkey }; + }; + + const rumorEvent = { + kind: 14, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", targetHex]], + content: trimmedMessage, + pubkey: actorHex, + }; + + const buildGiftWrapForRecipient = async (recipientPubkey, relayHint) => { + const sealPayload = { + kind: 13, + created_at: randomPastTimestamp(), + tags: [], + content: await signer.nip44Encrypt( + recipientPubkey, + JSON.stringify(rumorEvent), + ), + pubkey: actorHex, + }; + + const signedSeal = await signingAdapter.signEvent(sealPayload); + if (!signedSeal || typeof signedSeal.id !== "string") { + throw new Error("seal-signature-failed"); + } + + const wrapperKeys = generateEphemeralKeypair(); + if (!wrapperKeys) { + throw new Error("wrapper-keygen-failed"); + } + + const cipherClosures = await createPrivateKeyCipherClosures( + wrapperKeys.privateKey, + ); + if (typeof cipherClosures.nip44Encrypt !== "function") { + throw new Error("wrapper-encryption-unavailable"); + } + + const wrapCiphertext = await cipherClosures.nip44Encrypt( + recipientPubkey, + JSON.stringify(signedSeal), + ); + const wrapTags = relayHint + ? [["p", recipientPubkey, relayHint]] + : [["p", recipientPubkey]]; + + const wrapEvent = { + kind: 1059, + created_at: randomPastTimestamp(), + tags: wrapTags, + content: wrapCiphertext, + pubkey: wrapperKeys.pubkey, + }; + + return { + wrap: signEventWithPrivateKey(wrapEvent, wrapperKeys.privateKey), + seal: signedSeal, + }; + }; + + let recipientGiftWrap; + try { + recipientGiftWrap = await buildGiftWrapForRecipient( + targetHex, + recipientRelays[0] || "", + ); + } catch (error) { + devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error); + const errorCode = + error?.message === "wrapper-keygen-failed" + ? "nip17-keygen-failed" + : "encryption-failed"; + return { ok: false, error: errorCode, details: error }; + } + + const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`; + const dmRecord = { + id: recipientGiftWrap.wrap.id, + conversation_id: conversationId, + sender_pubkey: actorHex, + receiver_pubkey: targetHex, + created_at: rumorEvent.created_at, + kind: rumorEvent.kind, + content: trimmedMessage, + tags: rumorEvent.tags, + status: "pending", + seen: true, + }; + + try { + await writeMessages(dmRecord); + await updateConversationFromMessage(dmRecord, { + preview: trimmedMessage, + unseenDelta: 0, + }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist outgoing DM.", error); + } + + const recipientPublishResults = await Promise.all( + recipientRelays.map((url) => + publishEventToRelay(this.pool, url, recipientGiftWrap.wrap), + ), + ); + + const recipientSuccess = recipientPublishResults.some( + (result) => result.success, + ); + if (!recipientSuccess) { + try { + await writeMessages({ ...dmRecord, status: "failed" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM failure state.", error); + } + return { + ok: false, + error: "publish-failed", + details: recipientPublishResults.filter((result) => !result.success), + }; + } + + let senderGiftWrap = null; + try { + senderGiftWrap = await buildGiftWrapForRecipient( + actorHex, + senderRelayTargets[0] || "", + ); + } catch (error) { + devLogger.warn("[nostr] Failed to build NIP-17 sender copy.", error); + } + + if (senderGiftWrap && senderRelayTargets.length) { + const senderPublishResults = await Promise.all( + senderRelayTargets.map((url) => + publishEventToRelay(this.pool, url, senderGiftWrap.wrap), + ), + ); + if (!senderPublishResults.some((result) => result.success)) { + devLogger.warn("[nostr] Failed to publish sender copy of NIP-17 DM.", { + relays: senderRelayTargets, + }); + } + } + + try { + await writeMessages({ ...dmRecord, status: "published" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM publish state.", error); + } + + return { ok: true }; + } + const encryptionErrors = []; let ciphertext = ""; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index b99be8b0..debc2acd 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1244,6 +1244,7 @@ export class ProfileModalController { this.directMessagesUnsubscribes = []; this.pendingMessagesRender = null; this.messagesStatusClearTimeout = null; + this.dmPrivacyToggleTouched = false; this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; @@ -1919,7 +1920,24 @@ export class ProfileModalController { } } - return this.buildDmRecipientContext(normalized); + const context = this.buildDmRecipientContext(normalized); + this.updateDmPrivacyToggleForRecipient(context); + return context; + } + + updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) { + if (!recipientContext) { + return; + } + + const relayHints = Array.isArray(recipientContext.relayHints) + ? recipientContext.relayHints + : []; + const hasHints = relayHints.length > 0; + + if (!this.dmPrivacyToggleTouched || force) { + this.setPrivacyToggleState(hasHints); + } } setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) { @@ -1930,6 +1948,7 @@ export class ProfileModalController { this.state.setDmRecipient(nextRecipient); } + this.dmPrivacyToggleTouched = false; this.updateMessageThreadSelection(nextRecipient); if (nextRecipient) { @@ -2302,12 +2321,27 @@ export class ProfileModalController { } handlePrivacyToggle(enabled) { + const recipientContext = this.buildDmRecipientContext( + this.resolveActiveDmRecipient(), + ); + const relayHints = Array.isArray(recipientContext?.relayHints) + ? recipientContext.relayHints + : []; + + if (enabled && !relayHints.length) { + this.showError("Recipient has not shared NIP-17 relay hints yet."); + this.setPrivacyToggleState(false); + this.dmPrivacyToggleTouched = false; + return; + } + + this.dmPrivacyToggleTouched = true; const callback = this.callbacks.onTogglePrivacy; if (callback && callback !== noop) { callback({ controller: this, enabled: Boolean(enabled), - recipient: this.buildDmRecipientContext(this.resolveActiveDmRecipient()), + recipient: recipientContext, }); } } @@ -2318,6 +2352,12 @@ export class ProfileModalController { return "Connect a Nostr signer to send messages."; case "encryption-unsupported": return "Your signer does not support NIP-04 encryption."; + case "nip44-unsupported": + return "Your signer does not support NIP-44 encryption required for NIP-17."; + case "nip17-relays-missing": + return "Recipient has not shared NIP-17 relay hints yet."; + case "nip17-keygen-failed": + return "We couldn’t create secure wrapper keys for NIP-17 delivery."; case "extension-permission-denied": return "Please grant your Nostr extension permission to send messages."; case "missing-actor-pubkey": @@ -2361,6 +2401,22 @@ export class ProfileModalController { return; } + const recipientContext = this.buildDmRecipientContext(targetHex); + const recipientRelayHints = Array.isArray(recipientContext?.relayHints) + ? recipientContext.relayHints + : []; + const useNip17 = + this.profileMessagesPrivacyToggle instanceof HTMLInputElement + ? this.profileMessagesPrivacyToggle.checked + : false; + + if (useNip17 && !recipientRelayHints.length) { + this.showError("Recipient has not shared NIP-17 relay hints yet."); + this.setPrivacyToggleState(false); + this.dmPrivacyToggleTouched = false; + return; + } + if ( !this.services.nostrClient || typeof this.services.nostrClient.sendDirectMessage !== "function" @@ -2379,6 +2435,13 @@ export class ProfileModalController { const result = await this.services.nostrClient.sendDirectMessage( target, message, + null, + useNip17 + ? { + useNip17: true, + recipientRelayHints, + } + : {}, ); if (result?.ok) {