Shared media deduplication
Status: Phase 1 + Phase 2 in source (refcount-safe delete, unique-byte quotas, global index default). Deploy Functions + ship client to mark fully live — see docs/SHARED_MEDIA_DEDUP_VERIFY.md.
Surfaces: Communities · Broadcast channels · Open-history groups (OHG)
Out of scope: Stories · 1-to-1 · Whisper private chat · E2E groups
This page explains why Buzzio stores media files as CDN bytes with content-hash reuse on those shared surfaces. Text/captions stay server-held DEK ciphertext.
Short answer
| Content | Communities / Broadcast / OHG | Stories / 1:1 / E2E groups |
|---|---|---|
| Text / captions | Stay encrypted at rest under server-held DEK (not plaintext in history stores) | Unchanged sealed / story crypto |
| Images, video, files | Stored as CDN bytes on Bunny with membership + signed URLs; content-hash dedup | Stay client-encrypted (Stories / sealed paths) |
Buzzio still does not sell content. Shared rooms were never operator-blind E2EE — see Sealed vs shared.
Why we do this
1. Shared mode already allows the operator to operate the room
Communities, Broadcast, and open-history groups use server-held or server-managed keys so late joiners, moderation, feeds, and multi-device history work. Encrypting media under that same service-held DEK does not make those blobs operator-blind. It mainly adds upload CPU, download decrypt cost, and unique ciphertext per send.
Dropping media ciphertext on those surfaces is therefore an honest product trade, not a silent downgrade of a sealed promise.
2. Content-addressable reuse (Telegram-style storage savings)
Large messengers avoid storing the same popular video a thousand times:
- Client computes SHA-256 of the media bytes (before upload).
- Client asks the server: “Do we already have hash
Hin this scope?” - If yes — reuse the existing Bunny path; skip uploading the file again.
- If no — upload once; index
H → storage_path. - Downloaders still get a short-lived signed CDN URL after membership checks.
That only works cleanly when the bytes stored on Bunny match what was hashed. Per-upload AES-GCM (random nonces) makes ciphertext unique even for the same video under the same DEK — so classic dedup fails unless media is stored as the hashed bytes (or a carefully designed shared-ciphertext scheme we are not using here).
3. Text stays encrypted on purpose
Message bodies and captions remain server-held DEK ciphertext in history / Firestore / R2 paths as today:
- Not stored as readable plaintext in product databases
- Still decryptable by the service for moderation, search ops, and late-joiner history (shared-mode design)
- Not end-to-end / operator-blind
Media and text are treated differently because media is large, often repeated, and already effectively readable under service keys — while keeping text ciphertext-at-rest remains good hygiene and matches existing shared-mode docs.
4. What we deliberately leave alone
| Surface | Why untouched |
|---|---|
| Stories | Audience delivery uses stronger client media crypto / wrapped keys; privacy-sensitive |
| 1-to-1 / Whisper private / E2E groups | Sealed — operator must not read content |
Access control after the change
Media is not “public forever on the open internet.”
| Control | Role |
|---|---|
| Membership / follower checks | Cloud Functions refuse download credentials for outsiders |
| Signed Bunny CDN URLs | Short-lived tokens; paths are not durable public deep links in message payloads |
| Media pool expiry | Freemium cycle reset and post-boot media wipe clear the room Bunny prefix and detach that room from global shared_media/by_hash ACLs (expireSharedMediaForScope). Global objects are deleted only when no room scopes remain |
| Retention / deletes | Per-message delete uses refcounts; pool wipe expires the whole room’s shared media access |
| Bunny as subprocessor | CDN/storage provider can see shared-room media bytes (same class as many feed products) |
Threat framing: Threat model · Subprocessors.
Dedup vs media pool (two layers)
Shared rooms use two separate systems. Mixing them causes the “can’t delete yet / free re-attach” confusion.
| Layer | Purpose | On pool expiry |
|---|---|---|
| CDN / content-hash | Skip re-uploading the same bytes; share one Bunny object across rooms that still need it | Detach this room’s ACL. Delete Bunny only if no other room still lists the hash |
| Media pool (50 MB / boot) | Product quota for this room’s cycle | Reset media_bytes_used; room must pay pool quota again when it posts media |
Strong rule (implemented): CDN dedup never gives a free pool. Every media post charges this room’s pool by file size, even on a hash hit. After expiry, re-using the same video (if another room still holds the CDN object) skips upload but still fills this room’s new pool.
Delete rule: “Gone for this room” ≠ “gone from Bunny.” Other rooms can keep the file until they expire or delete their last reference.
flowchart TD
upload[User posts video]
hash[SHA-256 lookup]
put{CDN hash ready?}
bunny[Bunny PUT once]
reuse[Reuse path skip PUT]
pool[Charge THIS room media pool]
msg[Post message]
expire[Room pool expires]
detach[Detach room ACL]
orphan{Any room left?}
del[Delete Bunny + hash]
keep[Keep bytes for other rooms]
upload --> hash --> put
put -->|no| bunny --> pool
put -->|yes| reuse --> pool
pool --> msg
expire --> detach --> orphan
orphan -->|no| del
orphan -->|yes| keep
What users should expect
- Sending the same video again may skip upload (hash hit) while still posting a normal message and still using media-pool quota.
- When this room’s media pool expires, that room loses access; another room that still has the video keeps it until its pool expires.
- After expiry, posting the same video again can skip CDN upload if another room still holds it, but it counts against the new pool.
- Opening sealed chat or Stories behaves as before.
- Docs and store Data Safety wording must say: shared-room media is CDN-stored with access control; shared-room text remains service-key encrypted at rest — neither is sealed E2EE.
Implementation plan
Status legend: Docs (this change) · Code (not started until engineering picks up the plan).
Phase 0 — Documentation
- Public explanation page (this file)
- Update Communities, Broadcast, Groups, crypto, sealed-vs-shared, architecture, FAQ, changelog, index
- Mark shipped in changelog only after Functions deploy + client release
Phase 1 — Media plaintext path + within-room dedup
Status: implemented in app + Functions source (deploy required).
Client (Flutter) — done for Communities / Broadcast / OHG
- Stop encrypting media blobs before Bunny PUT.
- Compute SHA-256;
lookupOrAllocateSharedMediabefore PUT. - On hash hit: reuse
bunny_path; skip bytes upload. - Download: dual-read — no media DEK decrypt when
encryption: none; legacy.encstill decrypts. - Text/caption encrypt under server-held DEK unchanged.
Cloud Functions — done in source
lookupOrAllocateSharedMedia/confirmSharedMediaHash- Firestore
shared_media_hashes/{scopeType_scopeId_sha256} - Upload/download path asserts allow
.encor/by_hash/
Bunny paths —
…/media/by_hash/{sha256}/…(scoped per room)Compat — old
.encobjects remain downloadable until retention deletes them.
Phase 2 — Hardening + global index
Status: implemented in Functions source.
- Reference counting / safe delete (
releaseSharedMediaPaths) — Bunny object removed only when no room ACL remains - Global hash index +
shared_media/by_hash/paths (default for new allocations) - Pool quota always charges this room’s file size (CDN dedup ≠ free pool)
- Firestore rules deny client access to hash collections
- Metrics: anonymous counters in
ops_cost_metrics(seedocs/OPS_COST_TELEMETRY.md)
Phase 3 — Docs “shipped” + store forms
- Flip changelog to Shipped after production deploy + client release (checklist:
docs/SHARED_MEDIA_DEDUP_VERIFY.md) - Align Privacy Policy / store Data Safety media wording with CDN-stored shared media
- Confirm Stories / sealed media still described as encrypted
Suggested engineering order
| Order | Work | Owner area |
|---|---|---|
| 1 | Hash index + lookup/allocate callable | functions/community, broadcast, open_history_group |
| 2 | Flutter upload services skip encrypt; send hash | community_*, broadcast_*, open_history_group_* media services |
| 3 | Download path without media decrypt for new objects | same |
| 4 | Dual-read legacy .enc |
client + functions |
| 5 | Refcount deletes + optional global index | functions |
| 6 | Changelog + Data Safety | docs / store |
Internal code pointers (not public API): lib/features/communities/services/community_device_media_transfer.dart, broadcast_device_media_transfer.dart, open_history_group_device_media_transfer.dart, functions/*/media.js.
Related pages
- Sealed vs shared
- Communities
- Broadcast channels
- Groups
- Groups cryptography
- System architecture
- Stories (unchanged sealed-leaning media)
- Changelog