OpenE2EE

Encrypted attachments and object storage

Two encryption layers for large payloads, the brokered object-store model, and why WebCrypto's missing streaming API shapes attachment memory behaviour.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
A working client and an object store you control
Reading time
12 min

Signal Protocol messages suit small encrypted payloads, not large binary objects. A ten-megabyte photo does not belong inside a ratchet step. So attachments use two encryption layers:

  1. The client encrypts file bytes locally, and only ciphertext enters remote storage.
  2. The end-to-end encrypted message carries the key, digest, sizes, and opaque object identifier.

The SDK states this consequence plainly:

The remote object store never receives the media key or plaintext.

If you lose the message, you lose the ability to decrypt the object. The object alone is inert.

adapters.remoteObjectStore is optional. Configure it only if you move files. Calling downloadAttachment() or deleteRemoteAttachment() without one throws an EncryptionError. Its code is INITIALIZATION_FAILED, and its message names the missing config. The call fails instead of silently doing nothing.

A device holding the media key beside an object store bracket containing three opaque ciphertext slabs of unequal size.devicemedia keyobject store: ciphertext only
The media key stays inside the device outline. Only opaque objects and the metadata needed to authorize access cross into the store.

The client.media queue

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.

await client.media.upload(input, options);
await client.media.download(input, options);
await client.media.cleanup(input, options);

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

Each of upload, download, and cleanup returns a status and jobId. The status is 'completed' | 'pending' | 'skipped' | 'failed'. A 'pending' result is not an error. It means the broker queued the job and processPending() will drive it. This design lets attachments survive a backgrounded app, a lost network, or a process kill mid-transfer.

Bytes enter the queue through an application-supplied callback rather than hidden package storage. Your app owns draft files, cache paths, and file permissions. The default queue limit is 200 jobs. The queue retries with backoff and stops after the configured attempt count.

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 that references an object nobody can fetch.

The file methods

Flat client methods encrypt files directly. They pair a random symmetric key with Signal Protocol, which encrypts that key:

const { encryptedBlob, keyId, encryptedKey } = await client.encryptFile(
  remoteAddress,
  fileBlob,
  'image/jpeg',
);

const plaintextBlob = await client.decryptFile(remoteAddress, encryptedBlob, encryptedKey);

encryptFiles() and decryptFiles() take arrays and return results in the same order. Each file gets its own key. You can therefore revoke or grant access per file instead of per batch.

For the brokered upload path there are uploadAttachment(data, { mimeType, ...sendOptions }), downloadAttachment(attachment, options?), and deleteRemoteAttachment(attachment, options?). These operate on the object store and understand progress, checkpoints, abort signals, and retry policy.

Object store adapters

Two concrete adapters ship, both implementing SignalProtocolRemoteObjectStore.

Convex R2

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 }),
  },
});

ConvexR2ObjectStore is a client adapter, not a Convex component. The application installs, mounts, and configures @convex-dev/r2, owns the R2 bucket and credentials, and exposes authenticated app-owned functions.

The optional server entry point removes the repetitive broker plumbing without taking ownership away from you:

// convex/signalObjectStore.ts
import { defineConvexR2ObjectStore } from '@open-e2ee/signal-protocol-sdk/remote/object-store/convex-r2/server';

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,
  });

createDownload and completeUpload are actions because they produce time-sensitive credentials or await provider metadata. createUpload and deleteObject are mutations. The helper has no runtime import of @convex-dev/r2, so S3-only consumers do not load the component.

Amazon S3 and S3-compatible storage

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

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

S3ObjectStore is framework-neutral. AWS SDK clients and AWS credentials remain on the backend that implements the broker.

The brokered model is the security point

Both adapters are deliberately brokered:

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.

Read that as three separate rules.

The client never holds provider credentials. Not a scoped token, not a signing key. It receives short-lived presigned operations for one object, and nothing else. An attacker with full control of a client device gets what that device could already reach.

The backend names the object. The client does not name it. The client supplies requestId, a retry-stable key for one logical upload. The backend uses it to enforce idempotency. The backend maps it to objectId and a private provider key. It never returns that private key. storageId is the opaque object identifier that the backend issues.

The application must never treat requestId or storageId as a provider key. A client that selects its own storage path could read or overwrite another user's objects.

Security requires idempotency. Because requestId is retry-stable, a resumed upload reserves the same object after a crash. It does not orphan one object and create another. The reserve step must be idempotent. The completion step must authorize the caller again. The Convex helper does so because the action's query and mutation use separate transactions.

Every remote object store should receive only ciphertext plus the metadata needed to authorize and construct short-lived operations.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Attachment plaintext bytesyesnonono
Attachment ciphertext (encryptedBlob)stagednoyesobject size
Media keyyesnonono
encryptedKey (media key sealed to the peer)yesyesnono
keyIdyesyesnono
Digest and plaintext sizeyesyesnono
requestId (retry-stable idempotency key)yesnoyesyes
objectId (backend-canonical)nonoyesyes
Private provider keynonobackend onlyno
storageId (opaque, backend-issued)yesyesyesyes
contentType / allowedContentTypesyesnoyesyes
maxContentLength enforcement resultnonoyesyes
Presigned URL and downloadExpiresInSecondstransientnoyesyes
MediaAttachmentPointer in the messageyesyesnono
identityKeyPair private halfyesnonono

The encryptedKey row summarizes the design. The key that opens the object travels inside the end-to-end encrypted message. It never enters the object store.

Next

On this page