Report vulnerabilities privately via GitHub Security Advisories — the "Report a vulnerability" button on the Security tab — not through public issues. You'll get a response within 7 days. Please include reproduction steps and the version you tested.
Only the latest released version receives security fixes; there are no backport branches. Both
halves must be on that version anyway — wc-sync/4 refuses to talk to an older peer — so the
supported configuration is always "newest desktop app paired with newest phone app".
These are design trade-offs, documented so nobody has to rediscover them. They are not vulnerabilities in themselves, but you should understand them before trusting the app with sensitive data.
- Sync traffic is encrypted, mutually authenticated, and forward-secret. The phone and desktop
speak
wc-sync/4: a fresh P-256 ECDH key exchange every connection, HKDF-SHA256, and AES-256-GCM records, authenticated by a 128-bit pairing secret that becomes a persisted per-device key. Stealing a device key later does not decrypt sessions recorded earlier. There is no plaintext fallback in either direction — a peer that speaks the old protocol gets a tombstone reply and the connection is closed before any row moves. - Residual — first-pairing shoulder-surf. Anyone who can read the pairing code off your screen while you pair a device can pair a device of their own. This is a physical-presence trust ceremony, like Bluetooth pairing: one-time per device, and revocable afterwards. Pair out of sight of others.
- Residual — a stolen device key allows a future MITM until you re-pair. This is inherent to
symmetric-key authentication: whoever holds the key can impersonate that device until the key is
invalidated. Mitigated by per-device revocation (desktop sidebar, Paired devices → Remove).
For a broader compromise, Forget all devices (desktop sidebar, below the paired-devices list,
two-click confirmation) revokes every phone at once via
regeneratePairingToken— every device must re-pair afterwards. - Residual — LAN availability is not defended. An attacker already on your LAN (mDNS spoofing, ARP tricks) can deny sync — block it, delay it, make it fail. They cannot read or forge your data, and they cannot force your phone to drop its pairing: forgetting a device only ever happens through explicit user action, never through an unauthenticated network frame.
- Residual — clock skew affects conflict resolution, not the handshake. Last-write-wins ordering and the sync cursor compare timestamps across devices; a badly-wrong device clock can still mis-order or skip entries. This predates transport encryption and is unrelated to it — the handshake itself carries no timestamps and is immune. Re-pairing forces one full reconciling sync, which partially heals a skew-related gap.
- Residual — the phone's device name is visible before authentication completes. The phone sends its model name in the first handshake message to any LAN peer that answers with a plausible greeting, including one that never completes the cryptographic handshake. Only the device name is exposed this way; no entry data or credentials are, and nothing is served until the handshake finishes.
- Pairing is one 33-character code, not a shared password: a public lookup id plus a 128-bit
secret, typed once per device (e.g.
CPR38-KAHBY-4EBBB-QPG9N-B4WFR-HXTY9-NNT). The desktop sidebar lists every paired device with a last-seen time and its own Remove, so revoking one phone doesn't require forgetting the rest.
- Both databases are encrypted at rest.
%APPDATA%\wellness-companion\wellness.dbon the PC (better-sqlite3-multiple-ciphers, SQLCipher-compatible) and the Room database on the phone (SQLCipher) are ciphertext on disk. Each platform generates its own random key on first run and wraps it with a platform key store — ElectronsafeStorage(Windows DPAPI) on the desktop, a non-exportable AndroidKeyStore AES-GCM key on the phone — never a derived or hardcoded key. An existing plaintext database is migrated in place on first launch and verified under the new key before the plaintext copy is touched. - What this defends: a cold copy. A lost or stolen device imaged offline, a lost backup, an
adb pullrun without a live unlocked app — in those cases, what's on disk is ciphertext. - The pre-encryption backup is the exception, and it is permanent until you delete it. The
one-time migration keeps
wellness.db.plaintext.bak— your entire pre-migration history, in plaintext — right beside the now-encrypted database, on both platforms:%APPDATA%\wellness-companion\wellness.db.plaintext.bakon the PC, and the app's private database directory on the phone (alongsidewellness.db, reachable viarun-ason a debug-signed build — the privatedatabases/directory is notadb pull-able without root). Nothing prunes it automatically on either platform — it exists so a failed migration can never lose data, not as a temporary artifact — so it sits there in plaintext indefinitely unless you remove it yourself. Once you've confirmed your data made it across the upgrade intact, delete that file — but understand first that it doubles as the automatic recovery source if the encryption key is ever lost: on Android it is the only rollback path (with no.plaintext.bakpresent, a lost key leaves the app unable to start at all), and on desktop it is one of the two files the key-loss dialog names whenwellness.dbitself is missing. - What this does NOT defend: malware or a shell running as you. At-rest encryption does not
protect against code running as your own Windows account (DPAPI unwraps for that account by
design) or as the app's own UID on Android — including
run-asaccess on a debug-signed build, which is what ships today (see "Signing" below). Only release-signing the Android build (debuggable=false) closes therun-asgap; encryption at rest cannot compensate for a debuggable build. - Desktop backups are tied to your Windows account. The wrapped key only unwraps for the
Windows user account that created it, so
wellness.dbbackups are not portable across accounts or machines without separate export tooling. - Key-loss recovery is manual, and the two platforms behave differently — neither has an in-app
recovery screen yet, so getting the data back is always a manual file operation, not a button.
If the wrapped key ever becomes unusable (a corrupted DPAPI profile, a
wellness.keycopied to another machine, an AndroidKeyStore key invalidated by the OS):- Desktop fails closed with an on-screen dialog rather than silently starting from an empty
database. What it names depends on which failure occurred: when
wellness.dbitself is missing (an interrupted migration swap with no recoverable copy in place), the dialog names both survivor files —wellness.db.premigration.tmpandwellness.db.plaintext.bak. A key-only failure —wellness.dbis still present, but the key cannot be unwrapped — names the userData folder,wellness.key, and the backups folder instead; those two survivor files are not part of that dialog. - Android does not show an in-app message at all. If no pre-migration backup survives, the
key failure is raised inside a Hilt provider (
error(...)inAppModule.kt), which crashes the process — you see the OS's generic "Wellness Companion keeps stopping" dialog, and the file names that would explain what happened are only inadb logcat, not on screen. If awellness.db.plaintext.bakfrom the original migration does still survive, Android does not fail closed at all: it silently rolls back to that pre-migration snapshot, mints a fresh key, and re-encrypts under it — so the app keeps running. Entries written after the original migration and before the key loss are not deleted: the unreadablewellness.dbis preserved under awellness.db.keylost*.bakname, and the previous wrapped key blob is retained rather than reused (DbKeyManager'sPREF_KEY_BLOB_PREVIOUS), so recovery is possible in principle — but there is no tooling or in-app flow to perform it yet, and no warning is shown before the silent rollback. An in-app error screen, a warning before the rollback, and a recovery flow for the keylost file are known follow-ups.
- Desktop fails closed with an on-screen dialog rather than silently starting from an empty
database. What it names depends on which failure occurred: when
- The desktop sync server listens on all interfaces on port 9847 while the app is running, by design — that is how the phone reaches it. It serves nothing until a peer completes the authenticated handshake above.
- The shipped phone build is debug-signed unless the maintainer supplies a release keystore. A
debug-signed APK is
debuggable, which is what makes therun-asgap above possible for anyone with USB access to an unlocked device. Release signing is wired up inandroid/app/build.gradle.kts; see the comment at the top of that file, or the full walkthrough in docs/signing-guide.md.
- No account, no cloud service, no server component.
- No analytics, telemetry, crash reporting or update pings.
- No third-party network requests at runtime. The single outbound request in the entire project is the one-time AI model download during installation, which is verified against a pinned SHA-256.
If you find something that contradicts any of the above, that is a vulnerability — please report it.