Cross-platform hardware-based decryption: challenge accepted

Hardware encryption is becoming increasingly more common on mobile devices. One such example is the Secure Enclave (SE) chip in iOS devices with an A7 or later processor, first introduced late 2013. Apple’s Swift framework allows storing private keys in the keychain by default, but accessing the SE requires the use of CryptoKit. In an attempt to securely transfer data to such devices, my colleague and I started digging deeper into this library and found ourselves stumbling upon a few restrictions and challenges along the way. It’s safe to say documentation on the topic is a bit scarce, and figuring out the exact way to make it work proved to be quite the adventure. Where did we start? What’s in the box? And most importantly, can we send an encrypted message from one platform to another? Find out below!

Apple’s CryptoKit and the SealedBox

Encryption used in cryptography today can be software-based, hardware-based or a combination of both. While software encryption is usually easier and cheaper to implement, hardware encryption is safer and offers performance benefits because the encryption process runs a dedicated, separate processor. Consequently, even if the operating system or application is compromised, the encrypted data may still be secure.

Keys and data in the SE never leave it. They’re never loaded into memory or written to disk, so they’re completely protected. Your app communicates with the SE via a mailbox, where you deposit data to be encrypted or decrypted, then retrieve the results. CryptoKit enables a simple way to interact with the SE from inside iOS mobile apps. Developer documentation on the exact implementation is not very detailed, and all we could find was the following 2 restrictions imposed on the specific API we need:

  1. Only NIST P-256 Elliptic Curve keys are supported.
  2. Only available encryption method: “eciesEncryptionCofactorX963SHA256AESGCM

Additionally, when looking at the input for the decryption method in the CryptoKit library, it’s expecting something called a SealedBox. A SealedBox is an implementation of an Authenticated Encryption with Associated Data (AEAD) cipher which is ultimately what we have to end up with on the encryption side. It consists of 3 parameters which makes it securely transmittable out of band:

  1. nonce; prevents replay attacks
  2. ciphertext; encrypted data block that is unintelligible to anyone without the correct key
  3. tag; message digest which proves ciphertext authenticity


The good news is that, according to the official documentation, we know the algorithm relies on the Elliptic Curve Integrated Encryption Scheme (ECIES) encryption standard. ECIES is a hybrid encryption scheme, meaning it combines the properties of both symmetric and asymmetric encryption. The symmetric encryption is used for the actual data encryption, while the asymmetric encryption is used for key exchange and authentication.The benefit of Elliptic Curve Cryptography (ECC) is that it allows the use of much smaller key sizes than RSA while maintaining similar security: for example, the security of a 256-bit ECC public key is comparable to a 3072-bit RSA public key.

The bad news is that ECIES is a framework, and not a concrete algorithm. There are many ways to implement it and for it to work, the implementation has to be exactly right. In order to better understand what exactly we’re dealing with here, we have to dissect this encryption method into a few protocols. Beware, acronyms ahead!

  • NIST P-256 Elliptic Curve (EC) or “secp256r1” asymmetric keypair(s)
  • Elliptic-curve Diffie–Hellman (ECDH) key agreement with Cofactor
  • ANSI-X9.63 Key Derivation Function (KDF)
  • Hash-based Message Authentication Code with SHA256 hashing function (HMAC-SHA256)
  • Advanced Encryption Standard Galois/Counter Mode (AES-GCM)

An in-depth explanation of each of these is beyond the scope of this article. Instead you’ll find a high-level overview along with a brief explanation and code snippets for each step. In order to test cross-platform functionality, we chose to implement this in Java 17 using the BouncyCastle crypto library.

Putting it together

In this example Bob and Alice, our famous cryptography couple, want to securely exchange a message:

  1. Bob sends his EC public key to Alice. Alice generates a plaintext message.
  2. An ephemeral EC keypair is generated using the NIST P-256 curve, to be used for this transaction only, after which it is destroyed.
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
ECNamedCurveParameterSpec secp256r1 = ECNamedCurveTable.getParameterSpec("secp256r1");

KeyPair ephemeralKeyPair = kpg.generateKeyPair();
ECPublicKey ephemeralPublicKey = (ECPublicKey) ephemeralKeyPair.getPublic();
ECPrivateKey ephemeralPrivateKey = (ECPrivateKey) ephemeralKeyPair.getPrivate();
  1. Bob’s public key and Alice’s private key are then used during the Elliptic-curve Diffie–Hellman key agreement protocol to generate a shared secret.
KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH");
keyAgreement.doPhase(ephemeralPublicKey, true);

byte[] sharedSecret = keyAgreement.generateSecret();
  1. This shared secret is used in the key derivation function along with “shared info” input to generate the 32-byte AES-key. While the shared info parameter does impact the outcome of the AES-key, it should not be regarded as a secret, but rather an agreement between the two parties about the key usage.
byte[] AESKeyBytes = new byte[AES_KEY_BYTE_LENGTH];
byte[] sharedInfo = "SHARED_INFO".getBytes(StandardCharsets.UTF_8);
KDF2BytesGenerator kdf = new KDF2BytesGenerator(new SHA256Digest());
kdf.init(new KDFParameters(sharedSecret, sharedInfo));
kdf.generateBytes(AESKeyBytes, 0, AES_KEY_BYTE_LENGTH);

Key AESKey = new SecretKeySpec(AESKeyBytes, "AES");
  1. The derived AES-key is used to instantiate the AES-GCM cipher function along with a randomized 12-byte initialization vector (IV). Although AES expects a 16-byte IV (identical to the block size), in our testing only the first 12 bytes were used in the SealedBox implementation. As you might have guessed already, the generated IV will be the nonce for the SealedBox.
byte[] iv = new byte[IV_BYTE_LENGTH];
new SecureRandom().nextBytes(iv);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);

Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, AESKey, ivParameterSpec);
  1. The output of the cipher is the [ciphertext + tag] bytes. So all we have to do now is prepend it with the generated IV bytes and we have our SealedBox bytes object.
byte[] messageBytes = message.getBytes();
byte[] cipherResult = c.doFinal(messageBytes);
byte[] sealedBox = new byte[iv.length + cipherResult.length];

System.arraycopy(iv, 0, sealedBox, 0, iv.length);
System.arraycopy(cipherResult, 0, sealedBox, iv.length, cipherResult.length);

return sealedBox;

This SealedBox is then sent to Bob along with Alice’s ephemeral public EC key generated in step 2. Using this ephemeral public key and Bob’s private key, the reverse ECDH key agreement function should provide Bob with the shared secret needed to generate the same AES-key, and allow him to decrypt the ciphertext.

And there we have it: a way to send cross-platform encrypted data which should only be possible to be decrypted using the Secure Enclave on iOS devices. We came across quite a few articles and github projects working with older versions of the API, so this all might very well become outdated soon. Yet hopefully this post comes to good use for anyone trying to find out what exactly is in the (sealed) box and how to approach it, or trying to achieve similar functionality. And remember: no matter which crypto you implement, always keep your keys safe!