Skip to content

M5-01 Device key for Android

No Date Ruling Reason
D-M5-01-0 2026-10-15 Base int/m1-wave23 at d2d32fa, branch feat/M5-01-device-key-android in its own worktree; this build neither merges locally nor pushes, the PR lane lands it. Every rule the brief cites (R-CL-04, R-CL-05, R-ID-11, D-L10) is on the base, so nothing is ported from main. The base names the chapters docs/spec/15-client-architektur.md and 08-identitaet.md; text this ticket adds there is English. Names that differ from the brief: the Kotlin packages are devicekey.flutter (plugin, generated DeviceKeyApi.g.kt) and devicekey.core (core module), without the product name (02 6); the plugin has its own Gradle wrapper (Gradle 9.3.1 with a pinned distribution checksum) and AGP 9.1.0, Kotlin 2.4.0 and compile SDK 36 of the pinned Flutter's plugin template; the instrumented tests run with connectedDebugAndroidTest and the JVM tests with testDebugUnitTest. The launcher's instructions take precedence over the brief's landing step; merged and tool layouts win over the brief's names (D-W-100, D-W-120).
D-M5-01-1 2026-10-15 Key parameters (brief note 2): alias <prefix>.<sha256(scope)>.<purpose> (lowercase hex, prefix device_key, tests test.device_key.<uuid>), KeyPairGenerator EC in AndroidKeyStore with secp256r1, purpose SIGN, digest SHA256. dpop without user authentication. step_up with setUserAuthenticationRequired(true), from API 30 setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG or AUTH_DEVICE_CREDENTIAL), before it setUserAuthenticationValidityDurationSeconds(-1), and setInvalidatedByBiometricEnrollment(true). From API 28 StrongBox is tried first and the key is generated again without it on StrongBoxUnavailableException; below API 28 an SDK_INT check skips it. A missing step-up key is created only when BiometricManager.canAuthenticate accepts the authenticators of the API level, otherwise not_available. 08 6.1, 15 9; the alias keeps server addresses out of the Keystore, as on Apple (D-M4-10-3).
D-M5-01-2 2026-10-15 The attestation is the key's certificate chain from setAttestationChallenge: the DER certificates, leaf first, concatenated (the channel member is bytes and the DER framing separates them). Android binds the challenge when it creates the key, so attestation creates a missing key with the challenge and returns its chain, and returns null for an existing key and where the device does not attest (a chain of one certificate); the app asks for the attestation before ensure when it registers. M4-10 defined the member as bytes without an encoding; returning a chain with an old challenge would look like a fresh attestation.
D-M5-01-3 2026-10-15 Device credential rule: from API 30 the step-up accepts strong biometrics or the device credential (the prompt then has no negative button); on API 26 to 29 strong biometrics only (the prompt shows the negative button from Dart), and a device without enrolled biometrics on those levels gets not_available for step_up. Written into 15 9 and the Android row of 08 6.1. A per-operation key cannot be unlocked with the device credential before API 30 (brief note 3); the app explains that step-up needs biometrics there.
D-M5-01-4 2026-10-15 Signatures: SHA256withECDSA, DER converted to R \|\| S with strict DER parsing (minimal integers, at most 33 bytes, left padding to 32). hardwareBound: from API 31 KeyInfo.securityLevel StrongBox or trusted environment, before it isInsideSecureHardware. Errors leave with a fixed text and never with a cause: KeyPermanentlyInvalidatedException is key_invalidated; UserNotAuthenticatedException and, from API 33, android.security.KeyStoreException with ERROR_USER_AUTHENTICATION_REQUIRED (what the Keystore throws at sign for a step-up signature without the prompt) are not_available; below API 33 that case is internal, because the class is hidden there. Prompt errors: cancel, negative button and system cancel are user_cancelled; no hardware, nothing enrolled, no device credential and lockout are not_available; others internal. A step-up signature without prompt texts is internal. Brief note 4; the emulator at API 36 showed the KeyStoreException path, not UserNotAuthenticatedException.
D-M5-01-5 2026-10-15 apps_platform.py knows the system android, which is no host: its commands run on any host when adb devices lists exactly one device in state device, print SKIP ... android: no device without one (or without adb) and fail with two or more; --system android runs only them. The CI job apps-android (public phase, ubuntu-24.04, kvm enabled, Temurin 17, reactivecircus/android-emulator-runner pinned by SHA, API 26 and 36, x86_64, google_apis) runs apps_platform.py --system android and feeds ci-ok like apps-macos. make test runs ./gradlew -p <module> testDebugUnitTest for every Gradle module with a tracked src/test/ under packages/*/android/; the jobs gates and sonar install Temurin 17 and the Android SDK (android-actions/setup-android) when such a wrapper is tracked. The run script sets a temporary PIN on an emulator without one (and clears it at the end), because the step-up key needs a secure lock screen; it never touches a physical device's lock screen. Brief note 5 and its requirement that the JVM tests run in make test; an emulator can run on Linux and macOS, so a host check alone cannot select it.
D-M5-01-6 2026-10-15 The Kotlin core is the Gradle library module android/devicekeycore with its own build file and tests. The plugin library compiles the module's main sources into itself (sourceSets), and its standalone settings.gradle.kts includes the module for the tests. An app (the push service of M5-05, the share activity of M5-06) uses DeviceKeyCore.forContext through its dependency on the plugin project :device_key and does not include devicekeycore as a second project, which would duplicate the classes. Both build files list the same dependencies (androidx.biometric 1.1.0); the plugin adds kotlinx-coroutines-android for Pigeon's suspend handler. Flutter includes a plugin as one Gradle project and ignores its settings file, so a project dependency on the module would break every app build that does not include it by hand.
D-M5-01-7 2026-10-15 M4-10 had no prompt texts on the channel, so the Pigeon source gains PromptTextsMessage (title, optional subtitle, negativeButton) as an optional last parameter of sign; the Swift host ignores it. The Dart API stays source compatible: DeviceKeyClient.sign takes an optional named prompt of the new type StepUpPrompt. Pigeon writes analyzer: exclude: [android/**] into the member's analysis_options.yaml when it generates Kotlin; the folder holds no Dart, so it stays. Brief note 1; the texts come from packages/l10n (rule 8).
D-M5-01-8 2026-10-17 The ticket was ported from its integration base onto main with only its own commits; the English docs and spec of main win (the Android rows of 08-identity.md 6.1 and 15-client-architecture.md 9 and the devicekeycore paragraph there). The CI job apps-android with a matrix becomes the two jobs apps (Android API 26) and apps (Android API 36), and ci-ok accepts each as success or skipped through a rule of tools/ci/ci_ok.py instead of the needs list of the base. ci_ok.py on main requires its jobs by name and has no needs (the same reason as D-M7-01-7 for postgres-image). sonar.test.inclusions also names **/androidTest/**, so the instrumented tests count as tests like the SwiftPM tests of D-M4-10-7.