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:
- The client encrypts file bytes locally, and only ciphertext enters remote storage.
- 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.
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
requestIdto a canonicalobjectIdand 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
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
| Attachment plaintext bytes | yes | no | no | no |
Attachment ciphertext (encryptedBlob) | staged | no | yes | object size |
| Media key | yes | no | no | no |
encryptedKey (media key sealed to the peer) | yes | yes | no | no |
keyId | yes | yes | no | no |
| Digest and plaintext size | yes | yes | no | no |
requestId (retry-stable idempotency key) | yes | no | yes | yes |
objectId (backend-canonical) | no | no | yes | yes |
| Private provider key | no | no | backend only | no |
storageId (opaque, backend-issued) | yes | yes | yes | yes |
contentType / allowedContentTypes | yes | no | yes | yes |
maxContentLength enforcement result | no | no | yes | yes |
Presigned URL and downloadExpiresInSeconds | transient | no | yes | yes |
MediaAttachmentPointer in the message | yes | yes | no | no |
identityKeyPair private half | yes | no | no | no |
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
- Encrypted attachments with R2 and S3: the full broker walkthrough
- Convex relay integration: the relay that carries the pointer message
- Deletion and revocation: what deleting an object does and does not achieve
- Runtime support: WebCrypto and platform crypto availability
Convex relay integration
Wire a Convex deployment you own onto the relay contract, and know exactly which SDK artifact lands in which Convex table.
Offline send, receive, and reconciliation
How the SDK handles out-of-order and skipped messages, what the subscription APIs cover, and which parts of offline behaviour your application still owns.