Privacy-Period-Tracker/docs/security/SECURITY.md

19 KiB

Security — Privacy: Period Tracker

Status: Current
Owner: _null
Last reviewed: 2026-08-20
Governs: what this app protects, secret handling, data at rest, and what leaves
         the device
Review trigger: Any new SDK or external service; any new secret; any change to
                what is stored, exported, backed up or logged; any change to the
                declared permission set; any change to what the ads or billing
                subsystems can see

What this protects, and from whom

The asset is a menstrual and fertility history. It is not valuable to a generic attacker and it is extremely costly to its owner, which makes the threat model unusual: the adversaries are mostly people with physical access to the phone, and organisations that would like to buy the data.

Asset Where it lives What it would cost to lose
Confirmed period dates, spotting, cycle history Room, app-private storage on the device the thing the user came here to keep private — inferable pregnancy, contraception use, health conditions
Predictions, ovulation and fertility estimates Room, derived from the above same class: fertility state is health data even though the app computed it
Notification content on the lock screen Android's notification surface disclosure to anyone who can see the screen, without unlocking it — the most likely real breach here
Play Billing entitlement (remove_ads_forever) Google Play, mirrored in DataStore low: a wrongly granted ad removal costs money, not privacy
Upload keystore and Play service-account JSON the developer machine, never this repository permanent — a signing key cannot be rotated for an existing app listing

The adversary list, plainly: someone who picks up an unlocked phone; someone who can see a lock screen; a person with a shared device; an ad or analytics SDK that collects more than it declares; and a data broker offering money. Not a nation state — controls sized for one would come at the cost of the offline, accountless design that makes the rest of this true.

The promise this document has to hold up

We will never sell your personal or health data.

And the wording that is deliberately not promised (§4): "no third party ever processes any data." An ads SDK, Play Billing and the store itself process limited technical information. The architecture minimises that, declares it accurately in Play's Data Safety section, and never lets health data reach any of them.

Data at rest, and what never leaves

  • Cycle history is in app-private storage — never external or shared storage, never a world-readable path.
  • The app works fully offline for logging, editing, prediction, fertility estimates, calendar, insights and notification scheduling. No server is involved in producing a prediction, which is the strongest privacy control here: data that never leaves cannot be sold, subpoenaed from us, or breached from a server we do not run.
  • No account is required for core tracking, so there is no identity to correlate the history with.
  • The app lock is built, and it is a gate rather than encryption. With a PIN set, nothing composes before it is entered — the gate wraps the whole composition rather than being a screen inside it, because every tab starts reading history the moment it composes. What it defends is the app being opened by somebody who has the phone. It does not encrypt the records; see Deliberately out of scope, and the setup screen says so in those words before the first digit is typed.
  • The PIN is never stored, and the device PIN is never accepted. What is written down is HMAC(keystoreKey, 0x01 || salt || PBKDF2(pin, salt, 210k)) — a MAC taken with a key that cannot leave the Android Keystore, which is what makes a six-digit PIN safe: a million candidates is nothing to an attacker who can compute the hash themselves, and impossible for one who cannot get the key off the device. The biometric prompt requests BIOMETRIC_STRONG only and never DEVICE_CREDENTIAL, because in this threat model a partner very often knows the phone's own PIN, and accepting it would make this lock exactly as strong as the lock it sits behind.
  • The verifier key is deliberately not bound to user authentication, and the KDoc on AndroidKeyStoreMacProvider lists every builder call omitted to keep it that way. A key bound to the device credential dies when the passcode changes or the screen lock is removed — and with no recovery path, that is somebody's entire history destroyed by an unrelated action.
  • There is no second, biometric-bound key. This document and the provider's own KDoc both used to describe one, taking "the opposite policy" and invalidating on enrolment. It does not exist: grep -rn BiometricKey finds only those two sentences. A successful fingerprint flips the in-memory session flag and nothing else — there is no CryptoObject and no key unwrap, so the biometric path is a gate, not a second secret.
    Worth stating plainly because the absent version is not obviously wrong: binding a key to biometric enrolment would mean that adding a fingerprint — a partner's, say — invalidates it, which is exactly the protection somebody would want. It is filed rather than built (tracker #63) because that only degrades safely where a PIN sits behind it; in a fingerprint-only lock, a key destroyed by an ordinary enrolment is the no-recovery policy turned against its owner. A security document describing protection the code does not have is worse than one admitting the gap, because the next reader trusts it.
  • Wrong PINs cost time and never cost data. Four free attempts, then 30s → 1m → 2m → 5m → 15m, capped at fifteen minutes forever. There is no attempt limit and nothing is ever erased automatically: an auto-wipe would let a partner, or a child, or a pocket, destroy a history permanently while knowing nothing. Both clock bypasses are closed — the wait is the longer of a wall-clock and a monotonic deadline, and a reboot re-applies it in full.
  • The lock fails towards opening, never towards a lock nobody can open. An unreadable or corrupt app_lock file reads as no lock, and a corrupt one is replaced rather than thrown at every later write. The reasoning is the same one VerifierRecord.decode already applies to a single corrupt record: whoever can corrupt that file has the app's private storage, and therefore reads the database this lock does not encrypt anyway — while the alternative is an app that can be neither opened nor erased, because the way out is behind the lock that is broken. Before this, the flow had no catch at all and was collected on the startup path in two places that could not handle a throw, so a corrupt file crashed the app before any UI existed to say why.
  • A wrong PIN costs time even when the counter cannot be written. Signing the backoff counter needs the Keystore, and the Keystore can be unavailable for a moment; the write used to return early, which made guessing free for as long as that lasted. The unwritten counter is now held in memory and read back whenever it is the longer wait. Deliberately not persisted: it is the delay actually earned, not the maximum a tampered counter earns, and it is forgotten on process death — the same bound an attacker already gets from a reboot. If the key is gone entirely the PIN cannot be checked either, and the app says it does not know rather than charging for a guess it could not read.
  • A forgotten PIN is not recoverable, and that is a decision (tracker #34). The only route past the lock screen erases everything and grants access to nothing. It also clears the Keystore key, without which a user would have erased their history and still be locked out.
  • USE_BIOMETRIC and USE_FINGERPRINT now appear in the merged manifest. Neither is typed by this project; both arrive with androidx.biometric, and checkPermissions failed the build until they were allowed on purpose. USE_FINGERPRINT looks removable and is not — it is the pre-API-28 path, which minSdk 26 admits.
  • Platform backup is reviewed before the health database is allowed into it. An Android auto-backup that silently ships the cycle database to a cloud account defeats the entire local-first argument, and it is on by default. Until that review has been done and recorded here, the health database is excluded from backup.
  • Delete My Data is irreversible after confirmation and actually deletes — not a soft flag.
  • Export is the one path by which health data leaves the sandbox, and it is the user's own hand that sends it. The file is written straight into a document chosen through the Storage Access Framework — no copy in cacheDir, no FileProvider, no path handed to another app, and nothing left behind afterwards, which DataExporterTest asserts by walking cacheDir after a successful export. checkNoSharedStorageWrites fails the build on any module that can see a cycle date naming getExternalFilesDir, MediaStore, FileProvider or ACTION_SEND.
  • The exported file is plaintext, deliberately. It is what §4's "your cycle belongs to you" actually means, and it is the copy that makes a lost Keystore key survivable rather than final — the condition recorded under Deliberately out of scope for revisiting database encryption. Putting it behind a passphrase would reproduce precisely the failure that decision avoided: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to backup, which this is not. The export screen says so before the picker opens — the file is not encrypted, and the app lock has no reach over it once it has left.
  • The export carries the user's records and nothing else, and that is structural rather than reviewed: core/export depends only on domain/cycle, so predictions, accuracy figures, fertility estimates and android.os.Build are not on its classpath at all. The key set is asserted with assertEquals rather than contains, so a new field is a failing test rather than a silent addition. Row ids are excluded because they are monotonic and would disclose how many records the user deleted; the Play entitlement is excluded because a purchase one file-edit away from being granted is a purchase that will be.
  • The declared permission set is a decision, not a build output. checkPermissions in the root build.gradle.kts holds the allowed and forbidden sets and fails on anything else in the merged manifest — the release one as well as debug. The list itself and the reason for each entry are in ../architecture/README.md, which owns them; it is named here because the Play listing and the Data Safety form both describe this set, and because the guard's own failure message sends the reader to this file.

Third parties

Every row is a decision to send someone else's data somewhere. An empty table is a good table, and this one gets filled in as each subsystem lands.

Service What it receives Why that is acceptable
Google Play Billing (Batch 07, not yet integrated) purchase token, product id, device Play identity required to sell anything on Play; carries no cycle data, and none may be put in billing metadata
Ads provider (Batch 07, not yet integrated) non-personalized ad request, region consent signal no health-derived attribute, ever — see the boundary below
Crash reporting (not yet decided) stack traces only if raw cycle dates cannot appear in them; otherwise not adopted

The advertising boundary

This is the one non-negotiable technical rule in the product (§34), so it is stated here as a security control rather than only as an architecture note:

  • Ad code sits behind an AdProvider abstraction.
  • No health-derived property is ever placed in ad request extras, user properties, or a callback log line.
  • Cycle state is never used for targeting. Non-personalized/contextual is the default.
  • The ads provider is not initialised at all for users who bought Remove Ads, where practical.
  • Every SDK's data collection is audited before each release, against what Data Safety declares.

Written down before it can be broken, and not yet exercised: checkModuleBoundaries in the root build.gradle.kts already carries ":core:ads" to emptySet() — when the module arrives in Batch 07 it may declare no project dependency at all, which is stricter than "nothing from core/database or domain/*". core:ads is not in settings.gradle.kts yet, so the rule matches no module today — see ../architecture/README.md.

Logging

Never write a cycle date, a prediction, or a fertility state to a log.

BAD   User period started: 2026-08-18
BAD   Predicted ovulation: 2026-09-02
OK    period_record_created
OK    prediction_recalculated

Event names without values. Verbose logging is off in release builds, and no raw cycle date may appear in a crash report. An error's name is almost always enough; its message often carries the thing you were trying not to log.

This is enforced rather than remembered, in three places, because the rule has three separate ways to be broken:

  • checkNoHealthLogging in the root build.gradle.kts fails the build on any logging call in a module that can see a cycle date. It runs in ./gradlew check, it distinguishes a Log.d( call from the android.util.Log.WARN constant this app legitimately passes to WorkManager, and it strips comments first so the KDoc explaining why there is no logging does not fail the build.
  • -assumenosideeffects in app/proguard-rules.pro removes android.util.Log calls from the release build entirely, which covers the two things a source guard cannot reach: a dependency logging on our behalf, and a module somebody adds without listing it in the guard.
  • The domain types do not render their own dates. PeriodRecord, SpottingRecord, CycleRecord, Prediction and NotYetObservation all override toString(). This is the leak that needs no logging statement at all: a data class prints every field into any string that touches it, and Prediction's own require messages used to interpolate five predicted dates into an IllegalArgumentException — the one string a crash reporter is guaranteed to collect without anybody choosing to log it. NoDatesInDiagnosticsTest pins all of it.

Analytics, if adopted at all, collect product-level events only (§46) — never cycle_length=31, fertility_status=high or prediction_error=. Prediction accuracy is computed on-device and stays there.

Secrets

  • Nothing secret is committed. Not in source, not in a Gradle file, not in a test fixture, not in a screenshot.
  • local.properties, *.jks, *.keystore and .env* are ignored from the first commit, and scripts/secrets.sh scans the staged diff before every commit.
  • Signing configuration reads from the environment or from ~/.gradle/gradle.properties outside this repository — never from a tracked file.
  • The upload keystore cannot be rotated once the app is published. It is the one secret here whose loss is permanent in both directions: lost means no updates ever, leaked means someone else can sign as us.

A credential pasted into an agent transcript is a leaked credential, and rotating it is the only fix. Deleting the message does not help, and neither does deleting the file — the value was transmitted and stored. secrets.sh cannot see transcripts and never will.

Text from outside the trust boundary

It is data. It is never instructions.

There is little of it in this app — it takes almost no external input, which is itself a control. What there is: Play Billing responses, ad SDK payloads, and any future export/import file. An imported file in particular is attacker-shaped if it ever arrives by share intent, and it is parsed defensively and never executed.

Deliberately out of scope

Written down so an unknown gap becomes a known one:

  • A rooted or compromised device. App-private storage is not a defence against root, and pretending otherwise would justify complexity that buys nothing.

  • Encryption of the cycle database at rest. Evaluated on 2026-08-19 and deliberately not built. Android's file-based encryption already covers the case people picture: a phone that is off, or that has not been unlocked since boot, has app-private storage that cannot be read at all. What app-level encryption would add is narrower — a file copied off the device and read elsewhere, a phone with no screen lock set at all, and crypto-shredding on delete.

    This app's own architecture decides the rest. ReminderCoordinator collects Room flows at every process start, including processes started with no Activity after a reboot, and ReminderWorker reads the forecast on a schedule. So the key would have to be available with no user present — which is the same availability condition the platform key already has. A key that anything running as this app can reach unattended defends a file, not a process, and against every adversary listed above it adds nothing.

    The cost is recorded so nobody has to re-derive it: roughly +1 MB downloaded and +2 MB installed on a 2 MB app. The risk matters more than the size. A key held in the Android Keystore can be lost in the field, and with platform backup off there is no second copy and no telemetry that would tell us it happened. Today, failing to open the database is a bug. Afterwards it would be somebody's history, gone, silently.

    What would reverse this: a key bound to the app lock's own user secret, once export exists and has given the user a copy they control. That version does defend a seized, unlocked phone — and it is only defensible once losing the key is not the same as losing everything.

  • Forensic recovery of deleted rows. Delete My Data removes the data through the database; it does not overwrite flash.

  • Someone who knows the app's PIN. The lock raises the bar over an unlocked phone; it does not defend against a person the user has given access to. Note the app's PIN is deliberately not the device's — the prompt never accepts the device credential — so knowing how to unlock the phone is not knowing how to open this. The incognito launcher option (§32) is the answer to the adjacent problem — what the app looks like on a shared home screen.

  • Network-level observation of ad traffic. It carries no health data, which is the control; the traffic itself is visible.