161 lines
7.9 KiB
Markdown
161 lines
7.9 KiB
Markdown
# Security — Period
|
|
|
|
```
|
|
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 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, notification scheduling and app lock. **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.
|
|
- Biometric/PIN gating protects app launch. Where a secret is needed to back
|
|
that, it is Android Keystore-backed — never a value in DataStore.
|
|
- **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.
|
|
|
|
## 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 | 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.
|
|
|
|
Enforced structurally, not remembered: the `ads` module declares no dependency
|
|
on core/database or `domain/*`, and a Gradle guard proves it — 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.
|
|
|
|
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.
|
|
- **Forensic recovery of deleted rows.** Delete My Data removes the data through
|
|
the database; it does not overwrite flash.
|
|
- **Someone who knows the unlock PIN.** Biometric/PIN gating raises the bar over
|
|
an unlocked phone; it does not defend against a person the user has given
|
|
access to. The incognito launcher option exists for 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.
|