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
The Critical Boundary
Section titled “The Critical Boundary”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.
What Servers Store (Temporarily)
Section titled “What Servers Store (Temporarily)”Servers maintain three categories of state, all temporary and limited:
Routing State (Ephemeral)
Section titled “Routing State (Ephemeral)”/// Temporary mapping for message delivery (expired automatically)struct MessageDeliveryMapping { // Permanent device identity device_id: [u8; 32], // Full address (e.g., "a1b2c3d4...@chat.example.com") delivery_address: String, // Where this device connects server_url: String, // When this mapping was created announced_at: u64, // When the mapping expires if not renewed expires_at: u64,
// Notable: device_id stored for rate limiting, NOT exposed via public API}
/// In-memory cache for fast rate limit lookups (NOT persisted to disk)struct AddressDeviceCache { // Address -> device_id cache: HashMap<String, [u8; 32]>
// This cache is rebuild from delivery_mappings on server restart}
/// Messages queued for offline devices (also expires)struct QueuedMessage { // Where to deliver recipient_address: String, // Encrypted content (server can't read) mls_ciphertext: Vec<u8>, // For recipient to verify authenticity sender_signature: Ed25519Signature, // When the server received the message received_at: u64, // When to delete the message off the server automatically expires_at: u64,
// Notable: NO sender identity, NO decryptable content}
Multiple addresses can route to the same device:
// Example server state// delivery_mappings (persistent, 24h retention):delivery_mappings: HashMap<String, DeliveryMapping> { "a1b2c3d4@chat.example.com" => DeliveryMapping { device_id: [0xaa, 0xbb, ...], delivery_address: "a1b2c3d4@chat.example.com", // ... }, "f9e8d7c6@chat.example.com" => DeliveryMapping { device_id: [0xaa, 0xbb, ...], // ← Same device! delivery_address: "f9e8d7c6@chat.example.com", // ... }}
// address_to_device_cache (in-memory, rebuilt from delivery_mappings):address_to_device_cache: HashMap<String, DeviceId> { "a1b2c3d4@chat.example.com" => [0xaa, 0xbb, ...], "f9e8d7c6@chat.example.com" => [0xaa, 0xbb, ...],}
Multiple Addresses Per Device
Section titled “Multiple Addresses Per Device”Key Properties:
- One device can have multiple delivery addresses active simultaneously
- All addresses for a device map to the same
device_id
(cached in memory) - Messages sent to ANY active address are delivered to that device
- Each address has independent expiration (renewed via announcement)
Privacy-preserving design:
The device_id -> addresses
mapping exists in memory but:
- Not exposed via public API (no query endpoint by device_id)
- 24-hour automatic expiration (no long-term history)
- Only server operator has access to this data (not exposed to users to federation)
- In-memory cache for fast lookups during rate limiting
Rate limiting aggregation:
-
Servers MUST aggregate rate limits by
device_id
, not by individual addresses. -
This prevents spammers from bypassing rate limits by creating many addresses.
-
It is also recommended to have rate limits for address creation.
// Total active addressesconst MAX_ADDRESSES_PER_DEVICE = 10;
// Creation rateconst MAX_NEW_ADDRESSES_PER_DAY = 5;
// Announcement rateconst MAX_ANNOUNCEMENTS_PER_HOUR = 3;
Retention
Section titled “Retention”Delivery mappings expire after 24 hours, message queues after 30 days (configurable).
Behavioral Metadata (Traffic Shaping)
Section titled “Behavioral Metadata (Traffic Shaping)”To prevent spam and abuse, servers track minimal behavioral patterns:
/// Device behavior metrics for spam prevention/// Tracked by device_id (aggregated across all addresses)struct DeviceMetadata { // Main device identity device_id: [u8; 32],
// Trust indicators registered_at: u64, // Account creation time messages_sent: u64, // Aggregate count (not per-message) messages_received: u64, // Aggregate count spam_reports: u64, // Reports from recipients last_active: u64, // Last message timestamp
// Rate limiting state messages_this_hour: u32, rate_limit_reset: u64,
// Admin override admin_verified: bool, // Manual trust agent
// Notable: // - Aggregated per device_id, not per address // - NO message content, NO recipients, NO social graph // - Spam reports are also aggregated across all addresses}
Retention
Section titled “Retention”Behavioral metadata expires after 30 days of inactivity.
Privacy Preserving
Section titled “Privacy Preserving”This metadata enables spam prevention without revealing message content, recipients, or social relationships.
Authentication State (Session-Based)
Section titled “Authentication State (Session-Based)”/// JWT tokens for authenticated API accessstruct SessionToken { device_address: String, issued_at: u64, expires_at: u64, // Typically 24 hours}
Retention
Section titled “Retention”Tokens expire after 24 hours.
MLS KeyPackage Storage
Section titled “MLS KeyPackage Storage”For group additions, servers store pre-uploaded KeyPackages:
/// KeyPackages for adding devices to MLS groupsstruct KeyPackageStore { device_id: [u8; 32], key_packages: Vec<KeyPackage>, // Public KeyPackages only uploaded_at: u64, total_consumed: u32,}
// Indexed by device_idkeypackage_storage: HashMap<DeviceId, KeyPackageStore>
Privacy Considerations
Section titled “Privacy Considerations”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).
Retention
Section titled “Retention”KeyPackages expire after 30 days if not consumed. Devices rotate KeyPackages when count drops below 20.
What is NOT Stored
Section titled “What is NOT Stored”- 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)
Device Announcement Protocol
Section titled “Device Announcement Protocol”Devices must periodically “announce” themselves to servers to receive messages. Devices can announce multiple delivery addresses simultaneously.
Endpoint
Section titled “Endpoint”POST /api/v1/device/announceContent-Type: application/json
{ "device_id": "device_id_goes_here", "delivery_address_prefixes": [ "a1b2c3d4e5f6...", "f9e8d7c6b5a4..." ], "signature": "ed25519_signature_hex_128_chars", "timestamp": 1760129277, "storage_preferences": { "max_retention_days": 30, "offline_message_limit": 1000 }}
Field Descriptions
Section titled “Field Descriptions”device_id
: Main device identity (Ed25519 public key, 32 bytes hex-encoded = 64 hex chars)delivery_address_prefixes
: Array of address identifiers (each 16 bytes hex-encoded = 32 hex chars)- Server appends its domain to create full addresses
- Example:
"a1b2c3d4e5f6..."
becomes"a1b2c3d4e5f6...@chat.example.com"
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
Why prefixes instead of full address?
Section titled “Why prefixes instead of full address?”The server domain is an implicit part of the API endpoint. Clients don’t need to specify it, preventing claims on addresses for domains you don’t control.
Server Response
Section titled “Server Response”{ "status": "success", "device_id": "device_id_goes_here", "announced_addresses": [ "a1b2c3d4e5f6@chat.example.com", "f9e8d7c6b5a4@chat.example.com" ], "access_token": "jwt_token_for_sending_messages", "expires_at": 1758402063, "server_capabilities": { "max_message_size": 10000000, "federation_enabled": true, "supported_mls_versions": ["1.0"] }}
What the Server Does
Section titled “What the Server Does”- Verifies timestamp is recent (within 5 minute window, prevents replay)
- 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.delivery_address_prefixes.join(","), 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(())}
- Creates temporary delivery mappings (expire in 24 hours):
fn create_mappings(req: &AnnouncementRequest, server_domain: &str) -> Result<()> { for prefix in &req.delivery_address_prefixes { let full_addr = format!("{}@{}", prefix, server_domain);
// Create persistent delivery mapping self.delivery_mappings.insert( full_addr.clone(), DeliveryMapping { device_id: req.device_id, delivery_address: full_addr.clone(), server_url: format!("https://{}", server_domain), announced_at: req.timestamp, expires_at: req.timestamp + 24 * 3600, } );
// Update in-memory cache for rate limiting self.address_to_device_cache.insert(full_addr, req.device_id); }
Ok(())}
- Issue temporary access token for message sending
- Validate address creation limits:
fn validate_announcement_limits(req: &AnnouncementRequest) -> Result<()> { // Count current active addresses for this device (from in-memory cache) let active_count = self.address_to_device_cache.values() .filter(|id| **id == req.device_id) .count();
if active_count + req.delivery_address_prefixes.len() > MAX_ADDRESSES_PER_DEVICE { return Err("Too many active addresses for device"); }
// Check daily creation rate let created_today = self.count_addresses_created_today(req.device_id); if created_today + req.delivery_address_prefixes.len() > MAX_NEW_ADDRESSES_PER_DAY { return Err("Address creation rate limit exceeded"); }
Ok(())}
Progressive Trust Rate Limiting
Section titled “Progressive Trust Rate Limiting”New devices earn increased rate limits over 24 hours based on behavior. Rate limits are per device_id (aggregated across all addresses):
Trust Tier | Age | Rate Limit | Purpose |
---|---|---|---|
New | 0-6 hours | 10 messages / hour | Prevents bot spam |
Established | 6-24 hours | 60 messages / hour | Normal usage |
Trusted | 24+ hours | 300 messages / hour | Full access |
Verified | Admin override | 300 messages / hour | Instant trust |
Blocked | After a certain number of spam reports | 0 messages / hour | Spam blocked |
Why 24 hours?
Section titled “Why 24 hours?”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.
Rate Limit Aggregation
Section titled “Rate Limit Aggregation”Critical: Rate limits apply to the device_id
, not the individual addresses:
fn get_rate_limit(device_id: &[u8; 32]) -> u32 { // Get ALL addresses for this device let all_addresses = self.address_to_device_cache.iter() .filter(|(_, dev_id)| dev_id == device_id) .map(|(addr, _)| addr);
// Count total messages across all addresses let total_sent: u32 = all_addresses .map(|addr| self.messages_sent_this_hour.get(addr).unwrap_or(&0)) .sum();
// Apply device-level limit let tier = get_trust_tier(device_id); tier.messages_per_hour() - total_sent}
This prevents attackers from cycling addresses on the same server to bypass rate limits.
Privacy Impact
Section titled “Privacy Impact”Server learns account age and message volume (aggregate counts) per device_id, but never learns message content, recipients, or social relationships.
Address System Design
Section titled “Address System Design”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 NOT derived from device_id. They are purely random identifiers to prevent linkability across address rotations.
Server Message Handling
Section titled “Server Message Handling”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.mls_ciphertext.is_empty() { return Err("Invalid message format"); }
// 2. Progressive trust rate limiting check let sender_device_id = self.address_to_device_cache.get(&message.sender_address) .ok_or("Sender address not announced")?; let rate_limit = self.get_rate_limit(sender_device_id)?;
// Check aggregate limit across all senders' addresses if !check_rate_limits(sender_device_id, rate_limit) { return Err(RateLimitError { device_id: sender_device_id, current_limit: rate_limit, reset_at: get_rate_limit_reset(sender_device_id), }); }
// 3. Update behavioral metrics (for traffic shaping, tracked by device_id) self.update_device_metrics(sender_device_id);
// 4. Queue for delivery (server does NOT verify cryptographic correctness) let queued = QueuedMessage { recipient_address: message.recipient_address, mls_ciphertext: message.mls_ciphertext, sender_signature: message.sender_signature, received_at: current_unix_timestamp(), expires_at: current_unix_timestamp() + MAX_MESSAGE_RETENTION, };
queue_for_recipient(queued)?;
// 5. 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.
Server State Privacy Analysis
Section titled “Server State Privacy Analysis”The Privacy Boundary
Section titled “The Privacy Boundary”What Servers Observe | What Servers Never Know |
---|---|
Device addresses (pseudonymous) | User identities (no PII) |
Recipient addresses for each message | Message content (E2EE) |
Account creation time | Sender addresses (not transmitted) |
Message send/receive volume | Group membership lists |
Spam report counts | Social relationships beyond delivery patterns |
Online/offline status | Conversation context |
Federation domains contacted | Specific sender-recipient pairs |
Why This Preserves Privacy
Section titled “Why This Preserves Privacy”Content Confidentiality (Cryptographic)
Section titled “Content Confidentiality (Cryptographic)”All messages are MLS-encrypted before reaching the server and no plaintext content is ever stored.
Sender Privacy (Architectural)
Section titled “Sender Privacy (Architectural)”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. -
mls_ciphertext
which is just encrypted bytes. -
sender_signature
which is Ed25519 signature data.
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)
Social Graph Privacy (Design)
Section titled “Social Graph Privacy (Design)”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)
Minimal Behavioral Metadata (Traffic Shaping)
Section titled “Minimal Behavioral Metadata (Traffic Shaping)”The metadata tracked is deliberately limited to:
struct DeviceMetadata { registered_at: u64, // Needed for progressive trust messages_sent: u64, // Aggregate count for rate limiting spam_reports: u64, // Needed for blocking
// No per-message details // No recipient tracking // No conversation analysis}
This is analogous to an ISP knowing you use the internet heavily (bandwidth usage) but not which websites you visit (HTTPS hides that).
Comparison with Other Privacy-Focused Systems
Section titled “Comparison with Other Privacy-Focused Systems”System | Behavioral Tracking | Result |
---|---|---|
Signal | Phone number, message count, last seen | Similar to Cryptid |
Matrix (E2EE rooms) | Room membership, user-to-room mapping | More metadata than Cryptid |
Email (PGP) | Full email headers (To/From/Subject) | Much more metadata than Cryptid |
Tor | No behavioral tracking | Maximum privacy, but high latency/complexity |
Cryptid | Account 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).
The Trade-off: Why Not Zero State?
Section titled “The Trade-off: Why Not Zero State?”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 (10 msg/hour for new devices)
-
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.
Spam Prevention via Address Rotation
Section titled “Spam Prevention via Address Rotation”The Problem with Permanent Addresses
Section titled “The Problem with Permanent Addresses”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
Cryptid’s Approach: Ephemeral Addresses
Section titled “Cryptid’s Approach: Ephemeral Addresses”Multiple delivery addresses enable proactive spam prevention:
Example Scenario
Section titled “Example Scenario”Alice’s device:
device_id: aabbccdd… (permanent, known to contacts and members in the same group chats)
Alice’s current addresses:
- work-addr@server.com (given to colleagues)
- friends-addr@server.com (given to friends)
- public-addr@server.com (posted in online forum)
If the public address gets spammed:
- Alice burns the compromised address locally
- Alice stops announcing it to the server
- Server mapping expires (24 hours)
- Spammer’s messages to that address → 404 Not Found
- Alice creates new public address
- Spammer cannot find the new address
Spammers lose routing information when addresses rotate.
Address Rotation Strategies
Section titled “Address Rotation Strategies”Risk-based Rotation
Section titled “Risk-based Rotation”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
Recommended Practices
Section titled “Recommended Practices”- Context separation: Different addresses for different purposes
- One-time addresses: Create disposable address for each public interaction
- Group-specific addresses: Dedicate address per group, burn if group becomes spammy
- Regular rotation: Rotate public addresses proactively (don’t wait for spam)
Blocking vs. Address Rotation
Section titled “Blocking vs. Address Rotation”These are complementary defenses:
Defense | Use Case | How It Works | Evasion? |
---|---|---|---|
Blocking (by device_id) | Known harasser | Permanent block based on identity | No - survives address rotation |
Address rotation | Unknown spammer | Hide new routing info | No - spammer doesn’t know new address |
Server-Side Enforcement
Section titled “Server-Side Enforcement”Servers MUST prevent abuse of address rotation:
// Aggregate spam reports by device_id, not addressfn get_spam_score(device_id: &[u8; 32]) -> u32 { let all_addresses = self.get_all_addresses_for_device(device_id);
// Sum spam reports across ALL addresses all_addresses.iter() .map(|addr| self.spam_reports.get(addr).unwrap_or(0)) .sum()}
// Block device if aggregate score too highif get_spam_score(&device_id) > SPAM_THRESHOLD { self.ban_device(device_id, Duration::days(7)); // Blocks ALL current and future addresses for this device}
Prevents:
- Cycling addresses to reset spam count
- Bypassing blocks via new addresses
- Scaling spam operations through unlimited address creation
Limitations
Section titled “Limitations”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)
The Privacy-Spam Trade-off
Section titled “The Privacy-Spam Trade-off”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.
Server Announcement
Section titled “Server Announcement”Servers MAY restrict who can announce based on their policies:
Open Announcement (Default)
Section titled “Open Announcement (Default)”- Anyone can announce to any server (with Proof-of-work)
- Requires registration proof for the first announcement
- Good for publicly-run servers
Invite-Only
Section titled “Invite-Only”- New devices need an invite code
- Good for private/community servers
Federation Allowlist
Section titled “Federation Allowlist”- Only accept announcements from trusted servers
- Good for restricted environments
Multi Server Announcements
Section titled “Multi Server Announcements”Devices can announce to multiple servers simultaneously:
// Alice announces to 2 serversannounce_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: 60 msg/hour (Established tier)
- Server B: 60 msg/hour (Established tier)
- Server C: 10 msg/hour (New tier - just announced)
The total potential throughput for this device would be 130 msg/hour
Why independent limits?
- Simplicity: No server coordination or trust synchronization required
- Privacy: Servers don’t query each other about device activity
- Federation independence: Servers remain autonomous
- 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:
- Server-level reputation: Servers track spam from entire federated servers
- Spam report aggregation: High spam reports trigger cross-server blocks
- Progressive trust still applies: Attacker needs to wait 24h on EACH server
- 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)