New
Apps 1,554Makers 1,552This week 1,553
Promote your app−30%
0views in
the last 30 days

Zuno – Baby Tracker

End-to-end encrypted baby tracker for two parents. Every entry is encrypted on the phone – the...

Built with Claude Code

by oshovah · Unclaimed · from CHSwitzerland · added Oct 29, 2026
no ratings0 comments

A small, self-hosted newborn tracker for two parents — nursing, bottles, diapers, sleep, measurements and daily reminders, shared between both phones and end-to-end encrypted: everything about the baby reaches the server as ciphertext, and it never sees a password.

No e-mail address, no tracking, no third-party requests, no app store. The client is a vanilla-JS PWA (Vite; its only runtime dependencies are the map's — Leaflet, protomaps-leaflet and pmtiles, bundled from npm, nothing from a CDN), the server a tiny PHP 7.4+ API over SQLite that runs on cheap shared hosting. The «Karte» tab shows changing tables and nursing places from OpenStreetMap — and the places parents report in the app, checked by the operator — on a map this server hosts itself (see «Map» below). The UI ships in German (Swiss flavour, the source language) and English.

This repository is public so that anyone can check the privacy claims the app makes. The product decisions are in SPEC.md; code, comments and this documentation are in English, the UI labels quoted here are the German ones the app shows.

At a glance

  • Frontend: vanilla JS + Vite, mobile-first, installable as a home-screen app (manifest + service worker; the shell opens at once even on a bad connection).
  • Backend: PHP 7.4+ (api/), SQLite (WAL). Accounts live in families: every person has their own login (username + password) and a display name (Mama / Papa / …), every family a name and a family password that serves only to join and expires with every join (a member sets a new one to invite the next person). Entries belong to the family — one installation can host several families that never see each other. Sessions are database tokens in a six-month cookie.
  • End-to-end encrypted: every entry (kind, times, amounts, names) is encrypted on the phone with a random family key. The server stores usernames, family names, wrapped keys and the encrypted records — and can read none of it. The one exception is the places parents report on the map: public map data in the clear, with no link to an account (see «Map»). Passwords never leave the phone (the server sees a derived value), so there is no password reset by the operator — see «Keys and recovery».
  • Sync: the server is the source of truth for the encrypted records; every phone keeps a decrypted copy in memory and computes the «Jetzt» screen itself. The app syncs in the foreground (~60 s) and on focus; running timers live in the records and can be stopped from the other phone (a stale stop is refused).
  • Without network: logging still works — the entry is stored encrypted on the phone («wartet auf Netz»), shown at once and sent as soon as Zuno is open and the network is back. If the other phone ended, changed or deleted the same entry meanwhile, that version wins and the phone says so. Sending happens only while the app is open.

Security model — what the server can and cannot see

Everything that describes the baby is encrypted on the phone before it is sent. The server is an opaque per-family store: it hands rows back and forth and computes nothing.

The server stores The server never gets
Usernames and family names in plaintext (needed to log in and to join) Any password — only a value derived from it (PBKDF2 + HKDF), which is bcrypt-hashed again on the server
Per entry ONE opaque blob, a sequence number and day-granular created / updated / deleted dates What an entry is (feed, diaper, sleep, weight …), its times, amounts, notes or who logged it — all of that is inside the blob
The family's data key only wrapped (AES-KW) under keys derived from passwords The family data key itself: it is generated on the phone and only ever leaves it wrapped
Display names and family settings as encrypted blobs
SHA-256 hashes of the session tokens (HttpOnly, SameSite cookie)
Throttle counters keyed by the client's address (failed logins, registrations, entry writes, feedback messages, reported places) — rows older than a day are pruned at the next login or registration
Which parts of the self-hosted map a phone asks for while the «Karte» tab is open (byte ranges of one archive — ordinary access-log material) Where the phone is: the position stays on the phone, and the map never comes from a map provider
Feedback messages to the operator (optional, see «Feedback»): per message ONE blob sealed on the phone to the operator's inbox key, a day-granular date and a read mark — no column for the sender What a message says and who wrote it: a named message carries the name inside the blob, an anonymous one nowhere
Map places reported by parents (optional, see «Feedback»): the position, the kind of place, a name and a note in plaintext — public map data, nothing about a baby —, pending or approved, two day-granular dates — no column for who reported it Who reported a place: the row has no user or family column, and the request is throttled per address like a feedback message
Analog buttons (see «Analog buttons»): per device the family and the SHA-256 of its token; per press ONE blob sealed on the device, the device, the family and a day-granular date — deleted once a phone has made the entry What a press means or when it happened: the key that seals a press is made on the phone and never sent; the device's name lives inside an encrypted entry

What a server still learns, like any server: that a family exists, how many rows it has and roughly when a phone writes (request times, web-server logs), the client's IP address, and which of two size buckets an entry falls into (plaintext is padded to 256 or 512 bytes so the length does not give the entry type away).

How it works — the full picture is the header of src/crypto.js:

password ─PBKDF2-SHA256 (600 000 rounds, 16-byte salt)─▶ master key, discarded at once
  master key ─HKDF─▶ auth key  (sent to the server, bcrypt-hashed there)
  master key ─HKDF─▶ KEK       (AES-KW, never leaves the phone; wraps the family data key)
family data key = 32 random bytes (AES-GCM-256, one per family, encrypts every entry)
  base64url(family data key) = the 43-character recovery code
entry = base64url(0x01 ‖ iv ‖ AES-GCM(key, iv, AAD "bt1|<familyId>|<eid>", padded JSON))

On the phone the family data key lives in IndexedDB as a non-extractable WebCrypto key, next to the ciphertext mirror of the family's rows and the outbox — writes made without network, kept as ciphertext until they are sent. The family password is a one-time invite: the phone that joins replaces it with random credentials in the same transaction, so a guessed or passed-on family password opens nothing afterwards.

Where to check it

Limits, said plainly. End-to-end encryption in a web app protects against a leaked database or backup, curious hosting staff and an operator who would like to read along. It does not protect against a compromised phone, and — as with every web app — whoever controls the server could ship a modified client that leaks the key. The answers to that are the strict CSP, no third-party code at runtime, this public source, and that you can host it yourself. A weak password stays weak: with a stolen database an attacker can guess passwords offline, slowed only by PBKDF2. And there is no password reset — lose your password, the recovery code and every logged-in phone, and the data is gone. That is the price of the server not having a key.

Found a hole? See SECURITY.md.

Run it yourself

Requirements: Node 20+ and PHP ≥ 7.4 with pdo_sqlite on the PATH — the dev server and npm test call the php CLI. Without PHP, Vite still starts and every API request ends in «Keine Verbindung zum Server».

npm install
npm run dev     # PHP API on :8788 + Vite on :5173 (proxies /api)
npm test        # PHP API tests (api/tests/*.test.php) + node tests (src/tests/)

There is no dev password: on the first open choose «Neues Konto erstellen» — username, password (8 characters or more), display name, family and family password. An unknown family name creates the family (and shows the recovery code once); a known name needs the right family password, then the account joins — and that family password expires with the join (a member sets a new one under «Mehr › Konto» when someone else is to join). The database is created automatically at data/baby.db.

On a phone in the LAN, crypto.subtle only works over HTTPS (http://192.168… is no secure context): to test on the device, give the dev server a certificate (for example @vitejs/plugin-basic-ssl) or let the phone reach localhost through USB port forwarding.

To deploy, copy .env.example to .env, fill in the DEPLOY_* values and run npm run package / npm run deploy — any host with PHP and Apache-style .htaccess will do; the details are under «Deployment» below. Contributor guides: designs in src/themes/README.md, languages in src/i18n/locales/README.md.

Hosting somewhere else? Change the login page. The pitch under the login form describes the reference installation: it names its hoster (cyon in Basel, Switzerland) and says that nothing outside that hosting is involved. The second half holds for every copy — the app loads no third-party font, script, image or API, and the packaged CSP (default-src 'self', connect-src 'self') makes the browser enforce it. The first half is only true where it is true: if your copy runs elsewhere, change HOSTER in src/views/login.js, the taglineTrust, hosting.* and about.* strings in src/i18n/locales/*/login.js and the description in index.html before you put it online. The same page links to the source (SOURCE_URL, next to HOSTER): if you run a modified copy for other people, the AGPL asks you to offer them your source — point it there. The map is yours as well: its places and tiles are cut from OpenStreetMap on your machine (npm run map:data, npm run map:basemap; the area is BBOX in scripts/lib/map-config.mjs) and uploaded once with npm run deploy:basemap — see «Map».

The pictures in the login page's slider are the real app with invented data, taken by npm run screenshots (scripts/make-screenshots.mjs: a scratch database, headless Chrome, a week of a newborn written through the app's own code — needs Chrome, PHP and Node 22+). Re-run it after a change to one of the screens it shows.

Keys and recovery

Three things open a family's entries — the server has none of them:

  1. Your own password. At login it unwraps the family key for this device — on a new phone too; a member never needs the family password for that. Forgotten? Register with a new username and join again — with the recovery code, or with a family password the other person sets anew beforehand; every entry stays, the old account stays unused.
  2. The family password. Needed only to join, and a one-time key: with the join, the joining phone replaces it with random values nobody knows (rotateFamily in the registration), so a guessed or passed-on family password opens nothing after the join. Someone else to join? Every logged-in member sets a new one under «Mehr › Konto» (confirmed with their own password) and passes it on; whoever is in stays logged in.
  3. The recovery code. The raw family key (43 characters), shown once when the family is created and under «Mehr › Konto» after entering your own password. With it you create a new account in the family without any password («Mit Wiederherstellungscode» on the login screen). Write it down.

Lose all three with no device logged in, and the data is gone — the price of the encryption. The reserve: «Daten exportieren» under «Mehr › Konto» writes every entry decrypted into a JSON file; store one somewhere safe now and then.

An offline export from a database file (a backup, say) works with the recovery code or a currently set family password (the old one expires with every join — set a new one under «Mehr › Konto» first), without a server (needs the sqlite3 command line on the PATH; --family <name> when the file holds several families; deleted entries are left out):

node scripts/export-plain.mjs data/baby.db > export.json
node scripts/export-plain.mjs data/baby.db --recovery-code > export.json

No admin panel, no e-mail: the operator can delete accounts in the database but set no password and read no entry (see «Managing accounts by hand»). In the app the operator's account only reviews what parents send it: feedback and reported places for the map.

Deployment

Once: copy .env.example to .env and fill in the DEPLOY_* credentials.

npm run map:data  # fetch the map's places (Overpass) – before packaging, see «Map»
npm run package   # build + assemble deploy/
npm run preview   # test deploy/ locally: http://127.0.0.1:8081 (also under /baby/)
npm run deploy    # upload deploy/ (rsync / sftp / ftp as set in .env)

npm run map:basemap -- --build YYYYMMDD   # cut the basemap (once per build)
npm run deploy:basemap                    # … and upload it (once; never with the app)
npm run deploy:beta                       # the same to a second installation (.env.beta)
  • Map: npm run map:data before packaging (without public/map/pois.json the package step aborts); the basemap archive (basemap/, ~570 MB) goes up separately once per build — npm run deploy:basemap uploads the archive first, then index.json, then checks that the server serves byte ranges (206). The first time: archive first, then the app.
  • A second installation (a test server): .env.beta next to .env with its own DEPLOY_PATH and DEPLOY_URL (no DEPLOY_DB_PATH — the database then lives in that docroot); npm run deploy:beta and npm run deploy:basemap:beta use that file.
  • Re-deploys never touch the remote data/ — the database on the server stays.
  • Health check: after the upload deploy.mjs calls <DEPLOY_URL>/api/me. That is the first request after the upload, and it runs any pending database migration (200 = done, 500 = rolled back, the file untouched; the reason is in the PHP error log). Before the v3 → v4 migration the API writes one copy next to the database (baby.db.v3.bak) — delete it after a few quiet days.
  • The packaged .htaccess sets a Content-Security-Policy (own scripts only): with encryption in the browser, injected JavaScript is the last remaining attack path. Plus nosniff, Referrer-Policy: same-origin and HSTS.
  • The database outside the docroot (optional, recommended): by default it lives at <docroot>/data/baby.db, protected by two .htaccess rules. DEPLOY_DB_PATH in .env writes another location into the packaged api/config.php, and nothing hangs on .htaccess any more: absolute, or relative to the docroot with ../ (for example ../baby-data/baby.db — on shared hosting the absolute path is often unknown). Keep the order — at a missing path the API creates an empty database (a new feed id, no accounts: every phone would be logged out):
    1. At a quiet moment (at night, no timer open) create a directory next to the docroot in the hoster's file manager or over FTP (say baby-data, writable for the web server) and move baby.db, baby.db-wal and baby.db-shm there (move, not copy; all three).
    2. Set DEPLOY_DB_PATH=../baby-data/baby.db in .env, npm run package, npm run deploy — the health check /api/me must answer 200; then sync once on a phone.
    3. The empty data/ in the docroot may stay (only the deny stub is in it). Back: move the files back, clear the variable, deploy again.

Schema migrations

The schema is versioned (settings.schema_version) and detected structurally; a migration runs inside the first request after a deploy, in one transaction, and an older release opens a newer file unchanged (it never reads the tables it does not know).

v3 → v4. Schema v3 still had five legacy_* columns in entries: entries from before the encryption lay there in plaintext until a phone of the family had encrypted them. v4 drops those columns together with the code that read them — since then the API has no place that takes or returns an entry's content. Entries, accounts, sessions and the phones' sync cursors stay as they are. A v3 file that still holds a live plaintext entry (or one without a family) is refused and left untouched: the health check reports 500, the reason is in the error log. Then deploy the last v3 release again, let a phone of the family finish encrypting, and deploy anew. To check beforehand on the server (sqlite3 data/baby.db) — the result must be 0:

SELECT COUNT(*) FROM entries WHERE deleted_at IS NULL AND (family_id IS NULL
  OR legacy_type IS NOT NULL OR legacy_started_at IS NOT NULL OR legacy_ended_at IS NOT NULL
  OR legacy_details IS NOT NULL OR legacy_logged_by IS NOT NULL);

v4 → v5 only adds the table feedback (id, blob, created_at as a date, read_at) — no column for the sender, the account or the family. v5 → v6 only adds the table spots (id, status, lat, lon, kinds, name, note, created_at and decided_at as dates) — the places parents report on the map, no column for who reported them; plus, at the first approval, the counter settings.spots_version. v6 → v7 only adds the tables devices (id, family_id, token_hash, created_at as a date) and device_events (id, device_id, family_id, blob, created_at as a date) — the analog buttons and their sealed presses, no column for a name, a key or what was pressed. None of these three touches another table or needs a backup copy, and no phone re-syncs.

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#baby-tracker · #encryption · #parents