messagebox-protocol/SPECIFICATION.md
Zachery Aaron Shores-Chmielewski f3ab842cc2 Update specification with implementation insights
Add comprehensive implementation notes based on sender client development:
- Detailed cryptographic primitive specifications (Curve25519, XSalsa20-Poly1305)
- Memory safety patterns (move semantics for plaintext)
- State machine enforcement strategies
- Unicode and character counting considerations
- Cryptographic property documentation (randomness, overhead)
- Design decisions with rationales
- Additional open questions discovered during implementation
- Updated constraints with actual size calculations
2026-01-19 08:50:50 +07:00

931 lines
No EOL
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
---
## Future Enhancements (Out of Scope)
- Public key fingerprint verification
- Sender reply channel (optional contact info)
- Message categories/tags
- Read receipts
- Storage quotas and auto-cleanup
- Multi-device reader support
- Message threading
- Rate limiting and spam prevention
---
## 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
### Testing Considerations
**Essential Test Coverage:**
1. State transitions (valid and invalid)
2. Input validation (empty, max, over-max)
3. Cryptographic randomness (same input → different output)
4. Memory cleanup (plaintext destroyed after sealing)
5. Unicode and special character handling
6. Multiple messages in sequence
7. Timestamp correctness
8. UUID uniqueness
**Error Cases:**
- Invalid state transitions
- Missing public key
- Missing plaintext
- Missing sealed message
- Invalid sender name (empty, too long)
- Invalid message body (empty, too long)
---
## Design Decisions (from Implementation)
### Public Key Structure
**Decision:** PublicKey contains both key_id and public_key bytes
- **Rationale:** Sender needs key_id to include in MessageRecord
- **Alternative considered:** Fetch key_id separately from store
- **Chosen approach:** Bundle them together to reduce round trips
### State Machine Strictness
**Decision:** Enforce state transitions with compile-time types would be ideal, but runtime checks are practical
- **Rationale:** Makes invalid states unrepresentable
- **Trade-off:** More complex type system vs runtime flexibility
- **Implementation:** Runtime checks with explicit error types
### Plaintext Ownership
**Decision:** Move (consume) plaintext during seal operation
- **Rationale:** Prevents accidental reuse or logging
- **Alternative considered:** Clone and clear
- **Chosen approach:** Rust's move semantics provide memory safety guarantee
### Reset from Any State
**Decision:** Allow reset from any state, not just SUBMITTED or ERROR
- **Rationale:** Provides escape hatch for error recovery
- **Trade-off:** Less strict state machine vs operational flexibility
- **Chosen approach:** Flexible reset for better UX
### Timestamp Precision
**Decision:** Unix epoch seconds (not milliseconds)
- **Rationale:** Second precision sufficient for message ordering
- **Trade-off:** Less precision vs simpler arithmetic
- **Chosen approach:** Seconds for simplicity
### Character vs Byte Limits
**Decision:** Enforce character limits, not byte limits
- **Rationale:** User-facing (WYSIWYG behavior)
- **Trade-off:** Unicode overhead vs user expectations
- **Chosen approach:** Character counting for better UX
### Error Type Design
**Decision:** Use enum with contextual error messages
- **Rationale:** Structured errors enable better error handling
- **Alternative considered:** String errors
- **Chosen approach:** Typed errors with Display impl for messages
## Open Questions
1. **Authentication mechanism** for Reader → Store operations (key rotation, deletion)
2. **Public key verification** - how does sender know public_key is authentic?
3. **Rate limiting** - should store impose submission limits?
4. **Message expiry** - auto-delete after N days?
5. **Multi-device reader** - how to distribute private keys securely?
6. **Sealed box size** - current spec says max 50KB, but actual max message is 10KB chars + overhead (~48 bytes). Should store validate based on plaintext limit or total size?
7. **Key ID format** - should it be standardized (e.g., hash of public key) or implementation-defined?
8. **Concurrent key rotation** - if sender caches public key, how long is cache valid?
---
## Version History
- v0.1 - Initial specification (2026-01-19)