How it works
flutter_sign_keypair does one job: build a JSON payload, wrap it in a fixed ES256 header, sign it with a key that lives in the device's secure hardware, and return a compact JWS. This page follows a key from creation to signature.
The layers
SignKeypair app-facing API (lib/flutter_sign_keypair.dart)
└─ SignKeypairPlatform the backend contract (a PlatformInterface)
├─ MethodChannelSignKeypair Android, iOS, macOS
│ └─ channel "com.vaam/flutter_sign_keypair"
│ ├─ Kotlin: FlutterSignKeypairPlugin + SecureKeyStore
│ └─ Swift: FlutterSignKeypairPlugin (shared by iOS and macOS)
└─ SoftwareSecureSigner web, Windows, Linux (pure Dart)SignKeypair() picks the backend for the current target: the method channel on Android, iOS and macOS, and the pure-Dart signer on web, Windows, Linux (and Fuchsia). You can pass a backend of your own with SignKeypair(platform: ...); see the API overview.
Key generation happens in hardware
Every key is an ECDSA key on NIST P-256 (secp256r1), signing with SHA-256. That is the only curve the Secure Enclave supports, and also the curve JWS ES256 requires, so no translation is needed between the two.
Android
The key is generated by KeyPairGenerator with the AndroidKeyStore provider, for PURPOSE_SIGN with the SHA-256 digest. Where the device advertises FEATURE_STRONGBOX_KEYSTORE (API 28+), the plugin asks for a StrongBox key first and falls back to the TEE if StrongBox refuses the spec. After creating the key it asks the keystore where the key actually ended up (KeyInfo) instead of assuming:
| Keystore says | Reported as |
|---|---|
SECURITY_LEVEL_STRONGBOX (API 31+) | strongBox |
SECURITY_LEVEL_TRUSTED_ENVIRONMENT (API 31+) | trustedExecutionEnvironment |
SECURITY_LEVEL_UNKNOWN_SECURE (API 31+) | trustedExecutionEnvironment |
| Inside secure hardware (below API 31) | trustedExecutionEnvironment |
| Anything else | software |
Below API 31 the keystore can only answer "secure hardware or not", so a StrongBox key on an older release is reported as TEE. That understates the key's strength; it never overstates it.
iOS and macOS
The key is created with SecKeyCreateRandomKey, in the Secure Enclave (kSecAttrTokenIDSecureEnclave) when one is available, and stored as a permanent keychain item tagged com.vaam.flutter_sign_keypair.<keyId>. On macOS the plugin uses the data-protection keychain, where Secure Enclave keys are addressable.
Whether the enclave is available is probed by creating and discarding a throwaway, non-permanent enclave key. Whether a given key is in the enclave is read back from the key's own attributes, so a silent fallback inside the Security framework cannot be reported as enclave-backed. When there is no enclave (the iOS Simulator, a Mac without one), the key is an ordinary keychain key and reports keychain.
Everywhere else
SoftwareSecureSigner generates the key with pointycastle and keeps the private scalar in a SoftwareKeyStore. Every key it makes reports software.
What never leaves the device
- The private key. No method on any backend returns private key material, and the
SecureKeyhandle Dart receives has no field for it. On Android,KeyStore.getKeyreturns an opaque handle whoseencodedisnull; the instrumented tests assert exactly that. - The signing arithmetic.
Signature.initSignon Android andSecKeyCreateSignatureon Apple platforms take the keystore handle; the keymaster or the enclave does the work. - Keys do not roam. On iOS and macOS both keys use a
ThisDeviceOnlyaccessibility class, which keeps them out of iCloud Keychain and encrypted backups. A hardware key cannot be imported elsewhere either; see Migrating devices.
What does cross into Dart: the key id, the public key as a JWK (x and y coordinates), the reported backing, and signatures.
The software fallback is the exception, and says so: its private scalar lives in Dart heap memory and in whatever SoftwareKeyStore you give it.
Key backing
KeyBacking lists where a key can live, in decreasing order of assurance:
| Value | Wire tag | Hardware-backed | Meaning |
|---|---|---|---|
strongBox | strongbox | yes | Android StrongBox, a discrete security chip |
secureEnclave | secure_enclave | yes | The Apple Secure Enclave coprocessor |
trustedExecutionEnvironment | tee | yes | Android TEE, outside the Android OS |
keychain | keychain | no | An Apple keychain key, not in the enclave |
software | software | no | The private scalar is reachable from process memory |
isHardwareBacked is true only for the first three. An unrecognised tag from the native side becomes software: under-reporting strength is safe, over-reporting it would be a false security claim.
The two-key model
generateDeviceKeys() creates two keys, not one, because one key cannot serve both kinds of request correctly.
| Ambient key | User-present key | |
|---|---|---|
| Constant | SignKeypair.defaultKeyId | SignKeypair.defaultUserPresentKeyId |
| Default id | device | device_user_present |
KeyProtection | ambient | userPresent |
| Prompts | never | on every signature |
| Use it for | every request, including background timers | high-value transfers, security-relevant changes |
Requiring user presence on every signature would mean prompting on every background poll, where no UI can be shown. Requiring it never would mean in-process code could sign a sensitive operation as easily as a routine one. With two keys, code in the app can still use the ambient key (a secure element signs whatever it is asked to), but it cannot produce a user-present signature without a person authenticating. A backend can then require the user-present key for sensitive endpoints.
They are separate aliases rather than a flag on one key because the platform stores the protection policy with the key: one alias cannot be both silent and prompting, and changing it would destroy the key.
What each platform binds to the user-present key
| Platform | Binding |
|---|---|
| Android API 30+ | setUserAuthenticationRequired(true), per-operation (timeout 0), AUTH_BIOMETRIC_STRONG or AUTH_DEVICE_CREDENTIAL |
| Android API 24–29 | setUserAuthenticationRequired(true), per-operation (-1), biometric only: the platform has no credential option here |
| Android API 24+ | setInvalidatedByBiometricEnrollment(true) |
| iOS / macOS | .biometryCurrentSet or .devicePasscode (plus .privateKeyUsage in the enclave), kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
The ambient key carries no authentication binding. On iOS and macOS it uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, so it can sign while the screen is locked, from the first unlock after a reboot onwards.
The biometric-invalidation flag (setInvalidatedByBiometricEnrollment on Android, .biometryCurrentSet on Apple platforms) is carried by the user-present key alone, so enrolling a new fingerprint cannot quietly inherit that key's authority, and never takes the ambient key with it. Because the user-present key also accepts the device credential, re-enrolling a biometric invalidates only the biometric branch on iOS and macOS (and, per the plugin's own notes, Android does not invalidate a key that also accepts the device credential). That does not open a hole: both platforms ask for the existing credential before they accept a new biometric, so whoever can enrol one already holds what the credential branch asks for.
On Android, the prompt and the signature are one operation
For a user-present key, the plugin starts the Signature operation, hands it to BiometricPrompt as a CryptoObject, and finishes signing only with the operation the keymaster authorised. A boolean "the user authenticated" answer could not authorise a keystore operation, which is why the prompt is raised by the plugin and not from Dart. Whether to prompt is decided by reading the key's own KeyInfo, not by its alias.
On iOS and macOS, the Security framework raises the prompt itself during SecKeyCreateSignature; the plugin attaches an LAContext whose localizedReason is your reason.
Enrolment: both keys or neither
generateDeviceKeys() creates the ambient key, then the user-present key. If the user-present key cannot be created (no biometric or screen lock, a keymaster that refuses the spec, the software fallback), it deletes the ambient key it just created and rethrows the original error. The device is left as it was, rather than holding an ambient key your backend has never seen.
Two details matter if the device already had an ambient key:
- With
overwrite: false(the default),generateDeviceKeys()fails straight away withkeyAlreadyExists, because the ambient key exists. To add a user-present key to an already enrolled device, callgenerateKey(keyId: SignKeypair.defaultUserPresentKeyId, protection: KeyProtection.userPresent)on its own. - With
overwrite: true, the ambient key is replaced first. If the user-present key then fails, the rollback does not delete the ambient key (it existed before the call), but that key is already a new one, with a public key your backend has not registered.
generateKey(requireHardware: true) checks the device first and throws hardwareUnavailable before deleting anything, so a rejected call never destroys an existing key.
The signature format
Compact JWS
signCompactJws(payload: ...) produces RFC 7515 compact serialisation:
base64url(header) . base64url(payload) . base64url(signature)- The protected header is always exactly
{"alg":"ES256","typ":"JWT"}, in that key order. It is encoded once and reused. - The payload is
jsonEncode(payload), UTF-8 encoded. - All three segments are base64url without padding.
- The signing input is the ASCII string
header.payload; the platform hashes it with SHA-256 itself.
signCompactJws adds no claims of its own: no iat, no exp, no kid. Whatever freshness or binding your backend needs (a timestamp, a nonce, the request method and path) has to be in the payload you pass.
DER in, IEEE P1363 out
Every platform ECDSA API emits an ASN.1 DER SEQUENCE { INTEGER r, INTEGER s }. JWS ES256 needs IEEE P1363: r and s as two 32-byte big-endian integers, concatenated, 64 bytes in all. The plugin converts on the native side (derToP1363 in Kotlin, EcdsaSignatureCodec in Swift), and the Dart side rejects any native signature that is not exactly 64 bytes.
Getting this conversion wrong fails silently and only some of the time: DER drops leading zero bytes and adds a 0x00 sign byte, so a naive conversion breaks on roughly 1 signature in 256. The Dart, Kotlin and Swift converters are each tested against the same OpenSSL-produced known-answer vectors; see Testing & proof.
signRaw(signingInput: ...) returns the same 64-byte r‖s signature over bytes you supply, for when you build the signing input yourself.
Signatures are not stable
AndroidKeyStore and the Secure Enclave use a random k; the Dart fallback uses RFC 6979 deterministic k. All of them are valid ES256. Signing the same payload twice usually gives two different signatures, so never use a signature as a cache key or an idempotency token.
The wire contract
Strings cross the method channel because that is all a channel can carry, but each language maps strings to enums in exactly one place (models.dart, Wire.kt, Wire.swift) and switches exhaustively, with no default, everywhere else. Adding a member is a compile error in all three languages.
| Unknown value from the other side | Becomes | Why |
|---|---|---|
| backing tag | KeyBacking.software | under-reporting strength is harmless |
| protection tag | a hard failure, no default | either wrong guess bakes the wrong policy in |
| error code | SignerErrorCode.unknown, raw in rawCode | still diagnosable |
| method name | notImplemented() | what Flutter expects; lets newer Dart probe |