Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ gem "rails", "~> 8.1.3"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
gem "sqlite3", ">= 2.9.6"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
Expand Down Expand Up @@ -56,7 +56,7 @@ group :development, :test do
gem "bundler-audit", require: false

# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
gem "brakeman", "~> 8", require: false

# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ PLATFORMS

DEPENDENCIES
bootsnap
brakeman
brakeman (~> 8)
bundler-audit
capybara
debug
Expand All @@ -409,7 +409,7 @@ DEPENDENCIES
solid_cable
solid_cache
solid_queue
sqlite3 (>= 2.1)
sqlite3 (>= 2.9.6)
stimulus-rails
thruster
turbo-rails
Expand Down
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@ in development and shipped to the app stores by EAS in production.
| | |
|---|---|
| Ruby | 4.0.5 (see `.ruby-version`) |
| Node | **20.19.4 or newer** — React Native 0.86 refuses to build on older versions |
| Node | **20.19.4 or newer** — React Native 0.86 refuses to build on older versions. Two mobile test suites need **22.5 or newer** and skip below it |
| iOS | Xcode, which needs macOS |
| Android | Android Studio with the Android SDK |

Xcode or Android Studio are needed because the mobile app no longer runs in Expo
Go — the journal is encrypted with SQLCipher, which is native code. See
[`mobile/README.md`](mobile/README.md) for the build, and
[`docs/decisions/0016`](docs/decisions/0016-development-builds-required.md) for
why.

If `node -v` reports something older, install a current LTS with
[nvm](https://github.com/nvm-sh/nvm):
Expand All @@ -57,15 +65,24 @@ You need both processes up. Use two terminals:
# Terminal 1 — the API. Bind to 0.0.0.0 so a phone can reach it.
bin/rails server -b 0.0.0.0

# Terminal 2 — the phone app
# Terminal 2 — the phone app. First run only:
cd mobile && npx expo prebuild && npm run ios # or npm run android

# After that, Metro on its own is enough:
cd mobile && npx expo start
```

Then press `i` for the iOS simulator, `a` for the Android emulator, `w` for the
browser, or scan the QR code with Expo Go on a real phone. Open the **Tasks**
The first build takes tens of minutes and downloads several GB of native
toolchain; later ones are quick. Once the development build is installed, press
`i` for the iOS simulator or `a` for the Android emulator. Open the **Tasks**
tab: the list is served by Rails, and adding, ticking and deleting write back to
it.

`w` still opens the browser, but only the landing page — per
[`docs/decisions/0017`](docs/decisions/0017-journal-data-is-native-only.md) the
encrypted journal refuses to open on web rather than falling back to
unencrypted browser storage, so the web target is not a preview of the app.

## How the app finds Rails

`localhost` means a different machine on every target, so hardcoding it breaks
Expand Down Expand Up @@ -185,12 +202,10 @@ rotating-contributor model, read that folder before you read the code.
- **Rate limiting.** Also absent. Rails 8 ships `rate_limit` at the controller
level, which needs a cache store configured in whatever environment serves
this API.
- **Native builds.** `npx expo start` runs inside Expo Go, which only includes
Expo's own native modules. The moment you add a library with custom native
code you need a development build (`npx expo run:ios` / `run:android`) and
[EAS Build](https://docs.expo.dev/build/introduction/) for the app stores.
`mobile/app.json` still needs `ios.bundleIdentifier`, `android.package` and an
EAS project id before any of that works.
- **Store builds.** Development builds work locally, and `app.json` now carries
`ios.bundleIdentifier` and `android.package`. Shipping to the app stores still
needs [EAS Build](https://docs.expo.dev/build/introduction/) and an EAS
project id, which nothing here sets up yet.
- **The starter screens.** Home and Explore are still Expo's template: Expo
branding, Expo logo, links to Expo's docs. Tasks is the only screen that
belongs to this project. Replacing them also retires several dependencies
Expand Down
55 changes: 55 additions & 0 deletions docs/decisions/0015-database-key-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 0015 — Database key storage and the biometric boundary

**Status:** Proposed — implemented in `mobile/src/lib/db/key.ts`, needs technical-lead sign-off
**Resolves:** the key-storage half of the local-database-encryption decision that [0003](0003-device-encryption-exports-backups.md) and [0007](0007-authentication-recovery-deletion.md) both reference as living in `architecture-plan.md`

## Context

The journal database is encrypted with SQLCipher, which needs a key. Where that key lives and what protects it was never written down — `architecture-plan.md` settled "SQLCipher + biometric unlock" as a direction without specifying the mechanism. Building the storage foundation forced the question.

`expo-secure-store` offers `requireAuthentication: true`, which binds a stored value to the device's biometric in the secure element. It is the obvious way to implement "biometric unlock", and reading its documentation is what raised the problem: a value stored that way "will become inaccessible if there are changes to the user's biometric settings, such as adding a new fingerprint."

## Decision

**Store the key without `requireAuthentication`, and apply the biometric gate separately at the app shell.**

- A 256-bit CSPRNG key, hex-encoded, in Keychain/Keystore via `expo-secure-store` with `WHEN_UNLOCKED_THIS_DEVICE_ONLY`.
- The biometric prompt is a separate `expo-local-authentication` call gating the app, not a property of the key entry.
- The key is handed to SQLCipher as a raw key (`PRAGMA key = "x'...'"`) rather than a passphrase. It is already 256 bits of random, so the passphrase form's PBKDF2 pass would add startup latency and no security. This is only correct *because* of where the key comes from — a user-chosen passphrase would need the KDF.

## Rationale

Per [0001](0001-local-only-architecture.md) there is no server copy of anything a user writes. A key that becomes unreadable is therefore not an inconvenience; it is the permanent loss of someone's medical journal, with no support path that can recover it.

`requireAuthentication` puts that outcome one routine action away: adding a fingerprint because your other thumb is bandaged, re-enrolling Face ID after new glasses. [0007](0007-authentication-recovery-deletion.md) accepts "lose the phone, lose the journal" as a risk *and requires telling people about it during onboarding*. Nobody has told anyone "add a fingerprint, lose the journal", and it is not a risk a user would accept if asked.

Separating the two still delivers what 0007 actually asks for — a device-biometric lock with the OS passcode fallback — while leaving the key's survival independent of enrollment state.

## Threat model

Three rows, so that signing this off means agreeing to something specific rather than endorsing a direction.

| Who | What they get | What stops them |
|---|---|---|
| Someone who finds or steals the phone, locked | Nothing. An encrypted file they cannot open. | The device lock. `WHEN_UNLOCKED_THIS_DEVICE_ONLY` also keeps the key out of any backup they could restore elsewhere. |
| Someone reading the keystore off a rooted or jailbroken device | The key, and therefore the journal. | Nothing we control. `requireAuthentication` would have raised the cost here. |
| **Someone holding the phone while it is unlocked** | **The key, and the journal.** | **Nothing in this decision.** |

The third row is the one that matters, and it is not an abstract attacker. [0004](0004-primary-user-caregiver-role.md) has no caregiver account and no role distinction — whoever is holding the phone sees the journal, deliberately. [0006](0006-excluded-field-safety-boundary.md) exists because the team already accepts that some things are too sensitive to sit on a device other people handle. This is that same person, and the honest statement is that the app does not defend against them: the biometric gate is a prompt at app open, not a property of the key, so once the app is past it the key is readable to anyone the phone is passed to.

`requireAuthentication` would have covered part of that — a fresh OS check at the key itself rather than only at the door. Only part, because a caregiver whose own fingerprint is enrolled on the phone passes that check too. That is what is being given up, and it is the piece the sign-off is really about.

## Consequences

- **The trade, stated plainly:** the key is protected by the device lock rather than bound to biometric enrollment in the secure element — the threat model above says who that leaves in. Against that we are weighing a failure mode that is silent, permanent, and triggered by something people do routinely. For a journal whose worst case is disclosure and whose *other* worst case is total loss, this is the better side of the trade — but it is a security decision and deserves the second pair of eyes [0003](0003-device-encryption-exports-backups.md) also asks for.
- `WHEN_UNLOCKED_THIS_DEVICE_ONLY` keeps the key out of iCloud/Google backups, so a restored backup on a new phone cannot decrypt a copied database file. That is the same boundary 0003 draws for exports; issue #115 still has to do the equivalent for the database file itself.
- A unit test asserts the option stays off, so switching it on means deleting a test that says why not to.
- **If this is rejected,** the alternative is `requireAuthentication` plus an onboarding line telling people that changing their biometric settings destroys the journal. That is honest, and a worse product.

## What would change the answer

Availability only has to win here because of something missing, not something permanent. [0001](0001-local-only-architecture.md) means there is no server copy, and nothing shipped yet gives users a copy of their own — so an unreadable key ends the journal, and no confidentiality gain is worth that.

The encrypted device-transfer file [0003](0003-device-encryption-exports-backups.md) scopes, and export (issue #115), change that arithmetic. Once someone holds a passphrase-protected backup, losing the key costs them what they have written since they made it rather than everything, and `requireAuthentication: true` stops being a silent data-loss bomb and becomes an ordinary trade worth taking. Revisit this record when that ships rather than inheriting it. (`WHEN_PASSCODE_SET_THIS_DEVICE_ONLY` does not come back with it: it still cannot store a key at all on the devices [0018](0018-no-device-lock-behaviour.md) is about.)

So this is correct while the device holds the only copy of the journal, and that is the basis to sign it off on — not as a permanent position on the biometric boundary.
35 changes: 35 additions & 0 deletions docs/decisions/0016-development-builds-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 0016 — Development builds required; Expo Go no longer runs this app

**Status:** Proposed — follows from encrypting the database at all, but the workflow cost needs accepting explicitly
**Arises from:** building the storage foundation ([0015](0015-database-key-storage.md))

## Context

SQLCipher is a native fork of SQLite. `expo-sqlite` compiles it in only when `useSQLCipher` is set in its config plugin, which means a native rebuild, and Expo's SDK 57 documentation says so plainly: *"SQLCipher is not supported on Expo Go."*

Until now a contributor could clone the repo, run `npm start`, scan a QR code and be looking at the app in a minute. That stops being true the moment the database is encrypted.

## Decision

**Accept it: the project uses Continuous Native Generation (`npx expo prebuild`) and a development build. Expo Go is no longer a way to run this app.**

`ios/` and `android/` stay generated rather than committed — CNG regenerates them from `app.json`, which keeps configuration in one reviewable place.

## Rationale

The alternative that preserves Expo Go is application-level encryption: plain SQLite, with `expo-crypto` encrypting individual field values before they are written. It was considered and rejected.

- Every read and write would go through app code, and anything the app forgets to encrypt is stored in clear.
- Encrypted values cannot be indexed, sorted or compared by SQLite, so any list the user sorts or filters — medications by name, contacts alphabetically — has to be pulled into memory and decrypted wholesale first. Note this does **not** apply to drug search: per [0013](0013-medicine-diary-on-device-drug-search.md) that runs against `catalog.db`, which is public and deliberately unencrypted, so encryption never constrains it.
- It is a hand-rolled scheme in place of a reviewed one, protecting medical data, maintained by rotating volunteers. This is the argument that actually decides it.

SQLCipher encrypts the whole file, including indexes and the write-ahead log, and is the mechanism `architecture-plan.md` already assumed.

## Consequences

- **`useSQLCipher` is a build-wide flag, not a per-database one.** [0013](0013-medicine-diary-on-device-drug-search.md) puts two databases on the device: `journal.db` encrypted, and `catalog.db` — the public drug catalog — deliberately not, because it is public data and treating it otherwise buys nothing. Turning SQLCipher on links the whole app against it, so `catalog.db` gets opened by a SQLCipher build with no `PRAGMA key` set. Reading a plaintext file that way is ordinary SQLCipher behaviour, but Expo's documentation does not cover it and nothing here has tested it. **Verify on device before the catalog work depends on it** — issue #101 is the natural place, and it is a cheap check that would be expensive to discover late.
- **`enableFTS` must stay on.** It defaults to `true`, and 0013's search is SQLite FTS5. Expo's own config example sets `enableFTS` and `useSQLCipher` together, so there is no conflict — but an edit that switched FTS off would break the Medicine Diary while looking like a storage-layer change, which is not where anyone would think to look. Worth setting explicitly in `app.json` rather than relying on the default.
- **Contributor onboarding changes and the docs have to change with it.** `mobile/README.md` needs the prebuild/dev-client path, and the first-run cost (a native build) needs saying up front rather than discovered.
- This lands on the same people [0008](0008-first-vertical-slice.md) designed a gentle first ticket for, so the setup instructions matter more than usual.
- CI currently runs lint, typecheck and jest, none of which need a native build, so it is unaffected for now. A build job would need macOS runners for iOS.
- Anything that only ever runs in Expo Go — quick demos to non-technical stakeholders — needs a different answer, most likely a shared development build or the web landing page ([0017](0017-journal-data-is-native-only.md)).
30 changes: 30 additions & 0 deletions docs/decisions/0017-journal-data-is-native-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 0017 — Journal data is native-only; web is not a journal surface

**Status:** Proposed — needs sign-off
**Arises from:** building the storage foundation ([0015](0015-database-key-storage.md))

## Context

The app builds for web: `react-native-web`, a static export target, `.web.tsx` variants of several components, and a landing page that already exists. SQLCipher does not build for web — Expo's documentation lists it for Android, iOS and macOS only.

So the encrypted database cannot exist in the browser, and something has to give.

## Decision

**The journal database refuses to open on web, and web stays the landing and marketing surface.** No unencrypted fallback.

The unlock gate passes web traffic straight through, since gating a marketing page protects nothing.

## Rationale

The tempting move is to fall back to plain `expo-sqlite`, which does have a web build. That would mean the web build quietly writing an unencrypted medical journal into browser storage — on whatever computer someone happened to open it on, quite possibly a shared or library machine.

The whole basis of [0001](0001-local-only-architecture.md) is that this data stays under the user's control and the org never holds it. A silent downgrade from "encrypted" to "not encrypted", on a platform nobody was told behaves differently, makes that promise untrue in exactly the way nobody would notice until it mattered. Failing loudly is better than a fallback that is wrong.

## Consequences

- The web build serves the landing page and nothing that touches journal data. Any screen that reads the journal will throw there rather than render, which is intentional — but it does mean `npm run web` is not a preview of the app, and contributors need to know that.
- **This is about the journal, not about databases in general.** [0013](0013-medicine-diary-on-device-drug-search.md) explicitly contemplates a web client downloading the public drug catalog and searching it client-side, and nothing here rules that out: `catalog.db` is public, unencrypted, and has no reason to be platform-gated. The line is drawn around patient data, not around `expo-sqlite`.
- This makes the web target genuinely useful for one thing: demoing the product to stakeholders without a development build ([0016](0016-development-builds-required.md)).
- If browser access to the journal is ever wanted, it needs its own decision and its own mechanism. It is not a matter of removing a platform check.
- The "grab and go" emergency access in [0005](0005-grab-and-go-emergency-access.md) is a phone-in-hand scenario, so nothing in it depends on web.
27 changes: 27 additions & 0 deletions docs/decisions/0018-no-device-lock-behaviour.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 0018 — Unlock behaviour on a device with no lock screen

**Status:** **Open** — product call, needs a named owner
**Arises from:** building the unlock gate ([0015](0015-database-key-storage.md))

## Context

[0007](0007-authentication-recovery-deletion.md) settled what happens when the biometric *fails*: fall back to the device passcode, which is a mechanism people already know. It was written about lockout, and it assumes there is a device lock to fall back to.

Some phones have neither a biometric nor a passcode set. There is then nothing for the app to prompt with, and 0007 has no answer for it. This is not a rare edge case in this audience — a passcode is one more thing to remember, and people who find phones difficult are exactly who this app is for.

## Decision

**None yet.** This record exists so the gap is visible rather than settled by whoever writes the code.

The current implementation explains the situation and lets the user continue. The journal is still encrypted at rest either way; the device lock is a second layer, not the only one. That is a placeholder, chosen because silently doing nothing and hard-refusing both seemed worse than saying something true, and it should not be mistaken for a decision.

## Options

- **Refuse to open until a device lock is set.** Strongest, and locks someone out of their own medical information over a phone setting they may not know how to change. Hard to square with "keep technology in a supporting role".
- **Continue, with a plain explanation.** What is built today. Honest, and leaves the app's own protection at whatever the encrypted-at-rest key gives ([0015](0015-database-key-storage.md)).
- **Prompt once, remember the answer.** Nudges without trapping. More UI, and needs a place to store the answer.
- **Continue, and offer a shortcut into the OS settings screen.** Same as above with a lower barrier to actually fixing it.

## What is needed

A named owner to pick one, in the same way [0009](0009-hosting-support-incident-ownership.md) needs one. Whoever decides should also settle whether onboarding says anything about it, since [0007](0007-authentication-recovery-deletion.md) already commits to one honest line about backup being the user's responsibility and this is adjacent to it.
Loading
Loading