Security
End-to-end encryption model — per-record content keys, key wrapping schemes, and access control.
End-to-end encryption
All user content is encrypted client-side before sending to the server. The server stores encrypted fields as opaque enc_* blobs and never sees plaintext.
Per-record content key
Each record has its own random AES-256-GCM key (content_key). This key encrypts all enc_* fields on the record and its nested data (subtasks, attachments).
Key wrapping
The content_key is wrapped and stored in two ways depending on context:
Direct ownership — every record carries owner_id and owner_wrapped_key:
| Prefix | Scheme | Used for |
|---|---|---|
0x01 |
AES-GCM(account_sym_key, content_key) |
owner_wrapped_key on the record — symmetric wrap with owner’s account key |
0x02 |
ECDH(recipient_pub_key, content_key) |
wrapped_content_key in *_user_role — asymmetric wrap for shared recipients |
Container sharing — folders, groups, albums, and drive folders use *_user_role inlines (e.g. folder_user_role, group_user_role) to grant access to other users. Individual records (notes, tasks, contacts, photos) do not have their own *_user_role — ownership is tracked directly via owner_id + owner_wrapped_key. When a record belongs to a shared container, it also carries a folder_wrapped_key (or equivalent) that chains the record’s content key to the container’s key.
Wire format
Encrypted field names on the wire use the enc_ prefix: enc_title, enc_notes, enc_name, etc.
Each value is a base64-encoded blob:
nonce (12 bytes) || ciphertext || auth_tag (16 bytes)
The client sends encrypted values in the same format via create and updateOneById. The server stores and returns them as-is.
Access control
Records are scoped by ownership and container sharing:
- Every record has an
owner_idfield.owner_idis read-only and auto-set on create — the server fills it from the authenticated user’s id; the client never sends it. The owner always has access viaowner_wrapped_key(symmetric, prefix0x01). owner_idis immutable after creation. Ordinary CRUD (updateOneById/replaceOneById/ batchupdate) cannot change it — attempting to do so is rejected. A record cannot be seized by a shared editor or given away by its owner through the data API.owner_wrapped_keymay only be written by the record’s owner. A user editing a record shared with them (a container editor) cannot overwrite the owner’s wrapped key.- Container models (folders, groups, albums) use
*_user_roleinlines to share access with other users - Shared recipients decrypt via the container’s
wrapped_content_key(ECDH, prefix0x02), then chain throughfolder_wrapped_key/group_wrapped_key/album_wrapped_keyto reach the record’s content key - Users cannot enumerate records they don’t own or that aren’t in a container shared with them
Values above apply to app tokens (scope-limited callers). Administrative tooling authenticated with the unrestricted base scope may set any
owner_idand rewrite key material.
Ownership on write — client contract
| Operation | owner_id requirement |
|---|---|
create |
Required. Must equal the authenticated user’s id. Omitting it, or sending another user’s id, is a validation error on owner_id. |
updateOneById / update (batch) |
Omit it. If sent, it must equal the record’s current owner; any change is rejected. |
replaceOneById |
The full document is required, so echo the record’s current owner_id unchanged. |
owner_wrapped_key follows the same shape: send it only when you are the owner (on create, or when rotating your own key). Editors of a shared record must leave it out.