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
storageadapter. - 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.
Install
npm install @open-e2ee/signal-protocol-sdk@0.1.0For 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/r2For 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-presignerThe brokered model is the security thesis
Before any code, the rule both adapters exist to enforce:
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.
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
requestIdto the authenticated principal before mapping it. A client that could choose its own storage path could read or overwrite another user's objects. Never treatrequestIdorstorageIdas 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 scoperequestIdto the authenticated principal and return the same pair for every valid retry.resolve: an internal query taking{ objectId, operation }whereoperationis'download'or'complete', returning{ providerKey, contentType, contentLength }ornull.complete: an internal mutation that re-checks the principal and idempotently marks a provider-verified upload complete. It runs in a separate transaction fromresolve, 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
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
| Attachment plaintext bytes | yes | no | no | no |
| Attachment ciphertext | staged | no | yes | object size |
pointer.key (media key) | yes | inside ciphertext | no | no |
pointer.digest | yes | inside ciphertext | no | no |
pointer.segmentSize | yes | inside ciphertext | no | no |
pointer.ciphertextSize | yes | inside ciphertext | yes | yes |
pointer.size (plaintext size) | yes | inside ciphertext | no | no |
pointer.contentType (true type) | yes | inside ciphertext | no | no |
pointer.fileName / caption | yes | inside ciphertext | no | no |
pointer.storageId | yes | inside ciphertext | yes | yes |
requestId | yes | no | backend only | yes |
objectId (backend-canonical) | received | no | yes | yes |
providerKey | no | no | backend only | no |
Declared contentType (application/octet-stream) | yes | no | yes | yes |
maxContentLength enforcement result | no | no | backend only | yes |
downloadExpiresInSeconds / presigned URL | transient | no | yes | yes |
identityKeyPair private half | yes | no | no | no |
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
- Encrypted attachments and object storage: the concept page behind this build
- Limits and metadata: what object sizes and timing reveal
- Deletion and revocation: what deleting an object achieves
- Package subpaths:
/media,/files, and the object-store subpaths
Connect an Expo app to a Convex encrypted-envelope relay
Replace the in-memory relay with a Convex deployment you own, and get one-time-prekey consumption right under concurrency.
Design offline, device recovery, and identity-change behaviour safely
A design guide for the three product decisions that end-to-end encryption forces on you, with the SDK's real bounds, errors, and operations attached to each.