Skip to content

Minimal-State Server Architecture

Philosophy: Servers are Dumb Pipes with Traffic Shaping

Section titled “Philosophy: Servers are Dumb Pipes with Traffic Shaping”

Traditional federated systems (like Matrix) require servers to:

  • Store user accounts and authentication credentials
  • Maintain room/channel state and membership
  • Validate cryptographic operations
  • Store message history
  • Make trust decisions

Cryptid takes a different approach: servers act as encrypted message relays that forward MLS ciphertext without understanding content or relationships.

Dumb pipe means servers don’t:

  • Decrypt messages
  • Track who messages whom
  • Know group memberships
  • Build social graphs
  • Make trust decisions

Traffic shaping means servers do:

  • Track minimal behavioral patterns (account age, message volume)
  • Enforce progressive rate limits
  • Prevent spam and abuse
  • Maintain minimal metadata for spam prevention

Servers observe that communication happens (message volume, timing), not what (content) or between whom (servers don’t know who sent the message). This enables practical spam prevention while preserving the privacy guarantees that matter: content confidentiality and relationship privacy.

Servers maintain three categories of state, all temporary and limited:

/// A registered mailbox. Registration proves ownership of mailbox_public by
/// challenge-response and carries no device identity
struct MailboxRegistration {
// Full address (e.g., "7Q7BQXKQ5VHOW2ZV3STHZI7C@chat.example.com")
delivery_address: String,
// Mailbox public key, verified against the address prefix at registration
mailbox_public: X25519PublicKey,
// Where this device connects
server_url: String,
// When this registration was created
registered_at: u64,
// When the registration expires if not renewed
expires_at: u64,
}
/// Messages queued for offline devices (also expires)
struct QueuedMessage {
message_id: Uuid,
recipient_address: String, // routing string
encrypted_blob: Vec<u8>, // replaces mls_ciphertext + sender_signature
timestamp: u64,
expires_at: u64,
}

Registered mailboxes are mutually independent:

// Example server state
// mailbox_registrations (persistent, 24h retention):
mailbox_registrations: HashMap<String, MailboxRegistration> {
"7Y62ANID3IFPKRYZL4QVCE37@chat.example.com" => MailboxRegistration {
delivery_address: "7Y62ANID3IFPKRYZL4QVCE37@chat.example.com",
mailbox_public: "...",
// ...
},
"MM5MBX3MEOORROLIXUBFTOGP@chat.example.com" => MailboxRegistration {
delivery_address: "MM5MBX3MEOORROLIXUBFTOGP@chat.example.com",
mailbox_public: "...",
// No field relates these two entries
}
}

Key Properties:

  • A device registers one mailbox per group, plus one for contact requests
  • Registrations carry no device identity, so the server cannot group them
  • Messages are delivered only to the exact address they name
  • Each registration expires independently (renewed by re-registering)

Privacy-preserving design:

The device_id -> addresses mapping exists at any layer:

  • Registration authenticates the mailbox keypair, never a device
  • Addresses are derived from independent keypairs, so no two are relatable
  • 24-hour automatic expiration (no long-term history)
  • Not exposed via public API or to federation

Rate limiting:

  • Servers MUST enforce rate limits through token issuance budgets, and MUST NOT attempt to attribute redemptions to a device
  • Per-origin counters MUST be used for federated traffic
  • Mailbox registration MUST cost one token, which is what bounds mailbox creation
// Mailbox registrations are bounded by token budget, not by a
// server-side per-device count. The server cannot tell which
// registrations belong to one device
const MAX_MAILBOXES_PER_TOKEN_BATCH = 1;
// Announcement rate (identity-bound, so still enforceable)
const MAX_ANNOUNCEMENTS_PER_HOUR = 3;
// Queued envelopes for offline devices. Operators MAY lower this;
// raising it extends how long a compromised server's disk is interesting
const MAX_MESSAGE_RETENTION = Duration::days(30);

Delivery mappings expire after 24 hours, message queues after 30 days (configurable).

For group additions, servers store pre-uploaded KeyPackages:

/// Blind KeyPackage storage. The server holds ciphertext under a derived
/// handle and cannot determine which device an entry belongs to.
struct KeyPackageStore {
handle: [u8; 32],
entries: Vec<BlindKeyPackageEntry>,
uploaded_at: u64,
total_consumed: u32,
}
// Indexed by handle
keypackage_storage: HashMap<Handle, KeyPackageStore>

Servers can observe:

  • Total KeyPackages uploaded per device
  • Total KeyPackages consumed (aggregate group additions)
  • Approximate social activity level

See Contact Exchange and Trust for detailed privacy analysis and planned v2.0 improvements (blind KeyPackage storage).

KeyPackages expire after 30 days if not consumed. Devices rotate KeyPackages when count drops below 20.

For ephemeral contact and group invite sharing, servers store encrypted InfoPackages.

/// Encrypted identity or group invite packages
struct EncryptedInfoPackage {
// User who uploaded this package
user_id: UserId,
// Type of package (determines content schema)
package_type: InfoPackageType,
// Encrypted content (server cannot decrypt)
ciphertext: Vec<u8>,
// Metadata for cache invalidation
created_at: u64,
expires_at: u64,
// Usage tracking (for one-time tokens)
max_uses: Option<u32>,
uses_remaining: Option<u32>,
deleted: bool
}
enum InfoPackageType {
Identity,
GroupInvite { group_id: GroupId },
}
// Indexed by random URL segment (not by user_id)
info_packages: HashMap<String, EncryptedInfoPackage>

What’s Stored:

  • Ciphertext only: Server cannot decrypt (client holds key)
  • Package type: For routing to correct parser
  • TTL and Usage: For automatic cleanup and one-time enforcement

What’s not Stored:

  • Identity contents (encrypted)
  • Group contents (encrypted)
  • Who fetched the package (anonymous fetch)
  • Download history or statistics

Server observes:

  • URL segment created (no semantic meaning)
  • When package expires or gets revoked
  • Number of times fetched (if max_uses tracking enabled)

Server cannot observe:

  • Identity data inside package
  • Which device fetched the package (no auth required)
  • Who is adding whom as contact
  • Who is joining which groups

InfoPackages automatically deleted when:

  • Expiration: expires_at timestamp passes (TTL from upload)
  • Usage limit: uses_remaining reaches zero
  • Manual revocation: User calls DELETE endpoint (sets deleted flag)
  • Server cleanup job runs hourly

Typical Retention Periods:

  • Identity shares: 24 hours
  • One-time links: 1 hour
  • Group invites: 1 week

See InfoPackages for the complete specification.

  • Message content (always encrypted, never accessible)
  • Message senders (addresses not included in messages)
  • Group membership lists
  • Social graphs or contact lists
  • User profiles or identities beyond device_id
  • Permanent account data
  • Private cryptographic key material (only public KeyPackages stored)
  • KeyPackage private keys (stored on devices only)
  • InfoPackage plaintext (stored encrypted only)

Devices must periodically “announce” themselves to servers to receive messages. Devices can announce multiple delivery addresses simultaneously.

POST /api/v1/device/announce
Content-Type: application/json
{
"device_id": "device_id_goes_here",
"signature": "ed25519_signature_hex_128_chars",
"timestamp": 1760129277,
"storage_preferences": {
"max_retention_days": 30,
"offline_message_limit": 1000
}
}
  • device_id: Main device identity (Ed25519 public key, 32 bytes hex-encoded = 64 hex chars)
  • signature: Ed25519 signature covering device_id + prefixes + timestamp (64 bytes = 128 hex chars)
  • timestamp: Unix timestamp in seconds (prevents replay attacks)
  • storage_preferences: Optional preferences for message queueing

Mailbox registration only submits the prefix. The server domain is an implicit part of the API endpoint, so clients never specify it, which prevents claiming addresses on domains you don’t control.

{
"status": "success",
"device_id": "device_id_goes_here",
"expires_at": 1758402063,
"server_capabilities": {
"max_message_size": 10000000,
"federation_enabled": true,
"supported_mls_versions": ["1.0"]
}
}
  1. Verifies timestamp is recent (within 5 minute window, prevents replay)
  2. Verifies signature using device_id (the public key):
fn verify_announcement(
req: &AnnouncementRequest,
server_domain: &str
) -> Result<()> {
// Construct signed message
let message = format!("{}.{}",
hex::encode(&req.device_id),
req.timestamp
);
// Verify with device_id (which IS the public key)
if !ed25519_verify(&req.device_id, message.as_bytes(), &req.signature) {
return Err("Invalid signature");
}
// Check timestamp freshness
let now = current_unix_timestamp();
if req.timestamp < now - 300 || req.timestamp > now + 60 {
return Err("Timestamp outside acceptable window");
}
Ok(())
}
  1. Registers the device record (expire in 24 hours). Announcement creates no mailboxes.
fn register_device_record(req: &AnnouncementRequest) -> Result<()> {
self.device_records.insert(
req.device_id,
DeviceRecord {
device_id: req.device_id,
public_key: req.public_key,
registered_at: req.timestamp,
expires_at: req.timestamp + 24 * 3600,
}
);
Ok(())
}
  1. Issue the device’s token batch, sized by account age (see Progressive Trust below)

  2. Validate announcement limits:

fn validate_announcement_limits(req: &AnnouncementRequest) -> Result<()> {
// Announcement is identity-bound, so this limit is enforceable.
// Mailbox counts are not. The server cannot attribute registrations
// to a device, and each one already costs a token
let announced_today = self.count_announcements_today(req.device_id);
if announced_today >= MAX_ANNOUNCEMENTS_PER_HOUR {
return Err("Announcement rate limit exceeded");
}
Ok(())
}

New devices earn larger issuance budgets over 24 hours. Because issuance is the only identity-bound step, trust tiers apply when tokens are issued, never when they are spent.

Budgets are token buckets rather than fixed hourly grants. Each tier sets two values:

  • rate: sustained throughput, what a device can keep doing indefinitely
  • capacity: burst allowance, what it can spend at once after a quiet period
Trust TierAgeRateCapacityPurpose
New0-6 hours50 / hour200Prevents bot spam
Established6-24 hours500 / hour2,000Normal usage
Trusted24+ hours2,000 / hour10,000Full access
VerifiedAdmin override2,000 / hour10,000Instant
BlockedOperator decision after reports0 / hour0Spam blocked

This prevents spam bots (which operate at scale immediately) while minimizing friction for legitimate users. Server admins can grant instant verification for known community members.

TokenBucket Structure
/// Per-device issuance budget. Lives at the issuance endpoint, where device_id
/// is known. Nothing about it reaches redemption, which stays anonymous.
struct TokenBucket {
// Sustained throughput: tokens added per hour
rate: u32,
// Burst allowance. The bucket never holds more than this.
capacity: u32,
// Current level, lazily recomputed on access
level: u32,
// When level was last updated. Refill is derived from elapsed time rather
// than a timer, so idle devices cost the server no work
last_refill: u64,
}
impl TokenBucket {
fn refill(&mut self, now: u64) {
let elapsed_hours = (now - self.last_refill) as f64 / 3600.0;
let earned = (self.rate as f64 * elapsed_hours) as u32;
self.level = (self.level + earned).min(self.capacity);
self.last_refill = now;
}
// Grants what is available, up to what was requested. Partial grants are
// normal. The client tops up again once the bucket refills.
fn take(&mut self, requested: u32, now: u64) -> u32 {
self.refill(now);
let granted = requested.min(self.level);
self.level -= granted;
granted
}
fn for_tier(tier: TrustTier) -> Self {
TokenBucket {
rate: tier.rate(),
capacity: tier.capacity(),
// New buckets start full, so a device is usable immediately
level: tier.capacity(),
last_refill: current_unix_timestamp(),
}
}
}

Trust tier determines the bucket’s parameters, not what a device may do with the tokens:

fn issue_tokens(device_id: &DeviceId, requested: u32) -> TokenResponseBatch {
// Account age is known here because issuance is authenticated
let tier = get_trust_tier(device_id);
let bucket = self.buckets.entry(*device_id)
.or_insert_with(|| TokenBucket::for_tier(tier));
let granted = bucket.take(requested, current_unix_timestamp());
mint_blinded_tokens(granted)
}
  • Servers MUST enforce budgets at issuance and MUST NOT rate limit redemption
  • Servers MUST grant partial batches when a bucket is short, rather than rejecting the request
  • Servers SHOULD return the bucket’s current level and rate, so clients can top up before running dry
  • Bucket state MUST NOT be consulted at redemption, where the device is unknown

Cycling mailboxes gains an attacker nothing: every registration spends a token from the same bucket, so mailbox count is bounded by issuance rather than by counting addresses.

Generous capacity does not weaken the spam story. Sustained abuse is governed by rate alone. Capacity only lets a device front-load a reserve it already had to accumulate.

Server learns account age and how many tokens a device was issued. It does not learn what any token was spent on, how many messages a device sent, or which mailboxes belong to it.

Format: {first_16_bytes_of_device_id_hex}@{server.domain}

Example: a1b2c3d4e5f61728394a5b6c7d8e9f10@chat.example.com

Key properties:

  • Random generation: Address prefixes are randomly generated (NOT derived from device_id)
  • No cryptographic relationship: Cannot link addresses to device_id through cryptographic analysis
  • Privacy: Address rotation cannot be tracked through derivation patterns

Why 16 bytes?

  • Collision safety: 2^128 possible addresses - safe for trillions of users per servers

  • Reasonable length: 32 hex characters - manageable for humans if needed

  • Cryptographically derived: Cannot be guessed or enumerated

  • No PII: No phone numbers, emails, or any personally identifying information

Important: Delivery addresses are derived from per-group mailbox keypairs, not from device_id. Derivation is hardened, so no server-visible value links an address to a device identity or to another address.

When a server receives a message:

fn handle_incoming_message(message: &IncomingMessage) -> Result<()> {
// 1. Basic format validation only
if message.recipient_address.is_empty() || message.encrypted_blob.is_empty() {
return Err("Invalid message format");
}
// 2. Verify the PrivacyPass token. There is no sender to identify
let token = request.token.ok_or("Token required")?;
if !self.verify_token(&token)? {
return Err("Invalid token");
}
if !self.spend_nonce(&token.nonce)? {
return Err("Token already spent");
}
// 3. Queue for delivery (server does NOT verify cryptographic correctness)
let queued = QueuedMessage {
recipient_address: message.recipient_address,
encrypted_blob: message.encrypted_blob,
received_at: current_unix_timestamp(),
expires_at: current_unix_timestamp() + MAX_MESSAGE_RETENTION,
};
queue_for_recipient(queued)?;
// 4. For federated recipients, forward to their servers
if is_remote_address(&message.recipient_address) {
forward_to_remote_server(message)?;
}
Ok(())
}

Critical point: The server never validates signature correctness, MLS group membership, or makes any cryptographic trust decisions. It’s purely a routing service.

Delivery is transport-agnostic. The server holds an opaque envelope and a routing address; how those bytes reach a client is a separate concern.

DeliveryTransport
enum DeliveryTransport {
// Foreground: one connection, subscribe list of active mailboxes,
// server tags each frame with the arrival mailbox
WebSocket,
// Background: contentless wakeup, client then fetches.
// Endpoint is user-supplied: ntfy, gotify, or any UnifiedPush distributor
UnifiedPush { endpoint: String },
// Fallback for clients that can hold neither
Poll { interval_secs: u32 },
}

Wakeup notifications MUST be contentless and MUST NOT name the mailbox that received a message. Clients SHOULD register distinct push endpoints per mailbox where the distributor permits it.

What Servers ObserveWhat Servers Never Know
Device addresses (pseudonymous)User identities (no PII)
Recipient addresses for each messageMessage content (E2EE)
Account creation timeSender addresses (not transmitted)
Online/offline statusConversation context
Federation domains contactedSpecific sender-recipient pairs
Delivery volume per recipient addressGroup membership lists
Reports naming a deviceSocial relationships beyond delivery patterns
Token issuance counts per deviceWhich device sent anything

All messages are MLS-encrypted before reaching the server and no plaintext content is ever stored.

The server does not learn sender identity at the protocol level. Messages deliberately omit the sender_address field.

When routing a message, the server only sees:

  • recipient_address (e.g., a1b2c3d4e5f61728394a5b6c7d8e9f10@server.com) which is just a destination.

  • encrypted_blob which is just encrypted bytes.

The server does NOT see:

  • Who the sender is (address not included in messages)
  • Message content (MLS encrypted)
  • What groups the recipient is in
  • Who else received this message (if it’s a group message)

No contact lists, no friend relationships, no group memberships are visible to servers.

The server cannot answer:

  • Who are Alice’s contacts?

  • What groups is Bob in?

  • Do Alice and Bob know each other?

The server knows delivery patterns but not content or full relationships:

  • Device X sent 50 messages to specific recipient addresses (for routing)

  • Device Y received 80 messages from unspecified senders (sender addresses are not transmitted)

Comparison with Other Privacy-Focused Systems

Section titled “Comparison with Other Privacy-Focused Systems”
SystemBehavioral TrackingResult
SignalPhone number, message count, last seenSimilar to Cryptid
Matrix (E2EE rooms)Room membership, user-to-room mappingMore metadata than Cryptid
Email (PGP)Full email headers (To/From/Subject)Much more metadata than Cryptid
TorNo behavioral trackingMaximum privacy, but high latency/complexity
CryptidAccount age, message volume (aggregate)Balanced: privacy + spam prevention

Cryptid’s position: More private than Matrix or email (no recipient tracking), comparable to Signal (behavioral patterns only), more practical than Tor (lower latency, easier to use).

Without behavioral tracking:

  • Spam bots can register unlimited devices instantly

  • No rate limiting possible (can’t distinguish bots from humans)

  • Network becomes unusable due to spam flooding

  • Legitimate users abandon the platform

With minimal behavioral tracking:

  • Spam bots are rate-limited (50 tokens/hour for new devices, capacity 200)

  • Takes time to build trust (24 hours to full rate limit)

  • Spam attacks are economically infeasible at scale

  • Platform remains usable for legitimate communication

What’s preserved: The privacy boundaries that matter:

  • Message content remains encrypted

  • Senders remain hidden from servers

  • Social graphs remain unknown to servers

  • No personal information required

What’s observable: Behavioral patterns that are already visible to network observers (traffic volume, timing) and necessary for abuse prevention.

This is what I’m calling the “dumb pipe with traffic shaping” model. Servers route encrypted messages without understanding them, while maintaining just enough metadata to prevent abuse.

Traditional messaging systems use permanent identifiers (email, phone numbers, etc):

  • Spammers can target these indefinitely
  • Only defense is reactive blocking after spam is received
  • No proactive spam prevention

Multiple delivery addresses enable proactive spam prevention:

Alice’s device:

device_id: aabbccdd… (permanent, known to contacts and members in the same group chats)

Alice’s current addresses:

If the public address gets spammed:

  1. Alice burns the compromised address locally
  2. Alice stops announcing it to the server
  3. Server mapping expires (24 hours)
  4. Spammer’s messages to that address -> 404 Not Found
  5. Alice creates new public address
  6. Spammer cannot find the new address

Spammers lose routing information when addresses rotate.

These are just some general suggestions. The actual values may vary as per need.

  • High-risk addresses (public forums): Rotate weekly
  • Medium-risk addresses (semi-public groups): Rotate monthly
  • Low-risk addresses (private contacts): Rotate rarely or never
  1. Context separation: Different addresses for different purposes
  2. One-time addresses: Create disposable address for each public interaction
  3. Group-specific addresses: Dedicate address per group, burn if group becomes spammy
  4. Regular rotation: Rotate public addresses proactively (don’t wait for spam)

Rotation and blocking answer different problems, and only one of them is a server mechanism.

DefenseAnswersWhere it runs
Address rotationAn address that has attracted unwanted traffic.Client. The recipient burns it; the mapping expires and the sender loses routing
Personal blocklistA specific device you no longer want to hear fromClient, keyed by device_id after MLS decryption
Device reportA device abusing others, where an operator should interveneThe offender’s home server, at issuance, after operator review

Servers MAY rate limit inbound volume per recipient address as a resource-protection measure. That is queue management, not moderation, and MUST NOT be driven by reports.

// Federated traffic aggregates per origin server, feeding federation policy
fn get_origin_score(origin: &str) -> u32 {
self.origin_reports.get(origin).unwrap_or(0)
}

What address rotation does NOT prevent:

  • Spam from contacts
  • Spam within MLS groups (members will automatically get an updated delivery address when you rotate the one used for that group)
  • Targeted persistent harassment (attacker keeps discovering new addresses)
  • Server-level spam (malicious server operations)

For these cases:

  • Block by device_id (contacts/group members)
  • Leave group (group spam)
  • Report to server admin (persistent targeting)
  • Switch servers (malicious server operator)

Address rotation provides:

  • Proactive spam defense
  • User control over exposure
  • Context isolation

But requires:

  • User management of address lifecycle
  • Contacts handling address updates
  • Server queries when addresses change

This is a deliberate design choice: more user agency, more user responsibility.

Servers MAY restrict who can announce based on their policies:

  • Anyone can announce to any server (with Proof-of-work)
  • Requires registration proof for the first announcement
  • Good for publicly-run servers
  • New devices need an invite code
  • Good for private/community servers
  • Only accept announcements from trusted servers
  • Good for restricted environments

Devices can announce to multiple servers simultaneously:

// Alice announces to 2 servers
announce_to_server("primary.com", device, ["addr1", "addr2"]);
announce_to_server("backup.org", device, ["addr3"]);
// Alice now has 3 delivery addresses across 2 servers

Use cases:

  • Redundancy: Primary + backup servers
  • Migration: Announce to new server before leaving the old one
  • Context separation: Work, personal, public on different servers

Rate limiting implications:

Each server enforces rate limits independently.

Example:

For a device using 3 servers:

  • Server A: 500 tokens/hour, capacity 2,000 (Established tier)
  • Server B: 500 tokens/hour, capacity 2,000 (Established tier)
  • Server C: 50 tokens/hour, capacity 200 (New tier - just announced)

Budgets are per server and do not combine into a single throughput figure: a token issued by one server redeems only there, so each address is fed by its own bucket.

Why independent limits?

  1. Simplicity: No server coordination or trust synchronization required
  2. Privacy: Servers don’t query each other about device activity
  3. Federation independence: Servers remain autonomous
  4. Practical impact: Most users only use 1-2 servers

Attack mitigation:

While sophisticated attackers could exploit multi-homing to bypass rate limits per-server, several mechanisms mitigate this:

  1. Server-level reputation: Servers track abuse per federated origin
  2. Spam report aggregation: Sustained per-origin abuse tightens limits and ultimately triggers defederation
  3. Progressive trust still applies: Attacker needs to wait 24h on EACH server
  4. Registration costs: Proof-of-work makes multi-server Sockpuppet attacks expensive

Trade-offs:

  • Preserves privacy (no cross-server tracking)
  • Maintains federation independence
  • Simple implementation
  • Allows multi-server rate limit bypass (acceptable for v1.0)