End-to-End Encryption (E2EE)
MorisBox encrypts in-app content so its servers store only ciphertext and metadata. Private keys never leave user or agent devices.
What is encrypted
| Content | E2EE |
|---|---|
| Support conversations (user ↔ partner agents) | Yes |
Hub messages marked encryption: "e2e" | Yes |
| Media inside E2E envelopes | Yes |
| OTP / SMS / WhatsApp fallback bodies | No (those channels must be able to read them) |
| Metadata (partner, timestamps, state, and size) | No |
E2E messages store their content in-app only. If the user is not online in the app (no device activity for about five minutes), MorisBox also sends an opaque WhatsApp alert, followed by SMS:
New message from {partner} in your MorisBox inbox. Open the app to read it (secure message).
This alert never includes the encrypted body. OTP and sign-in codes are not E2EE, so users can always access their accounts.
Protocol (v1)
| Element | Algorithm |
|---|---|
| Device identity | X25519 |
| Key agreement | ECDH (X25519) |
| KDF | HKDF-SHA-256 |
| Content encryption | AES-256-GCM |
| Wire encoding | Base64url |
content_key → AES-GCM encrypt(plaintext JSON)
wrap(content_key) per recipient device via ECDH + AES-GCM
A Signal-style double ratchet is not part of v1; this hardening is planned.
Device key directory
User devices
After sign-in, the client generates an identity key pair and publishes its public half:
POST /api/v1/me/devices/keys
Authorization: Bearer <user_jwt>
Content-Type: application/json
{
"identity_key_pub": "<b64url x25519 public key>"
}
Partner agent devices (Partner Hub)
POST /api/v1/agent/devices/keys
Authorization: Bearer <partner_api_key>
{
"device_uuid": "<browser session uuid>",
"identity_key_pub": "<b64url>"
}
Retrieve keys for a phone number
GET /api/v1/keys/+228XXXXXXXX
Authorization: Bearer <partner_api_key>
{
"phone": "+228XXXXXXXX",
"devices": [
{
"device_uuid": "...",
"identity_key_pub": "...",
"platform": "web",
"has_keys": true
}
],
"agent_devices": [ ... ]
}
Send an encrypted hub message
POST /api/v1/messages
Authorization: Bearer <partner_api_key>
Content-Type: application/json
{
"phone": "+228XXXXXXXX",
"encryption": "e2e",
"e2e_envelope": {
"v": 1,
"alg": "x25519-hkdf-sha256-aes-256-gcm",
"mode": "sealed",
"key_version": 0,
"sender_device_uuid": "partner-agent-…",
"ciphertext": "<b64url>",
"nonce": "<b64url>",
"aad": "seed360|msg|<ref>|v1",
"key_wraps": [
{
"device_uuid": "user-device-…",
"wrap": "<b64url>",
"wrap_nonce": "<b64url>",
"sender_eph_pub": "<b64url>"
}
]
},
"content_type": "text"
}
The server stores only the envelope. body is never persisted in plaintext.
Errors:
| Code | Meaning |
|---|---|
e2e_no_devices | The user has not published identity keys yet |
validation | Missing e2e_envelope.ciphertext / nonce |
Conversation messages (client)
POST /api/v1/me/conversations/<ref>/messages
Authorization: Bearer <user_jwt>
{
"encrypted": true,
"e2e_mode": "conversation",
"e2e_key_version": 3,
"e2e_envelope": { ... }
}
Upload content-key wraps:
POST /api/v1/me/conversations/<ref>/keys
{
"key_version": 3,
"wraps": [
{
"device_uuid": "...",
"device_kind": "user",
"wrap": "...",
"wrap_nonce": "...",
"sender_eph_pub": "..."
}
]
}
Peer directory:
GET /api/v1/me/conversations/<ref>/devices
Client behavior
| Client | Behavior |
|---|---|
| Client Application / Mobile | Generate keys at sign-in; encrypt chat; decrypt envelopes for display |
| Partner Hub | Publish agent keys; encrypt replies / sealed hub sends |
| Server | Route ciphertext; never decrypt |
The UI displays a padlock label on encrypted messages. If the device has no wrap, the user sees “unable to decrypt.”
Security notes
- Private keys remain on the device (SecureStore / local storage; harden toward hardware-backed keys where available).
- AAD binds the envelope to a message-context string to reduce substitution attacks.
- Revoked devices are excluded from new wraps.
- Never log decrypted bodies on servers or gateways.
See also
- Platforms - where E2EE applies
- Messages - content types
- Conversations - support threads