From 77d3e8b8b3e305af1ccbe80623fc23cd85afbf7c Mon Sep 17 00:00:00 2001 From: filemon Date: Fri, 2 Oct 2026 13:00:00 -0300 Subject: [PATCH 1/7] feat: end-to-end encrypt private images as NIP-17 kind 15 file messages Images sent in private chats were uploaded to Blossom as plaintext (with EXIF), and only the URL was protected by the gift-wrapped kind 14 rumor. Images are now encrypted on the device before upload and sent as NIP-17 kind 15 file messages: - Strip JPEG EXIF/XMP/IPTC/comment segments and PNG text/EXIF/time chunks without re-encoding (JPEG orientation is kept). - Encrypt with AES-256-GCM via WebCrypto using a fresh random 32-byte key and 12-byte nonce per attachment. - Upload only the ciphertext as application/octet-stream; retries reuse the same ciphertext and authorization, and the returned hash must match. - Send file-type, encryption-algorithm, decryption-key, decryption-nonce (lowercase hex), x (ciphertext SHA-256), ox and size inside the kind 15 rumor, through the existing seal + gift-wrap, self-copy and group epoch paths. The key and nonce never reach the media server. - On receive, accept kind 15 only with valid tags and an HTTPS URL, verify x before decrypting, treat GCM failures as hard errors, and render only allowlisted raster types from a decrypted, reference-counted blob: URL. 12- and 16-byte nonces are accepted for interoperability. - Keep kind 15 through relay retry/outbox replay (NDK rewrites the rumor kind to rumorKind), deletions, reactions, reply previews and forwarding (by reference, without re-uploading), and Android notifications. - Redact decryption material in developer traces and the event JSON view. Legacy kind 14 + imeta media keeps working. Video and audio still use the plaintext upload path; the plaintext uploader now refuses images. --- e2e/dm/encryptedMedia.spec.ts | 252 ++++++++++++++++ e2e/helpers.ts | 8 + nips/NIPS_USED.md | 5 + .../nostr/chat/RelayNotificationService.java | 7 +- .../chat/RelayNotificationServiceTest.java | 10 + src/components/ChatThread.vue | 47 ++- src/components/MessageAttachmentImage.vue | 122 ++++++++ src/components/MessageBubble.vue | 115 +++++-- src/components/MessageComposer.vue | 22 +- src/i18n/locales/ar-EG.json | 2 +- src/i18n/locales/ar.json | 2 +- src/i18n/locales/bn-BD.json | 2 +- src/i18n/locales/de-DE.json | 2 +- src/i18n/locales/en-US.json | 3 +- src/i18n/locales/es-ES.json | 2 +- src/i18n/locales/fr-FR.json | 2 +- src/i18n/locales/hi-IN.json | 2 +- src/i18n/locales/id-ID.json | 2 +- src/i18n/locales/ja-JP.json | 2 +- src/i18n/locales/mr-IN.json | 2 +- src/i18n/locales/pcm-NG.json | 2 +- src/i18n/locales/pt-BR.json | 2 +- src/i18n/locales/ru-RU.json | 2 +- src/i18n/locales/ta-IN.json | 2 +- src/i18n/locales/te-IN.json | 2 +- src/i18n/locales/tr-TR.json | 2 +- src/i18n/locales/ur-PK.json | 2 +- src/i18n/locales/yue-HK.json | 2 +- src/i18n/locales/zh-CN.json | 2 +- src/services/blossomUploadService.ts | 247 +++++++++++++-- src/services/encryptedMediaService.ts | 161 ++++++++++ src/stores/messageStore.ts | 40 ++- src/stores/nostr/messageEventRuntime.ts | 5 +- src/stores/nostr/messageMutationRuntime.ts | 16 +- .../nostr/outboundMessageReplayRuntime.ts | 4 +- .../nostr/privateMessagesIngestRuntime.ts | 37 ++- .../nostr/subscriptionLoggingRuntime.ts | 15 +- src/stores/nostr/types.ts | 2 + src/stores/nostr/userActions.ts | 14 +- src/types/chat.ts | 12 + src/utils/imageMetadata.ts | 259 ++++++++++++++++ src/utils/mediaCrypto.ts | 198 ++++++++++++ src/utils/messageAttachments.ts | 279 ++++++++++++++++- tests/unit/blossomUploadService.spec.ts | 282 +++++++++++++++++- tests/unit/encryptedMediaService.spec.ts | 204 +++++++++++++ tests/unit/fileMessageGiftWrap.spec.ts | 110 +++++++ tests/unit/imageMetadata.spec.ts | 206 +++++++++++++ tests/unit/mediaCrypto.spec.ts | 227 ++++++++++++++ tests/unit/messageAttachments.spec.ts | 216 ++++++++++++++ tests/unit/messageMutationRuntime.spec.ts | 93 ++++++ tests/unit/messageStoreSend.spec.ts | 141 +++++++++ tests/unit/nostrRelaySubscriptions.spec.ts | 29 ++ .../unit/outboundMessageReplayRuntime.spec.ts | 30 ++ .../unit/privateMessagesIngestRuntime.spec.ts | 243 +++++++++++++++ tests/unit/userActions.spec.ts | 101 ++++++- 55 files changed, 3664 insertions(+), 136 deletions(-) create mode 100644 e2e/dm/encryptedMedia.spec.ts create mode 100644 src/components/MessageAttachmentImage.vue create mode 100644 src/services/encryptedMediaService.ts create mode 100644 src/utils/imageMetadata.ts create mode 100644 src/utils/mediaCrypto.ts create mode 100644 tests/unit/encryptedMediaService.spec.ts create mode 100644 tests/unit/fileMessageGiftWrap.spec.ts create mode 100644 tests/unit/imageMetadata.spec.ts create mode 100644 tests/unit/mediaCrypto.spec.ts diff --git a/e2e/dm/encryptedMedia.spec.ts b/e2e/dm/encryptedMedia.spec.ts new file mode 100644 index 00000000..df4e84f7 --- /dev/null +++ b/e2e/dm/encryptedMedia.spec.ts @@ -0,0 +1,252 @@ +import { createHash } from 'node:crypto'; +import { crc32, deflateSync } from 'node:zlib'; +import { type BrowserContext, expect, type Page, type Route, test } from '@playwright/test'; +import { + type BootstrappedUser, + bootstrapUser, + disposeUsers, + establishAcceptedDirectChat, + expectNoUnexpectedBrowserErrors, + getDeveloperDiagnosticsSnapshot, + navigateToChat, + reloadAndWaitForApp, + TEST_ACCOUNTS, +} from '../helpers'; + +test.describe.configure({ mode: 'serial' }); + +const BLOSSOM_ORIGIN = 'https://blossom.e2e.test'; +const SECRET_METADATA = 'SECRET-METADATA-E2E-GPS'; + +interface StoredBlob { + body: Buffer; + contentType: string; + declaredSha256: string; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(typeAndData)); + return Buffer.concat([length, typeAndData, crc]); +} + +// A real, decodable 3x2 PNG that also carries a text chunk with "private" metadata. +function buildPngWithMetadata(): Buffer { + const width = 3; + const height = 2; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + const rows = Buffer.alloc(height * (1 + width * 3)); + for (let y = 0; y < height; y += 1) { + const rowStart = y * (1 + width * 3); + for (let x = 0; x < width; x += 1) { + rows[rowStart + 1 + x * 3] = 0x00; + rows[rowStart + 2 + x * 3] = 0xa6; + rows[rowStart + 3 + x * 3] = 0x7e; + } + } + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk('IHDR', header), + pngChunk('tEXt', Buffer.from(`Comment\u0000${SECRET_METADATA}`, 'latin1')), + pngChunk('IDAT', deflateSync(rows)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-SHA-256', + 'Cache-Control': 'no-store', +}; + +// An in-memory Blossom server shared by both browsers. It records exactly what was uploaded. +function createFakeBlossom() { + const blobs = new Map(); + const downloads: string[] = []; + + async function handle(route: Route): Promise { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'OPTIONS') { + await route.fulfill({ status: 204, headers: CORS_HEADERS }); + return; + } + + if (request.method() === 'PUT' && url.pathname === '/upload') { + const body = request.postDataBuffer() ?? Buffer.alloc(0); + const headers = await request.allHeaders(); + const sha256 = createHash('sha256').update(body).digest('hex'); + blobs.set(sha256, { + body, + contentType: headers['content-type'] ?? '', + declaredSha256: headers['x-sha-256'] ?? '', + }); + await route.fulfill({ + status: 201, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `${BLOSSOM_ORIGIN}/${sha256}`, + sha256, + size: body.length, + type: headers['content-type'] ?? 'application/octet-stream', + uploaded: Math.floor(Date.now() / 1000), + }), + }); + return; + } + + const sha256 = url.pathname.slice(1); + const blob = request.method() === 'GET' ? blobs.get(sha256) : undefined; + if (!blob) { + await route.fulfill({ status: 404, headers: CORS_HEADERS, body: '' }); + return; + } + + downloads.push(request.url()); + await route.fulfill({ + status: 200, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/octet-stream' }, + body: blob.body, + }); + } + + return { + blobs, + downloads, + async install(context: BrowserContext): Promise { + await context.route(`${BLOSSOM_ORIGIN}/**`, handle); + }, + }; +} + +async function useBlossomServer(user: BootstrappedUser, serverUrl: string): Promise { + await expect + .poll( + async () => { + const snapshot = await getDeveloperDiagnosticsSnapshot(user.page); + return !snapshot.session.isRestoringStartupState; + }, + { timeout: 30_000 } + ) + .toBe(true); + await user.page.goto('/#/settings/media-data-storage'); + const serverInput = user.page.getByTestId('settings-blossom-server-input'); + await expect(serverInput).toBeVisible(); + await serverInput.fill(serverUrl); + const saveButton = user.page.getByTestId('settings-blossom-save'); + // The preference is restored from the account's relays, so a rerun may already have it saved. + if (await saveButton.isDisabled()) { + await expect(serverInput).toHaveValue(serverUrl); + return; + } + + await saveButton.click(); + await expect(user.page.getByText('Blossom server saved.', { exact: true })).toBeVisible({ + timeout: 12_000, + }); +} + +async function sendImageFromComposer(page: Page, file: { name: string; buffer: Buffer }) { + await page.getByTestId('message-composer-menu').click(); + await page.getByText('Photo or Video', { exact: true }).click(); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'OK', exact: true }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ name: file.name, mimeType: 'image/png', buffer: file.buffer }); +} + +async function readRenderedImage(page: Page) { + const image = page.getByTestId('message-image-preview').last(); + await expect(image).toHaveAttribute('src', /^blob:/u, { timeout: 30_000 }); + await expect + .poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth)) + .toBe(3); + return image; +} + +test('private images are encrypted before upload and decrypted by the recipient', async ({ + browser, +}) => { + test.slow(); + + const alice = await bootstrapUser(browser, TEST_ACCOUNTS.encryptedMediaAlice); + const bob = await bootstrapUser(browser, TEST_ACCOUNTS.encryptedMediaBob); + const blossom = createFakeBlossom(); + + try { + await blossom.install(alice.context); + await blossom.install(bob.context); + await establishAcceptedDirectChat(alice, bob); + await useBlossomServer(alice, BLOSSOM_ORIGIN); + await navigateToChat(alice.page, bob.session.publicKey); + + const png = buildPngWithMetadata(); + await sendImageFromComposer(alice.page, { name: 'holiday.png', buffer: png }); + await expect.poll(() => blossom.blobs.size, { timeout: 30_000 }).toBe(1); + + // What the server received: ciphertext only, declared as an opaque blob. + const [[storedSha256, stored]] = [...blossom.blobs.entries()]; + expect(stored.contentType).toBe('application/octet-stream'); + expect(stored.declaredSha256).toBe(storedSha256); + expect(stored.body.includes(png.subarray(0, 8))).toBe(false); + expect(stored.body.includes(Buffer.from('IHDR'))).toBe(false); + expect(stored.body.includes(Buffer.from(SECRET_METADATA))).toBe(false); + expect(stored.body.includes(Buffer.from('holiday'))).toBe(false); + + // The sender renders the image from a decrypted blob URL; copy/open-link actions are hidden. + await readRenderedImage(alice.page); + await expect(alice.page.getByTestId('message-image-copy-link')).toHaveCount(0); + + // The recipient decrypts it locally after revealing it. + await navigateToChat(bob.page, alice.session.publicKey); + await expect(bob.page.getByTestId('message-image-attachment').last()).toBeVisible({ + timeout: 30_000, + }); + const reveal = bob.page.getByTestId('message-image-show').last(); + if (await reveal.isVisible()) { + await reveal.click(); + } + const bobImage = await readRenderedImage(bob.page); + expect(blossom.downloads.every((url) => url === `${BLOSSOM_ORIGIN}/${storedSha256}`)).toBe( + true + ); + + // The decrypted bytes are the metadata-stripped image, not the raw upload. + const decrypted = await bobImage.evaluate(async (element) => { + const blob = await (await fetch((element as HTMLImageElement).src)).blob(); + return { type: blob.type, bytes: Array.from(new Uint8Array(await blob.arrayBuffer())) }; + }); + const decryptedBytes = Buffer.from(decrypted.bytes); + expect(decrypted.type).toBe('image/png'); + expect(decryptedBytes.subarray(0, 8).equals(png.subarray(0, 8))).toBe(true); + expect(decryptedBytes.includes(Buffer.from(SECRET_METADATA))).toBe(false); + expect(decryptedBytes.includes(Buffer.from('tEXt'))).toBe(false); + expect(decryptedBytes.length).toBeLessThan(png.length); + + // A blob that no longer matches the message hash is never rendered. + stored.body[0] ^= 0xff; + await reloadAndWaitForApp(bob.page); + await navigateToChat(bob.page, alice.session.publicKey); + const revealAfterReload = bob.page.getByTestId('message-image-show').last(); + if (await revealAfterReload.isVisible().catch(() => false)) { + await revealAfterReload.click(); + } + await expect(bob.page.getByTestId('message-image-unavailable').last()).toBeVisible({ + timeout: 30_000, + }); + await expect(bob.page.getByTestId('message-image-preview')).toHaveCount(0); + + await expectNoUnexpectedBrowserErrors([alice, bob]); + } finally { + await disposeUsers(alice, bob); + } +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 7f03c16e..fba8bad1 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -303,6 +303,14 @@ export const TEST_ACCOUNTS = { privateKey: '8888888888888888888888888888888888888888888888888888888888888888', displayName: 'Bob Media Viewer', }, + encryptedMediaAlice: { + privateKey: '9999999999999999999999999999999999999999999999999999999999999999', + displayName: 'Alice Encrypted Media', + }, + encryptedMediaBob: { + privateKey: '1212121212121212121212121212121212121212121212121212121212121212', + displayName: 'Bob Encrypted Media', + }, blockAlice: { privateKey: '732cc8f4c9bb1542b025b94aa494a706c9991bbf2dd23ce589cf51f99b0652ff', displayName: 'Alice Block', diff --git a/nips/NIPS_USED.md b/nips/NIPS_USED.md index 8f055d5a..24f34a04 100644 --- a/nips/NIPS_USED.md +++ b/nips/NIPS_USED.md @@ -27,6 +27,10 @@ This list is based on the current app code, especially `src/stores/nostrStore.ts - This is the app's main private-message transport. - It sends and receives `kind:14` private message rumors inside gift wraps, and it also uses the same DM flow for wrapped reactions (`kind:7`) and deletions (`kind:5`). - Group chat messages are also sent as NIP-17 DMs to the group's current epoch public key. +- Images are sent as NIP-17 `kind:15` file messages. The image is encrypted on the device with AES-256-GCM (WebCrypto) using a fresh random 32-byte key and 12-byte nonce per attachment, and only the ciphertext is uploaded (as `application/octet-stream`). The rumor content is the blob URL and its tags are `file-type` (plaintext MIME type), `encryption-algorithm` (`aes-gcm`), `decryption-key` and `decryption-nonce` (lowercase hex), `x` (SHA-256 of the ciphertext), `ox` (SHA-256 of the encrypted plaintext), and `size` (ciphertext bytes). The key and nonce only exist inside the gift-wrapped rumor. +- Before upload, JPEG APP1/APP13/COM segments and PNG text/EXIF/time chunks are removed without re-encoding; JPEG orientation is kept. Other image formats are encrypted unchanged. +- Received `kind:15` rumors are accepted only with a complete, valid set of these tags and an HTTPS URL. The downloaded blob must match `x` before it is decrypted, a GCM authentication failure is a hard error, and only JPEG, PNG, GIF, WebP, and AVIF are rendered (from a decrypted `blob:` URL). Nonces of 12 or 16 bytes are accepted for interoperability. Forwarding a `kind:15` attachment re-sends the same URL, key, and nonce in a new `kind:15` rumor without re-uploading. +- Video and audio still use the older plaintext upload with a `kind:14` rumor and a NIP-92 `imeta` tag, and existing `kind:14` + `imeta` media keeps rendering. - Text edits follow NIP-17's delete-and-replace convention: the app sends a wrapped `kind:5` deletion and a replacement wrapped `kind:14` rumor with the original message timestamp. Replacement rumors also carry a private `e` tag marked `edit` so this client can reconcile either relay arrival order without displaying duplicate messages. ## NIP-19 @@ -82,6 +86,7 @@ This list is based on the current app code, especially `src/stores/nostrStore.ts - Used for Blossom media uploads. - The app uploads blobs through the configured HTTPS Blossom server and signs server-scoped `kind:24242` upload authorization events. +- Encrypted image uploads send ciphertext only. A failed upload is retried up to three times with the same ciphertext and authorization, and the server's returned hash must match the ciphertext hash. - The server choice remains private in the app's NIP-78 preferences; the app does not currently publish a public `kind:10063` Blossom server list. ## NIP-171 diff --git a/src-capacitor/android/app/src/main/java/com/nostr/chat/RelayNotificationService.java b/src-capacitor/android/app/src/main/java/com/nostr/chat/RelayNotificationService.java index 7b31d34c..e1c87cbd 100644 --- a/src-capacitor/android/app/src/main/java/com/nostr/chat/RelayNotificationService.java +++ b/src-capacitor/android/app/src/main/java/com/nostr/chat/RelayNotificationService.java @@ -428,6 +428,11 @@ private String createSubscription(String relayUrl) throws JSONException { return new JSONArray().put("REQ").put(subscriptionId).put(filter).toString(); } + // NIP-17 kind 14 chat messages and kind 15 encrypted file messages. + static boolean isNotifiableRumorKind(int kind) { + return kind == 14 || kind == 15; + } + static long subscriptionSince(long nowSeconds) { return Math.max( 0L, @@ -670,7 +675,7 @@ private NotificationTarget resolveNotificationTarget(JSONObject wrappedEvent, St JSONObject rumor = new JSONObject(rumorPlaintext); long messageCreatedAt = rumor.optLong("created_at", 0L); if ( - rumor.optInt("kind", -1) != 14 || + !isNotifiableRumorKind(rumor.optInt("kind", -1)) || !senderPubkey.equals(rumor.optString("pubkey", "").toLowerCase(Locale.ROOT)) || !isValidRumor(rumor) || senderPubkey.equals(ownerPubkey) || diff --git a/src-capacitor/android/app/src/test/java/com/nostr/chat/RelayNotificationServiceTest.java b/src-capacitor/android/app/src/test/java/com/nostr/chat/RelayNotificationServiceTest.java index 04968b90..b5e38f92 100644 --- a/src-capacitor/android/app/src/test/java/com/nostr/chat/RelayNotificationServiceTest.java +++ b/src-capacitor/android/app/src/test/java/com/nostr/chat/RelayNotificationServiceTest.java @@ -39,6 +39,16 @@ public void boundsPendingEventRetentionAndBridgeBatches() { ); } + @Test + public void notifiesForNip17ChatAndFileMessageRumors() { + assertTrue(RelayNotificationService.isNotifiableRumorKind(14)); + assertTrue(RelayNotificationService.isNotifiableRumorKind(15)); + assertFalse(RelayNotificationService.isNotifiableRumorKind(7)); + assertFalse(RelayNotificationService.isNotifiableRumorKind(5)); + assertFalse(RelayNotificationService.isNotifiableRumorKind(1014)); + assertFalse(RelayNotificationService.isNotifiableRumorKind(-1)); + } + @Test public void subscriptionIncludesNip59TimestampRandomizationWindow() { long since = RelayNotificationService.subscriptionSince(NOW); diff --git a/src/components/ChatThread.vue b/src/components/ChatThread.vue index d9721890..a33c94b5 100644 --- a/src/components/ChatThread.vue +++ b/src/components/ChatThread.vue @@ -315,7 +315,7 @@ @click="downloadFullscreenImage" />
-
@@ -356,11 +356,13 @@ import { useQuasar } from 'quasar'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import MessageBubble from 'src/components/MessageBubble.vue'; +import MessageAttachmentImage from 'src/components/MessageAttachmentImage.vue'; import MessageComposer from 'src/components/MessageComposer.vue'; import CachedAvatar from 'src/components/CachedAvatar.vue'; import ReconnectHealingBanner from 'src/components/ReconnectHealingBanner.vue'; import StartupHistoryBanner from 'src/components/StartupHistoryBanner.vue'; import { contactsService } from 'src/services/contactsService'; +import { encryptedMediaService } from 'src/services/encryptedMediaService'; import { useChatStore } from 'src/stores/chatStore'; import { useMessageStore } from 'src/stores/messageStore'; import { useNostrStore } from 'src/stores/nostrStore'; @@ -569,13 +571,16 @@ function resolveImageDownloadName(attachment: MessageAttachmentMetadata): string return attachmentName.replace(/[\\/:*?"<>|]+/g, '-'); } - try { - const urlName = decodeURIComponent(new URL(attachment.url).pathname.split('/').pop() ?? '').trim(); - if (urlName) { - return urlName.replace(/[\\/:*?"<>|]+/g, '-'); + // An encrypted blob URL names the ciphertext, not the image, so only the MIME type is useful. + if (!attachment.encryption) { + try { + const urlName = decodeURIComponent(new URL(attachment.url).pathname.split('/').pop() ?? '').trim(); + if (urlName) { + return urlName.replace(/[\\/:*?"<>|]+/g, '-'); + } + } catch { + // Use the MIME-derived fallback below. } - } catch { - // Use the MIME-derived fallback below. } const extension = attachment.mimeType.split('/')[1]?.split(';')[0]?.trim() || 'image'; @@ -592,6 +597,20 @@ function closeImageAttachmentDialog(): void { fullscreenImageAttachment.value = null; } +async function readDownloadableImageBlob(attachment: MessageAttachmentMetadata): Promise { + // Encrypted images are only ever saved as verified, decrypted bytes. + if (attachment.encryption) { + return encryptedMediaService.fetchDecryptedMediaBlob(attachment); + } + + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error(`Image download failed with HTTP ${response.status}.`); + } + + return response.blob(); +} + async function downloadFullscreenImage(): Promise { const attachment = fullscreenImageAttachment.value; if (!attachment || isDownloadingImage.value) { @@ -601,12 +620,7 @@ async function downloadFullscreenImage(): Promise { isDownloadingImage.value = true; let objectUrl = ''; try { - const response = await fetch(attachment.url); - if (!response.ok) { - throw new Error(`Image download failed with HTTP ${response.status}.`); - } - - const blob = await response.blob(); + const blob = await readDownloadableImageBlob(attachment); if (blob.size === 0) { throw new Error('Image download returned an empty file.'); } @@ -1907,6 +1921,9 @@ function handleReplyToMessage(message: Message): void { ...(canShowReplyImage && replyContent.imageUrl ? { imageUrl: replyContent.imageUrl } : {}), + ...(canShowReplyImage && replyContent.imageAttachment + ? { imageAttachment: replyContent.imageAttachment } + : {}), sender: message.sender, authorName: authorIdentity.label, authorPublicKey: message.authorPublicKey, diff --git a/src/components/MessageAttachmentImage.vue b/src/components/MessageAttachmentImage.vue new file mode 100644 index 00000000..016db630 --- /dev/null +++ b/src/components/MessageAttachmentImage.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/components/MessageBubble.vue b/src/components/MessageBubble.vue index a742f91a..b7d663d9 100644 --- a/src/components/MessageBubble.vue +++ b/src/components/MessageBubble.vue @@ -199,10 +199,11 @@ @click.stop="handleOpenReplyTarget" > diff --git a/src/components/MessageBubble.vue b/src/components/MessageBubble.vue index be23a49d..43c5efe5 100644 --- a/src/components/MessageBubble.vue +++ b/src/components/MessageBubble.vue @@ -371,13 +371,14 @@
{ max-width: min(100%, 360px); } +.bubble__media-attachment { + margin-top: 6px; +} + .bubble__file-attachment { display: flex; align-items: center; diff --git a/src/components/MessageComposer.vue b/src/components/MessageComposer.vue index c2c11245..4801cabc 100644 --- a/src/components/MessageComposer.vue +++ b/src/components/MessageComposer.vue @@ -232,6 +232,15 @@
+ + void } | null>(null); const isComposerMenuOpen = ref(false); const isEmojiMenuOpen = ref(false); const isMediaPrivacyDialogOpen = ref(false); +const shouldDismissMediaNotice = ref(false); const isBlossomAuthInProgress = ref(false); const isMediaUploadDialogOpen = ref(false); const isMediaUploadInProgress = ref(false); @@ -750,6 +764,14 @@ function handlePhotoVideoAction(): void { } isComposerMenuOpen.value = false; + if (isPrivateMediaNoticeDismissed()) { + // Opened straight from the click so the browser keeps the user gesture for the picker. + if (!isBlossomAuthInProgress.value && !isMediaUploadInProgress.value) { + openMediaFileBrowser(); + } + return; + } + openMediaPrivacyDialog(); } @@ -781,7 +803,15 @@ function openMediaPrivacyDialog(file: File | null = null): void { } } + // The notice is informational only; once dismissed, media goes straight to the same + // encrypted upload flow, with the same error, retry and change-server handling. + if (file && isPrivateMediaNoticeDismissed()) { + void uploadAndSendMediaFile(file); + return; + } + activePrivateServerUrl.value = nostrStore.getPrivateMediaBlossomServerUrl(); + shouldDismissMediaNotice.value = false; pendingInlineMediaFile.value = file; isMediaPrivacyDialogOpen.value = true; } @@ -800,6 +830,10 @@ async function handleMediaConsentConfirm(): Promise { return; } + if (shouldDismissMediaNotice.value) { + dismissPrivateMediaNotice(); + } + isBlossomAuthInProgress.value = true; try { await nostrStore.ensureBlossomUploadAuthentication(); @@ -923,7 +957,11 @@ async function uploadAndSendMediaFile(file: File): Promise { } activePrivateServerUrl.value = privateMediaUpload.refreshActiveServerUrl(); - await runMediaUpload(() => privateMediaUpload.start(file)); + await runMediaUpload(async () => { + // Also covers uploads that skipped the notice dialog, which otherwise performs this check. + await nostrStore.ensureBlossomUploadAuthentication(); + return privateMediaUpload.start(file); + }); } async function runMediaUpload(start: () => Promise): Promise { @@ -1507,6 +1545,10 @@ defineExpose({ color: var(--nc-text-secondary); } +.composer__media-notice-dismiss { + margin-top: 12px; +} + .composer__media-warning { color: var(--nc-text); font-size: 14px; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 695ca3dc..ccb5c6da 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -431,13 +431,13 @@ "message.copyImageLink": "Copy link", "message.copyLink": "Copy link", "message.imageAttachment": "Image attachment", - "message.encryptedMedia.audio": "Encrypted audio", - "message.encryptedMedia.encrypted": "End-to-end encrypted", - "message.encryptedMedia.failed": "Could not decrypt this media.", - "message.encryptedMedia.load": "Decrypt and play", + "message.encryptedMedia.audio": "Audio", + "message.encryptedMedia.failed": "Could not load this media.", + "message.encryptedMedia.play": "Play", "message.encryptedMedia.retry": "Try again", - "message.encryptedMedia.video": "Encrypted video", + "message.encryptedMedia.video": "Video", "message.encryptedAttachmentUnsupported": "Encrypted attachment ({type}) cannot be shown yet", + "message.mediaNoticeDontShowAgain": "Don't show this again", "message.mediaEncryptionNotice": "Photos, videos and audio are end-to-end encrypted on your device before upload. Only encrypted data is stored on the media server.", "message.imageLinkCopied": "Image link copied.", "message.linkCopied": "Link copied.", diff --git a/src/utils/encryptedMedia.ts b/src/utils/encryptedMedia.ts index a7bbd623..dafc1bc9 100644 --- a/src/utils/encryptedMedia.ts +++ b/src/utils/encryptedMedia.ts @@ -57,3 +57,16 @@ export function getMaxEncryptedBlobBytes(info: EncryptedMediaKindInfo): number { export function shouldEncryptOutgoingMedia(file: Pick): boolean { return resolveEncryptedMediaKind(file.type) !== null; } + +// Approximate size shown on a media placeholder before it is downloaded. +export function formatMediaByteSize(bytes: unknown): string { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) { + return ''; + } + + if (bytes < MIB) { + return `${Math.max(1, Math.round(bytes / 1024))} KB`; + } + + return `${(bytes / MIB).toFixed(1)} MB`; +} diff --git a/src/utils/privateMediaNoticePreference.ts b/src/utils/privateMediaNoticePreference.ts new file mode 100644 index 00000000..9fe3c73e --- /dev/null +++ b/src/utils/privateMediaNoticePreference.ts @@ -0,0 +1,33 @@ +const PRIVATE_MEDIA_NOTICE_STORAGE_KEY = 'ui-private-media-notice-dismissed'; + +function canUseStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +// Only hides the informational "media is end-to-end encrypted" notice shown before picking +// private media. Upload errors, retries and server changes are never affected by it. +export function isPrivateMediaNoticeDismissed(): boolean { + if (!canUseStorage()) { + return false; + } + + try { + return window.localStorage.getItem(PRIVATE_MEDIA_NOTICE_STORAGE_KEY) === '1'; + } catch (error) { + console.error('Failed to read private media notice preference.', error); + } + + return false; +} + +export function dismissPrivateMediaNotice(): void { + if (!canUseStorage()) { + return; + } + + try { + window.localStorage.setItem(PRIVATE_MEDIA_NOTICE_STORAGE_KEY, '1'); + } catch (error) { + console.error('Failed to persist private media notice preference.', error); + } +} diff --git a/tests/unit/encryptedMedia.spec.ts b/tests/unit/encryptedMedia.spec.ts index 1eae7afd..ed631ac0 100644 --- a/tests/unit/encryptedMedia.spec.ts +++ b/tests/unit/encryptedMedia.spec.ts @@ -1,4 +1,5 @@ import { + formatMediaByteSize, getMaxEncryptedBlobBytes, resolveEncryptedMediaKind, shouldEncryptOutgoingMedia, @@ -64,3 +65,14 @@ describe('encrypted media kinds', () => { expect(shouldEncryptOutgoingMedia({ type: 'video/quicktime' })).toBe(false); }); }); + +describe('formatMediaByteSize', () => { + it('formats placeholder sizes and hides unknown ones', () => { + expect(formatMediaByteSize(16_044)).toBe('16 KB'); + expect(formatMediaByteSize(300)).toBe('1 KB'); + expect(formatMediaByteSize(5 * 1024 * 1024 + 16)).toBe('5.0 MB'); + expect(formatMediaByteSize(0)).toBe(''); + expect(formatMediaByteSize(undefined)).toBe(''); + expect(formatMediaByteSize(Number.NaN)).toBe(''); + }); +}); diff --git a/tests/unit/privateMediaNoticePreference.spec.ts b/tests/unit/privateMediaNoticePreference.spec.ts new file mode 100644 index 00000000..3e04acf5 --- /dev/null +++ b/tests/unit/privateMediaNoticePreference.spec.ts @@ -0,0 +1,62 @@ +import { + dismissPrivateMediaNotice, + isPrivateMediaNoticeDismissed, +} from 'src/utils/privateMediaNoticePreference'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +function stubStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + const localStorage = { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + }; + vi.stubGlobal('window', { localStorage }); + return { store, localStorage }; +} + +describe('private media notice preference', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('shows the notice by default', () => { + stubStorage(); + + expect(isPrivateMediaNoticeDismissed()).toBe(false); + }); + + it('remembers that the notice was dismissed', () => { + const { store } = stubStorage(); + + dismissPrivateMediaNotice(); + + expect(store.get('ui-private-media-notice-dismissed')).toBe('1'); + expect(isPrivateMediaNoticeDismissed()).toBe(true); + }); + + it('only treats the exact stored flag as dismissed', () => { + stubStorage({ 'ui-private-media-notice-dismissed': 'true' }); + + expect(isPrivateMediaNoticeDismissed()).toBe(false); + }); + + it('keeps showing the notice when storage is unavailable or throws', () => { + expect(isPrivateMediaNoticeDismissed()).toBe(false); + expect(() => dismissPrivateMediaNotice()).not.toThrow(); + + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { localStorage } = stubStorage(); + localStorage.getItem.mockImplementation(() => { + throw new Error('blocked'); + }); + localStorage.setItem.mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(() => dismissPrivateMediaNotice()).not.toThrow(); + expect(isPrivateMediaNoticeDismissed()).toBe(false); + }); +}); From ca51acf3411950b71a3e006f2beb74e9b2e0dc69 Mon Sep 17 00:00:00 2001 From: filemon Date: Wed, 7 Oct 2026 04:44:16 -0300 Subject: [PATCH 6/7] feat: add private media notice preference --- e2e/dm/encryptedMedia.spec.ts | 33 +++++++++++++++++++ src/components/MessageComposer.vue | 4 +-- src/i18n/locales/en-US.json | 1 + .../settings/MediaDataStorageSettingsPage.vue | 20 +++++++++++ src/utils/privateMediaNoticePreference.ts | 9 +++-- .../unit/privateMediaNoticePreference.spec.ts | 20 ++++++++--- 6 files changed, 79 insertions(+), 8 deletions(-) diff --git a/e2e/dm/encryptedMedia.spec.ts b/e2e/dm/encryptedMedia.spec.ts index 452d6b95..3e01cb9a 100644 --- a/e2e/dm/encryptedMedia.spec.ts +++ b/e2e/dm/encryptedMedia.spec.ts @@ -578,6 +578,39 @@ test('private audio and video are encrypted, decrypt locally and play with nativ true ); + // Settings shares the "Don't show this again" preference: it starts OFF here, turning it + // back ON restores the notice, and turning it OFF skips it again. Either way the upload is + // the same encrypted, ciphertext-only upload. + const sendAndExpectCiphertext = async (expectNotice: boolean, blobCount: number) => { + await navigateToChat(alice.page, bob.session.publicKey); + await sendPrivateMedia( + alice.page, + { + name: `tone-${blobCount}.wav`, + mimeType: 'audio/wav', + buffer: buildWavTone(blobCount), + }, + { expectNotice } + ); + await expect.poll(() => blossom.blobs.size, { timeout: 30_000 }).toBe(blobCount); + const upload = blossom.uploadAttempts.at(-1); + expect(upload?.contentType).toBe('application/octet-stream'); + expect(upload?.body.includes(Buffer.from('RIFF'))).toBe(false); + }; + const noticeToggle = alice.page.getByTestId('settings-private-media-notice-toggle'); + + await alice.page.goto('/#/settings/media-data-storage'); + await expect(noticeToggle).toHaveAttribute('aria-checked', 'false'); + await noticeToggle.click(); + await expect(noticeToggle).toHaveAttribute('aria-checked', 'true'); + await sendAndExpectCiphertext(true, startingBlobCount + 4); + + await alice.page.goto('/#/settings/media-data-storage'); + await expect(noticeToggle).toHaveAttribute('aria-checked', 'true'); + await noticeToggle.click(); + await expect(noticeToggle).toHaveAttribute('aria-checked', 'false'); + await sendAndExpectCiphertext(false, startingBlobCount + 5); + await expectNoUnexpectedBrowserErrors([alice, bob], { allowPatterns: [ /503/u, diff --git a/src/components/MessageComposer.vue b/src/components/MessageComposer.vue index 4801cabc..b2444c27 100644 --- a/src/components/MessageComposer.vue +++ b/src/components/MessageComposer.vue @@ -363,8 +363,8 @@ import { import { normalizeBlossomServerUrl } from 'src/utils/blossomServer'; import { shouldEncryptOutgoingMedia } from 'src/utils/encryptedMedia'; import { - dismissPrivateMediaNotice, isPrivateMediaNoticeDismissed, + setPrivateMediaNoticeDismissed, } from 'src/utils/privateMediaNoticePreference'; import { useChatStore } from 'src/stores/chatStore'; import { useNostrStore } from 'src/stores/nostrStore'; @@ -831,7 +831,7 @@ async function handleMediaConsentConfirm(): Promise { } if (shouldDismissMediaNotice.value) { - dismissPrivateMediaNotice(); + setPrivateMediaNoticeDismissed(true); } isBlossomAuthInProgress.value = true; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index ccb5c6da..b1afae3b 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -458,6 +458,7 @@ "mediaDataStorage.blossomServerDescription": "Used for unencrypted uploads. Private photos, videos and audio always use the encrypted media server above.", "mediaDataStorage.privateMediaServer": "Encrypted media server", "mediaDataStorage.privateMediaServerDescription": "Photos, videos and audio are encrypted on your device before upload. This server must store the encrypted file exactly as uploaded. Use Test server to check.", + "mediaDataStorage.showPrivateMediaNotice": "Show encryption notice before uploading private media", "mediaDataStorage.privateMediaServerSaved": "Encrypted media server saved.", "mediaDataStorage.privateMediaServerSaveFailed": "The encrypted media server could not be saved.", "mediaDataStorage.testServer": "Test server", diff --git a/src/pages/settings/MediaDataStorageSettingsPage.vue b/src/pages/settings/MediaDataStorageSettingsPage.vue index 77745b58..40ca8b52 100644 --- a/src/pages/settings/MediaDataStorageSettingsPage.vue +++ b/src/pages/settings/MediaDataStorageSettingsPage.vue @@ -61,6 +61,13 @@ /> + + @@ -136,6 +143,10 @@ import { getBlossomServerHost, normalizeBlossomServerUrl, } from 'src/utils/blossomServer'; +import { + isPrivateMediaNoticeDismissed, + setPrivateMediaNoticeDismissed, +} from 'src/utils/privateMediaNoticePreference'; import { reportUiError } from 'src/utils/uiErrorHandler'; const $q = useQuasar(); @@ -231,6 +242,15 @@ const privateField = useServerField({ const isTestingPrivateServer = ref(false); +// Same preference as the upload notice's "Don't show this again". It only controls that +// informational notice; private media is always encrypted either way. +const showPrivateMediaNotice = ref(!isPrivateMediaNoticeDismissed()); + +function handlePrivateMediaNoticeToggle(value: boolean): void { + setPrivateMediaNoticeDismissed(!value); + showPrivateMediaNotice.value = !isPrivateMediaNoticeDismissed(); +} + // Tests whichever URL is in the field, so a server can be verified before it is saved. async function testPrivateServer(): Promise { const serverUrl = privateField.normalizedUrl.value; diff --git a/src/utils/privateMediaNoticePreference.ts b/src/utils/privateMediaNoticePreference.ts index 9fe3c73e..499bcd57 100644 --- a/src/utils/privateMediaNoticePreference.ts +++ b/src/utils/privateMediaNoticePreference.ts @@ -20,13 +20,18 @@ export function isPrivateMediaNoticeDismissed(): boolean { return false; } -export function dismissPrivateMediaNotice(): void { +// Set from the notice's "Don't show this again" and from Settings; both share this key. +export function setPrivateMediaNoticeDismissed(dismissed: boolean): void { if (!canUseStorage()) { return; } try { - window.localStorage.setItem(PRIVATE_MEDIA_NOTICE_STORAGE_KEY, '1'); + if (dismissed) { + window.localStorage.setItem(PRIVATE_MEDIA_NOTICE_STORAGE_KEY, '1'); + } else { + window.localStorage.removeItem(PRIVATE_MEDIA_NOTICE_STORAGE_KEY); + } } catch (error) { console.error('Failed to persist private media notice preference.', error); } diff --git a/tests/unit/privateMediaNoticePreference.spec.ts b/tests/unit/privateMediaNoticePreference.spec.ts index 3e04acf5..caf3075e 100644 --- a/tests/unit/privateMediaNoticePreference.spec.ts +++ b/tests/unit/privateMediaNoticePreference.spec.ts @@ -1,6 +1,6 @@ import { - dismissPrivateMediaNotice, isPrivateMediaNoticeDismissed, + setPrivateMediaNoticeDismissed, } from 'src/utils/privateMediaNoticePreference'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +11,9 @@ function stubStorage(initial: Record = {}) { setItem: vi.fn((key: string, value: string) => { store.set(key, value); }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), }; vi.stubGlobal('window', { localStorage }); return { store, localStorage }; @@ -31,12 +34,21 @@ describe('private media notice preference', () => { it('remembers that the notice was dismissed', () => { const { store } = stubStorage(); - dismissPrivateMediaNotice(); + setPrivateMediaNoticeDismissed(true); expect(store.get('ui-private-media-notice-dismissed')).toBe('1'); expect(isPrivateMediaNoticeDismissed()).toBe(true); }); + it('shows the notice again once it is turned back on', () => { + const { store } = stubStorage({ 'ui-private-media-notice-dismissed': '1' }); + + setPrivateMediaNoticeDismissed(false); + + expect(store.has('ui-private-media-notice-dismissed')).toBe(false); + expect(isPrivateMediaNoticeDismissed()).toBe(false); + }); + it('only treats the exact stored flag as dismissed', () => { stubStorage({ 'ui-private-media-notice-dismissed': 'true' }); @@ -45,7 +57,7 @@ describe('private media notice preference', () => { it('keeps showing the notice when storage is unavailable or throws', () => { expect(isPrivateMediaNoticeDismissed()).toBe(false); - expect(() => dismissPrivateMediaNotice()).not.toThrow(); + expect(() => setPrivateMediaNoticeDismissed(true)).not.toThrow(); vi.spyOn(console, 'error').mockImplementation(() => undefined); const { localStorage } = stubStorage(); @@ -56,7 +68,7 @@ describe('private media notice preference', () => { throw new Error('blocked'); }); - expect(() => dismissPrivateMediaNotice()).not.toThrow(); + expect(() => setPrivateMediaNoticeDismissed(true)).not.toThrow(); expect(isPrivateMediaNoticeDismissed()).toBe(false); }); }); From de857c4af5ee30f5c940877881577a3132abca59 Mon Sep 17 00:00:00 2001 From: filemon Date: Wed, 7 Oct 2026 05:36:23 -0300 Subject: [PATCH 7/7] fix: validate encrypted media uploads before send --- nips/NIPS_USED.md | 15 +++++++++------ src/services/blossomUploadService.ts | 14 ++++++++++++-- tests/unit/blossomUploadService.spec.ts | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/nips/NIPS_USED.md b/nips/NIPS_USED.md index 081f1e1f..21007992 100644 --- a/nips/NIPS_USED.md +++ b/nips/NIPS_USED.md @@ -27,9 +27,13 @@ This list is based on the current app code, especially `src/stores/nostrStore.ts - This is the app's main private-message transport. - It sends and receives `kind:14` private message rumors inside gift wraps, and it also uses the same DM flow for wrapped reactions (`kind:7`) and deletions (`kind:5`). - Group chat messages are also sent as NIP-17 DMs to the group's current epoch public key. -- Images (JPEG, PNG, GIF, WebP, AVIF) are sent as NIP-17 `kind:15` file messages. The original image bytes are encrypted on the device with AES-256-GCM (WebCrypto) using a fresh random 32-byte key and 12-byte nonce per attachment, and only the ciphertext is uploaded (as `application/octet-stream`). The rumor content is the blob URL and its tags are `file-type` (plaintext MIME type), `encryption-algorithm` (`aes-gcm`), `decryption-key` and `decryption-nonce` (lowercase hex), `x` (SHA-256 of the ciphertext), `ox` (SHA-256 of the original image bytes), and `size` (ciphertext bytes). The key and nonce only exist inside the gift-wrapped rumor. Image metadata such as EXIF is not stripped; it is encrypted with the image and visible only to recipients. -- Received `kind:15` rumors are accepted only with a valid key, nonce, algorithm, `file-type`, `x`, and an HTTPS URL; `ox` and `size` are optional. The downloaded blob must match `x` before it is decrypted, a GCM authentication failure is a hard error, and only JPEG, PNG, GIF, WebP, and AVIF are rendered (from a decrypted `blob:` URL). Nonces of 12 or 16 bytes are accepted for interoperability. Forwarding a `kind:15` attachment re-sends the same URL, key, and nonce in a new `kind:15` rumor without re-uploading. -- Video and audio still use the older plaintext upload with a `kind:14` rumor and a NIP-92 `imeta` tag, and existing `kind:14` + `imeta` media keeps rendering. +- Private media is sent as NIP-17 `kind:15` file messages: images (JPEG, PNG, GIF, WebP, AVIF, up to 20 MiB), video (MP4, WebM, up to 20 MiB) and audio (MP3, MP4/AAC, Ogg, WebM, WAV, FLAC, up to 10 MiB). The original file bytes are encrypted on the device with AES-256-GCM (WebCrypto) using a fresh random 32-byte key and 12-byte nonce per file, and only the ciphertext is uploaded, as `application/octet-stream`, to the configured encrypted-media Blossom server. +- The rumor content is the HTTPS blob URL and its tags are `file-type` (plaintext MIME type), `encryption-algorithm` (`aes-gcm`), `decryption-key` and `decryption-nonce` (lowercase hex), `x` (SHA-256 of the ciphertext), `ox` (SHA-256 of the original file), and `size` (ciphertext bytes). The key and nonce only exist inside the gift-wrapped rumor. File metadata such as EXIF is not stripped; it is encrypted with the file and visible only to recipients. +- There is no plaintext fallback: media that cannot be encrypted is refused, and a failed encrypted upload can only be retried with the same ciphertext, key and nonce (optionally on another server) or cancelled. A changed encrypted-media server is saved only after the `kind:15` message has been sent. +- Received `kind:15` rumors are accepted only with a valid key, nonce, algorithm, `file-type`, `x`, and an HTTPS URL; `ox` and `size` are optional, and anything else is dropped rather than shown as a link. Downloads are capped at the per-type size limit plus the GCM tag, the downloaded blob must match `x` before it is decrypted, and a GCM authentication failure is a hard error. Supported images, video and audio are decrypted locally and rendered from a `blob:` URL with the allowlisted MIME type; other types are shown as unsupported. Nonces of 12 or 16 bytes are accepted for interoperability. +- Encrypted media follows the image trust setting: media from the user or a trusted sender is downloaded and decrypted only once it is near the viewport, so a long conversation is not fetched in the background; from other senders nothing is downloaded until the user reveals or plays it. +- Forwarding a `kind:15` attachment re-sends the same URL, key, and nonce in a new `kind:15` rumor without re-uploading. +- Existing `kind:14` + NIP-92 `imeta` media from older messages and clients keeps rendering. Clients that only understand `kind:14` do not show `kind:15` file messages. - Text edits follow NIP-17's delete-and-replace convention: the app sends a wrapped `kind:5` deletion and a replacement wrapped `kind:14` rumor with the original message timestamp. Replacement rumors also carry a private `e` tag marked `edit` so this client can reconcile either relay arrival order without displaying duplicate messages. ## NIP-19 @@ -79,14 +83,13 @@ This list is based on the current app code, especially `src/stores/nostrStore.ts - Used for app-specific private storage on Nostr. - The app uses `kind:30078` replaceable events for private preferences, group identity secrets, and per-contact cursor state. - Those payloads are encrypted with NIP-44 before publication. -- The user's configured Blossom upload server is stored in the encrypted private-preferences payload and restored with the account. +- The user's configured Blossom servers (the encrypted-media server and the regular server) are stored in the encrypted private-preferences payload and restored with the account. ## NIP-B7 - Used for Blossom media uploads. - The app uploads blobs through the configured HTTPS Blossom server and signs server-scoped `kind:24242` upload authorization events. -- Encrypted image uploads send ciphertext only, in a single PUT that is not retried automatically, and the server's returned hash must match the ciphertext hash. Servers must accept `application/octet-stream`; several public servers (including the default) currently reject it. -- The plaintext upload path (video and audio) refuses files whose type or leading bytes identify them as images. +- Encrypted media uploads go to the encrypted-media server and send ciphertext only, in a single PUT that is not retried automatically. The server's returned hash must match the ciphertext hash and its blob URL must use HTTPS, otherwise the upload fails. Servers must accept `application/octet-stream`; Settings can test a server by uploading, downloading and deleting random bytes. - The server choice remains private in the app's NIP-78 preferences; the app does not currently publish a public `kind:10063` Blossom server list. ## NIP-171 diff --git a/src/services/blossomUploadService.ts b/src/services/blossomUploadService.ts index 7d75e6fa..80b29690 100644 --- a/src/services/blossomUploadService.ts +++ b/src/services/blossomUploadService.ts @@ -7,7 +7,10 @@ import { } from 'src/utils/blossomServer'; import { type EncryptedMediaKind, resolveEncryptedMediaKind } from 'src/utils/encryptedMedia'; import { encryptMediaBytes, MEDIA_ENCRYPTION_ALGORITHM, sha256Hex } from 'src/utils/mediaCrypto'; -import { resolveSafeInlineImageMimeType } from 'src/utils/messageAttachments'; +import { + normalizeEncryptedMediaUrl, + resolveSafeInlineImageMimeType, +} from 'src/utils/messageAttachments'; export const BLOSSOM_MEDIA_MAX_BYTES = 20 * 1024 * 1024; const ENCRYPTED_BLOB_CONTENT_TYPE = 'application/octet-stream'; @@ -335,13 +338,20 @@ export async function uploadPreparedEncryptedMedia( throw new Error(`${serverHost} stored a blob with an unexpected hash.`); } + // Same HTTPS rule the kind 15 message and its receivers enforce. Checked here so a bad URL is + // an upload error the user can retry or route to another server, not a silently dropped send. + const blobUrl = normalizeEncryptedMediaUrl(descriptor.url); + if (!blobUrl) { + throw new Error(`${serverHost} returned a blob URL that does not use HTTPS.`); + } + const uploadedAt = descriptor.uploaded ? new Date(descriptor.uploaded * 1000).toISOString() : ''; return { descriptor, attachment: { type: 'media', - url: descriptor.url, + url: blobUrl, mimeType, size: ciphertext.byteLength, sha256, diff --git a/tests/unit/blossomUploadService.spec.ts b/tests/unit/blossomUploadService.spec.ts index 468f0bb0..3fda38e5 100644 --- a/tests/unit/blossomUploadService.spec.ts +++ b/tests/unit/blossomUploadService.spec.ts @@ -319,6 +319,28 @@ describe('blossomUploadService', () => { ); }); + it('rejects a descriptor whose blob URL is not HTTPS as an upload error', async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + const body = readRequestBody(init); + const sha256 = await sha256Hex(body); + return new Response( + JSON.stringify({ + url: `http://blossom.example.com/${sha256}`, + sha256, + size: body.byteLength, + type: 'application/octet-stream', + }), + { status: 201 } + ); + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(uploadEncryptedMedia(imageFile(), UPLOAD_OPTIONS)).rejects.toThrow( + 'media.example.com returned a blob URL that does not use HTTPS.' + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('does not upload unsupported image types', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock);