Implemented and tested the state machine model for the `Sender`. Tested against the rust bindings for libsodium using the `crypto_box` api.
20 KiB
Anonymous Messaging System Specification
System Overview
A secure anonymous messaging system where:
- Visitors leave messages via web form (name + message)
- Messages are cryptographically sealed in browser before transmission
- Encrypted messages stored on semi-untrusted VPS
- Only recipient can decrypt messages locally on their laptop
- Message content is opaque to all intermediaries
System Architecture
Three Components
- Sender Client - Browser-based message composition and encryption
- Message Store - VPS-hosted storage for encrypted messages
- Reader Client - Laptop-based decryption and archive
Trust Model
Sender Browser ─(sealed_box)─→ Message Store ─(sealed_box)─→ Reader Laptop
↓
Sees metadata only
Cannot read content
Security Property: Only holder of private key can decrypt message content.
Core Data Structures
Message Record
{
message_id: UUID,
sender_name: string, // PLAINTEXT
created_at: timestamp, // PLAINTEXT
key_id: string, // Which public key was used
sealed_box: bytes // ENCRYPTED message content
}
Public Key Record
{
key_id: string,
public_key: bytes,
created_at: timestamp,
status: ACTIVE | INACTIVE
}
Key Pool Entry (Reader only)
{
key_id: string,
public_key: bytes,
private_key: bytes,
created_at: timestamp,
status: ACTIVE | ARCHIVED
}
Decrypted Message (Reader local storage)
{
message_id: UUID,
sender_name: string,
sent_at: timestamp,
message_body: string,
retrieved_at: timestamp,
decrypted_with: string // key_id used for decryption
}
Component 1: Sender Client
State Machine
States:
IDLE
READY
COMPOSING
ENCRYPTING
SUBMITTED
ERROR
Transitions:
IDLE → READY (on load_public_key)
READY → COMPOSING (on user_input)
COMPOSING → ENCRYPTING (on submit)
ENCRYPTING → SUBMITTED (on encryption_complete)
SUBMITTED → IDLE (on confirmation)
* → ERROR (on any failure)
ERROR → IDLE (on reset)
State Context
The sender state machine maintains:
- current_state: One of {IDLE, READY, COMPOSING, ENCRYPTING, SUBMITTED, ERROR}
- public_key: The active public key loaded from the message store
- plaintext: Temporary storage for sender_name and message_body
- sealed_message: Encrypted message ready for transmission
State Transitions
IDLE → READY
Trigger: load_public_key(public_key)
Actions:
- Store public_key in context
- Transition to READY state
Postconditions:
- public_key is available for encryption
- System ready to accept message composition
READY → COMPOSING
Trigger: compose_message(sender_name, message_body)
Actions:
- Validate sender_name (non-empty, ≤200 chars)
- Validate message_body (non-empty, ≤10,000 chars)
- Store plaintext in context
- Transition to COMPOSING state
Postconditions:
- plaintext message is in memory
- Ready for encryption
COMPOSING → ENCRYPTING
Trigger: seal_message()
Actions:
- Retrieve public_key from context
- Apply cryptographic seal:
sealed_box = seal(message_body, public_key) - Create sealed_message with:
- sender_name (plaintext)
- key_id (from public_key)
- sealed_box (ciphertext)
- Destroy plaintext from memory
- Transition to ENCRYPTING state
Postconditions:
- plaintext no longer in memory
- sealed_message ready for transmission
- Message content is cryptographically sealed
ENCRYPTING → SUBMITTED
Trigger: submit_to_store()
Actions:
- Generate unique message_id (UUID)
- Capture current timestamp
- Construct MessageRecord:
- message_id
- sender_name
- created_at
- key_id
- sealed_box
- Transmit MessageRecord to message store
- Wait for confirmation
- Transition to SUBMITTED state
Postconditions:
- Message stored remotely
- Confirmation received
SUBMITTED → IDLE
Trigger: reset()
Actions:
- Clear sealed_message from memory
- Clear any remaining context
- Transition to IDLE state
Postconditions:
- No message data retained
- Ready for next message
Any State → ERROR
Trigger: Any operation failure
Actions:
- Capture error details
- Transition to ERROR state
- Preserve context for debugging
Recovery: Manual reset to IDLE
Data Flow
User Input (name, message_body)
↓
Store in memory as plaintext
↓
Load active public_key from store
↓
Encrypt: message_body + public_key → sealed_box
↓
Destroy plaintext from memory
↓
Create MessageRecord with sealed_box + metadata
↓
Transmit to Message Store
↓
Receive confirmation
↓
Reset state (sender retains nothing)
Constraints
- Message length: Max 10,000 characters (reasonable essay length)
- Sender name: Max 200 characters
- Memory safety: Plaintext destroyed immediately after encryption
- No persistence: Sender client stores nothing after submission
Component 2: Message Store
State Machine (per message)
States:
RECEIVED
VALIDATED
STORED
REJECTED
Transitions:
RECEIVED → VALIDATED (on validate_structure)
VALIDATED → STORED (on persist)
RECEIVED → REJECTED (on validation_failure)
VALIDATED → REJECTED (on storage_failure)
State Context
The message store maintains:
- messages: Collection of MessageRecord entries
- keys: Collection of PublicKeyRecord entries
Operations
receive_message(incoming_message)
State Flow: RECEIVED → VALIDATED → STORED (or REJECTED)
Validation Phase (RECEIVED → VALIDATED):
- Check sender_name: non-empty and ≤200 characters
- Check sealed_box: non-empty and ≤50KB
- Verify key_id exists in keys collection
- If any check fails: transition to REJECTED, return error
Storage Phase (VALIDATED → STORED):
- Generate unique message_id (UUID)
- Capture current timestamp
- Construct MessageRecord with all fields
- Add to messages collection
- Return message_id as confirmation
get_all_messages()
Action: Return all MessageRecord entries from messages collection
Use Case: Reader client batch retrieval
get_messages_since(timestamp)
Action: Return MessageRecord entries where created_at > timestamp
Use Case: Incremental message retrieval
delete_message(message_id)
Actions:
- Locate MessageRecord by message_id
- If not found: return error
- Remove from messages collection
- Return success
Use Case: Reader cleanup after successful decryption
add_public_key(public_key)
Actions:
- Find all keys with status=ACTIVE
- Update them to status=INACTIVE
- Generate new key_id
- Create PublicKeyRecord:
- key_id
- public_key
- created_at (current timestamp)
- status = ACTIVE
- Add to keys collection
- Return key_id
Side Effect: Only one key is ACTIVE at any time
get_active_key()
Action: Return PublicKeyRecord where status=ACTIVE
Use Case: Sender client retrieving current encryption key
get_all_keys()
Action: Return all PublicKeyRecord entries
Use Case: Reader client key synchronization
Storage Schema
messages: [MessageRecord]
keys: [PublicKeyRecord]
Validation Rules
On message submission:
sender_name: non-empty, max 200 charssealed_box: non-empty, max 50KBkey_id: must exist in keys table
On key addition:
- Authenticated request (recipient only)
- Valid public key format
- Automatically deactivates previous ACTIVE keys
Data Visibility
Store can see:
- Sender name (plaintext)
- Timestamp (plaintext)
- Which public key was used (key_id)
- Number and size of messages
Store cannot see:
- Message content (encrypted in sealed_box)
- Decryption success/failure
- Reader's retrieval patterns (stateless)
Component 3: Reader Client
State Machine (Batch Operation)
States:
IDLE
FETCHING_BATCH
DECRYPTING_BATCH
SAVING_BATCH
ERROR
Transitions:
IDLE → FETCHING_BATCH (on fetch_messages)
FETCHING_BATCH → DECRYPTING_BATCH (on messages_received)
DECRYPTING_BATCH → SAVING_BATCH (on batch_decrypted)
SAVING_BATCH → IDLE (on save_complete)
FETCHING_BATCH → ERROR (on network_failure)
DECRYPTING_BATCH → IDLE (on partial_success, logs failures)
SAVING_BATCH → ERROR (on storage_failure)
ERROR → IDLE (on reset)
State Context
The reader client maintains:
- current_state: One of {IDLE, FETCHING_BATCH, DECRYPTING_BATCH, SAVING_BATCH, ERROR}
- key_pool: Collection of KeyPoolEntry (all historical private keys)
- local_archive: Collection of DecryptedMessage entries
- undecryptable: List of message_id values that failed decryption
- last_fetch: Timestamp of most recent successful fetch
State Transitions
IDLE → FETCHING_BATCH
Trigger: fetch_messages()
Actions:
- Transition to FETCHING_BATCH state
- Request all MessageRecord entries from message store
- Receive batch of encrypted messages
Error Handling: Network failure → ERROR state
FETCHING_BATCH → DECRYPTING_BATCH
Trigger: decrypt_batch(messages)
Actions:
- Transition to DECRYPTING_BATCH state
- For each MessageRecord in batch:
- Call decrypt_single_message()
- On success: add to decrypted list
- On failure: add message_id to failed list
- Return BatchDecryptResult containing both lists
Decryption Strategy (per message):
- Primary attempt: Find key in pool matching message.key_id
- Try unseal:
unseal(sealed_box, private_key) - If fails: Iterate through all keys in pool
- If any succeeds: Return plaintext + key_id
- If all fail: Return decryption error
Partial Success: Successfully decrypted messages are saved; failures are logged
DECRYPTING_BATCH → SAVING_BATCH
Trigger: save_batch(result)
Actions:
- Transition to SAVING_BATCH state
- Append all decrypted messages to local_archive
- Append all failed message_ids to undecryptable list
- Update last_fetch to current timestamp
- Persist to local storage
Error Handling: Storage failure → ERROR state
SAVING_BATCH → IDLE
Trigger: complete()
Actions:
- Transition to IDLE state
- Ready for next fetch cycle
Key Pool Operations
rotate_key()
Actions:
- Generate new cryptographic keypair (public_key, private_key)
- Find all keys in pool with status=ACTIVE
- Update them to status=ARCHIVED
- Generate new key_id
- Create KeyPoolEntry:
- key_id
- public_key
- private_key
- created_at (current timestamp)
- status = ACTIVE
- Add to key_pool
- Publish public_key to message store (external call to add_public_key)
Key Retention: Archived keys are NEVER deleted (required for decrypting old messages)
sync_keys()
Actions:
- Fetch all PublicKeyRecord entries from message store
- Extract key_ids from local key_pool
- Identify remote keys not in local pool
- Return KeySyncReport listing missing private keys
Use Case: Detecting key pool desynchronization (e.g., backup restore scenario)
Local Storage
key_pool.json:
[KeyPoolEntry, ...]
messages.json:
[DecryptedMessage, ...]
state.json:
{
last_fetch: timestamp,
undecryptable: [UUID, ...]
}
Data Flow
Request all messages from Message Store
↓
Receive Vec<MessageRecord> (batch)
↓
For each message in batch:
↓
Find matching private_key by key_id
↓
Attempt decrypt with matched key
↓
If fail: try all keys in pool
↓
If success: add to decrypted list
If fail: add to undecryptable list
↓
Save all decrypted messages to local archive
↓
Update state (last_fetch timestamp)
↓
Return to IDLE
Key Pool Management
Key pool properties:
- Maintains ALL historical private keys (never deletes)
- One key marked ACTIVE (for rotation operations)
- Old keys marked ARCHIVED (still used for decryption)
- Keys never removed (would make old messages undecryptable)
Rotation process:
Generate new keypair
↓
Add to local pool as ACTIVE
↓
Mark previous ACTIVE → ARCHIVED
↓
Publish new public_key to Message Store
↓
Store marks new key ACTIVE, old key INACTIVE
↓
Future messages encrypted with new key
↓
Old messages still decryptable with archived keys
System-Wide Flows
End-to-End Message Flow
1. SENDER SIDE
User enters name + message
→ Sender loads active public key from store
→ Sender seals message with public key
→ Sender transmits sealed_box + metadata
→ Sender destroys plaintext
→ Sender receives confirmation
2. STORAGE
Store receives MessageRecord
→ Validates structure
→ Persists to storage
→ Returns message_id
3. READER SIDE
Reader fetches all messages (batch)
→ For each message:
Try decrypt with key_id match
Fallback to all keys in pool
→ Save successful decryptions
→ Log failed decryptions
→ Update local state
Key Rotation Flow
1. READER INITIATES ROTATION
Generate new keypair
→ Add to local key_pool (ACTIVE)
→ Archive old keys (ARCHIVED)
→ Publish new public_key to store
2. STORE UPDATES
Receive new public_key
→ Deactivate old keys (INACTIVE)
→ Activate new key (ACTIVE)
3. CONCURRENT SENDERS
Sender A: fetched old key before rotation
→ Encrypts with old key
→ Reader still has old private key (ARCHIVED)
→ Decryption succeeds
Sender B: fetches new key after rotation
→ Encrypts with new key
→ Reader has new private key (ACTIVE)
→ Decryption succeeds
Edge Cases & Failure Modes
Undecryptable Messages
Causes:
- Message encrypted with unknown key_id
- Corrupted sealed_box during transmission
- Key rotation timing edge case
- Malicious tampering
Handling:
- Add message_id to undecryptable list
- Preserve raw sealed_box for manual inspection
- Periodic retry (in case missing key added later)
- Log for debugging
Key Sync Mismatch
Scenario 1: Store has key X, Reader doesn't
Reader fetches messages encrypted to X
↓
Decryption fails (no matching private key)
↓
Reader calls sync_keys()
↓
Discovers missing private key for X
↓
Flags for manual intervention
Scenario 2: Reader has key Y, Store doesn't
No impact
↓
Key Y is historical/archived
↓
No new messages encrypted to Y
↓
Reader keeps Y for old messages
Store Compromise
Attacker gains access to VPS:
Can:
- Read all metadata (sender names, timestamps)
- See all sealed_box ciphertexts (useless without private keys)
- Delete messages (availability attack)
- Serve malicious public key (MITM future messages)
Cannot:
- Decrypt existing messages (no private keys)
- Forge messages that decrypt properly
- Retroactively decrypt past messages
Mitigation:
- Regular backups of message store
- Monitor for unexpected key rotations
- Out-of-band public key verification (future enhancement)
Concurrent Key Rotation
Scenario:
T0: Sender fetches public_key A
T1: Reader rotates to key B
T2: Store updates active key → B
T3: Sender submits message encrypted with A
Result:
Message encrypted with old key A
→ Reader still has private_key A in pool (ARCHIVED)
→ Decryption succeeds
→ No data loss
Message Store Full
Not currently specified - future consideration:
- Max storage quota
- Auto-deletion after N days
- Reader notification when approaching limit
Security Properties
Confidentiality
- Message content: Only reader with private key can decrypt
- Sender name: Visible to store (plaintext)
- Timing: Message timestamps visible to store
Integrity
- Sealed box cryptography provides authentication
- Tampering detection built into crypto scheme
- Failed authentication → decryption failure
Availability
- Stateless retrieval (no read locks)
- Store compromise → messages still readable from backups
- Key loss → messages permanently lost (by design)
Anonymity
- No sender authentication required
- IP addresses, user agents: implementation detail
- Sender name is self-asserted (no verification)
Constraints & Limits
Message Constraints
- Maximum message length: 10,000 characters (not bytes - Unicode aware)
- Maximum sender name: 200 characters (not bytes - Unicode aware)
- Maximum sealed_box size: ~40KB (10,000 chars × 4 bytes/char UTF-8 max + ~48 bytes overhead)
- Note: Actual sealed_box size depends on plaintext encoding
- Conservative estimate: 50KB covers worst case
- Minimum message length: 1 character (empty rejected)
- Minimum sender name: 1 character (empty rejected)
Storage Constraints
- Messages persist indefinitely (no auto-deletion)
- No maximum message count (unbounded growth)
Performance Constraints
- Batch operations preferred (reader fetches all at once)
- Stateless protocol (no session management)
- Crypto operations: single-threaded acceptable for low volume
Cryptographic Primitives
Sealed Box: NaCl/libsodium compatible
- Combines public key encryption + authentication
- No separate nonce management required
- All-in-one ciphertext blob
- Uses Curve25519 for public key cryptography (X25519)
- Encrypts to a public key without requiring the sender's private key
- Provides IND-CCA2 security with authenticated encryption
Key Generation:
- Public/private keypair generation using Curve25519
- Public key: 32 bytes
- Private key: 32 bytes
- Implementation: libsodium/sodiumoxide crypto_box keypair
Operations:
seal(plaintext, public_key) → sealed_box- Internally generates ephemeral keypair
- Encrypts using XSalsa20-Poly1305
- Returns ciphertext || ephemeral_public_key
unseal(sealed_box, private_key) → plaintext | error- Extracts ephemeral public key from sealed_box
- Derives shared secret
- Decrypts and authenticates
- Returns plaintext on success, error on tampering/wrong key
Implementation Insights
Sender Client Implementation Notes
State Enforcement:
- Strict state machine prevents invalid transitions
- Each operation checks current state before proceeding
- Invalid transitions return explicit error messages
- Error state allows for graceful recovery via reset
Memory Safety:
- Plaintext is consumed (moved) during seal operation
- After sealing, plaintext is automatically dropped
- Sealed message is consumed during submit operation
- No plaintext remains in memory after encryption
Validation Edge Cases:
- Empty strings are rejected (both sender_name and message_body)
- Unicode characters are supported and count correctly
- Character limits are enforced, not byte limits
- Newlines and special characters are preserved
Cryptographic Properties:
- Same plaintext produces different ciphertext each time (due to ephemeral keys)
- Sealed box is larger than plaintext by ~48 bytes (ephemeral PK + auth tag + nonce)
- Encryption cannot fail given valid public key
- No key management required on sender side (stateless)
UUIDs and Timestamps:
- UUIDs are v4 (random), not v1 (time-based)
- Timestamps are Unix epoch seconds (i64)
- Message IDs are globally unique with high probability
Reset Behavior:
- Reset can be called from any state (not just SUBMITTED)
- Allows error recovery by forcing return to IDLE
- Clears all intermediate state (plaintext, sealed_message)
- Public key is retained after reset (stays in IDLE, not destroyed)
Key ID Management:
- Key ID is opaque string (implementation-defined format)
- Sender doesn't validate key_id format
- Key ID is preserved from PublicKey through MessageRecord
- Enables key rotation without sender awareness
Version History
- v0.1 - Initial specification (2026-01-19)