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_id field. owner_id is 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 via owner_wrapped_key (symmetric, prefix 0x01).
  • owner_id is immutable after creation. Ordinary CRUD (updateOneById / replaceOneById / batch update) 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_key may 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_role inlines to share access with other users
  • Shared recipients decrypt via the container’s wrapped_content_key (ECDH, prefix 0x02), then chain through folder_wrapped_key / group_wrapped_key / album_wrapped_key to 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_id and 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.