feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View File

@@ -0,0 +1,130 @@
package wang.yaojia.webterm.tlsandroid
import android.security.keystore.KeyProperties
import android.security.keystore.KeyProtection
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.X509Certificate
import wang.yaojia.webterm.clienttls.ParsedClientIdentity
import wang.yaojia.webterm.clienttls.Pkcs12Parse
/**
* Imports a device `.p12` into AndroidKeyStore — the ONE runtime key home (plan §2/§8, R4).
*
* Two-step, validate-before-persist:
* 1. PARSE + VALIDATE with the pure [Pkcs12Parse] (a transient `KeyStore("PKCS12")` used ONLY to read
* the import file). This throws on a wrong passphrase / corrupt blob / certs-only file BEFORE any
* AndroidKeyStore mutation — so a bad import can NEVER clobber a previously installed identity.
* 2. IMPORT the private key NON-EXPORTABLE into AndroidKeyStore via `setEntry(..., KeyProtection)`.
* AndroidKeyStore keys are non-exportable by construction (there is no key-material getter); the
* private key never leaves secure hardware/OS custody once imported.
*
* No `.p12` bytes and no passphrase are ever persisted here (the cert chain + metadata is persisted
* separately, encrypted, by [TinkCertStore]).
*/
public class AndroidKeyStoreImporter(
public val alias: String = DEFAULT_ALIAS,
) {
/**
* The two physical AndroidKeyStore slots this importer ping-pongs between so a rotation can import
* the NEW key WITHOUT overwriting the still-live OLD key (single-commit rotation, see
* [AndroidIdentityRepository]). Neither is inherently "live" — the Tink-encrypted live pointer
* ([StoredIdentityMetadata.keyStoreAlias]) names whichever slot currently holds the live key. Each
* `setEntry` is per-alias atomic, so importing into one slot never disturbs the other.
*/
public val primarySlot: String get() = alias
public val secondarySlot: String get() = alias + STAGING_SUFFIX
/**
* Parse+validate then import into [slot]. Returns the [ParsedClientIdentity] so the caller can
* persist the chain/metadata and derive a display summary. Throws (leaving [slot] — and every other
* slot — untouched, validate-before-persist) on:
* - wrong passphrase → `UnrecoverableKeyException`,
* - corrupt / not-a-p12 → `Pkcs12DecodeException`,
* - certs-only (no key) → `NoClientIdentityException`.
*/
public fun import(p12Bytes: ByteArray, passphrase: String, slot: String = alias): ParsedClientIdentity {
// Step 1 — validate. Throws before we touch AndroidKeyStore (validate-before-persist).
val parsed = Pkcs12Parse.parse(p12Bytes, passphrase)
// Step 2 — persist the key non-exportably into `slot` (per-alias atomic; other slots untouched).
persist(parsed, slot)
return parsed
}
/** The AndroidKeyStore-backed (opaque, non-exportable) private key handle in [slot], or null. */
public fun loadPrivateKey(slot: String = alias): PrivateKey? =
androidKeyStore().getKey(slot, null) as? PrivateKey
/** The cert chain AndroidKeyStore stored alongside the key entry in [slot]; null if none. */
public fun loadCertificateChain(slot: String = alias): List<X509Certificate>? =
androidKeyStore().getCertificateChain(slot)
?.filterIsInstance<X509Certificate>()
?.takeIf { it.isNotEmpty() }
/** Cheap existence check for [slot] (does NOT read key material). */
public fun hasInstalledKey(slot: String = alias): Boolean = androidKeyStore().isKeyEntry(slot)
/** Delete the imported key entry in [slot]. Idempotent (a missing alias is a no-op). */
public fun remove(slot: String = alias) {
val keyStore = androidKeyStore()
if (keyStore.containsAlias(slot)) {
keyStore.deleteEntry(slot)
}
}
private fun persist(parsed: ParsedClientIdentity, slot: String) {
val keyStore = androidKeyStore()
keyStore.setEntry(
slot,
KeyStore.PrivateKeyEntry(parsed.privateKey, parsed.fullChain.toTypedArray()),
keyProtection(parsed.keyAlgorithm),
)
}
private fun androidKeyStore(): KeyStore =
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
/**
* Protection for the imported client-auth key. TLS client authentication signs the
* `CertificateVerify`, so SIGN is always required; RSA additionally needs DECRYPT (RSA key
* transport in TLS ≤1.2) and its padding set. EC keys reject paddings, so only SIGN is granted.
* A broad digest set keeps TLS 1.2/1.3 signature negotiation working. The key stays
* non-exportable (AndroidKeyStore has no export path).
*/
private fun keyProtection(keyAlgorithm: String): KeyProtection {
val isRsa = keyAlgorithm.equals(KeyProperties.KEY_ALGORITHM_RSA, ignoreCase = true)
val purposes = if (isRsa) {
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_DECRYPT
} else {
KeyProperties.PURPOSE_SIGN
}
val builder = KeyProtection.Builder(purposes)
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512,
)
if (isRsa) {
builder
.setSignaturePaddings(
KeyProperties.SIGNATURE_PADDING_RSA_PKCS1,
KeyProperties.SIGNATURE_PADDING_RSA_PSS,
)
.setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1,
KeyProperties.ENCRYPTION_PADDING_RSA_OAEP,
)
}
return builder.build()
}
public companion object {
/** The single device-identity alias this app presents (one pinned identity, plan §8). */
public const val DEFAULT_ALIAS: String = "webterm-device-identity"
/** Suffix of the second ping-pong slot ([secondarySlot]) used for single-commit rotation. */
public const val STAGING_SUFFIX: String = ".staging"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
}
}

View File

@@ -0,0 +1,252 @@
package wang.yaojia.webterm.tlsandroid
import android.util.Log
import java.security.KeyStore
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
/**
* The `(SSLSocketFactory, X509TrustManager)` pair the shared `OkHttpClient` is built with. The
* factory wraps the re-reading KeyManager (so it is STABLE across cert rotations — rotation is
* handled inside the KeyManager, not by swapping the factory), and the trust manager is the default
* system trust (tunnel/Tailscale present real LE certs — plan §6.9). This is the clean seam the `:app`
* wiring (A15) bridges to `:transport-okhttp`'s `ClientIdentityProvider`; A11 never depends on that
* module.
*/
public class ClientSslMaterial(
public val sslSocketFactory: SSLSocketFactory,
public val trustManager: X509TrustManager,
)
/**
* The current device client identity, exposed as a re-reading mTLS seam (plan §2 mTLS row, §8, R4).
*
* The [sslMaterial] pair is built ONCE and installed on the shared client for its whole lifetime; a
* mid-run [importIdentity]/[rotate] is presented on the NEXT handshake (re-reading KeyManager) and
* [rotate]/[remove] additionally evict pooled/resumed connections so they can't reuse the old
* identity. No relaunch, no client rebuild.
*/
public interface IdentityRepository {
/** The stable SSL material to install on the shared `OkHttpClient` (always present — presents no
* client cert when no identity is installed). */
public fun sslMaterial(): ClientSslMaterial
/** Summary of the currently installed identity for the cert screen, or null if none installed. */
public fun currentSummary(): CertificateSummary?
/** Cheap gating check — is a device identity installed? */
public fun hasInstalledIdentity(): Boolean
/** Import a `.p12` (validates before persisting), then evict pooled connections. Returns the summary. */
public suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary
/** Replace the installed identity with a new `.p12` (rotation — single-commit, prior stays live on failure). */
public suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary
/** Remove the installed identity, then evict pooled connections so none reuse it. Idempotent. */
public suspend fun remove()
}
/**
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
*
* ### Single-commit rotation (the security invariant, plan §8, A11)
* An import/rotation that fails at ANY point leaves the PRIOR working identity fully live and NEVER
* leaves the two stores diverged. This is achieved by staging, then committing with one atomic write:
* 1. Import the new key into the ping-pong slot that is NOT currently live
* ([AndroidKeyStoreImporter] `primary`/`secondary`) — the live key is never overwritten.
* 2. COMMIT by persisting one blob to the [CertStore] whose [StoredIdentityMetadata.keyStoreAlias]
* names the staged slot. That single write is the source-of-truth "live pointer" flip.
* - Any failure BEFORE the flip → the prior pointer + key are untouched → prior identity fully live
* (the half-written staging key is best-effort deleted and is never read as live).
* - Any failure AFTER the flip → the NEW identity is fully live (the superseded key is best-effort
* GC'd; a stray unreferenced key is harmless and gets overwritten on the next rotation).
* Startup and the re-reading KeyManager resolve the live identity SOLELY via that pointer.
* Validate-before-persist still holds (a bad passphrase throws before any keystore/store mutation).
*
* ### Concurrency & lazy init
* The mutating methods are `suspend` and serialized by a [Mutex] so overlapping calls (double-tap)
* can never interleave the two-store commit. The initial on-disk identity is loaded LAZILY on first
* access (not in the constructor), so this repository can be constructed on any thread with no I/O.
*
* A15 wiring note: the FIRST touch of the identity (e.g. [currentSummary]/[hasInstalledIdentity], a
* mutator, or the first TLS handshake) performs synchronous AndroidKeyStore + Tink I/O — trigger that
* first access off `Dispatchers.Main` (a background warm-up), never on the UI thread.
*
* The cached runtime identity always holds the AndroidKeyStore-backed (non-exportable) private-key
* handle, never the transient parsed software key.
*/
public class AndroidIdentityRepository(
private val importer: AndroidKeyStoreImporter,
private val certStore: CertStore,
private val sharedClient: OkHttpClient,
) : IdentityRepository {
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
/** Distinguishes "not yet mutated" (null) from "explicitly set, possibly to null" ([Box]). */
private class Box<T>(val value: T)
// Serializes the two-store commit so a double-tap can't interleave two rotations (FIX 2).
private val commitMutex = Mutex()
// Lazily loaded on FIRST access (never in the constructor) — no slow I/O to construct (FIX 3).
private val initialIdentity: LiveIdentity? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadInstalledOrNull()
}
// Post-mutation state shadows the lazily-loaded initial value; volatile read on the handshake path.
@Volatile
private var liveOverride: Box<LiveIdentity?>? = null
// Resolve via the Box PRESENCE, not the unwrapped value's null-ness: after remove() the override
// is Box(null) (explicitly "no identity"), which MUST win over a still-cached non-null
// initialIdentity — otherwise a removed cert (whose key/pointer are already deleted) would keep
// being reported live and presented in a handshake with a dangling key handle. `?: initialIdentity`
// would collapse Box(null) back to the stale identity, so this cannot use the Elvis operator.
private fun currentLive(): LiveIdentity? {
val override = liveOverride
return if (override != null) override.value else initialIdentity
}
// Default system trust: tunnel/Tailscale present real LE certs (plan §6.9). Built once.
private val systemTrustManager: X509TrustManager = systemDefaultTrustManager()
// Stable factory wrapping the re-reading KeyManager over `currentLive()`. Built once.
private val socketFactory: SSLSocketFactory = buildSocketFactory()
override fun sslMaterial(): ClientSslMaterial =
ClientSslMaterial(sslSocketFactory = socketFactory, trustManager = systemTrustManager)
override fun currentSummary(): CertificateSummary? =
currentLive()?.let { CertificateSummaryReader.summarize(it.installed.leafCertificate) }
override fun hasInstalledIdentity(): Boolean = currentLive() != null
override suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
install(p12Bytes, passphrase)
override suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
install(p12Bytes, passphrase)
override suspend fun remove(): Unit = commitMutex.withLock {
// Clear the pointer FIRST (no live identity even if a key delete lags), then both key slots.
certStore.clear()
importer.remove(importer.primarySlot)
importer.remove(importer.secondarySlot)
liveOverride = Box(null)
// Drop pooled/resumed connections so nothing reuses the removed identity (R4/§8).
sharedClient.connectionPool.evictAll()
}
/**
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that
* flips the live pointer. Only after the flip is the new identity published and the superseded
* key GC'd. Any earlier failure leaves the prior identity fully live and the stores consistent.
*/
private suspend fun install(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
commitMutex.withLock {
val liveAlias = currentLive()?.keyStoreAlias
val stagingAlias = stagingSlotFor(liveAlias)
// Stage into the NON-live slot then COMMIT. Everything up to and including the atomic
// certStore.save() flip is wrapped so ANY pre-flip failure (import, key readback, encode,
// or the commit write) best-effort drops the half-written staging key and rethrows —
// leaving the prior pointer + live key untouched (validate-before-persist still applies:
// a bad passphrase throws inside importer.import before any keystore write).
val (parsed, keyHandle) = try {
val p = importer.import(p12Bytes, passphrase, stagingAlias)
val k = importer.loadPrivateKey(stagingAlias)
?: error("Imported key not found in AndroidKeyStore slot '$stagingAlias'")
val metadata = StoredIdentityMetadata(
alias = p.alias,
keyAlgorithm = p.keyAlgorithm,
keyStoreAlias = stagingAlias,
certificateChain = p.fullChain,
)
certStore.save(metadata) // COMMIT — the single atomic durable live-pointer flip.
p to k
} catch (e: Exception) {
runCatching { importer.remove(stagingAlias) } // best-effort: drop any half-written key
throw e
}
// Committed: publish the new identity (AndroidKeyStore handle, non-exportable) and GC the old.
liveOverride = Box(
LiveIdentity(
InstalledIdentity(
alias = parsed.alias,
keyAlgorithm = parsed.keyAlgorithm,
privateKey = keyHandle,
leafCertificate = parsed.leafCertificate,
issuerCertificates = parsed.issuerCertificates,
),
stagingAlias,
),
)
liveAlias?.let { old -> runCatching { importer.remove(old) } } // best-effort GC of old slot
// Next handshake uses the new identity; drop pooled/resumed connections holding the old one.
sharedClient.connectionPool.evictAll()
parsed.summary()
}
/** The ping-pong slot to stage into: whichever physical slot is NOT the current live one. */
private fun stagingSlotFor(liveAlias: String?): String =
if (liveAlias == importer.primarySlot) importer.secondarySlot else importer.primarySlot
/**
* Reconstruct the installed identity from storage at startup, resolving the live key SOLELY via
* the persisted pointer ([StoredIdentityMetadata.keyStoreAlias]). A storage/decrypt fault must not
* crash launch — it degrades to "no identity" and is logged (never silently dropped), mirroring
* iOS `loadedIdentityOrNil`.
*/
private fun loadInstalledOrNull(): LiveIdentity? {
return try {
val metadata = certStore.load() ?: return null
val keyHandle = importer.loadPrivateKey(metadata.keyStoreAlias) ?: return null
LiveIdentity(
InstalledIdentity(
alias = metadata.alias,
keyAlgorithm = metadata.keyAlgorithm,
privateKey = keyHandle,
leafCertificate = metadata.leafCertificate,
issuerCertificates = metadata.issuerCertificates,
),
metadata.keyStoreAlias,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to load stored device identity; continuing without a client cert", e)
null
}
}
private fun buildSocketFactory(): SSLSocketFactory {
val keyManager = ReReadingX509KeyManager { currentLive()?.installed }
val context = SSLContext.getInstance("TLS")
context.init(arrayOf<KeyManager>(keyManager), arrayOf<TrustManager>(systemTrustManager), null)
return context.socketFactory
}
private fun systemDefaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
?: error("No system X509TrustManager available")
}
private companion object {
private const val TAG = "IdentityRepository"
}
}

View File

@@ -0,0 +1,93 @@
package wang.yaojia.webterm.tlsandroid
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.SSLEngine
import javax.net.ssl.X509ExtendedKeyManager
import wang.yaojia.webterm.clienttls.ClientKeyManagerLogic
import wang.yaojia.webterm.clienttls.KeyManagerIdentity
/**
* The live, non-exportable device identity as the KeyManager sees it: the AndroidKeyStore-backed
* [privateKey] handle (opaque — signing happens inside the keystore) plus the cert chain to present.
*/
public class InstalledIdentity(
public val alias: String,
public val keyAlgorithm: String,
public val privateKey: PrivateKey,
public val leafCertificate: X509Certificate,
public val issuerCertificates: List<X509Certificate>,
) {
/** Full chain, leaf at index 0, as presented to the server. */
public val chain: List<X509Certificate> get() = listOf(leafCertificate) + issuerCertificates
internal val keyManagerIdentity: KeyManagerIdentity
get() = KeyManagerIdentity(alias = alias, keyAlgorithm = keyAlgorithm)
}
/**
* A **re-reading** `X509KeyManager` (R4): every alias/key/chain lookup consults [identitySource]
* afresh, so a cert imported or rotated mid-run is presented on the NEXT handshake with no app
* relaunch and no rebuild of the shared `OkHttpClient`. The alias truth table is delegated to the
* pure [ClientKeyManagerLogic] (present iff an identity is installed AND its key type is acceptable),
* keeping the security-load-bearing decision unit-tested at JVM speed in `:client-tls`.
*
* Client-only: the server-side methods return null. Key material is gated by `ownsAlias` so a foreign
* alias never leaks the device identity.
*/
internal class ReReadingX509KeyManager(
private val identitySource: () -> InstalledIdentity?,
) : X509ExtendedKeyManager() {
override fun chooseClientAlias(
keyType: Array<String>?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = logicOrNull()?.chooseClientAlias(keyType)
override fun chooseEngineClientAlias(
keyType: Array<String>?,
issuers: Array<out Principal>?,
engine: SSLEngine?,
): String? = logicOrNull()?.chooseClientAlias(keyType)
override fun getClientAliases(
keyType: String?,
issuers: Array<out Principal>?,
): Array<String>? = logicOrNull()?.clientAliases(keyType)
override fun getPrivateKey(alias: String?): PrivateKey? {
val identity = identitySource() ?: return null
return if (ClientKeyManagerLogic(identity.keyManagerIdentity).ownsAlias(alias)) {
identity.privateKey
} else {
null
}
}
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
val identity = identitySource() ?: return null
return if (ClientKeyManagerLogic(identity.keyManagerIdentity).ownsAlias(alias)) {
identity.chain.toTypedArray()
} else {
null
}
}
// Client-only: never act as a TLS server.
override fun chooseServerAlias(
keyType: String?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = null
override fun getServerAliases(
keyType: String?,
issuers: Array<out Principal>?,
): Array<String>? = null
private fun logicOrNull(): ClientKeyManagerLogic? =
identitySource()?.let { ClientKeyManagerLogic(it.keyManagerIdentity) }
}

View File

@@ -0,0 +1,108 @@
package wang.yaojia.webterm.tlsandroid
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
/**
* The at-rest description of the installed device identity — and the single source-of-truth "live
* pointer" for the whole repository. It carries the [alias] + [keyAlgorithm] the pure
* `ClientKeyManagerLogic` needs for alias selection, the DER cert [certificateChain] (leaf at index 0)
* presented in the TLS handshake, AND the [keyStoreAlias] naming which physical AndroidKeyStore slot
* holds the live private key. Persisting a new blob (one atomic [TinkCertStore.save]) is the COMMIT
* that flips which identity is live; startup and the re-reading KeyManager resolve the live identity
* SOLELY through this pointer (see [AndroidIdentityRepository]).
*
* The PRIVATE KEY is deliberately NOT here — it lives non-exportable in AndroidKeyStore (the one key
* home, plan §2/§8). This blob is exactly what [TinkCertStore] AEAD-encrypts at rest; no `.p12` bytes
* and no passphrase are ever persisted.
*/
public data class StoredIdentityMetadata(
val alias: String,
val keyAlgorithm: String,
val keyStoreAlias: String,
val certificateChain: List<X509Certificate>,
) {
init {
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias (live-key slot) must not be blank" }
require(certificateChain.isNotEmpty()) { "certificateChain must contain at least the leaf" }
}
/** The leaf (device) certificate — chain[0]. */
public val leafCertificate: X509Certificate get() = certificateChain.first()
/** Issuer certificates accompanying the leaf (chain minus leaf); may be empty. */
public val issuerCertificates: List<X509Certificate> get() = certificateChain.drop(1)
}
/** The stored identity blob was present but could not be decoded (tamper / version skew / corruption). */
public class CorruptStoredIdentityException(message: String, cause: Throwable? = null) :
GeneralSecurityException(message, cause)
/**
* Length-prefixed binary codec for [StoredIdentityMetadata] (KISS: no serialization framework — the
* cert chain is `X509Certificate`, not a plain data type). Certificates are stored as their DER
* encoding and re-parsed with the JVM `CertificateFactory`.
*
* Layout: `UTF(alias) · UTF(keyAlgorithm) · UTF(keyStoreAlias) · Int(chainCount) · [Int(derLen) · derBytes]*`.
*/
public object IdentityMetadataCodec {
private const val X509 = "X.509"
private const val MAX_CHAIN = 32
private const val MAX_DER_BYTES = 1 shl 20 // 1 MiB per cert — a defensive upper bound.
public fun encode(metadata: StoredIdentityMetadata): ByteArray {
val out = ByteArrayOutputStream()
DataOutputStream(out).use { data ->
data.writeUTF(metadata.alias)
data.writeUTF(metadata.keyAlgorithm)
data.writeUTF(metadata.keyStoreAlias)
data.writeInt(metadata.certificateChain.size)
metadata.certificateChain.forEach { cert ->
val der = cert.encoded
data.writeInt(der.size)
data.write(der)
}
}
return out.toByteArray()
}
/** Decode [bytes]; any structural or certificate-parse failure → [CorruptStoredIdentityException]. */
public fun decode(bytes: ByteArray): StoredIdentityMetadata =
try {
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
val alias = data.readUTF()
val keyAlgorithm = data.readUTF()
val keyStoreAlias = data.readUTF()
val count = data.readInt()
if (count !in 1..MAX_CHAIN) {
throw CorruptStoredIdentityException("Implausible chain length: $count")
}
val factory = CertificateFactory.getInstance(X509)
val chain = (0 until count).map { readCertificate(data, factory) }
StoredIdentityMetadata(alias, keyAlgorithm, keyStoreAlias, chain)
}
} catch (e: CorruptStoredIdentityException) {
throw e
} catch (e: IOException) {
throw CorruptStoredIdentityException("Stored identity blob was truncated/malformed", e)
} catch (e: CertificateException) {
throw CorruptStoredIdentityException("Stored identity certificate could not be parsed", e)
}
private fun readCertificate(data: DataInputStream, factory: CertificateFactory): X509Certificate {
val len = data.readInt()
if (len !in 1..MAX_DER_BYTES) {
throw CorruptStoredIdentityException("Implausible certificate length: $len")
}
val der = ByteArray(len)
data.readFully(der)
return factory.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
}
}

View File

@@ -0,0 +1,135 @@
package wang.yaojia.webterm.tlsandroid
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
/**
* At-rest storage for the encrypted device-identity "live pointer" ([StoredIdentityMetadata]).
* Abstracted behind an interface so [AndroidIdentityRepository] depends on the operation contract
* (repository pattern) — the storage backend is swappable and a fault can be injected in tests. The
* single [save] is the atomic COMMIT that flips which identity is live (plan §8, A11).
*/
public interface CertStore {
/** Encrypt and persist [metadata], atomically replacing any previously stored blob (the commit). */
public fun save(metadata: StoredIdentityMetadata)
/** Load + decrypt the stored pointer, or null if none is stored. */
public fun load(): StoredIdentityMetadata?
/** Remove the stored pointer. Idempotent. */
public fun clear()
}
/**
* At-rest storage for the device identity's cert-chain + metadata blob ([StoredIdentityMetadata]),
* AEAD-encrypted with Tink and an AndroidKeystore-wrapped master key (plan §2 persistence-secret row,
* §8 cert storage).
*
* What is and is NOT stored here:
* - STORED (encrypted): the cert chain + alias + key algorithm — the material the re-reading
* KeyManager and the summary UI need.
* - NOT stored: the private key (non-exportable in AndroidKeyStore — the one key home), the raw
* `.p12` bytes, and the passphrase.
*
* The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, excluded
* from cloud backup by the app manifest); the Tink keyset itself is encrypted by the AndroidKeystore
* master key so the on-disk keyset is useless off this device.
*/
public class TinkCertStore(
context: Context,
private val keysetName: String = DEFAULT_KEYSET_NAME,
private val prefFileName: String = DEFAULT_PREF_FILE,
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
) : CertStore {
private val appContext: Context = context.applicationContext
// Built on first use: registers AEAD, loads-or-creates the AndroidKeystore-wrapped keyset.
private val aead: Aead by lazy { buildAead() }
/**
* Encrypt and persist [metadata], replacing any previously stored blob. Uses synchronous
* [SharedPreferences.Editor.commit] (NOT `apply()`): the pointer flip is the atomic durable
* commit of a rotation, and `install()` deletes the superseded key immediately after this
* returns — so the write MUST be on disk (and MUST surface a failure) before that delete. A
* non-durable/silently-failed write could leave the on-disk pointer naming an already-deleted
* key after a crash, losing BOTH identities.
*/
override fun save(metadata: StoredIdentityMetadata) {
val plaintext = IdentityMetadataCodec.encode(metadata)
val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA)
val committed = prefs().edit()
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
.commit()
if (!committed) {
throw java.io.IOException("Failed to durably persist the device-identity live pointer")
}
}
/**
* Load + decrypt the stored blob, or null if none is stored. Throws
* [CorruptStoredIdentityException] if a blob is present but fails to decrypt/decode (tamper or
* version skew) — callers treat that as "no usable identity" (see [IdentityRepository]).
*/
override fun load(): StoredIdentityMetadata? {
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
val ciphertext = try {
Base64.decode(encoded, Base64.NO_WRAP)
} catch (e: IllegalArgumentException) {
throw CorruptStoredIdentityException("Stored identity blob was not valid base64", e)
}
val plaintext = try {
aead.decrypt(ciphertext, ASSOCIATED_DATA)
} catch (e: java.security.GeneralSecurityException) {
throw CorruptStoredIdentityException("Stored identity blob failed AEAD decryption", e)
}
return IdentityMetadataCodec.decode(plaintext)
}
/**
* Remove the stored blob. Idempotent. Uses synchronous [SharedPreferences.Editor.commit] and
* throws on failure: `remove()` clears this pointer BEFORE deleting the AndroidKeyStore keys, so
* a silently-failed clear (pointer left naming a soon-to-be-deleted key) would diverge the two
* stores — surfacing the failure lets `remove()` abort with the prior identity still live. The
* Tink keyset (master key) is intentionally left intact.
*/
override fun clear() {
val committed = prefs().edit().remove(BLOB_KEY).commit()
if (!committed) {
throw java.io.IOException("Failed to durably clear the device-identity live pointer")
}
}
private fun buildAead(): Aead {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(appContext, keysetName, prefFileName)
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
.withMasterKeyUri(masterKeyUri)
.build()
.keysetHandle
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
public companion object {
private const val DEFAULT_KEYSET_NAME = "webterm_cert_keyset"
private const val DEFAULT_PREF_FILE = "webterm_client_tls_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_cert_master_key"
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "cert_chain_blob"
/**
* AEAD associated data — binds the ciphertext to this app's identity-blob context so a blob
* lifted into another Tink ciphertext slot fails to decrypt. Not secret.
*/
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.identity".toByteArray()
}
}