Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
626 changes: 626 additions & 0 deletions e2e/dm/encryptedMedia.spec.ts

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion nips/NIPS_USED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 44 additions & 17 deletions src/components/ChatThread.vue
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@
@click="downloadFullscreenImage"
/>
<a
v-if="fullscreenImageAttachment"
v-if="fullscreenImageAttachment && !fullscreenImageAttachment.encryption"
class="thread-image-dialog__action"
:href="fullscreenImageAttachment.url"
target="_blank"
Expand All @@ -339,11 +339,11 @@
/>
</div>
<div class="thread-image-dialog__body">
<img
<MessageAttachmentImage
v-if="fullscreenImageAttachment"
class="thread-image-dialog__media"
data-testid="message-image-fullscreen"
:src="fullscreenImageAttachment.url"
:attachment="fullscreenImageAttachment"
:alt="resolveImageAttachmentAlt(fullscreenImageAttachment)"
/>
</div>
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand All @@ -592,6 +601,20 @@ function closeImageAttachmentDialog(): void {
fullscreenImageAttachment.value = null;
}

async function readDownloadableImageBlob(attachment: MessageAttachmentMetadata): Promise<Blob> {
// 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<void> {
const attachment = fullscreenImageAttachment.value;
if (!attachment || isDownloadingImage.value) {
Expand All @@ -601,12 +624,7 @@ async function downloadFullscreenImage(): Promise<void> {
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.');
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
163 changes: 163 additions & 0 deletions src/components/MessageAttachmentImage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<template>
<img v-if="resolvedSrc" v-bind="$attrs" :src="resolvedSrc" :alt="alt" />
<div
v-else
ref="placeholderRef"
class="attachment-image__placeholder"
:class="$attrs.class"
role="img"
:aria-label="alt"
:data-testid="hasFailed ? 'message-image-unavailable' : 'message-image-pending'"
>
<q-icon v-if="hasFailed" name="broken_image" size="20px" />
<q-spinner v-else size="18px" />
</div>
</template>

<script setup lang="ts">
import { encryptedMediaService } from 'src/services/encryptedMediaService';
import type { MessageAttachmentMetadata } from 'src/types/chat';
import { isEncryptedAttachment } from 'src/utils/messageAttachments';
import { computed, onBeforeUnmount, ref, watch } from 'vue';

defineOptions({ inheritAttrs: false });

const props = defineProps<{
attachment?: MessageAttachmentMetadata | null;
src?: string;
alt: string;
}>();

// Encrypted images are only downloaded and decrypted once they are within this distance of the
// viewport, so opening a long conversation does not fetch every image in it.
const PRELOAD_ROOT_MARGIN = '600px 0px';

const placeholderRef = ref<HTMLElement | null>(null);
const isNearViewport = ref(false);
const decryptedSrc = ref('');
const hasFailed = ref(false);
let viewportObserver: IntersectionObserver | null = null;
let acquiredAttachment: MessageAttachmentMetadata | null = null;
let loadGeneration = 0;

const isEncrypted = computed(() =>
props.attachment ? isEncryptedAttachment(props.attachment) : false
);
// Encrypted attachments only ever render from the decrypted object URL; the ciphertext URL is
// never used as an image source.
const resolvedSrc = computed(() => {
if (isEncrypted.value) {
return decryptedSrc.value;
}

return (props.attachment?.url ?? props.src ?? '').trim();
});

function stopObservingViewport(): void {
viewportObserver?.disconnect();
viewportObserver = null;
}

watch(
[placeholderRef, isEncrypted],
([element, encrypted]) => {
stopObservingViewport();
if (!element || !encrypted || isNearViewport.value) {
return;
}

if (typeof IntersectionObserver === 'undefined') {
isNearViewport.value = true;
return;
}

viewportObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
isNearViewport.value = true;
stopObservingViewport();
}
},
{ rootMargin: PRELOAD_ROOT_MARGIN }
);
viewportObserver.observe(element);
},
{ flush: 'post' }
);

function releaseAcquiredAttachment(): void {
if (acquiredAttachment) {
encryptedMediaService.releaseDecryptedObjectUrl(acquiredAttachment);
acquiredAttachment = null;
}
decryptedSrc.value = '';
}

watch(
() => {
const attachment = props.attachment;
if (!attachment?.encryption || !isNearViewport.value) {
return '';
}

return [
attachment.url,
attachment.sha256 ?? '',
attachment.mimeType,
attachment.encryption.key,
attachment.encryption.nonce,
].join('|');
},
async (identity) => {
loadGeneration += 1;
const generation = loadGeneration;
releaseAcquiredAttachment();
hasFailed.value = false;

const attachment = props.attachment;
if (!identity || !attachment || !isEncryptedAttachment(attachment)) {
return;
}

const snapshot: MessageAttachmentMetadata = {
...attachment,
encryption: { ...attachment.encryption },
};
acquiredAttachment = snapshot;
try {
const objectUrl = await encryptedMediaService.acquireDecryptedObjectUrl(snapshot);
if (generation === loadGeneration) {
decryptedSrc.value = objectUrl;
}
} catch {
if (acquiredAttachment === snapshot) {
// A failed load is not cached, so there is no reference left to release.
acquiredAttachment = null;
}
if (generation === loadGeneration) {
hasFailed.value = true;
}
}
},
{ immediate: true }
);

onBeforeUnmount(() => {
loadGeneration += 1;
stopObservingViewport();
releaseAcquiredAttachment();
});
</script>

<style scoped>
.attachment-image__placeholder {
display: flex;
align-items: center;
justify-content: center;
min-width: 96px;
min-height: 72px;
color: var(--q-primary, currentColor);
background: rgba(127, 127, 127, 0.12);
border-radius: 8px;
}
</style>
Loading