| name | e2ee-envelope-operations |
| description | Use when adding encryption for a new data type, implementing envelope encryption, working with HPKE key wrapping, managing crypto labels, or handling multi-recipient decryption. Also use when the user mentions "encrypt", "E2EE", "envelope", "HPKE", "forward secrecy", "crypto label", "admin envelope", "content key", or needs to understand how data is encrypted at rest. |
E2EE Envelope Encryption Operations
Core Pattern
Per-record envelope encryption with forward secrecy. Every encrypted record follows this flow:
1. Generate random 32-byte content key (unique per record — NEVER reuse)
2. Encrypt plaintext with XChaCha20-Poly1305 using content key
3. HPKE-wrap content key for EACH reader (author + every admin)
4. Store: ciphertext + wrapped envelopes (server never sees plaintext or content key)
5. Decrypt: find your envelope by pubkey -> HPKE-unwrap -> decrypt content
Wire format for encrypted content: hex(nonce_24 + ciphertext)
Wire format for HPKE envelope: hex(ephemeral_pubkey_32 + ciphertext + auth_tag_16)
Existing Encrypted Data Types
| Type | Readers | Label Constant | Rust Label String | IPC Command |
|---|
| Notes | author + admins | LABEL_NOTE_KEY | llamenos:note-key | encrypt_note / decrypt_note_from_state |
| Messages | assigned volunteer + admins | LABEL_MESSAGE | llamenos:message | encrypt_message / decrypt_message_from_state |
| Call records | answering volunteer + admins | LABEL_CALL_META | llamenos:call-meta | decrypt_call_record_from_state |
| Files (key wrap) | uploader + admins | LABEL_FILE_KEY | llamenos:file-key | unwrap_file_key_from_state / rewrap_file_key_from_state |
| File metadata | uploader + admins | LABEL_FILE_METADATA | llamenos:file-metadata | decrypt_file_metadata_from_state |
| Transcriptions | volunteer + admins | LABEL_TRANSCRIPTION | llamenos:transcription | decrypt_transcription_from_state |
| Hub key | all hub members | LABEL_HUB_KEY_WRAP | llamenos:hub-key-wrap | unwrap_hub_key_from_state |
| Contact IDs | system | LABEL_CONTACT_ID | llamenos:contact-identifier | HKDF deterministic hash, NOT envelope |
| Drafts | author only | HKDF_CONTEXT_DRAFTS | llamenos:drafts | encrypt_draft_from_state / decrypt_draft_from_state |
| Exports | author only | HKDF_CONTEXT_EXPORT | llamenos:export | encrypt_export_from_state |
Notes and messages use the full envelope pattern (multi-recipient HPKE wrapping). Drafts and exports use HKDF-derived symmetric keys (single-user, no envelope).
Adding a New Encrypted Data Type — Checklist
Step 1: Add the crypto label
Add a new entry to packages/protocol/crypto-labels.json:
{
"labels": {
"LABEL_YOUR_TYPE": "llamenos:your-type"
}
}
Run codegen to generate TS/Swift/Kotlin constants:
bun run codegen
Step 2: Add Rust label constant
Add to packages/crypto/src/labels.rs:
pub const LABEL_YOUR_TYPE: &str = "llamenos:your-type";
Add assertion to the labels_match_typescript test in the same file.
Step 3: Add encrypt/decrypt functions
Add to packages/crypto/src/encryption.rs. Follow the existing pattern — see encrypt_note / decrypt_note or encrypt_message / decrypt_message as templates.
For multi-recipient envelope types:
pub struct EncryptedYourType {
pub encrypted_content: String,
pub author_envelope: KeyEnvelope,
pub admin_envelopes: Vec<RecipientKeyEnvelope>,
}
pub fn encrypt_your_type(
plaintext: &str,
author_pubkey: &str,
admin_pubkeys: &[String],
) -> Result<EncryptedYourType, CryptoError> {
let mut content_key = random_bytes_32();
content_key.zeroize();
}
Add roundtrip tests in the same file's mod tests.
Re-export the new struct from packages/crypto/src/lib.rs if needed.
Step 4: Add Tauri IPC commands
Add to apps/desktop/src/crypto.rs:
#[tauri::command]
pub fn encrypt_your_type(
payload_json: String,
author_pubkey: String,
admin_pubkeys: Vec<String>,
) -> Result<EncryptedYourType, String> {
encryption::encrypt_your_type(&payload_json, &author_pubkey, &admin_pubkeys)
.map_err(err_str)
}
#[tauri::command]
pub fn decrypt_your_type_from_state(
state: tauri::State<'_, CryptoState>,
encrypted_content: String,
envelope: KeyEnvelope,
) -> Result<String, String> {
let sk_hex = state.get_secret_key()?;
encryption::decrypt_your_type(&encrypted_content, &envelope, &sk_hex)
.map_err(err_str)
}
Encrypt commands are stateless (no secret key needed — only pubkeys).
Decrypt commands use _from_state suffix (secret key stays in Rust).
Step 5: Register in generate_handler
Add to apps/desktop/src/lib.rs inside tauri::generate_handler![]:
crypto::encrypt_your_type,
crypto::decrypt_your_type_from_state,
Step 6: Add platform.ts wrapper
Add to src/client/lib/platform.ts:
export async function encryptYourType(
payloadJson: string,
authorPubkey: string,
adminPubkeys: string[],
): Promise<EncryptedYourType> {
return invoke('encrypt_your_type', {
payloadJson, authorPubkey, adminPubkeys,
});
}
export async function decryptYourTypeFromState(
encryptedContent: string,
envelope: KeyEnvelope,
): Promise<string> {
return invoke('decrypt_your_type_from_state', {
encryptedContent, envelope,
});
}
Step 7: Add Playwright mock
Add to tests/mocks/tauri-ipc-mock.ts — mirror the Rust logic in JS using @noble/curves and @noble/ciphers. The mock must produce byte-compatible output.
Step 8: Add server-side storage
Add encrypted fields to the relevant Durable Object. Store ONLY:
encryptedContent (hex string)
authorEnvelope or readerEnvelopes (HPKE envelopes)
version: 2 (for future algorithm migration)
NEVER store plaintext. NEVER log plaintext.
Step 9: Update mobile platforms (if applicable)
- iOS: UniFFI auto-generates from
#[cfg_attr(feature = "mobile", derive(uniffi::Record))]
- Android: JNI wraps the same Rust functions
- Rebuild with
packages/crypto/scripts/build-mobile.sh ios|android
HPKE Implementation Details
Source: packages/crypto/src/hpke.rs
All envelope encryption uses HPKE (RFC 9180) with DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + AES-256-GCM.
Wrapping (hpke_wrap_key)
1. Generate ephemeral X25519 keypair (random per wrap)
2. Parse recipient X25519 pubkey (32 bytes)
3. ECDH: ephemeral_secret x recipient_pubkey -> shared_secret
4. KEM encapsulation + key derivation (RFC 9180 single-shot)
5. Output: KeyEnvelope {
wrapped_key: hex(ciphertext + auth_tag),
ephemeral_pubkey: hex(32_bytes)
}
6. Zeroize: ephemeral_secret, shared_secret
Unwrapping (hpke_unwrap_key)
1. Parse secret key (32 bytes hex, Ed25519 seed -> X25519 scalar)
2. Parse ephemeral pubkey (32 bytes X25519)
3. ECDH: decryptor_secret x ephemeral_pubkey -> shared_secret
4. KEM decapsulation + key derivation (RFC 9180 single-shot)
5. Decrypt: AES-256-GCM -> recover 32-byte content key
6. Zeroize: shared_secret, sk_bytes
Multi-Admin Decryption Flow
Each admin receives an independent HPKE envelope with its own ephemeral keypair:
Record encrypted with content_key K:
admin_envelopes: [
{ pubkey: "admin1_pk", wrappedKey: HPKE(K, admin1_pk), ephemeralPubkey: "eph1" },
{ pubkey: "admin2_pk", wrappedKey: HPKE(K, admin2_pk), ephemeralPubkey: "eph2" },
]
Decryption:
- Find envelope where
pubkey == my_pubkey
- ECDH:
my_secret x ephemeral_pubkey -> shared_secret
- HPKE decapsulate to derive symmetric key
- Unwrap content key K
- XChaCha20-Poly1305 decrypt the
encrypted_content with K
If no envelope matches your pubkey, decryption fails with DecryptionFailed.
Platform-Specific Patterns
Desktop (Tauri)
- Encrypt: stateless IPC command (e.g.,
encrypt_note) — only needs pubkeys
- Decrypt: stateful
_from_state command — CryptoState holds nsec in Rust memory
- The nsec NEVER crosses the IPC boundary (except during one-time provisioning with token)
- Always import from
src/client/lib/platform.ts, never from @tauri-apps/* directly
iOS (UniFFI)
CryptoService.shared.encryptNote(payload:authorPubkey:adminPubkeys:) -> EncryptedNote
- nsecHex is private property, never exposed outside CryptoService
- Structs auto-generated via
#[cfg_attr(feature = "mobile", derive(uniffi::Record))]
Android (JNI)
CryptoService.encryptNote(payload, authorPubkey, adminPubkeys) -> EncryptedNote
- Same Rust code, JNI binding layer
- Placeholder mock active until native
.so files are linked
Server (Worker/Node.js)
- Server encrypts inbound messages on webhook receipt, discards plaintext immediately
- Uses
@noble/curves/ed25519 + @noble/hashes for HPKE in JS
- Server CANNOT decrypt E2EE data (no access to volunteer/admin secret keys)
Critical Rules
-
NEVER do crypto in the webview — always route through Tauri IPC (desktop), CryptoService (mobile), or Worker (server-side encryption of inbound data). A single IPC command handles the full encrypt or decrypt operation.
-
NEVER use raw string literals for labels — import from generated constants (LABEL_NOTE_KEY, etc.). Source of truth: packages/protocol/crypto-labels.json.
-
NEVER put plaintext in WebSocket events — WebSocket events are encrypted notifications for real-time transport. Data at rest lives in PostgreSQL.
-
NEVER reuse content keys — each record (note, message, call record, file) gets its own random_bytes_32() content key. This provides forward secrecy.
-
Hub key is for WebSocket event encryption ONLY — do NOT use LABEL_HUB_KEY_WRAP for data at rest. Hub key encrypts ephemeral transport; envelope encryption protects stored data.
-
Forward secrecy means old data is NOT re-encrypted for new admins — a new admin cannot decrypt records created before they were added. This is by design.
-
Always include version: 2 in encrypted record storage for future algorithm migration.
-
Zeroize all key material — call .zeroize() on content keys, symmetric keys, shared secrets, and secret key bytes after use. Use Zeroizing<Vec<u8>> for plaintext outputs.
Common Mistakes
| Mistake | Why It Breaks | Fix |
|---|
| Crypto operations in JS/webview | nsec exposure to webview process | Route through Tauri IPC as a single command |
| Forgetting admin envelopes | Only the author can decrypt | Always wrap for author_pubkey AND each admin_pubkeys |
| Using hub key for data at rest | Hub key is shared with all members, no per-record isolation | Use envelope pattern with per-record random key |
Raw string label "llamenos:note-key" | Typo risk, no compile-time checking | Import LABEL_NOTE_KEY from labels module |
Not running bun run codegen after label change | TS/Swift/Kotlin constants out of sync with JSON Schema | Always run codegen, CI validates with codegen:check |
| Re-encrypting old data for new admins | Breaks forward secrecy guarantee | New admins only decrypt future records |
Missing version: 2 in stored records | No migration path for future algorithm changes | Include version field in every encrypted record |
| Forgetting to zeroize content key | Key material lingers in memory | Call content_key.zeroize() after all envelopes are created |
Using hpke_unwrap_key (stateless) on desktop | Secret key crosses IPC boundary | Use hpke_unwrap_key_from_state (stateful) |
File Locations
| File | Purpose |
|---|
packages/protocol/crypto-labels.json | Source of truth for all 68 domain separation labels |
packages/crypto/src/labels.rs | Rust label constants (must match JSON) |
packages/crypto/src/hpke.rs | HPKE wrap/unwrap implementation (RFC 9180) |
packages/crypto/src/encryption.rs | High-level encrypt/decrypt for notes, messages, call records, drafts, exports |
apps/desktop/src/crypto.rs | Tauri IPC command wrappers + CryptoState |
apps/desktop/src/lib.rs | generate_handler![] registration (line 144) |
src/client/lib/platform.ts | Frontend platform abstraction (Tauri IPC calls) |
tests/mocks/tauri-ipc-mock.ts | Playwright test mock (JS crypto matching Rust output) |
packages/protocol/generated/typescript/ | Generated TS types from JSON Schema |
packages/protocol/generated/swift/ | Generated Swift types |
packages/protocol/generated/kotlin/ | Generated Kotlin types |