Skip to content

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 ​

text
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 saysReported 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 elsesoftware

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 SecureKey handle Dart receives has no field for it. On Android, KeyStore.getKey returns an opaque handle whose encoded is null; the instrumented tests assert exactly that.
  • The signing arithmetic. Signature.initSign on Android and SecKeyCreateSignature on 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 ThisDeviceOnly accessibility 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:

ValueWire tagHardware-backedMeaning
strongBoxstrongboxyesAndroid StrongBox, a discrete security chip
secureEnclavesecure_enclaveyesThe Apple Secure Enclave coprocessor
trustedExecutionEnvironmentteeyesAndroid TEE, outside the Android OS
keychainkeychainnoAn Apple keychain key, not in the enclave
softwaresoftwarenoThe 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 keyUser-present key
ConstantSignKeypair.defaultKeyIdSignKeypair.defaultUserPresentKeyId
Default iddevicedevice_user_present
KeyProtectionambientuserPresent
Promptsneveron every signature
Use it forevery request, including background timershigh-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 ​

PlatformBinding
Android API 30+setUserAuthenticationRequired(true), per-operation (timeout 0), AUTH_BIOMETRIC_STRONG or AUTH_DEVICE_CREDENTIAL
Android API 24–29setUserAuthenticationRequired(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 with keyAlreadyExists, because the ambient key exists. To add a user-present key to an already enrolled device, call generateKey(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:

text
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 sideBecomesWhy
backing tagKeyBacking.softwareunder-reporting strength is harmless
protection taga hard failure, no defaulteither wrong guess bakes the wrong policy in
error codeSignerErrorCode.unknown, raw in rawCodestill diagnosable
method namenotImplemented()what Flutter expects; lets newer Dart probe

Released under the MIT License. A fork of webank_secure_signer.