commit 184702057b5b773e8d07ac81e505156cfc60348b Author: Zachery Aaron Shores-Chmielewski Date: Mon Jan 19 08:45:16 2026 +0700 Add initial protocol specification Documents the messagebox protocol with implementation-agnostic state machines for sender, store, and reader components. diff --git a/SPECIFICATION.md b/SPECIFICATION.md new file mode 100644 index 0000000..d79f0d1 --- /dev/null +++ b/SPECIFICATION.md @@ -0,0 +1,801 @@ +# 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 (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 +- Maximum sender name: 200 characters +- Maximum sealed_box size: 50KB + +### 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 + +**Key Generation:** +- Public/private keypair generation +- Implementation agnostic (can be RSA, ECC, X25519, etc.) + +**Operations:** +- `seal(plaintext, public_key) → sealed_box` +- `unseal(sealed_box, private_key) → plaintext | error` + +--- + +## 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? + +--- + +## Version History + +- v0.1 - Initial specification (2026-01-19) \ No newline at end of file