Skip to content

Repository files navigation

ATSMS Relay (Cloudflare Workers)

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.

What a relay does, and what it cannot do

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.

Overview

Two workers:

  1. 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.
  2. 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.

Message Types

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.

Getting Started

git clone <repo-url>
cd atsms-worker
bun install

Architecture

Email Address Format

Emails 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:
    1. Consecutive dots (.., ...) — encode as # to avoid invalid local-parts
    2. Trailing . at the end of the local-part

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.com
  • did: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.

Email Worker

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-envelope attachment, 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.

API Worker

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

Shared Utilities

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

Directory Structure

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

Prerequisites

  1. Bun or Node.js (v18 or later)
  2. Cloudflare account
  3. Wrangler CLI
  4. A domain configured in Cloudflare (for email routing and API endpoints)

Configuration

1. API Worker Configuration

Edit wrangler-api.jsonc:

{
  "name": "atsms-api",
  // Uncomment and update routes for your domain
  // "routes": [
  //   {
  //     "pattern": "api.yourdomain.com/*",
  //     "zone_id": "YOUR_ZONE_ID"
  //   }
  // ]
}

2. Email Worker Configuration

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_DOMAIN

deploy: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.

3. Email Routing Setup

In Cloudflare Dashboard:

  1. Go to Email > Email Routing
  2. Enable Email Routing for your domain
  3. Add a catch-all route (or specific address rules) pointing to the email worker
  4. Configure DNS records as prompted

4. TURN (optional)

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_ID

It 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  # development

For 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.

Development

Start Development Servers

bun run dev:api     # Start API worker locally
bun run dev:email   # Start email worker locally

View Logs

bun run tail:api    # Tail production API worker logs
bun run tail:email  # Tail production email worker logs

Run Tests

bun run test        # Run tests once
bun run test:watch  # Run tests in watch mode

Deployment

The API worker MUST be deployed before the email worker because it defines the Durable Object class.

bun run deploy:all   # Deploy both in correct order

Or individually:

bun run deploy:api   # Deploy API worker first
bun run deploy:email # Deploy email worker second

Development environment:

bun run deploy:all:dev

API Endpoints

A summary follows. API.md is the full reference — request and response bodies, every WebSocket command, error codes, limits, and worked client examples.

Public Endpoints

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)

Authenticated Endpoints (per device)

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

Authentication

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.

Email Processing Flow

  1. Mail arrives at Cloudflare Email Routing
  2. The email worker validates the domain matches EMAIL_DOMAIN and decodes the recipient DID from the local-part
  3. It looks for an application/atsms-envelope attachment. Mail without one is dropped — there is nothing here for ordinary email
  4. It fetches the recipient's active certificates from AT Protocol
  5. 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
  6. Connected WebSocket clients receive a real-time notification

Durable Objects

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

Security Considerations

  1. JWT Verification - All API requests verify JWT signatures against certificates in AT Protocol
  2. Certificate Validation - Certificates are validated for expiry
  3. DID Ownership - Users can only access inboxes for certificates/DIDs they own
  4. Message Encryption - ATSMS messages are stored encrypted (P7M format)
  5. S/MIME Verification - Incoming signed emails are cryptographically verified
  6. Client-side Signing - The server never holds private keys; signing is done by the client
  7. Email Size Limits - Normal emails are limited to 128KB for DO storage (phase 2 will add R2 for larger attachments)

Known Limitations

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.

Troubleshooting

Email Not Received

  1. Check Cloudflare Email Routing configuration
  2. Verify DNS records (MX, TXT) are correct
  3. Check email worker logs: bun run tail:email
  4. Ensure EMAIL_DOMAIN is set in your wrangler config
  5. For encrypted messages: ensure recipient has valid certificates in AT Protocol

API Authentication Failures

  1. Verify JWT token is correctly signed with certificate private key
  2. Check certificate is published to AT Protocol
  3. Ensure the DID and device fingerprint in the JWT match the ones in the path
  4. Check API worker logs: bun run tail:api

WebSocket Connection Issues

  1. Send {type: 'auth', token: '<jwt>'} within 5 seconds of connecting
  2. Check WebSocket upgrade headers are correct
  3. Ensure DID and certificate serial are correct
  4. Monitor API worker logs for connection errors

License

Apache-2.0. See LICENSE and NOTICE.

About

Reference relay for ATSMS: store-and-forward for sealed envelopes on Cloudflare Workers. Carries bytes it cannot read.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages