Skip to content

Add end-to-end encrypted private media - #81

Closed
Danidfra wants to merge 7 commits into
lnbits:mainfrom
Danidfra:feat/encrypted-private-media
Closed

Danidfra wants to merge 7 commits into
lnbits:mainfrom
Danidfra:feat/encrypted-private-media

Conversation

@Danidfra

@Danidfra Danidfra commented Oct 7, 2026

Copy link
Copy Markdown

Summary

Adds end-to-end encrypted media attachments to private Anagram conversations.

Private images, videos and audio are encrypted locally with AES-256-GCM before upload. Blossom only receives ciphertext, while the information required to decrypt the attachment is carried inside the private Nostr message using kind 15.

What changed

  • Encrypt private images, videos and audio locally before upload
  • Send encrypted file messages as NIP-17 kind 15
  • Upload ciphertext as application/octet-stream
  • Use a dedicated configurable Blossom server for encrypted media
  • Add Retry / Change server / Cancel handling for failed encrypted uploads
  • Validate the returned ciphertext hash and HTTPS blob URL before sending
  • Decrypt and render supported media locally
  • Lazy-load encrypted media based on sender trust and viewport proximity
  • Add native audio and video playback
  • Add an optional encryption notice and Settings preference
  • Persist a changed encrypted-media server only after the message is successfully sent
  • Update NIPS_USED.md with the encrypted-media behavior

There is no automatic or plaintext fallback if an encrypted upload fails.

Compatibility

Older Anagram clients that only understand kind 14 attachments will not display the new kind 15 encrypted media messages.

Validation

  • 710 unit tests passing
  • quality:all passing
  • git diff --check clean
  • Encrypted image, audio and video flows exercised in Chromium
  • Upload failure, retry and server-change flows tested
  • Ciphertext uploads verified as application/octet-stream
  • Object URL cleanup and lazy loading covered by tests

Docker-based CI, Electron, mobile layout and some browser codec combinations have not been manually tested locally.

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.
Cleanup of the kind 15 encrypted image implementation. The NIP-17 kind 15
protocol, the AES-256-GCM scheme, and the send/receive/retry/replay/forward
paths are unchanged.

- Upload a single PUT with no automatic retries, so a rejected or
  CORS-masked upload (e.g. a 415 without CORS headers) is not sent three
  times; explain opaque network failures instead of "Failed to fetch".
- Encrypt the original image bytes directly; metadata stripping is removed
  from this change (metadata is encrypted and only visible to recipients).
- Load encrypted images lazily with an IntersectionObserver so long chats
  do not download and decrypt every image up front.
- Refuse files whose leading bytes identify them as images on the plaintext
  video/audio upload path.
- Keep NIP-17 size optional on received kind 15 instead of inventing one.
- Trim the crypto and media service APIs to what production uses and reuse
  sha256Hex for the legacy upload hash.
- Move the new upload notice to an en-US-only key and revert the locale
  churn; correct NIPS_USED.md.
Encrypted images no longer share the regular Blossom setting. They upload
to a separate private-media server that must store ciphertext byte for
byte, defaulting to https://blossom.ditto.pub. Video and audio keep using
the regular server unchanged.

- Add privateMediaBlossomServerUrl to private preferences, with its own
  getter/setter and a settings card.
- Add a "Test server" probe that uploads random bytes, downloads them,
  compares hashes, and best-effort deletes them.
- Split encryption from upload so a retry reuses the same ciphertext, key
  and nonce.
- On upload failure, offer Retry, Change server and Cancel. There is no
  plaintext fallback.
Extend the encrypted private-media pipeline from images to MP4/WebM video
and MP3/MP4/AAC/Ogg/WebM/WAV/FLAC audio using the existing whole-file
AES-GCM scheme and kind 15 metadata.

- Add centralized encrypted-media MIME and size handling.
- Route private image, video and audio through the dedicated private-media
  Blossom server with no plaintext fallback.
- Enforce bounded ciphertext downloads and per-media size limits.
- Add click-to-decrypt video/audio playback with safe object URL cleanup.
- Preserve ciphertext, key and nonce across upload retries and server changes.
- Persist a changed private-media server only after the kind-15 send succeeds.
- Fix encrypted audio (and video) players collapsing to 0px wide: the
  player's width:100% resolved against a shrink-wrapped container, hiding
  the native controls. The media box now has a definite width.
- Render encrypted video/audio as media attachments: a play placeholder
  until loaded, then the native player. Media from trusted senders
  (including our own) decrypts automatically once near the viewport;
  others load on tap, so long conversations are not downloaded eagerly.
- Add "Don't show this again" to the private media notice, stored locally.
  It only skips the informational notice; uploads still go through the
  same encrypted flow with the same errors, retry and change-server UI.
- Cover audio/video upload, decryption, playback, lazy loading, object URL
  release, the dismissed notice and retry with identical ciphertext in e2e.
@Danidfra

Danidfra commented Oct 7, 2026

Copy link
Copy Markdown
Author

Superseded by #82, which ports the encrypted private media work onto the current brutal branch and its new architecture.

@Danidfra Danidfra closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant