Supreme KMP Crypto Provider
This Kotlin Multiplatform library provides platform-independent data types and functionality related to crypto and PKI applications:
- Multiplatform ECDSA and RSA Signer and Verifier → Check out the included CMP demo App to see it in action
- Biometric Authentication on Android and iOS without Callbacks or Activity Passing** (✨Magic!✨)
- Support Attestation on Android and iOS
Tip
Do check out the full API docs here!
Using it in your Projects
This library was built for Kotlin Multiplatform. Currently, it targets the JVM, Android and iOS.
Simply declare the desired dependency to get going:
Key Design Principles
The Supreme KMP crypto provider works differently than JCA. It uses a Provider
to manage private key material and create Signer
instances,
and a Verifier
, that is instantiated on a SignatureAlgorithm
, taking a CryptoPublicKey
as parameter.
In addition, creating ephemeral keys is a dedicated operation, decoupled from a Provider
.
The actual implementation of cryptographic functionality is delegated to platform-native implementations.
Moreover, the Supreme KMP crypto provider heavily relies on a type-safe DSL for configuration. This type-safety goes so far as to expose platform-specific configuration options only in platform-specific sources, even when the actual calls to some DSL-configurable type reads the same as in common code.
Warning
Do not ignore the results returned by any operation!
We heavily rely on KmmResult
to communicate the success or failure of operations. Nothing ever throws!
Provider Initialization
Currently, we provide only one provider, the SigningProvider
, which is used to manage signing keys and create signer
instances. Due to limitations of Kotlin, two discrete implementations of the provider exist: one for mobile targets, and
one for the JVM. Their initialization differs.
iOS and Android
On mobile targets (Android and iOS), simply reference the PlatformSigningProvider
object, and you're good to go!
This provider is backed by the AndroidKeyStore and the KeyChain/Secure Enclave and requires no configuration.
JVM
On the JVM, you need to instantiate the JKSProvider
back it with a JCA KeyStore
.
This can either be an already initialized, loaded one, or you can pass a path to a keystore file:
File-Based | with pre-loaded KeyStore |
---|---|
Usually, passing pre-initialized keystore is enough to cover even custom KeyStore
implementations depending on
a specific SecurityProvider
.
In cases where even more flexibility is needed, it is possible to use withCustomAccessor{}
and pass a custom
KeyStore-accessor, implementing the JKSAccessor
interface.
In addition, the JKSProvider
can be initialized without any backing keystore to create only ephemeral keys, if no
options are passed.
Key Management
The provider enables creating, loading and deleting signing keys.
Key Generation
A key's properties cannot be modified after its creation. Fundamental key-generation options, such as key type, are available on all targets and in common code.
The common options include key type and specifics to the key type. As EC and RSA keys are the only supported ones, this amounts to the following configuration options:
EC | RSA |
---|---|
For EC keys, the digest is optional and if none is set, it defaults to the curve's native digest. For RSA keys, the set of digests defaults to SHA-256 and the padding defaults to PSS. It is also possible to override the public exponent, although not all platform respect this override.
iOS and Android
Both iOS and Android support attestation, hardware-backed key storage and authentication to use a key.
Since all of this is, at least in part, hardware-dependent, the PlatformSigningProvider
supports an additional
hardware
configuration block for key generation.
The following snippet is a comprehensive example showcasing this feature set:
val serverChallenge: ByteArray = TODO("This was unpredictably chosen by your server.")
PlatformSigningProvider.createSigningKey(alias = "Swordfish") {
ec {
// as supported by iOS and Android in hardware
curve = ECCurve.SECP_256_R_1
}
hardware {
// you could use PREFERRED if you want the operation to succeed (without hardware backing) on devices that do not support it
backing = REQUIRED
attestation { challenge = serverChallenge }
protection {
timeout = 5.seconds
factors {
biometry = true
deviceLock = false
}
}
}
}
If multiple protections factors are chosen, any one of them can be used to unlock the key. Biometry could be face unlock or fingerprint unlock, depending on the device and how it is configured. If no timeout is specified, the key requires authentication on every use.
In case an attestation challenge is specified, an attestation proof is generated alongside the key. On iOS, this requires an Internet connection! See also Attestation.
Warning
iOS only supports P-256 keys in hardware! Yes, this means hardware-backed RSA keys are altogether unsupported on iOS!
JVM
The JVM supports no additional configuration options, since it supports none of the above features.
Key Loading
To load a key, simply call provider.getSignerForKey(alias) {…}
.
Depending on how the key was created, it may be necessary or just useful to pass additional options.
Most prominently, you may want to display a custom unlock prompt on mobile targets, if the key
is protected by biometry:
provider.getSignerForKey("Swordfish") {
unlockPrompt {
message = "Authenticate key usage"
subtitle = "We require your authentication to sign data" //Android-only
cancelText = "Cancel"
}
}
This configuration will be used for every sign operation as well.
More often than not, though, you'll want to setup an unlockPrompt
as part of the signing operation
(see Signature Creation).
On the JVM (using the JKSProvider
), another toplevel configuration property is present: privateKeyPassword
,
which is used to unlock the private key, in case it is password-protected
In addition, EC and RSA-specific configuration options are available, to specify a digest and/or padding.
To configure such algorithm-specific options, invoke the ec{}
or rsa{}
block accordingly.
Key Deletion
Simply call provider.deleteSigningKey(alias)
to delete a key.
If the operation succeeds, a key was indeed deleted.
If not, it usually means that a non-existent alias was specified.
Signature Creation
Regardless of whether a key was freshly created or a pre-existing key way loaded. The result of either operation
is a Signer
, which can be used as desired.
To sign, simply pass data to sign.
On iOS and Android, it is possible to perform additional optional configuration, such as
setting up an unlockPrompt
:
signer.sign(data) {
unlockPrompt {
message = "Authenticate key usage"
subtitle = "We require your authentication to sign data" //Android-only
cancelText = "Cancel"
}
}
Signature Verification
To verify a signature, obtain a Verifier
instance using verifierFor(k: PublicKey)
, either directly on a
SignatureAlgorithm
, or on one of the specialized algorithms (X509SignatureAlgorithm
, CoseAlgorithm
, ...).
A variety of constants, resembling the well-known JCA names, are also available in SignatureAlgorithm
's companion.
As an example, here's how to verify a basic signature using a public key:
val publicKey: CryptoPublicKey.EC = TODO("You have this and trust it.")
val plaintext = "You want to trust this.".encodeToByteArray()
val signature: CryptoSignature = TODO("This was sent alongside the plaintext.")
val verifier = SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow()
val isValid = verifier.verify(plaintext, signature).isSuccess
println("Looks good? $isValid")
Tip
Not every platform supports every algorithm parameter. For example, iOS does not support raw ECDSA verification (of pre-hashed data) for curve P-521.
If you use .verifierFor
, and this happens, the library will transparently substitute a pure-Kotlin implementation.
If this is not desired, you can specifically enforce a platform verifier by using .platformVerifierFor
.
That way, the library will only ever act as a proxy to platform APIs (JCA, CryptoKit, etc.), and will not use its own implementations.
You can also further configure the verifier, for example to specify the provider
to use on the JVM.
To do this, pass a DSL configuration lambda to verifierFor
/platformVerifierFor
.
There really is not much more to it. This pattern works the same on all platforms. Details on how to parse cryptographic material can be found in the section on decoding in of the Indispensable module description.
Ephemeral Keys and Ephemeral Signers
Ephemeral keys and ephemeral signers are not backed by a provider, but are still delegated to platform functionality. They are just not persisted and work the same across platforms.
To obtain an ephemeral signer, call Signer.Ephemeral{}
and pass EC or RSA-specific configuration options as you would when creating a key using
the SigningProvider
.
Alternatively, you can create an ephemeral key using EphemeralKey{}
(and again, pass algorithm-specific configuration options).
To obtain a signer from this ephemeral key, call getSigner{}
on it. This, similarly to provider-backed keys, takes
algorithm-specific configuration options, such as a specific hash algorithm or padding, in case more than one was
specified when creating the ephemeral key.
Digest Calculation
The Supreme KMP crypto provider introduces a digest()
extension function on the Digest
class.
For a list of supported algorithms, check out the feature matrix.
Attestation
The Android KeyStore offers key attestation certificates for hardware-backed keys.
These certificates are exposed by the signer's .attestation
property.
For iOS, Apple does not provide this capability, but rather supports app attestation. We therefore piggy-back onto iOS app attestation to provide a home-brew "key attestation" scheme. The guarantees are different: you are trusting the OS, not the actual secure hardware; and you are trusting that our library properly interfaces with the OS. On a technical level, it works as follows:
Note
This section assumes in-depth knowledge of how an Apple attestation statement is created and validated, as described in the Apple Developer Documentation on AppAttest.
We make use of the fact that verification of clientHashData
is purely up to the back-end.
Hence, we create an attestation key, immediately afterwards create a P-256 key inside the secure enclave, and compute
clientHashData
over both the nonce obtained from the back-end and the public key bytes of the freshly created, SE-protected
EC key.
The iOS attestation type hence includes an attestation statement, the challenge, and the public key, so that the back-end
can easily verify the attestation result based on Apple's AppAttest service and the public key bytes, hence emulating
key attestation. Strictly speaking, this is a violation of the process described by Apple, but cryptographically, it is
perfectly sound!
The JVM also "supports" a custom attestation format. By default, it is rather nonsensical. However, if you plug an HSM that supports attestation to the JCA, you can make use of it.
The feature matrix also contains remarks on attestation, while details on the attestation format can be found in the corresponding API documentation.