M5-08: App lock¶
Status: ready | Depends on: M5-03 | Milestone: M5
Goal¶
After this ticket the phone apps lock (08 9, 01 5.6 E6): when the person has turned the app lock on, or the owner requires it instance-wide with security.app_lock_required, the app shows E6 at every start and after client.app_lock_after without use, hides all content (also in the app switcher and the recent apps screen), and unlocks only through the system's biometrics or device credential (iOS LocalAuthentication, Android BiometricPrompt with the device credential); after a failure the lock stays with "Try again", "Log out" leads to E7 and deletes the local data. The server delivers the two values the app needs, because both have place Server in the configuration reference: getInstance gains the member app_lock with required and after_ms (spec first in openapi.yaml, served by the instance module from the process configuration). When the lock is required, the setting cannot be turned off, and a device without a device credential cannot use the app until one is set. The lock publishes a locked event that the terminal view of M5-12 uses to detach and that the quick reattach rule follows ("only after the app lock", PLAN 11 red team). The local database stays encrypted with the key from secure storage, which is readable only after the device was unlocked (15 9: WhenUnlockedThisDeviceOnly on iOS); a test proves that a copy of the database file is unreadable without that key. Why: a lost or borrowed unlocked phone must not show chats, administration or terminals to the next person (T-DV-01); the server cannot check the lock, so the app must enforce it (08 9).
Read first¶
docs/spec/08-identity.md: 9 "App lock" (all of it), 10 (lost device; the lock is the residual protection), 18 (client.app_lock_after,security.app_lock_required).docs/spec/01-product.md: 5.6 E6 (screen, "Try again", "Log out" to E7, the lock detaches terminal sessions), E7, 5.3 M8 (reattach after the window with Face ID or fingerprint), 11 (personal settings; the app lock switch is not a synced key, Design note 4).docs/spec/15-client-architecture.md: 6.3 (database key in secure storage, protection classcomplete), 9 (rows iOS and Android: DB keyWhenUnlockedThisDeviceOnly), 23.2 item 7 (detach in the background), 21 (R-CL-08).docs/operations/configuration.md: rowsclient.app_lock_afterandsecurity.app_lock_required(placeServer, statusplanned (M5)).docs/api/openapi.yaml:getInstance, schemasInstanceInfo,InstanceLimits.docs/security/threat-model.md: T-DV-01 and the M5 red team items indocs/PLAN.mdsection 11 ("bypassing the app lock").docs/DECISIONS.md: D-W-100 and its extension to M5 and M6 (D-W-120), D-W-115; section 10 (format ofD-M5-08-<n>rows).- Briefs (merged names win, D-W-100):
M1-46-instance-settings.md(getInstanceand the instance module),M5-02-iphone-layout.md(E6 screen, privacy cover, lifecycle mapping),M5-03-android-layout.md(recent apps protection), the brief of M4-12 (database key and encryption), the brief of M4-18 (logout deletes local data, R-SY-14). - Code on the base branch:
services/server/internal/instance,apps/client/lib,packages/local_store.
This ticket touches no table, no action and no event.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come frompackages/l10n. - Limits only through named configuration keys:
client.app_lock_afterandsecurity.app_lock_requiredfrom the server's process configuration, delivered bygetInstance; code comments name the key, never the number. - Specification first: the new member of
InstanceInfogoes intodocs/api/openapi.yamlfirst; the configuration reference rows change fromplanned (M5)to active with this ticket as the first user. The core contract is not edited by this ticket.
Product requirements implemented here:
- The lock applies at every cold start and after
client.app_lock_afterwithout use, when the person turned it on orsecurity.app_lock_requiredis true (08 9). - While locked, no content is visible: the E6 screen replaces the whole app, the app switcher and recent apps show no content, notifications tapped while locked open E6 first and then the target (01 5.6 E6).
- Unlock only through the system authentication (biometrics or device credential); a failure keeps the lock; "Log out" ends the session, deletes local data and keys, and shows E7.
- With
security.app_lock_requiredthe switch is on and disabled; a device without a device credential shows a notice and no content until a credential is set. - Locking publishes an event that detaches open terminal sessions (they keep running on the host, 01 5.6 E6); the lock controller itself never reattaches; the terminal view of M5-12 decides that after
unlocked. - The local database is readable only with the key from secure storage; the key is stored
ThisDeviceOnlyand, on iOS,WhenUnlocked(15 9).
Scope¶
Server (paths under services/server/ unless they start with docs/; every new Go file starts with // SPDX-License-Identifier: Apache-2.0):
docs/api/openapi.yaml:InstanceInfogainsapp_lock(AppLockPolicy {required: boolean, after_ms: integer int64}, required in the response, description naming both keys);make api-lint,make api-checks,make codegen-check.internal/instance:getInstancefillsapp_lockfrom the configuration keysclient.app_lock_afterandsecurity.app_lock_required, which enter the configuration schema with this ticket (make config-reference,make config-doc-check), with a handler test.
Client:
apps/client/lib/lock/:AppLockController(policy fromgetInstancecached in the local database, the person's switch, last activity time from an injected clock, stateunlocked,locked,unavailable),AppLockPortwith implementations over the platform authentication (local_author Pigeon toLAContextandBiometricPrompt; choose by license and maintenance and record it), thelockedevent stream, and the E6 screen wiring (Design notes 1 to 5).apps/client/lib/phone/settings/: the switch in settings (Me, Security) with the disabled state underrequired.- Tests (Tests).
Change:
tools/configdoc/planned.yaml: remove the planned rules ofclient.app_lock_afterandsecurity.app_lock_required, which this ticket makes active (otherwisemake config-doc-checkreports them as stale);services/server/internal/wirethe key file that registers both keys.packages/sync_engine/lib/src/testing/(fake server of M4-17): the fake world gains theapp_lockpolicy (defaultrequired = false,after_msfrom the world) and itsgetInstancehandler returns it, so the fake keeps validating every response againstopenapi.yamlonce the member is required; tests set the policy per case.packages/local_store: the cachedapp_lockpolicy with the instance row (Design note 1) and, if Design note 6 needs it, the migration of the stored key item, with the migration test of 15 6.2.apps/client/lib/platform/lifecycle.dart(M5-02): the cover and the lock decision oninactive,pausedandresumed; activity tracking on pointer and keyboard events.- Router: while locked every route resolves to E6; a deep link or notification tap is kept and opened after unlock.
- iOS
Info.plist:NSFaceIDUsageDescription(localized). packages/l10nARB files: E6 texts, settings texts, the "no device credential" notice.- Docs:
docs/spec/08-identity.md9 (the delivery throughgetInstance, the device-local switch, the rule without a device credential),docs/spec/15-client-architecture.md9 (the lock and the database key),apps/client/README.md,services/server/internal/instance/README.md,services/server/CHANGELOG.mdand rootCHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M5-08-1toD-M5-08-6.
Out of scope¶
- The app lock on desktop (macOS, Windows): 08 9 names Windows Hello as well, but the plan places the lock in M5 for the phones; the desktop gets it with a later ticket that reuses
AppLockController(record it as a follow-up). - The terminal's reattach logic (M5-12 consumes the
lockedevent). - An instance setting for the policy in the console (the keys are process configuration; M6-09 shows them read-only if it lists process configuration at all).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M5-08-app-lock; one writer per worktree. - Conventional Commits with the module or component as scope (for example
feat(instance): ...,feat(client): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Server tests bind only
127.0.0.1and port 0; PostgreSQL only throughpgtest. Client tests use the fake server of M4-17; device tests enrol and match biometrics without a person: on the iOS simulator throughxcrun simctl spawn <udid> notifyutil -s com.apple.BiometricKit.enrollmentChanged 1andnotifyutil -p com.apple.BiometricKit_Sim.fingerTouch.match(or.nomatch), on the emulator throughadb -s <serial> emu finger touch <id>after enrolling a fingerprint with the emulator's settings automation; record the commands that worked. Temporary directories only viamktemp -d, removed at the end. Stop only processes you started, by PID; simulators by UDID, emulators by serial. - Never read or print secrets; never log the database key.
- The device tests need macOS with Xcode and an iOS simulator, and an Android emulator; where missing,
make apps-platformprints its SKIP line; the report names where they ran. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M5 and M6 by D-W-120). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Policy delivery (08 9; Ruling for docs/DECISIONS.md).
getInstancereturnsapp_lock: {required, after_ms}for every authenticated caller; the app stores it with the instance row (15 6.1instance) so the lock works offline and at a cold start before the network. Reason for an API member instead ofAppConfig(D-W-115): the owner must be able to require the lock, so the value must come from the instance, not from the build. -
When to lock (08 9; 01 5.6 E6). Lock at a cold start (before the first frame with content: the first route is E6 when the lock applies), and when the time since the last user activity reaches
client.app_lock_afterwhile the app is in the foreground or since it went to the background. Activity is any pointer or key event; a running upload or an incoming message is not activity. The clock is injected; the decision is a pure function tested without a device. -
Unlock (08 9; Ruling for docs/DECISIONS.md). The system prompt with biometrics or the device credential (iOS
LAPolicy.deviceOwnerAuthentication; AndroidBIOMETRIC_STRONG or DEVICE_CREDENTIALfrom API 30, biometrics with the device credential fallback intent before it). Success unlocks; cancel or failure keeps E6 with "Try again". No counter of the app's own: the system limits attempts. "Log out" calls the logout of M4-18 (server logout best effort, local data, keys and tokens deleted, E7). -
The switch (01 11; Ruling for docs/DECISIONS.md). The person's choice is a device-local preference stored in secure storage, not a synced
user_settingskey, because the lock protects this device and another device may lack biometrics. Withrequiredthe switch shows on and disabled with the reason. Turning the lock on asks for authentication once (so a person cannot lock themselves out on a device without a credential). Without a device credential and withrequired, the app shows only a notice with the way to the system settings and "Log out". -
Hiding content and the event (01 5.6 E6; 15 23.2 item 7). The privacy cover of M5-02 and the recent apps protection of M5-03 are always on while the lock is on. The controller emits
AppLockEvent.lockedbefore E6 appears andunlockedafter success; M5-12 subscribes to detach terminal sessions and to require the system authentication before a quick reattach. -
Database key (15 6.3, 9). Verify that the database key of M4-12 is stored with
ThisDeviceOnlyand on iOSWhenUnlocked(notAfterFirstUnlock, which the extensions' tokens use); if M4-12 chose another class, change it to these values with a migration of the stored item (read with the old attributes, write with the new) and a test. -
Rulings for
docs/DECISIONS.md(D-M5-08-<n>): policy ingetInstance(note 1); lock triggers and activity (note 2); unlock and log out (note 3); device-local switch and the rule without a device credential (note 4); the lock event for the terminal (note 5); the database key accessibility (note 6).
Tests (write them first)¶
Server (pgtest, full chain): TestGetInstance_AppLock: the default configuration gives required = false and after_ms from client.app_lock_after; with security.app_lock_required = true the response says so; the members are present for every authenticated caller and absent for an unauthenticated discovery call.
Client unit and widget (apps/client/test/lock/, fake clock and fake authentication port):
lock_decision_test.dart: locked at a cold start when on; not locked when off and not required; locked afterclient.app_lock_afterof inactivity in the foreground and in the background; activity resets the timer; an incoming message does not.lock_screen_test.dart: E6 replaces every route; no message text in the widget tree while locked; a deep link is opened after unlock; failure keeps E6; "Log out" deletes local data (the fake store is empty) and shows E7.lock_switch_test.dart:requiredshows the switch on and disabled; turning on asks for authentication; without a device credential andrequiredonly the notice and "Log out" are shown.lock_event_test.dart:lockedis emitted before E6,unlockedafter success.database_key_test.dart(PLAN row): a copy of the database file opened with a wrong or no key fails; the key item has the expected accessibility attributes (through the secure storage fake that records them).
Device (patrol, apps/client/integration_test/phone/app_lock_test.dart, iOS simulator with enrolled Face ID, Android emulator with an enrolled fingerprint, fake server):
- enable the lock; send the app to the background beyond the configured
client.app_lock_after(set very low in the fakegetInstance); return: E6 is shown and the app switcher snapshot contains no content (assert the cover is in the tree when the snapshot is taken); a failed match keeps the lock; a matching biometric unlocks.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/instance/... ./internal/wire/...cd apps/client && flutter analyze --fatal-infos && flutter test --coverage- on macOS with Xcode and with an attached Android emulator:
make apps-platform; otherwise the SKIP lines make codegen-check,make api-lint,make api-checks,make config-doc-check,make arch,make lint,make fmt-check,make coveragemake check
Acceptance criteria¶
- Device test: the lock appears after
client.app_lock_afterand at start, hides content, and unlocks only through the system authentication. -
security.app_lock_requiredenforces the lock instance-wide throughgetInstance; the switch is disabled then. - The local database is readable only with the key from secure storage (test with a copied file).
- The
lockedevent exists for the terminal; "Log out" deletes local data and shows E7. -
openapi.yaml, 08 9, 15 9, the configuration reference, READMEs, CHANGELOGs and the rulings are updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comments on every public Dart and Go symbol; comments explain why.
apps/client/README.mdand the instance README updated in English.- Rulings of Design note 7 recorded in
docs/DECISIONS.mdsection 10. services/server/CHANGELOG.mdand rootCHANGELOG.mdentries under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- Where the device tests ran; the chosen authentication package with version and license.
- What is not proven (for example the app switcher image on a physical device).
- Follow-ups that could not be done in this scope (the desktop lock), each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.