Implemented and tested the state machine model for the `Sender`. Tested against the rust bindings for libsodium using the `crypto_box` api.
841 lines
No EOL
20 KiB
Markdown
841 lines
No EOL
20 KiB
Markdown
# 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) |