Your data is encrypted before it leaves your browser. The server only ever sees ciphertext.
This is the whole reason the MCP server runs on your machine rather than being hosted: a remote server would have to receive plaintext diagrams to do its job, which would undo everything below. Sealing and opening happen locally, exactly as they do in the browser.
Several of the claims on this page are asserted by the eval suite rather than only described — see Evaluation.
Most "secure" SaaS tools encrypt data at rest on the server. That still means the server can read your files — and so can anyone who compromises the server. DrawPro is different:
sessionStorage for the lifetime of the tab, and is wiped on logout.| Purpose | Algorithm |
|---|---|
| Key agreement | X25519 ECDH (ECIES pattern) |
| Content encryption | AES-256-GCM |
| Key derivation (passcode → AES key) | Argon2id — 128 MB / 4 iter / 2 par |
| Key derivation (recovery code → AES key) | PBKDF2-SHA256 — 100 000 iterations |
| KDF for ECDH shared secret → AES key | HKDF-SHA512 |
| AAD for private-key blob | "drawpro-e2ee-private-key" |
| AAD for all content blobs | "drawpro-e2ee-message" |
User enables encryption
│
▼
PasscodeSetup (3 steps)
│
├─ 1. Choose passcode
│
├─ 2. Key generation (browser, crypto.getRandomValues)
│ ├─ X25519 key pair (32-byte private + public)
│ ├─ 32-byte random salt
│ ├─ Argon2id(passcode, salt) → 32-byte wrapping key
│ └─ AES-256-GCM(wrapping key, privateKeyPEM) → encryptedPrivateKey blob
│
├─ 3. Recovery codes
│ └─ 6 × PBKDF2-SHA256(code, salt) → AES-256-GCM(passcode) → stored
│
└─ PUT /auth/keys ──► server stores:
publicKey (base64, 32 bytes)
encryptedPrivateKey (base64, iv|AES-GCM output)
salt (hex, 32 bytes)
recoveryCodesData (JSON array, encrypted)
The server receives a public key and an encrypted private key blob — never the raw private key, never the passcode.
User clicks "Save"
│
▼
Editor collects { name, elements, appState } from Excalidraw
│
▼
user.publicKey present?
│
YES
▼
encryptMessage(JSON payload, publicKey) ← in the browser
│
├─ Generate ephemeral X25519 key pair
├─ X25519 ECDH(ephemeralPrivate, userPublicKey) → shared secret
├─ HKDF-SHA512(shared secret, salt="drawpro-e2ee-salt") → 32-byte AES key
├─ AES-256-GCM(AES key, payload, AAD="drawpro-e2ee-message")
└─ Wire format: ephPub(32) | iv(16) | authTag(16) | ciphertext → base64
│
▼
PUT /workspaces/:wid/sheets/:id { encryptedData } ← only ciphertext crosses the wire
│
▼
API: stores the blob verbatim. Rejects plaintext name/elements/appState
from any account that has keys — it never encrypts anything itself.
│
▼
Prisma: Sheet.encryptedData = base64 blob
Sheet.name = "[encrypted]"
Sheet.elements = null
Sheet.appState = null
Encryption happens in your browser, before the request is sent. The server holds your public key but has no way to read what it stores. Sheet names are sealed the same way at creation time, so a new sheet never reaches the server with a readable title.
User opens a sheet
│
▼
GET /workspaces/:wid/sheets/:id → { encryptedData, isEncrypted: true }
│
▼
cachedPrivateKey in sessionStorage?
│
YES ──┤ NO
│ │
│ ▼
│ GlobalUnlockModal (passcode prompt)
│ │
│ decryptPrivateKey(encryptedPrivateKey, passcode, salt)
│ ├─ Argon2id(passcode, salt) → wrapping key
│ ├─ AES-256-GCM decrypt → privateKeyPEM
│ └─ Extract raw 32-byte Uint8Array
│ │
│ Cache raw privateKey in sessionStorage
│ │
└──────────────────────┤
▼
decryptMessage(encryptedData, privateKey)
├─ Parse: ephPub(32) | iv(16) | authTag(16) | ciphertext
├─ X25519 ECDH(privateKey, ephPub) → shared secret
├─ HKDF-SHA512(shared secret) → AES key
├─ AES-256-GCM decrypt → JSON string
└─ Parse { name, elements, appState }
│
▼
Excalidraw renders the canvas
Decryption happens entirely in the browser. The server sees only an opaque ciphertext blob going out, and never the plaintext coming back.
User enters recovery code in GlobalUnlockModal
│
▼
decryptPasscodeWithRecoveryCode(recoveryCodesData, code, salt)
│
├─ PBKDF2-SHA256(code, salt, 100 000 iter) → 32-byte key
├─ AES-256-GCM decrypt → original passcode
└─ Mark code as used → PUT /auth/keys (updates recoveryCodesData)
│
▼
Proceed as normal load flow (passcode → private key → decrypt content)
Each recovery code is single-use. After use, the server records it as consumed. You start with 6; generate a new set at any time from account settings.
Workspace and sheet names follow the same ECIES path:
createWorkspace({ name })
│
▼
encryptMessage(name, user.publicKey) → encryptedName blob
│
▼
POST /workspaces { encryptedName, name: "[encrypted]" }
│
▼
Dashboard: decryptWorkspaceNames(privateKey) runs once per session
Nothing on the server side reveals workspace or sheet names.
Every encrypted blob produced by DrawPro uses the same layout:
┌──────────────┬──────────┬───────────┬──────────────┐
│ ephPub (32) │ iv (16) │ tag (16) │ ciphertext │
└──────────────┴──────────┴───────────┴──────────────┘
↑ all concatenated, then base64-encoded
| Data | Server sees |
|---|---|
| Drawing elements & app state | Ciphertext only |
| Workspace & sheet names | [encrypted] placeholder |
| Your passcode | Never |
| Your private key | Never (only the encrypted blob) |
| Your public key | Yes — published so others can seal data to you |
| Your email / account metadata | Yes — standard account management |
| Yjs real-time collab updates | N/A — real-time collab is not enabled (future phase) |
Note on real-time collaboration: Not applicable today — no Yjs client is wired up, so no live updates are transmitted. When collab is picked up in a future phase, encrypting the Yjs wire protocol needs to be designed in from the start rather than retrofitted.