286 lines
16 KiB
Markdown
286 lines
16 KiB
Markdown
# Security — Privacy: Period Tracker
|
|
|
|
```
|
|
Status: Current
|
|
Owner: _null
|
|
Last reviewed: 2026-08-18
|
|
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](../planning/PRODUCT_PLAN.md)): "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. The *biometric*
|
|
key is separate and takes the opposite policy, where invalidation correctly
|
|
degrades to "use your PIN" rather than to lockout.
|
|
- **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.
|
|
- **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`](../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](../planning/PRODUCT_PLAN.md)), 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`](../architecture/README.md).
|
|
|
|
## Logging
|
|
|
|
**Never write a cycle date, a prediction, or a fertility state to a log.**
|
|
|
|
```text
|
|
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](../planning/PRODUCT_PLAN.md)) — 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](../planning/PRODUCT_PLAN.md),
|
|
not built yet) 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.
|