messagebox-protocol/SPECIFICATION.md
Zachery Aaron Shores-Chmielewski cc39f8a0a8 Initital Sender state machine logic implemented
Implemented and tested the state machine model for the `Sender`. Tested against the rust bindings for libsodium using
the `crypto_box` api.
2026-01-19 11:09:17 +07:00

20 KiB
Raw Permalink Blame History

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

  1. Sender Client - Browser-based message composition and encryption
  2. Message Store - VPS-hosted storage for encrypted messages
  3. 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:

  1. Store public_key in context
  2. 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:

  1. Validate sender_name (non-empty, ≤200 chars)
  2. Validate message_body (non-empty, ≤10,000 chars)
  3. Store plaintext in context
  4. Transition to COMPOSING state

Postconditions:

  • plaintext message is in memory
  • Ready for encryption

COMPOSING → ENCRYPTING

Trigger: seal_message()

Actions:

  1. Retrieve public_key from context
  2. Apply cryptographic seal: sealed_box = seal(message_body, public_key)
  3. Create sealed_message with:
    • sender_name (plaintext)
    • key_id (from public_key)
    • sealed_box (ciphertext)
  4. Destroy plaintext from memory
  5. 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:

  1. Generate unique message_id (UUID)
  2. Capture current timestamp
  3. Construct MessageRecord:
    • message_id
    • sender_name
    • created_at
    • key_id
    • sealed_box
  4. Transmit MessageRecord to message store
  5. Wait for confirmation
  6. Transition to SUBMITTED state

Postconditions:

  • Message stored remotely
  • Confirmation received

SUBMITTED → IDLE

Trigger: reset()

Actions:

  1. Clear sealed_message from memory
  2. Clear any remaining context
  3. Transition to IDLE state

Postconditions:

  • No message data retained
  • Ready for next message

Any State → ERROR

Trigger: Any operation failure

Actions:

  1. Capture error details
  2. Transition to ERROR state
  3. 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):

  1. Check sender_name: non-empty and ≤200 characters
  2. Check sealed_box: non-empty and ≤50KB
  3. Verify key_id exists in keys collection
  4. If any check fails: transition to REJECTED, return error

Storage Phase (VALIDATED → STORED):

  1. Generate unique message_id (UUID)
  2. Capture current timestamp
  3. Construct MessageRecord with all fields
  4. Add to messages collection
  5. 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:

  1. Locate MessageRecord by message_id
  2. If not found: return error
  3. Remove from messages collection
  4. Return success

Use Case: Reader cleanup after successful decryption


add_public_key(public_key)

Actions:

  1. Find all keys with status=ACTIVE
  2. Update them to status=INACTIVE
  3. Generate new key_id
  4. Create PublicKeyRecord:
    • key_id
    • public_key
    • created_at (current timestamp)
    • status = ACTIVE
  5. Add to keys collection
  6. 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 chars
  • sealed_box: non-empty, max 50KB
  • key_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:

  1. Transition to FETCHING_BATCH state
  2. Request all MessageRecord entries from message store
  3. Receive batch of encrypted messages

Error Handling: Network failure → ERROR state


FETCHING_BATCH → DECRYPTING_BATCH

Trigger: decrypt_batch(messages)

Actions:

  1. Transition to DECRYPTING_BATCH state
  2. For each MessageRecord in batch:
    • Call decrypt_single_message()
    • On success: add to decrypted list
    • On failure: add message_id to failed list
  3. Return BatchDecryptResult containing both lists

Decryption Strategy (per message):

  1. Primary attempt: Find key in pool matching message.key_id
  2. Try unseal: unseal(sealed_box, private_key)
  3. If fails: Iterate through all keys in pool
  4. If any succeeds: Return plaintext + key_id
  5. 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:

  1. Transition to SAVING_BATCH state
  2. Append all decrypted messages to local_archive
  3. Append all failed message_ids to undecryptable list
  4. Update last_fetch to current timestamp
  5. Persist to local storage

Error Handling: Storage failure → ERROR state


SAVING_BATCH → IDLE

Trigger: complete()

Actions:

  1. Transition to IDLE state
  2. Ready for next fetch cycle

Key Pool Operations

rotate_key()

Actions:

  1. Generate new cryptographic keypair (public_key, private_key)
  2. Find all keys in pool with status=ACTIVE
  3. Update them to status=ARCHIVED
  4. Generate new key_id
  5. Create KeyPoolEntry:
    • key_id
    • public_key
    • private_key
    • created_at (current timestamp)
    • status = ACTIVE
  6. Add to key_pool
  7. 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:

  1. Fetch all PublicKeyRecord entries from message store
  2. Extract key_ids from local key_pool
  3. Identify remote keys not in local pool
  4. 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)