Kotlin Multiplatform Crypto/PKI Library with ASN1 Parser + Encoder
This last bit means that you can share ASN.1-related logic across platforms.
The very first bit means that you can create and verify signatures on the JVM, Android and on iOS, using platform-native
crypto hardware.
Do check out the full manual with examples and API docs here!
This README provides just an overview.
The full manual is more comprehensive, has separate sections for each module, provides examples, and a full API documentation.
Using it in your Projects
This library was built for Kotlin Multiplatform. Currently, it targets
the JVM, Android and iOS.
It consists of four modules, each of which is published on maven central:
This separation keeps dependencies to a minimum, i.e. it enables including only JOSE-related functionality, if COSE is irrelevant.
More importantly, in a JVM, iOS, or Android-only project, it allows for processing cryptographic material without imposing the inclusion of a crypto provider.
Simply declare the desired dependency to get going:
implementation("at.asitplus.signum:indispensable:$version")
implementation("at.asitplus.signum:indispensable-josef:$version")
implementation("at.asitplus.signum:indispensable-cosef:$version")
implementation("at.asitplus.signum:supreme:$supremeVersion")
Rationale
Looking for a KMP cryptography framework, you have undoubtedly come across
cryptography-kotlin. So have we and it is a powerful
library, supporting more platforms and more cryptographic operations than Signum Supreme.
This begs the question: Why implement another, incompatible
cryptography framework from scratch? The short answer is: Signum and cryptography-kotlin pursue different goals and priorities.
cryptography-kotlin strives for covering a wide range of targets and a broad range of operations based on a flexible provider architecture.
Signum, on the other hand, focuses on tight platform integration (including hardware-backed crypto and attestation!),
and comprehensive ASN.1, JOSE, and COSE support.
The following table provides a detailed comparison between Signum and cryptography-kotlin.
Supreme Demo Reel
The Supreme KMP crypto provider works differently from JCA. Configuration is type-safe, more expressive and more concise,
meaning you'll end up with less code. Nothing throws! Do not discard the results returned from any operation!
Signature Creation
To create a signature, obtain a Signer instance.
You can do this using Signer.Ephemeral to create a signer for a throwaway keypair:
val signer = Signer.Ephemeral {}.getOrThrow()
val plaintext = "You have this.".encodeToByteArray()
val signature = signer.sign(plaintext).signature
println("Signed using ${signer.signatureAlgorithm}: $signature")
If you want to create multiple signatures using the same ephemeral key, you can obtain an EphemeralKey instance, then create signers from it:
val key = EphemeralKey { rsa {} }.getOrThrow()
val sha256Signer = key.getSigner { rsa { digest = Digests.SHA256 } }.getOrThrow()
val sha384Signer = key.getSigner { rsa { digest = Digests.SHA384 } }.getOrThrow()
The instances can be configured using the configuration DSL.
Any unspecified parameters use sensible, secure defaults.
Platform Signers
On Android and iOS, signers using the systems' secure key storage can be retrieved.
To do this, use PlatformSigningProvider (in common code), or interact with AndroidKeystoreProvider/IosKeychainProvider (in platform-specific code).
New keys can be created using createSigningKey(alias: String) { /* configuration */ },
and signers for existing keys can be retrieved using getSignerForKey(alias: String) { /* configuration */ }.
For example, creating an elliptic-curve key over P256, stored in secure hardware, and with key attestation using a random challenge provided by your server, might be done like this:
val serverChallenge: ByteArray = TODO("This was unpredictably chosen by your server.")
PlatformSigningProvider.createSigningKey(alias = "Swordfish") {
ec {
curve = ECCurve.SECP_256_R_1
}
hardware {
backing = REQUIRED
attestation { challenge = serverChallenge }
protection {
timeout = seconds
factors {
biometry =
deviceLock =
}
}
}
}
If this operation succeeds, it returns a Signer. The same Signer could later be retrieved using PlatformSigningProvider.getSignerForKey(alias: String).
When you use this Signer to sign data, the user would be prompted to authorize the signature using an enrolled fingerprint, because that's what you specified when creating the key.
You can configure the authentication prompt:
val plaintext = "A message".encodeToByteArray()
val signature = signer.sign(plaintext) {
unlockPrompt {
message = "Signing a message to Bobby"
}
}.signature
... but you cannot change the fact that you configured this key to need biometry. Consider this when creating your keys.
On the JVM, no native secure hardware storage is available.
File-based keystores can be accessed using JKSProvider { file { /* ... */ } }.
Other keystores can be accessed using JKSProvider { withBackingObject{ /* ... */ } } or JksProvider { customAccessor{ /* ... */ } }.
For more details, please refer to the provider's configuration options.
Key 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.
We instead 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.
Attestation types are serializable for transfer, and correspond to those in Indispensable's attestation module.
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")
Or here's how to validate a X.509 certificate:
val rootCert: X509Certificate = TODO("You have this and trust it.")
val untrustedCert: X509Certificate = TODO("You want to verify that this is trustworthy.")
val verifier = untrustedCert.signatureAlgorithm.verifierFor(rootCert.publicKey).getOrThrow()
val plaintext = untrustedCert.tbsCertificate.encodeToDer()
val signature = untrustedCert.signature
val isValid = verifier.verify(plaintext, signature).isSuccess
println("Certificate looks trustworthy: $isValid")
Platform Verifiers
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.
val publicKey: CryptoPublicKey.EC = TODO("You have this.")
val plaintext: ByteArray = TODO("This is the message.")
val signature: CryptoSignature.EC = TODO("And this is the signature.")
val verifier = SignatureAlgorithm.ECDSAwithSHA512
.platformVerifierFor(publicKey) { provider = "BC"}
.getOrThrow()
val isValid = verifier.verify(plaintext, signature).isSuccess
println("Is it trustworthy? $isValid")
Symmetric Encryption
We currently support ChaCha20-Poly1503, AES-CBC, AES-GCM, AES-KW, AES-ECB, and a very flexible flavour of AES-CBC-HMAC.
Every symmetric operation is rooted in an algorithm, since the algorithm defines characteristics, such as nonce requirement,
authentication capabilities, etc. Hence, you need to know the algorithm.
Baseline Usage
Once you know decided on an encryption algorithm, encryption itself is straight-forward:
val secret = "Top Secret".encodeToByteArray()
val secretKey = SymmetricEncryptionAlgorithm.ChaCha20Poly1305.randomKey()
val encrypted = secretKey.encrypt(secret).getOrThrow()
encrypted.decrypt(secretKey).getOrThrow() shouldBe secret
Encrypted data is always structured and the individual components are easily accessible:
val nonce = encrypted.nonce
val ciphertext = encrypted.encryptedData
val authTag = encrypted.authTag
val keyBytes = secretKey.secretKey.getOrThrow()
Decrypting data received from external sources is also straight-forward:
val box = algo.sealedBox.withNonce(nonce).from(ciphertext, authTag).getOrThrow()
box.decrypt(preSharedKey, externalAAD).getOrThrow() shouldBe secret
preSharedKey.decrypt(nonce, ciphertext, authTag, externalAAD).getOrThrow() shouldBe secret
Custom AES-CBC-HMAC
Supreme supports AES-CBC with customizable HMAC to provide AEAD.
This is supported across all Supreme targets and works as follows:
ASN.1 Demo Reel
Classes like CryptoPublicKey, X509Certificate, Pkcs10CertificationRequest, etc. all
implement Asn1Encodable and their respective companions implement Asn1Decodable.
Which means that you can do things like parsing and examining certificates, creating CSRs, or transferring key
material.
Certificate Parsing
val cert = X509Certificate.decodeFromDer(certBytes)
when (val pk = cert.publicKey) {
is CryptoPublicKey.EC -> println(
"Certificate with serial no. ${
cert.tbsCertificate.serialNumber
} contains an EC public key using curve ${pk.curve}"
)
is CryptoPublicKey.RSA -> println(
"Certificate with serial no. ${
cert.tbsCertificate.serialNumber
} contains a bit RSA public key"
)
}
println()
println()
Which produces the following output:
Certificate with serial no. 19821EDCA68C59CF contains an EC public key using curve SECP_256_R_1
The full certificate is:
Re-encoding it produces the same bytes? true
Creating a CSR
val ecPublicKey: ECPublicKey = TODO("From platform-specific code")
val cryptoPublicKey = CryptoPublicKey.EC.fromJcaPublicKey(ecPublicKey).getOrThrow()
val commonName = "DefaultCryptoService"
val signatureAlgorithm = X509SignatureAlgorithm.ES256
val tbsCsr = TbsCertificationRequest(
version = 0,
subjectName = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8(commonName)))),
publicKey = cryptoPublicKey
)
val signed: ByteArray = TODO("pass tbsCsr.encodeToDer() to platform code")
val csr = Pkcs10CertificationRequest(tbsCsr, signatureAlgorithm, signed)
println(csr.encodeToDer())
Which results in the following output:
3081D9308181020100301F311D301B06035504030C1444656661756C74437279
70746F536572766963653059301306072A8648CE3D020106082A8648CE3D0301
07034200043797E977E359AAABFC9177E7C95FD5B4BE4AC24C4FF13F3233F774
E8B65FE5FBA5057513BD076CFFB2E17567AC9BD43737FB6BDF496CC6DCB47194
BBE7512F0BA000300A06082A8648CE3D0403020347003044022079D188C09E20
C70AFF096B9484DDDE70484485FD551676273A517E818B94644E02206B222905
D343C1D6FC9319A364CECA7E67956E4B99D63537E17A9F5D4093D7AE
Working with Generic ASN.1 Structures
The magic shown above is based on a from-scratch 100% KMP implementation of an ASN.1 encoder and parser.
To parse any DER-encoded ASN.1 structure, call either:
Asn1Element.parse(), which will consume all bytes and return the first parsed ASN.1 element.
This method throws if parsing errors occur or any trailing bytes are left after parsing the first element.
Asn1Element.parseFirst(), which will try to parse a single toplevel ASN.1 element.
Any remaining bytes can still be consumed from the iterator, as it will only be advanced to right after the first parsed element.
Asn1Element.parseAll(), wich consumes all bytes, parses all toplevel ASN.1 elements, and returns them as list.
Throws on any parsing error.
Asn1Elements can encoded by accessing the lazily evaluated .derEncoded property.
Even for parsed elements, this is a true re-encoding. The original bytes are discarded after decoding.
Note that decoding operations will throw exceptions if invalid data is provided!
A parsed Asn1Element can either be a primitive (whose tag and value can be read) or a structure (like a set or
sequence) whose child nodes can be processed as desired. Subclasses of Asn1Element reflect this:
Convenience wrappers exist, to cast to any subtype (e.g. .asSequence()). These shorthand functions throw an Asn1Exception
if a cast is not possible.
Any complex data structure (such as CSR, public key, certificate, …) implements Asn1Encodable, which means you can:
- encapsulate it into an ASN.1 Tree by calling
.encodeToTlv()
- directly get a DER-encoded byte array through the
.encodetoDer() function
A tandem of helper functions is available for primitives (numbers, booleans, string, bigints):
encodeToAsn1Primitive to produce an Asn1Primitive that can directly be DER-encoded
encodeToAsn1ContentBytes to produce the content bytes of a TLV primitive (the V in TLV)
Variations of these exist for Instant and ByteArray.
Check out Asn1Encoding.kt for a full
list of helper functions.
Decoding Values
Various helper functions exist to facilitate decoding the values contained in Asn1Primitives, such as readInt(),
for example. To also support decoding more complex structures, the companion objects of complex classes (such as certificates, CSRs, …)
implement Asn1Decodable, which allows for:
- directly parsing DER-encoded byte arrays by calling
.decodeFromDer(bytes) and .decodeFromDerHexString
- processing an
Asn1Element by calling .decodefromTlv(src)
Both encoding and decoding functions come in two safe (i.e. non-throwing) variants:
…Safe() which returns a KmmResult
…orNull() which returns null on error
Similarly to encoding, a tandem of decoding functions exists for primitives:
decodeToXXX to be invoked on an Asn1Primitive to decode a DER-encoded primitive into the target type
decodeFromAsn1ContentBytes to be invoked on the companion of the target type to decode the content bytes of a TLV primitive (the V in TLV)
However, anything can be decoded and tagged at will. Therefore, a generic decoding function exists, which has the
following signature:
inline fun <reified T> Asn1Primitive.decode(assertTag: Asn1Element.Tag, decode: (content: ByteArray) -> T)
Check out Asn1Decoding.kt for a full
list of helper functions.
ASN1 DSL for Creating ASN.1 Structures
While it is perfectly possible to manually construct a hierarchy of Asn1Element objects, we provide a more convenient
DSL, which returns an Asn1Structure:
Asn1.Sequence {
+ExplicitlyTagged(1uL) {
+Asn1Primitive(Asn1Element.Tag.BOOL, byteArrayOf())
}
+Asn1.Set {
+Asn1.Sequence {
+Asn1.SetOf {
+PrintableString()
+PrintableString()
}
+Asn1.Set {
+PrintableString()
+PrintableString()
+Utf8String()
}
}
}
+Asn1.Null()
+ObjectIdentifier()
+(Utf8String() withImplicitTag (0xCAFEuL withClass TagClass.PRIVATE))
+PrintableString()
+(Asn1.Sequence { +Asn1.() } withImplicitTag (0x5EUL without CONSTRUCTED))
+Asn1.Set {
+Asn1.()
+Asn1.(-)
+Asn1.Bool()
+Asn1.Bool()
}
+Asn1.Sequence {
+Asn1.Null()
+Asn1String.Numeric()
+UtcTime(Clock.System.now())
}
} withImplicitTag (1337uL withClass TagClass.APPLICATION)
In accordance with DER-Encoding, this produces the following ASN.1 structure:
Limitations
- Only DER encoding and parsing of ASN.1 structures is supported
- Higher-level abstractions (such as
X509Certificate) are too lenient in some aspects and
too strict in others.
For example: DSA-signed certificates will not parse to an instance of X509Certificate.
At the same time, certificates containing the same extension multiple times will work fine, even though they violate
the spec.
This is irrelevant in practice, since platform-specific code will perform the actual cryptographic operations on these
data structures and complain anyway, if something is off.
- No OCSP and CRL Checks (though it is perfectly possible to parse this data from a certificate and implement the checks)
- Number of supported Algorithms is limited to the usual suspects (sorry, no Bernstein curves )-:)
Contributing
External contributions are greatly appreciated! Be sure to observe the contribution guidelines (see CONTRIBUTING.md).
In particular, external contributions to this project are subject to the A-SIT Plus Contributor License Agreement (see also CONTRIBUTING.md).
The Apache License does not apply to the logos, (including the A-SIT logo) and the project/module name(s), as these are the sole property of
A-SIT/A-SIT Plus GmbH and may not be used in derivative works without explicit permission!