CLOUDMORROW

Resources / Operations handbook

Encryption

What is sealed, how, where the key lives, and what that protects against.

Cloudmorrow keeps what you write as ciphertext on the server's disk and only ever moves it over TLS. You never hold a key: you sign in with a password, and the server does the rest. This document says exactly what that means, where the edges are, and what an operator has to do about the key.

The model in one paragraph

Encryption at rest, not end to end. The server holds one key in a file, seals every piece of content with it before writing and opens it again when reading. Someone with the data alone — a copied database, a stolen disk, an old backup — has nothing. Someone with the data and the key file has everything, and so does the running server, because it is the thing that hands your messages back to you. The choice was deliberate: an end-to-end design puts device keys and recovery phrases in users' hands, and the product's point is that anyone can run it on their own hardware without any of that. Volume encryption (LUKS, ZFS) would also have kept keys out of users' hands, but it depends on the operating system, and Cloudmorrow has to install with pip on a Raspberry Pi. So the application does it itself.

What is sealed

Content is sealed. Metadata the server needs to look things up, sort by, or range over stays plain.

Store Sealed Plain
Channels (records) message bodies, channel names and topics — sealed to the channel; a reaction's emoji likewise channel kind and scope, who is in a channel, authors, timestamps, when each person last looked
Calendar event titles, notes, locations calendar slugs and names, colours, members, start and end times, all-day flag
Tasks board titles, task titles and bodies board slugs, lanes, positions, timestamps
Notifications title and body kind, machine, timestamps, read state
Agent jobs payload and result type, status, timestamps
Web push the subscription's p256dh and auth keys the endpoint URL (needed to send, and unique)
Secrets values vaults, key names, environments, lengths, a keyed fingerprint per value
Users — usernames, display names, roles; passwords are argon2 hashes, which is not encryption and needs no key

Not sealed, by choice: the files. Notes and their pictures, My Files and the fileshares are plain files on disk. They are served over WebDAV straight from disk, a mount expects to read and write raw bytes with range requests, and a note is meant to be a file anyone can open with an editor. Sealing them would mean a WebDAV provider of our own that decrypts on read and encrypts on write, and would take all of that away; it is not on the list.

How it is sealed

The cipher. AES-256-GCM, a fresh 96-bit random nonce per value, from the cryptography package, which ships wheels for every platform Cloudmorrow installs on. A sealed database value is stored as text: s1:<nonce>:<ciphertext>, both parts URL-safe base64.

Files are not sealed. Notes, the pictures in them, My Files and the shares are plain files on disk, as files on a server are: a note is Markdown in the Notes folder of its owner's drive, readable with any editor, on WebDAV, and in a backup. What is sealed is the database.

One key, one subkey. The key file holds 32 random bytes. From it, HKDF-SHA256 derives a subkey for database content (cloudmorrow/content/v1). Secrets use the key itself, because they were sealed under it before the rest existed and their format did not change. The content subkey leaking opens no secret.

Every seal is bound to its row. GCM's associated data carries the table, the column, and the row's scope — the channel a message is in, the owner of a task, the calendar an event is on, the user a push key belongs to. Moving a ciphertext to another row by editing the database makes it fail to open. The scopes were chosen to be immutable for a row, or to be rewritten by the same update that could change them (an event moved to another calendar is resealed by the move). Secrets are bound to owner, vault, environment and key name.

Where in the code. src/cloudmorrow/server/sealed.py is the whole mechanism: the Sealer, the versioned table SEALED of which columns are sealed and by what scope, the boot migration, and rotation. db.connect returns a connection with seal and unseal methods, so a store writes conn.seal("tasks", "body", (owner,), body) and reads with the matching unseal. Nothing else in the server touches ciphertext.

What it costs. Sorting on a sealed column is done in Python rather than SQL; boards are the only case. None of this is noticeable at the scale of a home server.

The key

Where it is. key_file in server.toml, or the CLOUDMORROW_KEY_FILE environment variable. Unset, it is <data_dir>/secrets.key, which is where every server had it before this. The installer writes /etc/cloudmorrow/cloudmorrow.key and sets key_file to it, so the key is on a different path from the data in /var/lib/cloudmorrow, and a copy of the data directory on its own opens nothing. The service unit runs with ProtectSystem=strict, so the service cannot write /etc; the installer generates the key as root, owned by the service user, mode 600. A server that boots without a key file at the configured path generates one there, if it can write there.

Back it up with the data, and never instead of it. Lose the key and every message, event, task, dotfile and secret is gone; there is no recovery, by design. Keep a copy somewhere that is not the server. Keep it apart from your backup of the data, or the backup is as good as plain.

The built-in database is three files while the server runs. It is kept in SQLite's write-ahead mode, so beside cloudmorrow.db there are cloudmorrow.db-wal, the writes not yet folded in, and cloudmorrow.db-shm. A copy of the data directory takes all three; a copy of the .db alone, made while the server runs, is the database as it was at the last checkpoint. For a copy at one moment, sqlite3 cloudmorrow.db ".backup copy.db" folds the log in as it copies. All of it is ciphertext without the key either way.

On PostgreSQL, a copy of the data directory is not the whole truth. The rows are on the database server. With the container the installer runs, cloudmorrow-pgdump.timer writes a pg_dump into <data_dir>/postgres/cloudmorrow.dump every day, so a copy of the data directory carries yesterday's database; for a copy at one moment, run the dump yourself. With a server of your own, its backups are yours. A dump is sealed rows like the file is: ciphertext without the key.

A backup into a bucket is the same ciphertext. Administration, Backups puts a copy of everything into an S3 bucket every day (MANUAL.md, Backups): the database as the file at one moment, or the dump, sealed rows and all; the drives and shares as archives, plain, as they are on disk. The sealing key is not in it unless the administrator ticks The sealing key too, so a bucket on its own holds a database that opens with nothing in it — and a bucket with the key holds everything. The manifest carries a fingerprint of the key, and a restore of a database sealed under another key is refused unless that key comes back with it. The secret the store is reached with is itself sealed in the settings table, like any other value.

Every command sees the same key. cloudmorrow-server reads the config and registers the key for the database before it opens it, so user create and the rest seal and open under the service's key. A program that opens a SQLite file through the database layer without going through the config falls back to secrets.key beside the file, which is only right when that is where the key is; a PostgreSQL database has no beside, and refuses until a key is registered. Use the config.

Changing the key.

sudo systemctl stop cloudmorrow
sudo -u cloudmorrow /opt/cloudmorrow/venv/bin/cloudmorrow-server rotate-key
sudo systemctl start cloudmorrow

Every sealed row, and every secret and its fingerprint, is opened under the key and sealed under a new one, in one transaction, which then replaces the file. The old key is kept as <key_file>.old until you delete it, which you should do once the service is up and you have opened a message. The service must be stopped: a row written under the old key while rotation runs is a row nobody can open afterwards.

Older servers

The database is migrated on the first connection after the upgrade. schema_meta holds a sealed version; a database below it has every listed column of every listed table sealed in one transaction under the write lock, then the version written. A second worker booting at the same moment waits on the lock, reads the version, and does nothing. From then on every read expects ciphertext, and there is no sniffing: a message that happens to look like s1:... is still a message. The table is versioned so a column sealed in a later release migrates on its own without touching the rest; an entry that has shipped is never edited.

In transit

TLS ends at the reverse proxy, Caddy or nginx, and the server listens on plain HTTP behind it on the same machine. The proxy is the one plain hop, and it is on the loopback. Three things make sure it stays the only one.

The server insists. require_tls is on whenever public_url is https (set it explicitly to override). A request whose scheme is not https is answered 426 Upgrade Required. The scheme is what uvicorn's proxy-header handling says it is: X-Forwarded-Proto from a trusted proxy (the loopback, and whatever trusted_proxies names when the proxy is another box), else the socket's own. A request that comes straight to the port with no proxy header, from this machine or from the local network — a curl 127.0.0.1:8787 on the box, a browser on the LAN or the tailnet opening http://<ip>:8787 — is let through: the server answers on its own address as well as through the proxy. What is refused is a plain request the proxy relayed, and a plain one from off the local network. Every https answer carries Strict-Transport-Security for a year, so a browser that has seen the app never tries plain http again.

The clients refuse. The TUI, the CLI and the agent will not open a connection to a plain http:// address unless it is this machine (localhost, 127.0.0.1, ::1) or their config says allow_insecure_http = true. That setting is for a box you have decided to reach in the clear, on purpose; cloudmorrow config set allow_insecure_http true sets it, and the agent copies it from the client at enrolment.

Every share is the server's. A fileshare is served by the server, over the same TLS as everything else. Machine shares — a directory served by the agent on one of your machines, over plain HTTP on the LAN with Basic auth — were the one place content and credentials crossed a wire unencrypted, and they are gone.

What this does and does not protect against

Threat Covered
The database file or a backup of it is copied yes: ciphertext without the key
The files directory or a backup of it is copied no: notes, pictures, My Files and shares are plain files
The backup bucket is read the database, yes, unless the sealing key was sent with it; the files, no
The server's disk is lost, stolen or decommissioned yes, if the key was not on it or was on a different volume; on a single-disk box, put the key in /etc and back the data up separately
Someone reads traffic on the network yes, on every hop, when the server is reached over https
Someone with root on the running server no: they have the key, and the server itself must be able to read everything
Another Cloudmorrow user on the same server not by encryption: by the access checks in the API, which are the same as before
The operator, or Cloudmorrow itself no; this is not end to end
Files in My Files and fileshares no, not yet

For anyone adding a feature

If it stores something a person wrote, seal it. Add the table, its scope columns and its content columns to SEALED under a new version in sealed.py, seal on every write with conn.seal and open on every read with conn.unseal, and never sort or LIKE on a sealed column in SQL. tests/test_sealed.py shows the shape of a test that proves the disk holds ciphertext.

From docs/ENCRYPTION.md in the Cloudmorrow repository. Something wrong? A pull request there fixes it here.