Loading...
Loading...

Anders & A-Cube S.r.l. / December 15, 2024 ⢠6 min read
expo-mutual-tls is a production-ready Expo module that provides secure, hardware-backed mutual TLS (mTLS) client certificate authentication for mobile applications. Built with native implementations for both iOS (Swift) and Android (Kotlin), it offers enterprise-grade security features with a developer-friendly API.
The module follows Expo's native module architecture pattern with three distinct layers:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā JavaScript/TypeScript Layer ā
ā (ExpoMutualTls Utility Wrapper) ā
āāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
āāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Expo Module Bridge Layer ā
ā (ExpoModulesCore Integration) ā
āāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāā
ā ā
āāāāāāāā¼āāāāāāāāā āāāāāāāāāā¼āāāāāāāāāā
ā iOS Native ā ā Android Native ā
ā (Swift) ā ā (Kotlin) ā
āāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāā
src/)1. ExpoMutualTls.ts - Main utility class providing simplified API
2. ExpoMutualTls.types.ts - TypeScript type definitions
3. ExpoMutualTlsModule.ts - Raw module exports
ios/)1. ExpoMutualTlsModule.swift - Main iOS module
// Thread-safe state management
private let stateQueue = DispatchQueue(label: "com.expo.mutualtls.state")
private var _isConfigured: Bool = false
private var _currentConfig: MutualTlsConfig?
// Core components initialization
private let keychainManager = KeychainManager.shared
private let certificateParser = CertificateParser.shared
internal let networkManager = NetworkManager()2. KeychainManager.swift - Secure certificate storage
3. CertificateParser.swift - Certificate format handling
4. SSLContextManager.swift - TLS context management
5. NetworkManager.swift - HTTP networking
6. ExpoMutualTlsTypes.swift - Swift type definitions
7. ExpoMutualTlsErrors.swift - Error handling
android/)1. ExpoMutualTlsModule.kt - Main Android module
companion object {
private const val MODULE_NAME = "ExpoMutualTls"
@Volatile private var isConfigured = false
@Volatile private var sslSocketFactory: SSLSocketFactory? = null
@Volatile private var trustManager: X509TrustManager? = null
}
private val keychain by lazy { KeychainManager(ctx) }
private val pemParser by lazy { PemCertificateParser() }2. KeychainManager.kt - Android secure storage
3. PemCertificateParser.kt - Certificate parsing
Source Files (TypeScript + Native)
ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā expo-module-scripts ā
ā - TypeScript compilation ā
ā - Type generation ā
ā - Build orchestration ā
āāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāā
ā
āāāāāāāāā“āāāāāāāāā
ā¼ ā¼
āāāāāāāāāāā āāāāāāāāāāāā
ā iOS ā ā Android ā
ā (Xcode) ā ā (Gradle) ā
āāāāāā¬āāāāā āāāāāā¬āāāāāā
ā ā
āāāāāāāā¬āāāāāāāā
ā¼
NPM Package (.tgz)
Configuration (tsconfig.json):
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build"
},
"include": ["./src"],
"exclude": ["**/__tests__/*"]
}Build Steps:
# 1. Clean previous builds
npm run clean # ā expo-module clean
# 2. Compile TypeScript to JavaScript
npm run build # ā expo-module build
# Outputs: build/index.js, build/index.d.ts
# 3. Generate type definitions
# Auto-generated from .types.ts filesCocoaPods Integration (ExpoMutualTls.podspec):
Pod::Spec.new do |s|
s.name = 'ExpoMutualTls'
s.version = '1.0.3'
s.summary = 'Expo Mutual TLS module'
s.license = 'MIT'
s.authors = 'A-Cube S.r.l.'
s.homepage = 'https://github.com/a-cube-io/expo-mutual-tls'
s.platform = :ios, '13.0'
s.swift_version = '5.4'
s.source = { git: 'https://github.com/a-cube-io/expo-mutual-tls.git' }
s.source_files = 'ios/**/*.{h,m,swift}'
s.dependency 'ExpoModulesCore'
endBuild Process:
Key iOS Frameworks Used:
Security - Keychain and certificate managementFoundation - Core utilitiesNetwork - TLS configurationCryptoKit - Cryptographic operationsGradle Integration (android/build.gradle):
dependencies {
implementation project(':expo-modules-core')
implementation 'org.bouncycastle:bcpkix-jdk15on:1.70'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
}
android {
compileSdkVersion 33
defaultConfig {
minSdkVersion 24
targetSdkVersion 33
}
kotlinOptions {
jvmTarget = '1.8'
}
}Build Process:
Key Android Dependencies:
expo-modules-core - Expo module frameworkbouncycastle - Certificate parsing and cryptookhttp3 - HTTP client with SSL supportExpo Module Config (expo-module.config.json):
{
"platforms": ["apple", "android", "web"],
"apple": {
"modules": ["ExpoMutualTlsModule"]
},
"android": {
"modules": ["expo.modules.mutualtls.ExpoMutualTlsModule"]
}
}This configuration tells Expo how to link native modules on each platform.
{
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly"
}
}Script Purposes:
build - Compile TypeScript and generate typesclean - Remove build artifactslint - Run ESLint checkstest - Execute test suiteprepare - Pre-installation setup (auto-runs on install)prepublishOnly - Final checks before NPM publishImplementation Flow:
Base64 P12 Data + Password
ā
iOS: Security Framework Parser
Android: KeyStore.getInstance("PKCS12")
ā
Extract Certificate + Private Key
ā
Store in Secure Storage
ā
Create SSL Context
Implementation Flow:
PEM Certificate + PEM Private Key
ā
iOS: CertificateParser (SecCertificateCreateWithData)
Android: BouncyCastle PEMParser
ā
Parse X.509 Certificate
ā
Parse PKCS#8 Private Key
ā
Store Separately in Keychain
ā
Create SSL Context
// Keychain storage with hardware backing
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecAttrService as String: serviceIdentifier,
kSecValueData as String: certificateData
]
// Biometric protection (optional)
if requireBiometric {
query[kSecAttrAccessControl] =
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)
}// Generate wrapping key in hardware
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val keyGenSpec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(requireBiometric)
.setUserAuthenticationValidityDurationSeconds(validitySeconds)
.build()
keyGenerator.init(keyGenSpec)
val secretKey = keyGenerator.generateKey()func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Load client certificate from keychain
let identity = loadClientIdentity()
let credential = URLCredential(
identity: identity,
certificates: [certificate],
persistence: .forSession
)
completionHandler(.useCredential, credential)
}val sslContext = SSLContext.getInstance("TLS")
val keyManagerFactory = KeyManagerFactory.getInstance(
KeyManagerFactory.getDefaultAlgorithm()
)
keyManagerFactory.init(keyStore, password.toCharArray())
sslContext.init(
keyManagerFactory.keyManagers,
trustManagerFactory.trustManagers,
SecureRandom()
)
val client = OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()The module provides three event channels for comprehensive monitoring:
ExpoMutualTls.onDebugLog((event) => {
// event.type: 'certificate_storage' | 'network_request' |
// 'keychain_operation' | 'tls_handshake'
// event.message, event.url, event.statusCode, event.duration
})ExpoMutualTls.onError((event) => {
// event.message, event.code
// Codes: 'CERTIFICATE_NOT_FOUND', 'SSL_HANDSHAKE_FAILED', etc.
})ExpoMutualTls.onCertificateExpiry((event) => {
// event.subject, event.expiry, event.warning
// Automatic warnings N days before expiry
})1. Format Validation
ā Verify P12/PEM structure
2. Date Validation
ā Check notBefore/notAfter
3. EKU Validation
ā Verify clientAuth usage
4. Key Pair Validation
ā Ensure private key matches certificate
5. Chain Validation
ā Verify certificate chain integrity
6. Storage Validation
ā Confirm secure storage success
iOS Security Framework:
Android Keystore:
| Operation | iOS | Android | Notes |
|---|---|---|---|
| Configure Module | 50ms | 100ms | One-time setup |
| Store P12 Certificate | 200-400ms | 300-600ms | Includes parsing + storage |
| Store PEM Certificate | 150-300ms | 250-500ms | Faster than P12 |
| Load from Keychain | 50-100ms | 80-150ms | Cached after first load |
| mTLS Handshake | 300-800ms |
# 1. Clone repository
git clone https://github.com/a-cube-io/expo-mutual-tls.git
cd expo-mutual-tls
# 2. Install dependencies
npm install
# 3. Build the module
npm run build
# 4. Run example app (iOS)
cd example
npx expo prebuild --platform ios
npx expo run:ios
# 5. Run example app (Android)
npx expo prebuild --platform android
npx expo run:androidUnit Tests (TypeScript):
Integration Tests (Native):
E2E Tests (Example App):
# Automated via scripts/release.sh
./scripts/release.sh patch # 1.0.3 ā 1.0.4
./scripts/release.sh minor # 1.0.3 ā 1.1.0
./scripts/release.sh major # 1.0.3 ā 2.0.0
# Steps performed:
# 1. Run tests
# 2. Update version in package.json
# 3. Build module
# 4. Git tag
# 5. NPM publish
# 6. GitHub releaseimport React, { useEffect, useState } from 'react'
import ExpoMutualTls from '@a-cube-io/expo-mutual-tls'
import { Asset } from 'expo-asset'
import * as FileSystem from 'expo-file-system'
export default function SecureApiClient() {
const [status, setStatus] = useState('initializing')
useEffect(() => {
setupMtls()
}, [])
const setupMtls = async () => {
try {
// 1. Configure module
await ExpoMutualTls.
ā What Worked Well:
š Key Takeaways:
useMtls() custom hooksThe project welcomes contributions in:
expo-mutual-tls demonstrates production-ready implementation of complex security features in a cross-platform mobile environment. The project successfully balances security requirements with developer experience, achieving:
The module serves as an excellent reference for:
Repository: github.com/a-cube-io/expo-mutual-tls Package: @a-cube-io/expo-mutual-tls License: MIT
| 400-900ms |
| Network dependent |
| Authenticated Request | 500-1500ms | 600-1600ms | Network + TLS overhead |