A reference relay for ATSMS, the open protocol for end-to-end encrypted messaging on AT Protocol identities. It runs as two Cloudflare Workers and is one implementation of a role the protocol deliberately keeps replaceable: anyone can run one, users choose which they use, and no deployment is privileged.
A relay holds sealed envelopes until the device they are addressed to collects them, and it wakes that device up when new mail arrives. That is the entire job.
It cannot read anything it carries. Envelopes are encrypted end to end by the clients, and the relay has no key material of any kind. It cannot tell who sent an envelope: ingress is anonymous and carries no sender authentication. For the newer sealed-envelope path it cannot even tell which of an account's devices an envelope is for, so it fans a copy to each of them. What it does see is the destination account, the arrival time, and a payload size the clients have already padded into fixed buckets.
Nothing about protocol correctness depends on any particular relay staying up. A device that misses mail collects it later, and a group whose relay disappears keeps working as soon as its members point at another one.
Two workers:
- API Worker — the sealed-envelope ingress (
POST /inbox/{did}), authenticated inbox reads, and WebSocket delivery notifications. Also issues short-lived TURN credentials for WebRTC calls, which is optional. - Email Worker — receives mail through Cloudflare Email Routing, which is the interoperability floor of the protocol: an ATSMS message can always arrive as email, and remains unreadable to every server that carried it.
Both use Cloudflare Durable Objects with WebSocket Hibernation, one instance per device inbox.
The Durable Object stores four kinds of message. The first three are opaque to the relay and share one storage, deduplication and notification path; only the last is parsed.
| Type | What it is |
|---|---|
atsms-envelope |
A sealed envelope from the group-encryption layer. This is what current clients send. |
atsms |
A P7M payload from the X.509 one-shot path |
atsms-email |
An S/MIME enveloped-data blob |
All three are opaque base64 blobs stored in the same per-device inbox
(inbox-{did}-{deviceFingerprint}), deduplicated by the hash of their content. The type is a label; it
changes nothing about how a message is stored or served. The relay parses nothing — it has no
notion of a subject, a body, or a sender, and holds no key that could give it one.
A device fetches one kind with the ?type= filter on the list endpoint. The device fingerprint is the
SHA-256 of the raw public key point from the device's at.atsms.x509 certificate, lowercase hex, and it
is the same identifier everywhere: record key, inbox key, and JWT subject.
git clone <repo-url>
cd atsms-worker
bun installEmails are addressed to [encoded-did]@EMAIL_DOMAIN. The encoding maps W3C DID characters to email-safe equivalents:
:is always replaced with!.is replaced with#only when needed for email validity:- Consecutive dots (
..,...) — encode as#to avoid invalid local-parts - Trailing
.at the end of the local-part
- Consecutive dots (
Single dots between other characters are valid in email local-parts and should be left as-is. This keeps the encoded form close to the original DID for common methods (did:plc, did:web, did:fid).
Examples:
did:plc:abc123->did!plc!abc123@provider.example.comdid:web:example.com->did!web!example.com@provider.example.com
This package only handles decoding (server-side). Encoding is the responsibility of the sending client — see the rules above for implementers.
The EMAIL_DOMAIN is configured via environment variable in wrangler-email.jsonc.
The decodeDID function is in src/workers/shared/did-encoding.js.
The email worker (src/workers/email/cloudflare-email-worker.js) is the SMTP half of inbound delivery.
It accepts exactly one thing:
- Receives mail through Cloudflare Email Routing
- Validates the recipient domain matches
EMAIL_DOMAIN - Decodes the recipient DID from the local-part
- Extracts the
application/atsms-envelopeattachment, and drops the mail if there isn't one - Fans a copy into each of the account's per-device inboxes
The extracted bytes are stored exactly as POST /inbox/{did} stores its posted bytes, so a message that
arrives by SMTP and the same message over HTTPS collide on content identity and deduplicate against each
other.
This worker does not read mail. It does not parse subjects or bodies, verify signatures, or handle attachments other than the one it is looking for. That machinery existed once and was removed deliberately — see TODO.md.
The API worker (src/workers/api/cloudflare-api-worker.js) provides the message API:
- REST endpoints for message operations (
/messages/...) - Anonymous sealed-envelope ingress (
POST /inbox/{did}) - WebSocket support for real-time message notifications (using Hibernatable WebSockets)
- JWT-based authentication using X.509 certificates stored in AT Protocol
Located in src/workers/shared/:
- inbox.js - Durable Object implementation for per-device message storage
- jwt-verification.js - JWT authentication and certificate validation
- at-protocol-utils.js - AT Protocol DID resolution and certificate lookup
- did-encoding.js - DID decoding for email addresses
atsms-worker/
├── src/
│ └── workers/
│ ├── api/ # API worker
│ │ └── cloudflare-api-worker.js
│ ├── email/ # Email worker
│ │ └── cloudflare-email-worker.js
│ └── shared/ # Shared utilities
│ ├── inbox.js # Durable Object for message storage
│ ├── jwt-verification.js # JWT auth
│ ├── at-protocol-utils.js # AT Protocol utilities
│ └── did-encoding.js # DID decoding for email addresses
├── wrangler-api.jsonc # API worker configuration
├── wrangler-email.jsonc # Email worker configuration
├── package.json
└── README.md
- Bun or Node.js (v18 or later)
- Cloudflare account
- Wrangler CLI
- A domain configured in Cloudflare (for email routing and API endpoints)
Edit wrangler-api.jsonc:
Set EMAIL_DOMAIN — the domain Cloudflare Email Routing delivers to this worker. Mail addressed to any
other domain is rejected, and with no value set the worker rejects everything rather than guessing.
It is not in wrangler-email.jsonc. Put it in a gitignored .env:
cp .env.example .env # then set ATSMS_EMAIL_DOMAIN
bun run deploy:email:dev # passes it through as --var EMAIL_DOMAINdeploy:email and deploy:email:dev refuse to run without it, so a deploy cannot quietly ship the
wrong domain.
Why it works this way. wrangler deploy treats the config's vars block as the complete set of
plaintext variables and replaces what is deployed — including anything set through the dashboard. So a
placeholder in the committed config is worse than no value: a routine deploy would reset the domain and
the worker would start dropping mail, silently and with nothing in the diff to explain it. Supplying it
at deploy time keeps our domain out of a repository meant to be deployable by anyone, while leaving the
value a plaintext var you can read in the dashboard.
Secrets behave differently and are the right home for anything that is secret: wrangler deploy never
touches them. That is where CF_TURN_API_TOKEN lives (see TURN below). One trap worth knowing —
secrets and vars belong to a single Worker script. The email worker and the API worker have separate
stores, so a value set on the wrong one silently does nothing.
In Cloudflare Dashboard:
- Go to Email > Email Routing
- Enable Email Routing for your domain
- Add a catch-all route (or specific address rules) pointing to the email worker
- Configure DNS records as prompted
WebRTC calls between two devices that cannot reach each other directly need a TURN relay. This is
optional: leave it unconfigured and POST /turn-credentials answers TURN not configured, while every
other endpoint works normally and calls still connect whenever a direct path exists.
To enable it, create a TURN key in the Cloudflare dashboard (Calls > TURN) and set two values.
CF_TURN_KEY_ID identifies the key. It is an account identifier rather than a secret, so it is deployed
as a plaintext var and stays readable in the dashboard — useful for telling at a glance which key a
deployment is using. Put it in your gitignored .env:
cp .env.example .env # then set ATSMS_TURN_KEY_ID
bun run deploy:api:dev # passes it through as --var CF_TURN_KEY_IDIt is deliberately not listed in wrangler-api.jsonc. wrangler deploy replaces the deployed plaintext
vars with whatever the config says, so a committed placeholder would silently overwrite a working key on
the next deploy. Leave ATSMS_TURN_KEY_ID unset and the deploy proceeds without TURN, printing a note
saying so.
CF_TURN_API_TOKEN is a secret and must never be committed. Set it per environment with wrangler, which
stores it encrypted at Cloudflare and prompts for the value rather than taking it on the command line:
wrangler secret put CF_TURN_API_TOKEN --config wrangler-api.jsonc # production
wrangler secret put CF_TURN_API_TOKEN --config wrangler-api.jsonc --env development # developmentFor local wrangler dev, copy .dev.vars.example to .dev.vars and fill in both values. .dev.vars is
gitignored.
Both belong to the API worker. Vars and secrets are scoped to a single Worker script, so setting either on the email worker does nothing at all.
The worker issues short-lived credentials to authenticated callers and never hands the API token to a
client. See turn-cloudflare.md for the design and the rate-limiting options.
bun run dev:api # Start API worker locally
bun run dev:email # Start email worker locallybun run tail:api # Tail production API worker logs
bun run tail:email # Tail production email worker logsbun run test # Run tests once
bun run test:watch # Run tests in watch modeThe API worker MUST be deployed before the email worker because it defines the Durable Object class.
bun run deploy:all # Deploy both in correct orderOr individually:
bun run deploy:api # Deploy API worker first
bun run deploy:email # Deploy email worker secondDevelopment environment:
bun run deploy:all:devA summary follows. API.md is the full reference — request and response bodies, every
WebSocket command, error codes, limits, and worked client examples.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
POST |
/send-message |
Store encrypted message for recipient ({did, encryptedContent}) |
POST |
/inbox/{did} |
Anonymous sealed-envelope ingress ({envelope: "<base64>"}); fans a copy to each of the account's devices and records no sender |
WS |
/ws/{did}/{deviceFingerprint} |
WebSocket connection (requires post-connect auth) |
All require an Authorization: Bearer <jwt> header whose subject matches the DID and device
fingerprint in the path.
| Method | Path | Description |
|---|---|---|
GET |
/messages/{did}/{deviceFingerprint}/list?after={seq}&limit={count}&type={type} |
List messages (type: atsms, atsms-email, atsms-envelope, email, all) |
GET |
/messages/{did}/{deviceFingerprint}/{messageId} |
Get specific message |
DELETE |
/messages/{did}/{deviceFingerprint}/{messageId} |
Delete message |
GET |
/messages/{did}/{deviceFingerprint}/stats |
Inbox statistics, including this month's TURN usage |
POST |
/turn-credentials |
Issue short-lived Cloudflare TURN credentials for a WebRTC call |
All message endpoints require JWT authentication:
Authorization: Bearer <jwt-token>
The JWT must be signed with the X.509 certificate's private key (RS256 or ES256) and include:
sub: AT Protocol URL format:at://{did}/at.atsms.x509/{deviceFingerprint}iss: User's DID (must match DID in subject)
The JWT must match both the DID and the device fingerprint in the path — an inbox is served only to the device that owns it.
- Mail arrives at Cloudflare Email Routing
- The email worker validates the domain matches
EMAIL_DOMAINand decodes the recipient DID from the local-part - It looks for an
application/atsms-envelopeattachment. Mail without one is dropped — there is nothing here for ordinary email - It fetches the recipient's active certificates from AT Protocol
- It stores the extracted bytes in each device's inbox, byte-identical to what the HTTPS ingress would have stored, so the two transports deduplicate against each other
- Connected WebSocket clients receive a real-time notification
The Inbox Durable Object provides:
- One inbox per device - keyed
inbox-{did}-{deviceFingerprint} - Strong consistency - Each inbox instance is isolated
- Automatic deduplication - Messages are identified by content hash
- Sequence numbers - Messages are ordered by arrival
- WebSocket support - Real-time notifications via Hibernatable WebSockets
- Message limits - Automatic cleanup of oldest messages (max 1000 per inbox)
- Type filtering - List endpoints support
?type=encrypted|email|all
- JWT Verification - All API requests verify JWT signatures against certificates in AT Protocol
- Certificate Validation - Certificates are validated for expiry
- DID Ownership - Users can only access inboxes for certificates/DIDs they own
- Message Encryption - ATSMS messages are stored encrypted (P7M format)
- S/MIME Verification - Incoming signed emails are cryptographically verified
- Client-side Signing - The server never holds private keys; signing is done by the client
- Email Size Limits - Normal emails are limited to 128KB for DO storage (phase 2 will add R2 for larger attachments)
Worth knowing before you deploy this, and tracked in TODO.md:
- TURN issuance is metered but not capped. Usage is counted per device per month and exposed through
/stats; nothing refuses a caller who mints credentials in a loop. - Multi-homing does not work. If an account has a device homed at a different relay, this one still writes that device a local mailbox it will never poll, and does not forward the message on. Nothing reports an error. Every device we run is homed here, so this has never bitten — it would bite the moment a second relay exists.
- Outbound cross-domain delivery is a stub. Sending to a recipient on another relay's domain returns a failure. That path is being removed rather than finished: a sender resolves its recipients' own endpoints and delivers to them directly, so a relay has no business being handed a recipient list. See TODO.md.
The protocol's own unfinished business is tracked separately, in the atsms
repository's KNOWN-ISSUES.md, along with the brief for an external security review.
- Check Cloudflare Email Routing configuration
- Verify DNS records (MX, TXT) are correct
- Check email worker logs:
bun run tail:email - Ensure
EMAIL_DOMAINis set in your wrangler config - For encrypted messages: ensure recipient has valid certificates in AT Protocol
- Verify JWT token is correctly signed with certificate private key
- Check certificate is published to AT Protocol
- Ensure the DID and device fingerprint in the JWT match the ones in the path
- Check API worker logs:
bun run tail:api
- Send
{type: 'auth', token: '<jwt>'}within 5 seconds of connecting - Check WebSocket upgrade headers are correct
- Ensure DID and certificate serial are correct
- Monitor API worker logs for connection errors
{ "name": "atsms-api", // Uncomment and update routes for your domain // "routes": [ // { // "pattern": "api.yourdomain.com/*", // "zone_id": "YOUR_ZONE_ID" // } // ] }