diff --git a/e2e/dm/encryptedMedia.spec.ts b/e2e/dm/encryptedMedia.spec.ts new file mode 100644 index 00000000..3e01cb9a --- /dev/null +++ b/e2e/dm/encryptedMedia.spec.ts @@ -0,0 +1,626 @@ +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, + sendMessage, + sendMessagesViaBridge, + TEST_ACCOUNTS, + waitForThreadMessage, +} 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[] = []; + // Every upload request body, including the ones answered with an error. + const uploadAttempts: { body: Buffer; contentType: string }[] = []; + const state = { failUploads: false }; + + 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(); + uploadAttempts.push({ body, contentType: headers['content-type'] ?? '' }); + if (state.failUploads) { + await route.fulfill({ + status: 503, + headers: CORS_HEADERS, + body: 'unavailable', + }); + return; + } + 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, + uploadAttempts, + state, + 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-private-media-input'); + await expect(serverInput).toBeVisible(); + await serverInput.fill(serverUrl); + const saveButton = user.page.getByTestId('settings-private-media-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('Encrypted media 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 recipient gets back exactly the original image; its metadata travelled encrypted. + 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())), + }; + }); + expect(decrypted.type).toBe('image/png'); + expect(Buffer.from(decrypted.bytes).equals(png)).toBe(true); + expect(stored.body.length).toBe(png.length + 16); + + // Replies show the encrypted image from a decrypted blob URL, never the ciphertext URL. + const ciphertextUrl = `${BLOSSOM_ORIGIN}/${storedSha256}`; + await alice.page + .locator('.bubble') + .filter({ has: alice.page.getByTestId('message-image-attachment') }) + .last() + .click({ button: 'right', position: { x: 4, y: 4 } }); + await alice.page.getByText('Reply', { exact: true }).click(); + await expect(alice.page.getByTestId('composer-reply-preview-image')).toHaveAttribute( + 'src', + /^blob:/u, + { timeout: 30_000 } + ); + const replyText = `encrypted-image-reply-${Date.now()}`; + await sendMessage(alice.page, replyText, { chatId: bob.session.publicKey }); + await expect(alice.page.getByTestId('message-reply-preview-image').last()).toHaveAttribute( + 'src', + /^blob:/u, + { timeout: 30_000 } + ); + await expect(alice.page.locator(`img[src="${ciphertextUrl}"]`)).toHaveCount(0); + + // Images far from the viewport are not downloaded until the user scrolls near them. + const fillerTexts = Array.from( + { length: 40 }, + (_, index) => `lazy-filler-${index}\nline two\nline three\nline four` + ); + await sendMessagesViaBridge(alice.page, bob.session.publicKey, fillerTexts); + await reloadAndWaitForApp(alice.page); + blossom.downloads.length = 0; + await navigateToChat(alice.page, bob.session.publicKey); + await waitForThreadMessage(alice.page, 'lazy-filler-39', { + chatId: bob.session.publicKey, + }); + const pendingImage = alice.page.getByTestId('message-image-pending').first(); + await expect(pendingImage).toBeAttached(); + await alice.page.waitForTimeout(1500); + expect(blossom.downloads).toHaveLength(0); + await pendingImage.scrollIntoViewIfNeeded(); + await readRenderedImage(alice.page); + expect(blossom.downloads.length).toBeGreaterThan(0); + + // 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); + } +}); + +// A real, playable 1 second mono 16-bit PCM WAV (440 Hz tone). +function buildWavTone(seconds = 1, sampleRate = 8000): Buffer { + const samples = seconds * sampleRate; + const data = Buffer.alloc(samples * 2); + for (let index = 0; index < samples; index += 1) { + const value = Math.sin((index / sampleRate) * 440 * 2 * Math.PI) * 8000; + data.writeInt16LE(Math.round(value), index * 2); + } + + const header = Buffer.alloc(44); + header.write('RIFF', 0, 'latin1'); + header.writeUInt32LE(36 + data.length, 4); + header.write('WAVE', 8, 'latin1'); + header.write('fmt ', 12, 'latin1'); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36, 'latin1'); + header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +// Records a short, real WebM clip in the browser, so the test needs no binary fixture. +async function recordWebmClip(page: Page): Promise { + const bytes = await page.evaluate(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 48; + const context = canvas.getContext('2d'); + const recorder = new MediaRecorder(canvas.captureStream(15), { + mimeType: 'video/webm', + }); + const chunks: Blob[] = []; + recorder.ondataavailable = (event) => chunks.push(event.data); + const stopped = new Promise((resolve) => (recorder.onstop = resolve)); + recorder.start(); + for (let frame = 0; frame < 12; frame += 1) { + if (context) { + context.fillStyle = `hsl(${frame * 30}, 80%, 50%)`; + context.fillRect(0, 0, 64, 48); + } + await new Promise((resolve) => setTimeout(resolve, 60)); + } + recorder.stop(); + await stopped; + return Array.from(new Uint8Array(await new Blob(chunks).arrayBuffer())); + }); + return Buffer.from(bytes); +} + +// Opens the media picker. The encryption notice is expected unless it was dismissed earlier. +async function sendPrivateMedia( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, + options: { expectNotice: boolean; dontShowAgain?: boolean } +) { + await page.getByTestId('message-composer-menu').click(); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByText('Photo or Video', { exact: true }).click(); + if (options.expectNotice) { + await expect(page.getByText(/end-to-end encrypted on your device/u)).toBeVisible(); + if (options.dontShowAgain) { + await page.getByTestId('composer-media-notice-dont-show').click(); + } + await page.getByRole('button', { name: 'OK', exact: true }).click(); + } else { + await expect(page.getByTestId('composer-media-notice-dont-show')).toHaveCount(0); + } + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(file); +} + +interface PlayerSnapshot { + width: number; + height: number; + src: string; + duration: number; + currentTime: number; + paused: boolean; +} + +function readPlayer(page: Page, testId: string): Promise { + return page + .getByTestId(testId) + .last() + .evaluate((element: HTMLMediaElement) => { + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + src: element.src, + duration: element.duration, + currentTime: element.currentTime, + paused: element.paused, + }; + }); +} + +async function expectUsablePlayer(page: Page, testId: string): Promise { + const player = page.getByTestId(testId).last(); + await expect(player).toHaveAttribute('src', /^blob:/u, { timeout: 30_000 }); + await expect(player).toHaveAttribute('controls', ''); + await expect + .poll(() => player.evaluate((element: HTMLMediaElement) => element.readyState), { + timeout: 15_000, + }) + .toBeGreaterThanOrEqual(1); + const snapshot = await readPlayer(page, testId); + // Regression: the player used to collapse to a 0px wide box, hiding its native controls. + expect(snapshot.width).toBeGreaterThan(200); + expect(snapshot.height).toBeGreaterThan(20); + return snapshot; +} + +test('private audio and video are encrypted, decrypt locally and play with native controls', 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 startingBlobCount = blossom.blobs.size; + + // Audio: the notice is shown the first time and dismissed for good. + const wav = buildWavTone(); + await sendPrivateMedia( + alice.page, + { name: 'tone.wav', mimeType: 'audio/wav', buffer: wav }, + { expectNotice: true, dontShowAgain: true } + ); + await expect.poll(() => blossom.blobs.size, { timeout: 30_000 }).toBe(startingBlobCount + 1); + const audioUpload = blossom.uploadAttempts.at(-1); + expect(audioUpload?.contentType).toBe('application/octet-stream'); + expect(audioUpload?.body.length).toBe(wav.length + 16); + expect(audioUpload?.body.includes(Buffer.from('RIFF'))).toBe(false); + expect(audioUpload?.body.includes(Buffer.from('WAVE'))).toBe(false); + + // Own media is trusted, so it decrypts on its own once visible and shows a real player. + const audio = alice.page.getByTestId('message-encrypted-audio').last(); + const aliceAudio = await expectUsablePlayer(alice.page, 'message-encrypted-audio'); + expect(aliceAudio.duration).toBeCloseTo(1, 1); + const decryptedAudio = await audio.evaluate(async (element: HTMLMediaElement) => { + const blob = await (await fetch(element.src)).blob(); + return { + type: blob.type, + bytes: Array.from(new Uint8Array(await blob.arrayBuffer())), + }; + }); + expect(decryptedAudio.type).toBe('audio/wav'); + expect(Buffer.from(decryptedAudio.bytes).equals(wav)).toBe(true); + + // Play, pause, seek and replay through the native element. + await audio.evaluate((element: HTMLMediaElement) => element.play()); + await expect + .poll(async () => (await readPlayer(alice.page, 'message-encrypted-audio')).currentTime) + .toBeGreaterThan(0); + await audio.evaluate((element: HTMLMediaElement) => element.pause()); + expect((await readPlayer(alice.page, 'message-encrypted-audio')).paused).toBe(true); + await audio.evaluate((element: HTMLMediaElement) => { + element.currentTime = 0.5; + }); + await expect + .poll(async () => (await readPlayer(alice.page, 'message-encrypted-audio')).currentTime) + .toBeCloseTo(0.5, 1); + await audio.evaluate((element: HTMLMediaElement) => element.play()); + await expect + .poll(() => audio.evaluate((element: HTMLMediaElement) => element.ended), { timeout: 10_000 }) + .toBe(true); + await audio.evaluate((element: HTMLMediaElement) => element.play()); + await expect + .poll(async () => (await readPlayer(alice.page, 'message-encrypted-audio')).paused) + .toBe(false); + await audio.evaluate((element: HTMLMediaElement) => element.pause()); + + // Video: the notice stays dismissed and the picker opens directly. + const webm = await recordWebmClip(alice.page); + await sendPrivateMedia( + alice.page, + { name: 'clip.webm', mimeType: 'video/webm', buffer: webm }, + { expectNotice: false } + ); + await expect.poll(() => blossom.blobs.size, { timeout: 30_000 }).toBe(startingBlobCount + 2); + const videoUpload = blossom.uploadAttempts.at(-1); + expect(videoUpload?.contentType).toBe('application/octet-stream'); + expect(videoUpload?.body.subarray(0, 4).equals(webm.subarray(0, 4))).toBe(false); + await expectUsablePlayer(alice.page, 'message-encrypted-video'); + + // The recipient has not trusted the sender: nothing is downloaded until they press play. + blossom.downloads.length = 0; + await navigateToChat(bob.page, alice.session.publicKey); + await expect(bob.page.getByTestId('message-encrypted-media-placeholder')).toHaveCount(2, { + timeout: 30_000, + }); + await bob.page.waitForTimeout(1500); + expect(blossom.downloads).toHaveLength(0); + + await bob.page.getByTestId('message-encrypted-media-load').first().click(); + const bobAudio = await expectUsablePlayer(bob.page, 'message-encrypted-audio'); + expect(bobAudio.duration).toBeCloseTo(1, 1); + await bob.page.getByTestId('message-encrypted-media-load').first().click(); + await expectUsablePlayer(bob.page, 'message-encrypted-video'); + const video = bob.page.getByTestId('message-encrypted-video').last(); + await video.evaluate((element: HTMLMediaElement) => element.play()); + await expect + .poll(async () => (await readPlayer(bob.page, 'message-encrypted-video')).paused) + .toBe(false); + await video.evaluate((element: HTMLMediaElement) => element.pause()); + expect((await readPlayer(bob.page, 'message-encrypted-video')).paused).toBe(true); + + // The decrypted object URL is released once the thread is no longer shown. + const bobAudioUrl = (await readPlayer(bob.page, 'message-encrypted-audio')).src; + await bob.page.goto('/#/settings/media-data-storage'); + await expect(bob.page.getByTestId('settings-private-media-input')).toBeVisible(); + const isRevoked = await bob.page.evaluate(async (url) => { + try { + await fetch(url); + return false; + } catch { + return true; + } + }, bobAudioUrl); + expect(isRevoked).toBe(true); + + // With the notice dismissed, upload failures still surface with retry and change-server, and + // a retry re-sends the identical ciphertext. Plaintext is never offered or uploaded. + blossom.state.failUploads = true; + const attemptsBeforeFailure = blossom.uploadAttempts.length; + await sendPrivateMedia( + alice.page, + { name: 'tone-again.wav', mimeType: 'audio/wav', buffer: wav }, + { expectNotice: false } + ); + await expect(alice.page.getByTestId('composer-media-upload-retry')).toBeVisible({ + timeout: 30_000, + }); + await expect(alice.page.getByTestId('composer-media-upload-change-server')).toBeVisible(); + await expect(alice.page.getByText(/plaintext|unencrypted/iu)).toHaveCount(0); + const failedAttempt = blossom.uploadAttempts.at(-1); + expect(blossom.uploadAttempts.length).toBe(attemptsBeforeFailure + 1); + expect(failedAttempt?.contentType).toBe('application/octet-stream'); + expect(failedAttempt?.body.includes(Buffer.from('RIFF'))).toBe(false); + + blossom.state.failUploads = false; + await alice.page.getByTestId('composer-media-upload-retry').click(); + await expect.poll(() => blossom.blobs.size, { timeout: 30_000 }).toBe(startingBlobCount + 3); + expect(blossom.uploadAttempts.at(-1)?.body.equals(failedAttempt?.body ?? Buffer.alloc(0))).toBe( + 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, + /Failed to upload media to Blossom/u, + /unavailable/u, + // The deliberate fetch of the revoked object URL above. + /ERR_FILE_NOT_FOUND/u, + ], + }); + } 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..21007992 100644 --- a/nips/NIPS_USED.md +++ b/nips/NIPS_USED.md @@ -27,6 +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. +- 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 @@ -76,12 +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 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-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..cf4d56ef 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'; @@ -422,7 +424,11 @@ const emit = defineEmits<{ (event: 'edit-message', payload: { message: Message; text: string }): void; ( event: 'send-media', - payload: { attachment: MessageAttachmentMetadata; replyTo: MessageReplyPreview | null } + payload: { + attachment: MessageAttachmentMetadata; + replyTo: MessageReplyPreview | null; + privateMediaServerToPersist?: string; + } ): void; (event: 'back'): void; (event: 'open-profile', publicKey: string): void; @@ -569,13 +575,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 +601,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 +624,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.'); } @@ -1877,12 +1895,18 @@ function handleSend(payload: { text: string }): void { } } -function handleSendMedia(payload: { attachment: MessageAttachmentMetadata }): void { +function handleSendMedia(payload: { + attachment: MessageAttachmentMetadata; + privateMediaServerToPersist?: string; +}): void { try { pendingSentMessageReveal = true; emit('send-media', { attachment: payload.attachment, replyTo: activeReply.value, + ...(payload.privateMediaServerToPersist + ? { privateMediaServerToPersist: payload.privateMediaServerToPersist } + : {}), }); activeReply.value = null; } catch (error) { @@ -1907,6 +1931,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..685e0646 --- /dev/null +++ b/src/components/MessageAttachmentImage.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/MessageAttachmentMedia.vue b/src/components/MessageAttachmentMedia.vue new file mode 100644 index 00000000..6ffb1bc2 --- /dev/null +++ b/src/components/MessageAttachmentMedia.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/src/components/MessageBubble.vue b/src/components/MessageBubble.vue index a742f91a..43c5efe5 100644 --- a/src/components/MessageBubble.vue +++ b/src/components/MessageBubble.vue @@ -199,10 +199,11 @@ @click.stop="handleOpenReplyTarget" >