Seal
Certificate Transparency for Kotlin Multiplatform
Seal is a Kotlin Multiplatform library that brings Certificate Transparency (CT) verification to Android, iOS, JVM Desktop, and Web (wasmJs) applications.
It verifies Signed Certificate Timestamps (SCTs) to ensure TLS certificates have been publicly logged, protecting users against misissued or rogue certificates.
What is Certificate Transparency?
Certificate Transparency is a public framework of append-only certificate logs that allows anyone to monitor the certificates issued for their domains.
When a Certificate Authority (CA) issues a TLS certificate, it submits the certificate to one or more CT logs and receives a Signed Certificate Timestamp (SCT) — a cryptographic proof that the certificate has been logged.
Seal verifies these SCTs during HTTPS connections to ensure that:
- The certificate was publicly logged before use.
- Enough independent log operators have witnessed the certificate (as required by the configured policy).
- The SCT signatures are valid and from trusted logs.
Without CT enforcement, a compromised or rogue CA can issue certificates that silently intercept traffic.
Seal closes this gap at the application layer.
Features
Architecture
graph TD
composeApp[":composeApp<br>(Demo App)<br>Android · iOS · Desktop · Web"]
ktor[":seal-ktor<br>Ktor Plugin<br>Android · iOS · JVM · wasmJs"]
core[":seal-core<br>Models · ASN.1 · SCT Parser<br>Policy Engine · Platform Integrations<br>Android · iOS · JVM · wasmJs"]
composeApp --> ktor
composeApp --> core
ktor --> core
Which module do I need?
Installation
Version Catalog
Add the Seal versions to your libs.versions.toml:
[versions]
seal = "<version>"
[libraries]
seal-core = { module = "io.github.jermeyyy:seal-core", version.ref = "seal" }
seal-ktor = { module = "io.github.jermeyyy:seal-ktor", version.ref = "seal" }
Gradle Dependencies
commonMain.dependencies {
implementation(libs.seal.core)
}
commonMain.dependencies {
implementation(libs.seal.ktor)
}
Or with raw coordinates:
implementation("io.github.jermeyyy:seal-core:<version>")
implementation("io.github.jermeyyy:seal-ktor:<version>")
Quick Start — OkHttp (Android & JVM Desktop)
import com.jermey.seal.jvm.okhttp.certificateTransparencyInterceptor
val client = OkHttpClient.Builder()
.addNetworkInterceptor(
certificateTransparencyInterceptor {
}
)
.build()
Important: The interceptor must be added as a network interceptor (not an application interceptor) so it runs after TLS negotiation and has access to the certificate chain.
Quick Start — Ktor
import com.jermey.seal.ktor.CertificateTransparency
val client = HttpClient(OkHttp) {
install(CertificateTransparency) {
+"*.example.com"
-"internal.example.com"
failOnError = false
}
}
val client = HttpClient(Darwin) {
install(CertificateTransparency) {
+"*.example.com"
-"internal.example.com"
failOnError = false
}
}
client = HttpClient(Js) {
install(CertificateTransparency)
}
The Ktor plugin uses the same configuration DSL on all platforms. The underlying implementation delegates to the appropriate platform engine automatically.
For full guide, see Ktor Integration.
OkHttp Integration
The OkHttp integration provides a CertificateTransparencyInterceptor that verifies SCTs on every HTTPS request.
It supports all three SCT delivery methods: X.509 embedded, TLS extension, and OCSP stapling.
This works on both Android and JVM Desktop.
import com.jermey.seal.jvm.okhttp.certificateTransparencyInterceptor
import com.jermey.seal.jvm.okhttp.installCertificateTransparency
val client = OkHttpClient.Builder()
.installCertificateTransparency {
+"*.example.com"
-"internal.example.com"
policy = ChromeCtPolicy()
failOnError = true
logger = { host, result ->
println("CT: $host: $result")
}
}
.build()
Conscrypt is required for TLS extension and OCSP SCT access. Initialize it early:
import com.jermey.seal.jvm.ConscryptInitializer
ConscryptInitializer.initialize()
For full guide, see OkHttp Integration.
TrustManager Integration (Android & JVM Desktop)
For apps that need CT verification at the TLS level (instead of the OkHttp interceptor level):
import com.jermey.seal.jvm.trust.CTTrustManagerFactory
val ctTrustManager = CTTrustManagerFactory.create {
+"*.example.com"
failOnError = true
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(ctTrustManager), null)
Configuration Reference
Both the OkHttp interceptor and the Ktor plugin share the same DSL (CTConfigurationBuilder):
Host Patterns
Excludes always take precedence over includes.
For full reference, see Configuration.
CT Policy Selection
By default, Seal uses ChromeCtPolicy, which requires SCTs from at least one Google-operated
log and one non-Google log. This mirrors Chrome's behavior but can be too strict for some
certificates.
If you encounter "Too few distinct operators" failures, you can switch to Apple's policy:
installCertificateTransparency {
useApplePolicy()
}
| Policy | SCT Count (< 180 days) | SCT Count (≥ 180 days) | Operator Diversity |
|---|
Custom Policies
Seal ships with two built-in CT policies:
| Policy | Description |
|---|
ChromeCtPolicy() | Mirrors Chrome's CT requirements — SCT count depends on certificate lifetime (default) |
AppleCtPolicy() | Mirrors Apple's CT requirements — stricter operator diversity rules |
You can implement a custom policy by implementing the CTPolicy interface:
val custom = CTPolicy { certificateLifetimeDays, sctResults ->
val validFromDistinct = sctResults
.filterIsInstance<SctVerificationResult.Valid>()
.distinctBy { it.logOperator }
if (validFromDistinct.size >= 2) {
VerificationResult.Success.Trusted(validFromDistinct)
} else {
VerificationResult.Failure.TooFewDistinctOperators(
found = validFromDistinct.size,
required = 2,
)
}
}
For more details, see Configuration.
iOS
SecTrust Integration
IosCertificateTransparencyVerifier combines manual embedded-SCT verification with OS-level TLS/OCSP CT checking:
import com.jermey.seal.ios.IosCertificateTransparencyVerifier
val configuration = ctConfiguration {
failOnError = true
}
val verifier = IosCertificateTransparencyVerifier(configuration)
val result = verifier.verify(secTrust, host)
URLSession Delegate
UrlSessionCtHelper wraps CT verification for use in a URLSessionDelegate:
import com.jermey.seal.ios.urlsession.UrlSessionCtHelper
val helper = UrlSessionCtHelper(configuration, verifier)
override fun URLSession(
session: NSURLSession,
didReceiveChallenge: NSURLAuthenticationChallenge,
completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit,
) {
val (disposition, credential) = helper.handleServerTrustChallenge(didReceiveChallenge)
completionHandler(disposition, credential)
}
Ktor Darwin Engine
For Ktor-based iOS apps, use the CertificateTransparency plugin with the Darwin engine — no additional iOS-specific setup required.
For full guide, see iOS Integration.
Verification Results
Seal reports results through a sealed class hierarchy:
Platform Notes
Android
| |
|---|
| Min SDK | 24 (Android 7.0) |
| SCT Methods | Embedded, TLS extension, OCSP stapling (all three) |
|
iOS
| |
|---|
| Min Target | iOS 15+ |
| Embedded SCTs | Verified manually by Seal against its bundled log list |
JVM Desktop
Web (wasmJs)
| |
|---|
| Runtime | Modern browsers with WebAssembly support |
| CT Verification | Handled natively by the browser — Seal provides audit-mode reporting |
|
API Documentation
Full KDoc API documentation is available at: jermeyyy.github.io/seal/api/
Demo App
The repository includes a Compose Multiplatform demo app targeting Android, iOS, JVM Desktop, and Web.
# Android
./gradlew :composeApp:assembleDebug
# JVM Desktop
./gradlew :composeApp:run
# Web (wasmJs)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# iOS — open in Xcode
open iosApp/iosApp.xcodeproj
The demo app makes HTTPS requests and displays CT verification results in real time.
FAQ / Troubleshooting
Why must it be a network interceptor?
OkHttp network interceptors run after the TLS handshake, which means the certificate chain is available for inspection.
Application interceptors run before the connection is established and cannot access TLS state.
What happens when CT verification fails?
By default (failOnError = false), failures are reported through the logger callback but the connection proceeds normally (fail-open).
If you set failOnError = true, a SSLPeerUnverifiedException is thrown and the connection is aborted.
Do I need Conscrypt?
On Android and JVM Desktop, Conscrypt is required to access TLS extension and OCSP-stapled SCTs. Without it, only embedded X.509 SCTs are verified.
Call ConscryptInitializer.initialize() as early as possible.
On iOS and Web, Conscrypt is not used.
What if the log list is stale or unavailable?
Seal ships a bundled log list compiled at build time. If the network fetch fails or the cached list is stale, the bundled list is used as a fallback.
When failOnError = false (default), a stale log list will not block connections.
Does this library pin certificates?
No. Seal performs Certificate Transparency verification, not certificate pinning. CT checks that certificates were publicly logged;
pinning restricts which certificates are accepted. They are complementary techniques.
Does the web target actually verify CT?
No. In browsers, Certificate Transparency is enforced natively by the browser engine.
The wasmJs target provides the same API surface for code sharing but does not perform additional CT verification — the browser already handles it.
Building
# Build all modules
./gradlew assemble
# Run all tests
./gradlew allTests
# Build the demo app (Android)
./gradlew :composeApp:assembleDebug
# Run the demo app (Desktop)
./gradlew :composeApp:run
# Run the demo app (Web)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# Generate API documentation
./gradlew dokkaGenerateModuleHtml
For the iOS demo app, open iosApp/iosApp.xcodeproj in Xcode and run from there.
Contributing
Contributions are welcome! Please open an issue first to discuss what you'd like to change.
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature)
- Commit your changes (
git commit -am 'Add my feature')
- Push to the branch (
git push origin feature/my-feature)
- Open a Pull Request
Please ensure all tests pass (./gradlew allTests) and follow the existing code style.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Documentation · API Reference · Certificate Transparency · Kotlin Multiplatform