device_key
Device keys in the secure storage of each platform, ES256 (P-256): a Flutter plugin with a typed Pigeon interface. Two keys per device and server (docs/spec/08-identity.md 6.1, D-L10):
| Purpose | Signs | Asks the user |
|---|---|---|
dpop |
every DPoP proof | never; the app extensions use it too |
step_up |
step-up confirmations | on every signature (biometrics or device password) |
Status: iOS and macOS (M4-10), Android (M5-01), Windows (M4-11; the dpop key only, see
"Step-up key on Windows"); on other platforms every call fails with
DeviceKeyError.notAvailable. Kind: Flutter plugin, a member of the Dart
workspace at the repository root (resolution: workspace, publish_to: none).
API
import 'package:device_key/device_key.dart';
final keys = DeviceKeyClient();
const scope = 'https://chat.example.com'; // separates the keys per server
final info = await keys.ensure(scope, KeyPurpose.dpop); // creates the key if missing
info.publicJwk; // {"crv":"P-256","kty":"EC","x":...,"y":...}
info.thumbprint; // RFC 7638, base64url
info.hardwareBound; // false: report the key to the server as not hardware-bound
final signature = await keys.sign(scope, KeyPurpose.dpop, signingInput); // R || S, 64 bytes
final confirmation = await keys.sign(
scope,
KeyPurpose.stepUp,
challengeInput,
prompt: StepUpPrompt(title: l10n.stepUpTitle, negativeButton: l10n.cancel), // Android only
);
await keys.delete(scope); // removes both keys; there is no key change (08 5.4)
| Member | Purpose |
|---|---|
DeviceKeyClient |
ensure, keyInfo, sign, attestation, delete; api: takes a fake host API in tests |
DeviceKeyInfo |
x, y, hardwareBound, requiresUserPresence; publicJwk and thumbprint computed in Dart for every platform |
KeyPurpose |
dpop, stepUp |
StepUpPrompt |
title, subtitle, negativeButton of the Android prompt, from packages/l10n; iOS and macOS ignore it |
DeviceKeyException, DeviceKeyError |
userCancelled, keyInvalidated (register as a new device), notAvailable, internal; never a platform message or key material |
attestation returns null on iOS, macOS and Windows (App Attest and TPM key attestation are
planned with auth.require_key_attestation). On Android it returns the key's certificate chain
(DER certificates, leaf first, concatenated) only when the call creates the key, because Android
binds the challenge at creation; call it before ensure when registering (docs/DECISIONS.md
D-M5-01-2).
Storage per platform
| Platform | Secure Enclave available | Without an enclave (older Macs, simulator) |
|---|---|---|
iOS, macOS: dpop |
enclave key, [.privateKeyUsage], AfterFirstUnlockThisDeviceOnly, shared access group |
software P-256 key in the keychain item, same accessibility and group, hardwareBound = false |
iOS, macOS: step_up |
enclave key, [.privateKeyUsage, .biometryCurrentSet] with enrolled biometrics, otherwise [.privateKeyUsage, .userPresence]; WhenPasscodeSetThisDeviceOnly |
software key, the keychain item carries .userPresence, WhenPasscodeSetThisDeviceOnly, hardwareBound = false |
| Platform | TPM 2.0 with P-256 | Without a TPM or without P-256 |
|---|---|---|
Windows: dpop |
TPM 2.0 key of the Microsoft Platform Crypto Provider after NCryptIsAlgSupported confirmed ECDSA_P256 |
key of the Microsoft Software Key Storage Provider, hardwareBound = false |
Windows: step_up |
not offered: ensure fails with notAvailable (see below) |
the same |
Every key on Apple platforms is a generic password item in the Data Protection keychain (kSecUseDataProtectionKeychain,
also on macOS), kSecAttrSynchronizable = false, so no key reaches a backup or the iCloud keychain.
The item holds the enclave-wrapped blob of the key (useless on another device) or the software key;
the public key sits in kSecAttrGeneric, so it is readable without a prompt. Service is the label
prefix (device_key), account <sha256(scope)>.<purpose>, label <prefix>.<account>.
| Platform | Hardware-backed Keystore | Without secure hardware |
|---|---|---|
Android: dpop |
Android Keystore, P-256, StrongBox where the device has it, otherwise the trusted execution environment; no user authentication | (emulators, devices without secure hardware) software Keystore key, hardwareBound = false |
Android: step_up |
as dpop, plus authentication for every signature through BiometricPrompt: from API 30 strong biometrics or the device credential, on API 26 to 29 strong biometrics only; invalidated by new biometrics |
same rule; on API 26 to 29 without enrolled biometrics there is no step-up key (notAvailable) |
On Android the key never leaves the Keystore and the system excludes the Keystore from backups.
StrongBox is tried first from API 28; on StrongBoxUnavailableException the key is generated
again without it. hardwareBound reports the real level: KeyInfo.securityLevel from API 31,
isInsideSecureHardware before it. A new fingerprint or face makes the Keystore drop the step-up
key's authorisation for good; the next sign fails with DeviceKeyError.keyInvalidated, and the
app registers as a new device. The Android alias is <prefix>.<sha256(scope)>.<purpose>.
The step-up prompt needs the app's activity to be a FragmentActivity (FlutterFragmentActivity
in MainActivity) and the texts of StepUpPrompt; without either sign fails with
notAvailable or internal. The app needs minSdk 26.
The access group of the dpop key comes from the app's build configuration: set the Info.plist key
DeviceKeyAccessGroup to the group of the entitlements (for example
$(AppIdentifierPrefix)com.example.shared). Without it the key stays in the app's default group.
On Windows every key is a persisted CNG key of the current user (no NCRYPT_MACHINE_KEY_FLAG),
ECDSA P-256, export policy 0 set before NCryptFinalizeKey, named
device-key.<sha256(scope)>.<purpose>. A software key is a file of the user profile, so on a
machine with roaming profiles it can follow the profile; only the TPM key is bound to the
machine. A key stays in the provider it was created in (D-M4-11-1). When the plugin creates a key it writes a marker with the provider into the user's
Credential Manager (generic credential named like the key, CRED_PERSIST_LOCAL_MACHINE, no key
material): a marker without an openable key (for example after a TPM clear) reports
keyInvalidated and the key is never recreated silently; the app registers as a new device
(08 5.4). The dpop key signs with NCRYPT_SILENT_FLAG, so it can never show a dialog.
Step-up key on Windows
08 6.1 requires a confirmation per use that comes from the person, not from code in the process.
Windows offers none for a P-256 key: strong key protection (NCRYPT_UI_POLICY_PROPERTY) is a
password that the Microsoft providers cache per process and that the process can supply itself
(NCRYPT_PIN_PROPERTY), and Windows Hello's KeyCredentialManager creates RSA keys only. So
ensure(scope, KeyPurpose.stepUp) fails with notAvailable on Windows, and the step-up of the
instance falls back to the other methods of 08 6 (D-M4-11-2).
Layout
| Path | Content |
|---|---|
lib/device_key.dart, lib/src/device_key.dart |
the Dart API |
pigeons/device_key_api.dart |
the Pigeon source of the channel |
lib/src/generated/, darwin/device_key/Sources/device_key/DeviceKeyApi.g.swift, android/src/main/kotlin/devicekey/flutter/DeviceKeyApi.g.kt, windows/device_key_api.g.* |
generated by Pigeon, checked in |
darwin/device_key/ |
the Swift package of the plugin (Swift Package Manager; sharedDarwinSource for iOS and macOS), DeviceKeyPlugin.swift connects the channel to the core |
darwin/device_key/DeviceKeyCore/ |
the Swift package DeviceKeyCore with the key code and its tests |
android/ |
the Gradle project of the plugin (build.gradle.kts, Gradle wrapper), DeviceKeyPlugin.kt connects the channel to the core |
android/devicekeycore/ |
the Kotlin core module DeviceKeyCore with its JVM tests (src/test) and instrumented tests (src/androidTest) |
windows/ |
the CMake project of the Windows plugin; device_key_plugin.cpp connects the channel to the core |
windows/core/ |
the static library device_key_core (CNG, no Flutter dependency) and its CTest suite in tests/ |
darwin/run_core_tests.sh, darwin/run_ios_sim_tests.sh, android/run_instrumented_tests.sh, platform-tests.json |
the native tests (make apps-platform) |
test/ |
Dart tests against a fake host API |
Reuse in the app extensions
The Notification Service Extension and the Share Extension (M5-04, M5-06) add the local package
packages/device_key/darwin/device_key/DeviceKeyCore to their targets and sign with the app's DPoP
key without the Flutter engine:
import DeviceKeyCore
let group = Bundle.main.object(forInfoDictionaryKey: "DeviceKeyAccessGroup") as? String
let keys = DeviceKeyManager(store: KeychainKeyStore(), accessGroup: group)
let signature = try keys.sign(scope: "https://chat.example.com", purpose: .dpop, data: signingInput)
The extension needs the same keychain access group in its entitlements as the app.
Reuse in the Android push service
The push service (M5-05) and the share activity (M5-06) live in the app's Gradle project, which
depends on the plugin project :device_key. The plugin library compiles the sources of
android/devicekeycore into itself, so the app uses the same classes without including the module
as a second project (that would duplicate them):
import devicekey.core.DeviceKeyCore
import devicekey.core.forContext
val keys = DeviceKeyCore.forContext(applicationContext) { null } // no activity: DPoP only
val signature = keys.signDpop("https://chat.example.com", signingInput) // R || S
Generated code
After a change of pigeons/device_key_api.dart, from this folder:
dart run pigeon --input pigeons/device_key_api.dart && dart format lib/src/generated
make codegen-check runs the same and fails when the checked-in files differ. Pigeon also writes
analyzer: exclude: [android/**] into analysis_options.yaml; there is no Dart code there.
Native tests
make apps-platform on a Mac runs platform-tests.json:
darwin/run_core_tests.sh:swift testofDeviceKeyCore(Swift Testing). With only the command line tools installed, the script passes their Swift Testing paths, so a Mac with an Apple chip proves the Secure Enclave path without Xcode.testHardwarePathOnAppleChipreports a skip withsecure enclave not available on this hostwhere there is no enclave (CI virtual machines),testKeychainRoundTripa skip when the test process lacks the keychain entitlement.darwin/run_ios_sim_tests.sh: the same tests in the iOS simulator (the first available iPhone), which proves the fallback without an enclave; without Xcode it prints a SKIP line.
The CI jobs apps-macos (the core tests) and apps-ios (the simulator tests, both macos-15) run them.
Android, with a JDK 17 and the Android SDK (ANDROID_HOME), from android/:
./gradlew -p devicekeycore testDebugUnitTest: the JVM tests with the in-memoryKeyStoreAccess(DER conversion, alias, error mapping, the core's rules);make testruns them.sh run_instrumented_tests.sh: the instrumented tests against the real Keystore on the one attached device or emulator (make apps-platformruns it whenadb deviceslists exactly one, printsSKIP ... android: no devicewithout one and fails with two). On an emulator without a screen lock it sets a temporary PIN, because the step-up key needs a secure lock screen, and clears it afterwards.strongBoxWhenAvailableskips withstrongbox not available on this deviceon emulators; the step-up tests skip where the device has no authenticator for the key (API 26 to 29 without enrolled biometrics), andstepUpNotAvailableWithoutAnAuthenticatorruns there instead. The CI jobapps-androidruns them on emulators with API 26 and 36.
Every Swift and Android test key has a unique test.device_key. prefix and is deleted by the
test, also after a failure (on Android in an @After method).
On Windows (Visual Studio C++ build tools, CMake) make apps-platform builds windows/core into
build/core-tests and runs its CTest suite (check.h is the whole harness, D-M4-11-3); the CI
job apps-windows does the same. By hand, from this folder:
cmake -S windows/core -B build/core-tests
cmake --build build/core-tests --config Debug
ctest --test-dir build/core-tests -C Debug --output-on-failure
tpm_path_when_available reports a skip with platform crypto provider not available on this host where there is no TPM 2.0 with P-256 (CI virtual machines); the fallback tests always run,
through a fake provider that hides the TPM and against the real software provider. Every test key
and marker starts with device-key-test- and is deleted before and after each test, also after a
failure. No test shows a prompt.
Dependencies
Allowed members (docs/spec/15-client-architecture.md, 3.1; tools/arch-check/rules.yaml): none: no
arrow of the diagram leaves this package. Runtime dependency: crypto (SHA-256 of the thumbprint);
dev dependency: pigeon. Android: androidx.biometric and kotlinx-coroutines-android. Windows links ncrypt, bcrypt and advapi32. Native sources only in darwin/, ios/, macos/, android/,
windows/, linux/, web/ (rule 4, checked by make arch).
Run from the repository root: make lint test coverage arch codegen-check COMPONENTS=apps. The
API pages come from make docs (Dart API reference).
Libraries
- device_key
- Device keys in the secure storage of each platform (Flutter plugin, Pigeon).