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:

/// 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, ...],
}

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 addresses
const MAX_ADDRESSES_PER_DEVICE = 10;
// Creation rate
const MAX_NEW_ADDRESSES_PER_DAY = 5;
// Announcement rate
const MAX_ANNOUNCEMENTS_PER_HOUR = 3;

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

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
}

Behavioral metadata expires after 30 days of inactivity.

This metadata enables spam prevention without revealing message content, recipients, or social relationships.

/// JWT tokens for authenticated API access
struct SessionToken {
device_address: String,
issued_at: u64,
expires_at: u64, // Typically 24 hours
}

Tokens expire after 24 hours.

For group additions, servers store pre-uploaded KeyPackages:

/// KeyPackages for adding devices to MLS groups
struct KeyPackageStore {
device_id: [u8; 32],
key_packages: Vec<KeyPackage>, // Public KeyPackages only
uploaded_at: u64,
total_consumed: u32,
}
// Indexed by device_id
keypackage_storage: HashMap<DeviceId, 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.

  • 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)

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",
"delivery_address_prefixes": [
"a1b2c3d4e5f6...",
"f9e8d7c6b5a4..."
],
"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)
  • 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

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.

{
"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"]
}
}
  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.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(())
}
  1. 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(())
}
  1. Issue temporary access token for message sending
  2. 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(())
}

New devices earn increased rate limits over 24 hours based on behavior. Rate limits are per device_id (aggregated across all addresses):

Trust TierAgeRate LimitPurpose
New0-6 hours10 messages / hourPrevents bot spam
Established6-24 hours60 messages / hourNormal usage
Trusted24+ hours300 messages / hourFull access
VerifiedAdmin override300 messages / hourInstant trust
BlockedAfter a certain number of spam reports0 messages / hourSpam 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.

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.

Server learns account age and message volume (aggregate counts) per device_id, but never learns message content, recipients, or social relationships.

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.

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.

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)
Message send/receive volumeGroup membership lists
Spam report countsSocial relationships beyond delivery patterns
Online/offline statusConversation context
Federation domains contactedSpecific sender-recipient pairs

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.

  • 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)

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”
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 (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.

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)

These are complementary defenses:

DefenseUse CaseHow It WorksEvasion?
Blocking (by device_id)Known harasserPermanent block based on identityNo - survives address rotation
Address rotationUnknown spammerHide new routing infoNo - spammer doesn’t know new address

Servers MUST prevent abuse of address rotation:

// Aggregate spam reports by device_id, not address
fn 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 high
if 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

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: 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?

  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 spam from entire federated servers
  2. Spam report aggregation: High spam reports trigger cross-server blocks
  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)