OpenE2EE

Add encrypted attachments with an opaque object store

A complete build for encrypted file transfer through a brokered object store, using either the Convex R2 adapter or the S3 adapter, with the memory and failure behaviour stated plainly.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
A working client that already sends and receives messages
Reading time
16 min

You have messages working. Now your product needs photos, voice notes, and PDFs. Putting a file in a bucket and its URL in a message exposes sensitive content to the storage provider.

This guide builds an end-to-end encrypted alternative. Your backend acts as a broker, the object store holds opaque bytes, and the media key travels only in an encrypted message.

Intended audience

Engineers who already have send() and startRelaySubscription() working against a relay, and who own both the client and an authenticated application backend. You should be comfortable writing backend functions: the security of this design lives in code you write, not in code the SDK ships.

Prerequisites

  • A working client from Quickstart, including a storage adapter.
  • A relay, because the attachment pointer travels as an ordinary encrypted message. See Relay and prekeys or Convex relay.
  • An authenticated application backend. Every broker operation in this guide must be able to answer "which principal is calling, and may they touch this object?"
  • A bucket you own: Cloudflare R2 via @convex-dev/r2, or S3 or an S3-compatible service.
A device holding the media key inside its outline, a trust boundary gutter, and beyond it a relay carrying a sheared envelope above an object store bracket holding three opaque slabs of unequal sizedevicemedia keysealrelay: pointer envelopeobject store: ciphertext only
The media key stays inside the device and never leaves in the clear. The relay carries a sealed pointer envelope, and the object store holds opaque data. Metadata ticks represent observable object sizes and write times.

Install

npm install @open-e2ee/signal-protocol-sdk@0.1.0

For the Convex R2 path, add the Convex client and the R2 component:

npm install @open-e2ee/signal-protocol-sdk@0.1.0 convex @convex-dev/r2

For the S3 path, the AWS SDK belongs on your backend only. Install it in the backend package, not the app:

npm install @open-e2ee/signal-protocol-sdk@0.1.0 @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

The brokered model is the security thesis

Before any code, the rule both adapters exist to enforce:

An authenticated application backend maps a retry-stable requestId to a canonical objectId and a private provider key, then issues short-lived upload and download operations. Cloud credentials and unrestricted provider clients do not belong in the app runtime.

The identity chain in the SDK's own words is retry-stable requestId -> canonical objectId -> private provider key, and each arrow is a place where authorization happens.

What goes wrong if S3 credentials live in the app runtime. An access key in a mobile or browser bundle is not secret. Attackers can extract it from an APK, a rooted-device proxy, or source maps. The holder receives all permissions that the key grants across the bucket until you rotate it. Rotation requires a new release and user adoption. Even a read-only key enables bulk exfiltration and exposes object metadata. A presigned operation limits access to one object, method, and expiration time.

Why requestId must be retry-stable. requestId is an idempotency key for one logical upload, not an object name. Uploads stop when apps enter the background, connections drop, or processes end during transfer. If the client creates a new identifier for each attempt, every retry reserves another object. An upload that takes four attempts would leave three unused objects. The completion step would also lack a stable identifier to authorize. A stable requestId makes the backend return the same reservation: one objectId, one private provider key, and one object.

Read the rule as three obligations:

  • Keep provider credentials out of the client. This rule includes scoped tokens and signing keys. Give the client only short-lived presigned operations.
  • Let the backend name the object. Scope each untrusted requestId to the authenticated principal before mapping it. A client that could choose its own storage path could read or overwrite another user's objects. Never treat requestId or storageId as a provider key.
  • Every operation re-authorizes. Reservation, download, completion, and deletion are four decisions, not one decision cached in a URL.

Your broker also enforces content-type and size limits. Cryptography does not enforce them: a 4 GB object encrypted correctly is still a 4 GB object in your bucket.

Stage 1: the broker

Convex and R2

ConvexR2ObjectStore is a client adapter for application-owned Convex functions. It is not the R2 component and does not own your bucket, credentials, schema, authorization, or metadata model. The optional server helper removes the repetitive plumbing while leaving those with you:

// convex/signalObjectStore.ts
import { R2 } from '@convex-dev/r2';
import {
  defineConvexR2ObjectStore,
  type ConvexR2ObjectCallbacks,
} from '@open-e2ee/signal-protocol-sdk/remote/object-store/convex-r2/server';
import { components, internal } from './_generated/api';

const objects = internal.signalObjectStoreModel satisfies ConvexR2ObjectCallbacks;

export const { createUpload, createDownload, completeUpload, deleteObject } =
  defineConvexR2ObjectStore({
    r2: new R2(components.r2),
    limits: {
      maxContentLength: 50 * 1024 * 1024,
      allowedContentTypes: ['application/octet-stream'],
      downloadExpiresInSeconds: 15 * 60,
    },
    objects,
  });

allowedContentTypes of ['application/octet-stream'] is deliberate: your app uploads ciphertext, and declaring its true content type to the store would leak the plaintext's type for free.

objects is four internal functions you write, and they are where authorization lives:

  • reserve: an internal mutation taking { requestId, contentType, contentLength } and returning { objectId, providerKey }. It must scope requestId to the authenticated principal and return the same pair for every valid retry.
  • resolve: an internal query taking { objectId, operation } where operation is 'download' or 'complete', returning { providerKey, contentType, contentLength } or null.
  • complete: an internal mutation that re-checks the principal and idempotently marks a provider-verified upload complete. It runs in a separate transaction from resolve, so it must re-authorize rather than trust the earlier lookup.
  • remove: an internal mutation that logically removes an object and returns its provider key.

createDownload and completeUpload are actions because they produce time-sensitive credentials or await provider metadata. createUpload and deleteObject are mutations. The helper derives expiry from the actual signed operation and checks the reserved content type and byte length before completion. It does not import @convex-dev/r2 at runtime, so S3-only consumers never load the component.

S3 and S3-compatible services

S3ObjectStore is framework-neutral. It consumes only the short-lived operations your backend returns:

import { s3ObjectStore } from '@open-e2ee/signal-protocol-sdk/remote/object-store/s3';

const remoteObjectStore = s3ObjectStore({
  broker: {
    createUpload: (input) => appStorageApi.createS3Upload(input),
    createDownload: (input) => appStorageApi.createS3Download(input),
    completeUpload: (input) => appStorageApi.completeS3Upload(input),
    deleteObject: (input) => appStorageApi.deleteS3Object(input),
  },
});

The four broker inputs and outputs are the SignalProtocolRemoteObjectStore contract: createUpload({ requestId, contentType, contentLength }) returns { objectId, uploadUrl, expiresAt, headers?, protocol? } where protocol is 'put' or 'tus'. createDownload({ objectId }) returns { downloadUrl, expiresAt, headers? }. completeUpload({ objectId }) and deleteObject({ objectId }) take the canonical identifier and return nothing.

Your backend must authenticate the caller and make upload reservations idempotent. It must generate opaque object identifiers and private provider keys. Restrict signed operations to the reserved key and expected method. Enforce the content length and type. Keep bucket names, credentials, and unrestricted SDK clients out of the app.

If you have neither R2 nor S3, implement SignalProtocolRemoteObjectStore directly: it is four methods, two of them optional.

Stage 2: wire it into the client

adapters.remoteObjectStore is optional. Configure it only if you move files:

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { convexR2ObjectStore } from '@open-e2ee/signal-protocol-sdk/remote/object-store/convex-r2';
import { api } from '../convex/_generated/api';

const client = await createSignalProtocolClient({
  identity: { userId },
  adapters: {
    storage,
    relay,
    remoteObjectStore: convexR2ObjectStore({ convex, api: api.signalObjectStore }),
  },
});

Calling downloadAttachment() or deleteRemoteAttachment() without one throws an EncryptionError whose .code is INITIALIZATION_FAILED, naming the missing config: a loud failure rather than a silent no-op.

Stage 3: send a file

Two paths exist, and they are for different products.

The direct path

uploadAttachment(data, { mimeType, ...sendOptions }) encrypts the bytes, runs the broker handshake, uploads ciphertext, and returns a MediaAttachmentPointer. That pointer is what you put inside an encrypted message:

const pointer = await client.uploadAttachment(photoBytes, {
  mimeType: 'image/jpeg',
  attachment: {
    policy: { maxPlaintextSizeBytes: 25 * 1024 * 1024, allowedContentTypes: ['image/*'] },
    onProgress: (progress) => updateUi(progress),
  },
});

await client.send(recipientUserId, JSON.stringify({ kind: 'photo', pointer }));

The pointer's fields are the design in one object: storageId, key, digest, segmentSize, ciphertextSize, contentType, size, and uploadTimestamp, plus optional presentation fields such as fileName, caption, blurHash, width, height, durationMs, and isViewOnce. The key field, the media key, is in the pointer, and the pointer is inside the encrypted message. It is never in the object store.

AttachmentTransferOptions also carries transfer, retry, signal, onCheckpoint, and resume, which is how you get cancellation and resumable transfers.

The queued path

client.media is the only namespace-shaped property on the client, and it manages attachment work as durable jobs rather than fire-and-forget promises:

const result = await client.media.upload(input, options);

if (result.status === 'completed') {
  await sendPointerMessage(result.attachment);
}
// 'pending' is not an error — the job is queued.

const processed = await client.media.processPending({ limit: 5 });
// { attempted, completed, skipped, failed, expired, results }

upload, download, and cleanup each return a status of 'completed' | 'pending' | 'skipped' | 'failed' with a jobId. A completed upload also carries attachment, the pointer. A 'pending' result means the broker queued the job. processPending() will drive it: that is what makes attachments survive a backgrounded app or a process kill mid-transfer. Bytes enter the queue through an application-supplied callback, because your app owns draft files, cache paths, and file permissions.

Call processPending() from whatever your platform gives you for background work. The failure mode of not calling it is an attachment that never uploads and a message pointing at an object nobody can fetch.

Stage 4: receive and open

client.registerHook('onMessageDecrypted', async (message) => {
  const parsed = JSON.parse(message.content);
  if (parsed.kind !== 'photo') return;

  const downloaded = await client.downloadAttachment(parsed.pointer, {
    signal: abortController.signal,
    onProgress: (progress) => updateUi(progress),
  });

  await appMediaCache.write(downloaded.data, downloaded.contentType);
});

downloadAttachment() fetches ciphertext through a short-lived download operation, verifies digest and both size fields, decrypts, and returns { data, attachment, contentType, size, storageId, ... }. The digest check protects you from a store that returns bytes different from the uploaded bytes.

Use deleteRemoteAttachment(pointer, options?) to delete an attachment. On Convex, the provider deletion is asynchronous. The mutation records the application removal and asks the R2 component to schedule a retried deletion. If you need proof that the provider removed the object, track completion separately.

Streaming, and why it does not exist

The /files subpath exposes exactly four things: streamingEncrypt, streamingDecrypt, DEFAULT_SEGMENT_SIZE, and secureZeroBytes. Use them if you build your own attachment flow rather than using media:

import {
  streamingEncrypt,
  streamingDecrypt,
  DEFAULT_SEGMENT_SIZE,
} from '@open-e2ee/signal-protocol-sdk/files';

const encrypted = await streamingEncrypt(keyBytes, plaintextBytes);
// { ciphertext, segmentSize }

const plaintext = await streamingDecrypt(keyBytes, encrypted.ciphertext, new Uint8Array(0), {
  segmentSize: encrypted.segmentSize,
});

DEFAULT_SEGMENT_SIZE is 1024 * 1024: 1 MB ciphertext segments. Note the shapes: both functions take and return complete Uint8Array buffers. "Streaming" here names the framing, segmented AES-GCM with HKDF-derived per-stream keys, not an incremental I/O interface.

Trust boundaries: what crosses which line

There are three lines here, and they are not the same line.

Device to backend. Across it go requestId, the declared contentType, contentLength, and later objectId. Plaintext does not cross. The media key does not cross.

Backend to object store. Your backend holds the provider key and the credentials. Across it go ciphertext bytes and a private key name the client never sees. The store learns object sizes, write times, and access patterns: real metadata, drawn on the diagram for that reason.

Device to relay. The pointer and media key travel inside an end-to-end encrypted message. The relay does not receive message plaintext or device private keys. The object store cannot get the key because it cannot read the message.

The consequence: losing the message loses the ability to decrypt the object. The object alone is inert. That is why "put the URL in the message" is a different product.

What the backend can see

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Attachment plaintext bytesyesnonono
Attachment ciphertextstagednoyesobject size
pointer.key (media key)yesinside ciphertextnono
pointer.digestyesinside ciphertextnono
pointer.segmentSizeyesinside ciphertextnono
pointer.ciphertextSizeyesinside ciphertextyesyes
pointer.size (plaintext size)yesinside ciphertextnono
pointer.contentType (true type)yesinside ciphertextnono
pointer.fileName / captionyesinside ciphertextnono
pointer.storageIdyesinside ciphertextyesyes
requestIdyesnobackend onlyyes
objectId (backend-canonical)receivednoyesyes
providerKeynonobackend onlyno
Declared contentType (application/octet-stream)yesnoyesyes
maxContentLength enforcement resultnonobackend onlyyes
downloadExpiresInSeconds / presigned URLtransientnoyesyes
identityKeyPair private halfyesnonono

The pointer.key row summarizes the design. The true contentType stays inside the encrypted pointer, while the declared type is application/octet-stream. The store therefore learns "an object of 4.2 MB arrived" instead of "a PDF arrived".

Failure and recovery behaviour

An interrupted upload. Reuse the same requestId. The backend returns the same reservation, and the transfer resumes against the same object. Persist checkpoints from onCheckpoint alongside your draft row, not in memory.

A completion that never ran. completeUpload must be idempotent, because a client may retry it after an interrupted workflow. If completion never runs you have a reserved object with no application record. Sweeping reservations older than your transfer timeout is application work the SDK does not do.

A pointer whose object is gone. downloadAttachment() fails with blob-not-found. Show a missing-attachment state. There is no recovery path, because the ciphertext was the only copy.

Deletion is not remote deletion. Deleting the object stops future downloads. It does not delete the copy a recipient already fetched into their cache or photo roll. Once another participant holds decrypted bytes, cryptographic confidentiality creates no remote deletion guarantee: see Deletion and revocation.

Production caveats

0.1.x; public APIs and persisted formats may change before 1.0. The media pointer carries version: 1, so a format change is detectable, but a change is possible.

If your attachment flow runs on the React Native store (/local/store/react-native), the key-value backend you supply is the part of the stack whose durability is yours to prove — verify it with the exported backend-conformance kit. Expo (/local/store/expo) is the primary supported adapter and requires a development build. The in-memory store and in-memory relay are development only.

The SDK is reviewed continuously by adversarial AI agents; it is not audited by any independent firm. The broker functions you write sit outside that review by construction: they are your code, and they hold the authorization decisions.

Browser deployments carry an unsolved problem this guide cannot fix: the server in your threat model also ships the JavaScript that encrypts. See Threat model.

Next

On this page