M5-01: Device key for Android¶
Status: ready | Depends on: M4-10 | Milestone: M5
Goal¶
After this ticket packages/device_key works on Android with the same Dart API and Pigeon interface as on macOS and iOS (M4-10): a Kotlin core creates, uses and deletes the two device keys of 08 6.1 (D-L10) in the Android Keystore, both ES256 (P-256), in StrongBox where the device has it (setIsStrongBoxBacked(true)), with a fallback to the trusted execution environment on StrongBoxUnavailableException and, where neither exists (emulators without a secure element), a key that the API reports as not hardware-bound. The dpop key signs without user confirmation (the push service of M5-05 uses it in the background); the step_up key requires user authentication for every use through BiometricPrompt (biometrics or the device credential) and is invalidated when new biometrics are enrolled. Signatures come back in the JOSE form R || S, the public JWK and RFC 7638 thumbprint come from the shared Dart code of M4-10, and Android key attestation (the certificate chain of setAttestationChallenge) is returned where the device offers it. The Kotlin core is a separate Gradle module that the app's push service (M5-05) and share activity reuse (15 9). Instrumented tests run on an Android emulator in a new CI job apps-android, and the fallback is labelled. Why: DPoP binding and step-up of the Android app rest on keys that cannot be exported (08 6.1, R-CL-04, R-CL-05); a stolen refresh token is worthless without them (15 9).
Read first¶
docs/PLAN.md: section 3; section 11 "M5 Mobile clients" (Goal, Exit criteria, Test strategy, Red team: T-DV-01), rows M5-01, M5-03, M5-05; section 10 row M4-10.docs/spec/15-client-architecture.md: 2 (Android minimum API 26), 3.1 (rule 4: platform code only indevice_keyandapps/client), 9 "Secure storage and device key" (row Android: Android Keystore, StrongBox if available, fallback onStrongBoxUnavailableException, P-256; step-up with user authentication per operation throughBiometricPrompt; the plugin offer), 10.2 (the push service signs with the device key), 21 (R-CL-04, R-CL-05).docs/spec/08-identity.md: 5.1 (dpop_jwk,step_up_jwk,attestation), 5.4 (no key change, R-ID-11), 6.1 (row Android: step-up key with mandatory user authentication), 6.2, 9 (keys only on this device, never in backups).docs/DECISIONS.md: D-L10, D-ID-02, D-W-100 and its extension to M5 and M6 (D-W-120); section 10 (format ofD-M5-01-<n>rows) and the rowsD-M4-10-<n>(decisions the Android side follows).- Android documentation: Keystore (
KeyGenParameterSpec.Builder,setIsStrongBoxBacked,setUserAuthenticationRequired,setUserAuthenticationParametersfrom API 30 andsetUserAuthenticationValidityDurationSeconds(-1)before it,setInvalidatedByBiometricEnrollment,setAttestationChallenge,KeyInfo.getSecurityLevelfrom API 31 andisInsideSecureHardwarebefore it),BiometricPromptwith aCryptoObjectaround aSignature(https://developer.android.com/privacy-and-security/keystore, https://developer.android.com/identity/sign-in/biometric-auth). - RFC 7518 3.4 (ES256
R || S), RFC 7638, RFC 7517. - Briefs (merged names win, D-W-100):
docs/briefs/M4-10-device-key-apple.md(all of it: the Pigeon interfaceDeviceKeyHostApi,DeviceKeyClient,KeyPurpose,DeviceKeyInfo, error codesuser_cancelled,key_invalidated,not_available,internal, theplatform-tests.jsonshape),docs/briefs/M4-01-workspace-arch.md(tools/ci/apps_platform.py, the platform CI jobs and their rules inci_ok.py,tools/arch-check/rules.yamlrule 4),docs/briefs/M4-09-auth-dpop.md(the signer port). - Code on the base branch:
packages/device_key(Dart API, Pigeon source and generated code,darwin/),tools/ci/apps_platform.py,.github/workflows/ci.yml,tools/ci/ci_ok.py(or where M4-01 put the platform job rules),tools/ci/check_runners.py.
This ticket touches no API operation, no table, no action, no event and no configuration key.
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, never as literals in widgets; theBiometricPrompttitle and subtitle are passed in from Dart. - No configuration key is added; the plugin has no limits of its own.
- Specification first: if the Android behaviour forces a change of 15 9 or 08 6.1 (for example the device credential as an allowed step-up authenticator), update them in the same change. The core contract is not edited by this ticket.
Product requirements implemented here:
- Two keys per device and server (
dpop,step_up), separated per server scope like M4-10 (08 6.1, D-L10, R-CL-05). - ES256 only (P-256) (15 9, R-CL-04).
- StrongBox where available, the trusted execution environment otherwise, a software-backed Keystore key on devices without either;
hardwareBoundreports the real security level (15 9 row Android). - Keys never leave the device: Keystore keys are not exportable; no key material in backups (the Keystore is excluded by the system; the app's
allowBackupsettings are the app's concern, M5-03). - The
step_upkey requires user authentication for every signature and is invalidated by a new biometric enrollment; thedpopkey requires none (08 6.1). - No key change:
deleteremoves both keys of a scope (08 5.4, R-ID-11). - Platform code only in
packages/device_keyandapps/client(15 3.1 rule 4).
Scope¶
Create or fill in packages/device_key/:
android/plugin module:build.gradle.kts(or the Groovy file the pinned Flutter plugin template generates),src/main/kotlin/.../DeviceKeyPlugin.kt(Pigeon host implementation), generatedDeviceKeyApi.g.kt(Pigeon output for Kotlin added to the existing Pigeon source; Design note 1).android/devicekeycore/: a Gradle library module with the Kotlin core (DeviceKeyCore,KeyStoreAccessinterface with a real and an in-memory implementation,StepUpSignerthat runsBiometricPromptthrough an injected prompt host) and its unit tests (src/test, JVM with the in-memory store) and instrumented tests (src/androidTest) (Design notes 2 to 4).pubspec.yamlplugin section:androidwithpackageandpluginClass.platform-tests.json: anandroidentry (Design note 5).
Change:
pigeons/device_key_api.dart: Kotlin output options; regenerate all outputs;make codegen-checkcompares them.tools/ci/apps_platform.pyand its tests: a systemandroidthat runs whenadb deviceslists exactly one device or emulator and otherwise printsSKIP apps-platform <member> android: no device; selection by an explicit--system androidargument as well as by the host check, because an Android emulator can run on Linux and macOS (Design note 5)..github/workflows/ci.yml: jobapps-androidonubuntu-24.04with hardware acceleration, the Android emulator runner action pinned by SHA (API level 26 and the current stable API, x86_64, Google APIs image without Play Store), runningtools/ci/apps_platform.py --system android;timeout-minutes,permissions: contents: read; the job feedsci-okthrough the same rule mechanism asapps-macosandapps-windows;make runners-checkandmake lint(actionlint, zizmor) stay green.tools/arch-check/rules.yamlonly if the module layout needs a folder name the rules do not list (Kotlin sources underpackages/device_key/androidare allowed by rule 4).packages/device_key/README.md(English: Android storage, StrongBox fallback, biometric enrollment invalidation, how the push service reuses the core, how to run the instrumented tests),docs/spec/15-client-architecture.md9 (row Android: the module for reuse, the device credential rule of Design note 3),docs/quality/gates.md(the job), rootCHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M5-01-1toD-M5-01-6.
Out of scope¶
- The adapter from this plugin to the signer port of
authand its use in the Android app (M5-03 wires the Android app; the adapter of M4-18 is reused). - The push service and the share activity (M5-05, M5-06).
- Secret storage of tokens and the database key (
flutter_secure_storage, M4-12 and M5-03). - Windows (M4-11), macOS and iOS (M4-10).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M5-01-device-key-android; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(device-key): ...,ci(apps): ...) 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. Never publish a Dart package (publish_to: none). - 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).
- Tests bind only
127.0.0.1with port 0 and make no network call. Temporary directories only viamktemp -d, removed at the end. Stop only processes you started, by PID; an emulator you started is stopped by its PID or withadb emu killfor its own serial. - Never read or print secrets. No network calls except package downloads for pinned tools, pub packages, Gradle dependencies and system images, and what the gates of
make checkdo themselves. - The Flutter SDK at the pinned version, a JDK and the Android SDK with an emulator image must be available for the instrumented tests; without them
make apps-platformprints its SKIP line, theapps-androidCI job is the evidence, and the report names where the tests ran. The JVM unit tests run inmake teston every host with a JDK; without a JDK the gate fails by design and the ticket stops withVerdict: BLOCKED <tool> missing. - 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 or
docs/api/lacks something this ticket needs, stop withVerdict: 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>. - Every test key uses a test-only alias prefix and is deleted in teardown, also after a failure.
Design notes¶
-
Pigeon (M4-10 Design note 1). The interface stays exactly as M4-10 defined it; add the Kotlin output and the Android implementation.
signforstep_upis asynchronous and shows the prompt; the prompt texts (title,subtitle,negativeButtonwhere the API needs it) come from Dart in an optional message member added to the Pigeon source only if M4-10 has none (then the Apple side ignores it, because the system shows its own text; record it). -
Key generation (15 9; 08 6.1; Ruling for docs/DECISIONS.md). Alias
<prefix>.<sha256(scope)>.<purpose>.KeyPairGenerator.getInstance(KEY_ALGORITHM_EC, "AndroidKeyStore")withECGenParameterSpec("secp256r1"), purposesSIGN, digestSHA256.dpop: no user authentication.step_up:setUserAuthenticationRequired(true); from API 30setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG or AUTH_DEVICE_CREDENTIAL), before API 30setUserAuthenticationValidityDurationSeconds(-1)(per operation, biometric only on those levels);setInvalidatedByBiometricEnrollment(true). From API 28 trysetIsStrongBoxBacked(true)first and onStrongBoxUnavailableExceptiongenerate again without it; below API 28 the method does not exist, so generate without it (anSDK_INTcheck, never a caughtNoSuchMethodError). With a challenge from the caller setsetAttestationChallengeand return the certificate chain as the attestation (DER certificates concatenated in the encoding the M4-10 attestation member already uses, or a JSON array of base64 certificates if it has none; record it). -
Step-up with the device credential (08 6.1, 15 9; Ruling for docs/DECISIONS.md). On API 30 and later the step-up accepts strong biometrics or the device credential, like
.userPresenceon Apple without biometrics (M4-10 Design note 3); on API 26 to 29 a per-operation key cannot be used with the device credential, so the step-up key there accepts strong biometrics only, and a device without enrolled biometrics on those levels getsnot_availableforstep_up(the app then explains that step-up needs biometrics; the person can still use the app without step-up actions). Write this rule into 15 9. -
Signatures and security level.
Signature.getInstance("SHA256withECDSA"), forstep_upwrapped inBiometricPrompt.CryptoObjectand authenticated beforesign; DER output converted toR || Swith 32 bytes each (leading zero handling tested).hardwareBound: from API 31KeyInfo.securityLevelinSECURITY_LEVEL_STRONGBOXorSECURITY_LEVEL_TRUSTED_ENVIRONMENT; before itisInsideSecureHardware. Error mapping: user cancel and negative button touser_cancelled;KeyPermanentlyInvalidatedExceptiontokey_invalidated; no biometrics or no hardware for the purpose tonot_available; everything elseinternal; messages carry no key material. -
Platform tests and CI (M4-01 Design note 6).
platform-tests.jsonandroid:[["sh", "android/run_instrumented_tests.sh"]], a wrapper that runs the instrumented tests ofdevicekeycorewith Gradle against the one attached device and fails on any failure.tools/ci/apps_platform.pygains the systemandroidas described in Scope; on macOS and Windows hosts without a device it prints its SKIP line, somake apps-platformstays usable everywhere. The CI jobapps-androidruns it on the emulator; StrongBox is not available on emulators, so the StrongBox path test reports itself as skipped with the reasonstrongbox not available on this device, and the report must name a run on a physical device with StrongBox where it passed, or state under "not proven" that it did not run. -
Rulings for
docs/DECISIONS.md(D-M5-01-<n>): key parameters and alias (note 2); attestation encoding (note 2); the device credential rule per API level (note 3); signature conversion and security level mapping (note 4); theandroidplatform system and the CI job (note 5); the separate core module for reuse by the push service (Scope).
Tests (write them first)¶
Kotlin, android/devicekeycore/src/test (JVM, in-memory KeyStoreAccess):
derToRawConversion(lengths 31, 32, 33 ofrands, leading zeros),aliasPerScopeAndPurpose,errorMappingCarriesNoKeyMaterial.
Kotlin, android/devicekeycore/src/androidTest (instrumented, emulator or device):
createsTwoDistinctKeysPerScope,scopesAreSeparated,signatureIsRawAndVerifies(withjava.securityverification against the public key),dpopSignsWithoutPrompt,stepUpRequiresAuthentication(the key'sKeyInforeports user authentication required; asignwithout a prompt fails withUserNotAuthenticatedExceptionmapped to the expected error),stepUpInvalidatedByEnrollmentFlag(the spec flag is set; the real invalidation is a device test listed in the report),strongBoxWhenAvailable(skipped with the reason on emulators),fallbackLabelledNotHardwareBound(forced through an injected availability flag on the emulator),attestationChainReturned(present on images that support key attestation, otherwise skipped with the reason),deleteRemovesBothKeys.
Dart, packages/device_key/test/ (fake host API): the Android error codes map to DeviceKeyError; the prompt texts pass through.
Python, tools/ci/tests/test_apps_platform.py: test_android_runs_with_one_device, test_android_skips_without_device, test_android_fails_with_two_devices (an ambiguous target is an error, not a guess), test_explicit_system_argument. tools/ci/tests/test_ci_toolchains.py (or the M4-01 file holding the platform jobs): apps-android exists, is pinned, feeds ci-ok.
Commands that must pass, from the repository root:
cd packages/device_key && flutter analyze --fatal-infos && flutter test --coveragecd packages/device_key/android && ./gradlew :devicekeycore:test(or the Gradle invocation the plugin layout gives)- with an attached emulator or device:
make apps-platform; otherwise it prints its SKIP line make codegen-check,make arch,make lint,make fmt-check,make coverage,make runners-checkmake check
Acceptance criteria¶
- Instrumented tests on Android are green on the emulator in the
apps-androidCI job; the StrongBox path ran on a physical device or is listed as not proven. - Two keys per device and server, ES256, StrongBox where available, labelled as not hardware-bound in the fallback.
- The step-up key requires authentication per signature and carries the biometric enrollment invalidation; the DPoP key signs without a prompt.
- The Dart API is unchanged and returns
R || Ssignatures; errors carry no key material. - The Kotlin core is a separate module that the app can reuse; native code only in
packages/device_key. - 15 9, gates.md, README, CHANGELOG and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comments on every public Dart symbol and KDoc on every public Kotlin declaration; comments explain why.
packages/device_key/README.mdupdated in English.- Rulings of Design note 6 recorded in
docs/DECISIONS.mdsection 10. - Root
CHANGELOG.mdentry 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 instrumented tests ran (emulator image and API level, physical devices with StrongBox), which tests were skipped with which reason.
- What is not proven (for example the real invalidation after a new fingerprint, which needs a device and a person).
- Follow-ups that could not be done in this scope, 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>.