A tiny, zero-knowledge sync server for the Encryptilock password manager. Run it on a NAS / home server (or any box with Docker) to sync your vault across devices. It stores only opaque ciphertext blobs plus an auth verifier — it can never decrypt a vault, and neither can anyone who steals its disk.
- Stack: Dart +
shelf+ SQLite — compiles to a single self-contained binary. - Stores: the encrypted blob, the public account salt, a verifier (a hash), a key epoch, and a monotonic version. Nothing else.
- Concurrency: optimistic, via
If-Match/412.
This is the primary "host it yourself" transport for Encryptilock sync. The app also supports WebDAV/Nextcloud/S3 and serverless LAN-QR, but this server is the recommended option.
# on the server box, in an empty dir holding this project's files:
mkdir -p tls data
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout tls/key.pem -out tls/cert.pem \
-subj "/CN=encryptilock-sync" \
-addext "subjectAltName=IP:192.168.1.50,DNS:encryptilock.lan" # ← your server IP
docker compose up -d --build # builds the image, runs the container
docker compose ps # -> Up (healthy)
openssl x509 -in tls/cert.pem -noout -fingerprint -sha256 # ← pin this in the appThen point each device at https://<server-ip>:8443, Trust certificate (confirm
that fingerprint), and register/enroll. Full walkthrough below.
Needs the Dart SDK.
dart pub get
dart test # unit + integration tests
PORT=8443 DB_PATH=./sync.db dart run bin/server.dart # plain HTTP, no TLS
curl http://localhost:8443/healthz # -> okAdd TLS_CERT_PATH / TLS_KEY_PATH to serve HTTPS directly (see below).
The repo ships a Dockerfile (multi-stage → small debian-slim runtime) and a
docker-compose.yml. The compose defaults to direct self-signed TLS, which is
ideal for a home/LAN server with no domain.
- Docker Engine + Compose plugin on the server box:
curl -fsSL https://get.docker.com | sudo sh sudo usermod -aG docker "$USER" && newgrp docker
- A static/reserved LAN IP for the box (set a DHCP reservation in your router).
Copy this server/ directory to the box (it's self-contained — only it is needed
to build). A common layout is one dir per container, e.g. ~/docker/encryptilock_server/:
# from your dev machine (Git-Bash/WSL), copy the contents (incl. the hidden
# .dockerignore, which keeps data/, tls/ and *.db out of the image build):
tar czf - -C server --exclude=.dart_tool --exclude=data --exclude='*.db*' . \
| ssh you@192.168.1.50 'mkdir -p ~/docker/encryptilock_server && tar xzf - -C ~/docker/encryptilock_server'cd ~/docker/encryptilock_server
mkdir -p tls data
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout tls/key.pem -out tls/cert.pem \
-subj "/CN=encryptilock-sync" \
-addext "subjectAltName=IP:192.168.1.50,DNS:encryptilock.lan"
openssl x509 -in tls/cert.pem -noout -fingerprint -sha256 # save for pinningOn Windows
openssl, use-subj "//CN=…"(double slash). ThesubjectAltNamemust include your server's IP or the app's hostname check fails.
docker compose up -d --build
docker compose ps # STATUS -> Up (healthy) after ~10–30s
docker compose logs -f # expect: server.start … "tls":true … "port":8443The docker-compose.yml bind-mounts ./data (the SQLite store — visible + easy to
back up) and ./tls (your cert, read-only), and has a healthcheck.
Port already in use? Map a free host port — change ports: ["8443:8443"] to e.g.
["8444:8443"] (keep PORT: "8443" inside), or set PORT and the mapping to the
same new value and update the healthcheck port to match. The cert is keyed to the
IP, not the port, so it stays valid.
sudo ufw allow from 192.168.1.0/24 to any port 8443 proto tcp # LAN-onlycurl -k https://localhost:8443/healthz # on the box -> ok
curl -k https://192.168.1.50:8443/healthz # from a LAN device -> okOn each device → Settings → Cross-Device Sync → Transport = Encryptilock
server (REST) → Server URL https://192.168.1.50:8443 → Trust certificate
(confirm the fingerprint from 2.3) → Register this device (first device) or
login screen → Join existing account (others).
# re-copy the source over the dir (your tls/ and data/ are preserved), then:
docker compose up -d --build --force-recreate
--force-recreateensures the container swaps to the freshly built image even though the:latesttag is unchanged (otherwise Compose may keep the old image).
The whole server state is one SQLite file in ./data:
cp ./data/encryptilock_sync.db* ~/backups/ # cron it; contains only ciphertextLosing it only loses the server copy — every device still holds its own vault.
| Symptom | Cause / fix |
|---|---|
address already in use on up |
Another service holds the port → map a different host port (2.4). |
Container Restarting (255) |
docker compose logs — a startup crash. Usually a missing/bad tls/cert.pem, or the /data mount isn't writable. |
| Edited the Dockerfile but nothing changed | docker compose build --no-cache && docker compose up -d --force-recreate. |
| App says "certificate changed" | You regenerated the cert → re-pin the new fingerprint in the app. |
| App can't reach it | Firewall (2.5); confirm curl -k https://IP:PORT/healthz from the device's network. |
If you already run Caddy / Traefik / Nginx Proxy Manager, serve plain HTTP
internally and let the proxy terminate TLS with a real CA cert — then the app needs
no fingerprint pinning, just the https://sync.example.com URL.
In docker-compose.yml: remove the TLS_* env + the ./tls mount, put the
container on your proxy network. Caddy example:
sync.example.com {
reverse_proxy encryptilock-sync:8443
}
The proxy forwards the client IP via X-Forwarded-For, which the rate limiter honors.
| Var | Default | Meaning |
|---|---|---|
PORT |
8443 |
listen port |
DB_PATH |
/data/encryptilock_sync.db (image) |
SQLite file path |
TLS_CERT_PATH / TLS_KEY_PATH |
– | serve HTTPS directly; omit both to terminate TLS at a reverse proxy |
RATE_LIMIT_THRESHOLD |
5 |
failed auths before lockout |
RATE_LIMIT_BASE_SECONDS |
1 |
base backoff |
RATE_LIMIT_MAX_SECONDS |
900 |
max backoff |
| Method & path | Auth | Notes |
|---|---|---|
GET /healthz |
– | liveness |
GET /v1/account/{lookup} |
– (pre-auth) | { accountSalt, keyEpoch } or 404 |
POST /v1/account/{lookup} |
– | register { accountSalt, authSecret, keyEpoch } → { accountSalt, verifierServerSalt, keyEpoch }; 409 if exists |
GET /v1/vault/{lookup} |
Bearer | { blob, version, keyEpoch }; empty vault → version:"", blob:null |
PUT /v1/vault/{lookup} |
Bearer + If-Match |
body { blob, keyEpoch } → { version }; 412 on version mismatch; If-Match: "" = create-only |
POST /v1/account/{lookup}/rotate |
Bearer + If-Match |
master-password change: { accountSalt, authSecret, keyEpoch, blob }, atomic swap; 412 on version mismatch |
GET /v1/account/{lookup}/devices |
Bearer | { devices: [{ deviceId, label, lastSeen }] } (best-effort registry) |
DELETE /v1/account/{lookup}/devices/{deviceId} |
Bearer | remove a device from the registry |
Auth: send the base64url authSecret as Authorization: Bearer <authSecret>
(or X-Auth-Secret: <authSecret>). The server recomputes
sha256(authSecret ‖ verifierServerSalt) and constant-time compares it to the
stored verifier. Failed auth is rate-limited per account + per IP with exponential
backoff (429 + Retry-After).
If-Match carries the version as an ETag, e.g. If-Match: "3". If-Match: ""
means "create only". rotate is also conditional — on a concurrent push the client
pulls + merges, then retries.
Device registry (best-effort): a client may send X-Device-Id (+ optional
X-Device-Label) on register/auth; the server records it and refreshes last_seen.
Removing a device is best-effort — its authSecret keeps working until the next
master-password change (which re-keys the account). True cutoff = rotate.
- The password, vault key, and plaintext never reach the server.
authSecretis HKDF-derived from the vault key and cannot decrypt anything; the server stores onlysha256(authSecret ‖ verifierServerSalt).- Blobs are never decrypted, parsed, inspected, or logged. Logs contain only sizes, versions, timestamps, account lookups, and outcomes — never blob bytes or secrets.
dart pub get
dart test # unit + integration (in-memory + SQLite)
dart run bin/server.dart # PORT/DB_PATH/TLS_* from the environment
# End-to-end smoke test against a running instance:
BASE_URL=https://localhost:8443 INSECURE=1 ./scripts/smoke.sh