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 — the server sets it to the authenticated user on create
  • The owner always has access via owner_wrapped_key (symmetric, prefix 0x01)
  • 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