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
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:
@@ -0,0 +1,73 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import java.security.UnrecoverableKeyException
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
|
||||
import wang.yaojia.webterm.clienttls.NoClientIdentityException
|
||||
|
||||
/**
|
||||
* Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) tests for the framework import path.
|
||||
* COMPILES in CI here; RUNS on a device/emulator during device QA.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AndroidKeyStoreImporterTest {
|
||||
|
||||
private val alias = "test-device-identity"
|
||||
private val importer = AndroidKeyStoreImporter(alias = alias)
|
||||
|
||||
@Before
|
||||
fun clean() = importer.remove()
|
||||
|
||||
@After
|
||||
fun tearDown() = importer.remove()
|
||||
|
||||
@Test
|
||||
fun importHappyPath_importsRetrievableNonExportableKey() {
|
||||
val parsed = importer.import(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
|
||||
assertEquals("RSA", parsed.keyAlgorithm)
|
||||
assertEquals(Fixtures.LEAF_SUBJECT_CN, parsed.summary().subjectCommonName)
|
||||
assertEquals(Fixtures.LEAF_ISSUER_CN, parsed.summary().issuerCommonName)
|
||||
|
||||
assertTrue("key entry present after import", importer.hasInstalledKey())
|
||||
assertNotNull("AndroidKeyStore key handle readable", importer.loadPrivateKey())
|
||||
val chain = importer.loadCertificateChain()
|
||||
assertNotNull("cert chain stored alongside key", chain)
|
||||
assertTrue("chain has at least the leaf", chain!!.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun certsOnlyP12_mapsToNoClientIdentity_andImportsNothing() {
|
||||
assertThrows(NoClientIdentityException::class.java) {
|
||||
importer.import(Fixtures.trustP12(), Fixtures.PASSPHRASE)
|
||||
}
|
||||
assertFalse("nothing persisted for a certs-only .p12", importer.hasInstalledKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importValidatesBeforePersist_badPassphraseCannotClobberPriorIdentity() {
|
||||
// Install a good identity first.
|
||||
importer.import(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
|
||||
// A wrong passphrase must throw during PARSE (before any AndroidKeyStore mutation).
|
||||
assertThrows(UnrecoverableKeyException::class.java) {
|
||||
importer.import(Fixtures.leafP12(), Fixtures.WRONG_PASSPHRASE)
|
||||
}
|
||||
|
||||
// The prior identity must still be present AND unchanged after the failed import.
|
||||
assertTrue("prior key survives a failed import", importer.hasInstalledKey())
|
||||
val leafCnAfter = importer.loadCertificateChain()!!.first().let {
|
||||
CertificateSummaryReader.summarize(it).subjectCommonName
|
||||
}
|
||||
assertEquals(Fixtures.LEAF_SUBJECT_CN, leafCnAfter)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,209 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import wang.yaojia.webterm.clienttls.Pkcs12Parse
|
||||
|
||||
/**
|
||||
* Instrumented (real AndroidKeyStore + Tink — plan §7) tests for the re-reading mTLS seam.
|
||||
* COMPILES here; RUNS on a device/emulator during device QA.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IdentityRepositoryTest {
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
private val alias = "test-repo-identity"
|
||||
private val prefFile = "webterm_client_tls_test_prefs"
|
||||
private val keysetName = "webterm_cert_test_keyset"
|
||||
private val masterKeyUri = "android-keystore://webterm_cert_test_master_key"
|
||||
|
||||
private lateinit var importer: AndroidKeyStoreImporter
|
||||
private lateinit var certStore: TinkCertStore
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
importer = AndroidKeyStoreImporter(alias = alias)
|
||||
certStore = TinkCertStore(context, keysetName, prefFile, masterKeyUri)
|
||||
client = OkHttpClient()
|
||||
wipe()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = wipe()
|
||||
|
||||
/** Both ping-pong slots + the pointer, so no state leaks across tests. */
|
||||
private fun wipe() {
|
||||
importer.remove(importer.primarySlot)
|
||||
importer.remove(importer.secondarySlot)
|
||||
certStore.clear()
|
||||
}
|
||||
|
||||
private fun newRepository(store: CertStore = certStore): AndroidIdentityRepository =
|
||||
AndroidIdentityRepository(importer, store, client)
|
||||
|
||||
@Test
|
||||
fun reReadingKeyManager_presentsMidRunImportedCertOnNextHandshake() {
|
||||
// A mutable holder stands in for the repository's live identity source.
|
||||
var installed: InstalledIdentity? = null
|
||||
val keyManager = ReReadingX509KeyManager { installed }
|
||||
|
||||
// No identity → present nothing (a clean, classifiable handshake failure, never a wrong cert).
|
||||
assertNull(keyManager.chooseClientAlias(arrayOf("RSA"), null, null))
|
||||
assertNull(keyManager.getPrivateKey(alias))
|
||||
|
||||
// Simulate a mid-run import (no relaunch, no factory rebuild).
|
||||
val parsed = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
installed = InstalledIdentity(
|
||||
alias = parsed.alias,
|
||||
keyAlgorithm = parsed.keyAlgorithm,
|
||||
privateKey = parsed.privateKey,
|
||||
leafCertificate = parsed.leafCertificate,
|
||||
issuerCertificates = parsed.issuerCertificates,
|
||||
)
|
||||
|
||||
// The very next lookup re-reads the source and presents the freshly-imported identity.
|
||||
assertEquals(parsed.alias, keyManager.chooseClientAlias(arrayOf("RSA"), null, null))
|
||||
assertEquals(parsed.alias, keyManager.chooseEngineClientAlias(arrayOf("RSA"), null, null))
|
||||
assertNotNull(keyManager.getPrivateKey(parsed.alias))
|
||||
assertNotNull(keyManager.getCertificateChain(parsed.alias))
|
||||
// A foreign alias never leaks our key material.
|
||||
assertNull(keyManager.getPrivateKey("someone-else"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importPublishesIdentityAndSummary() = runBlocking {
|
||||
val repository = newRepository()
|
||||
assertFalse(repository.hasInstalledIdentity())
|
||||
assertNotNull("SSL material installed even with no cert", repository.sslMaterial().sslSocketFactory)
|
||||
|
||||
val summary = repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
|
||||
assertEquals(Fixtures.LEAF_SUBJECT_CN, summary.subjectCommonName)
|
||||
assertEquals(Fixtures.LEAF_ISSUER_CN, summary.issuerCommonName)
|
||||
assertTrue(repository.hasInstalledIdentity())
|
||||
assertEquals(Fixtures.LEAF_SUBJECT_CN, repository.currentSummary()?.subjectCommonName)
|
||||
// First install lands on the primary slot; the pointer names it (single source of truth).
|
||||
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
|
||||
val repository = newRepository()
|
||||
repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
assertTrue(repository.hasInstalledIdentity())
|
||||
|
||||
// Rotation stages into the OTHER slot and evicts pooled/resumed connections.
|
||||
repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
assertTrue(repository.hasInstalledIdentity())
|
||||
assertEquals(importer.secondarySlot, certStore.load()?.keyStoreAlias)
|
||||
assertEquals(0, client.connectionPool.connectionCount())
|
||||
|
||||
// Removal clears both stores and evicts again so nothing reuses the old identity.
|
||||
// (A populated pool needs a live TLS server → the no-reuse proof is the A34 E2E; here we
|
||||
// assert eviction ran and left the pool empty + the identity cleared.)
|
||||
repository.remove()
|
||||
assertFalse(repository.hasInstalledIdentity())
|
||||
assertNull(repository.currentSummary())
|
||||
assertNull(certStore.load())
|
||||
assertEquals(0, client.connectionPool.connectionCount())
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-commit invariant (plan §8, A11): a rotation that fails AT the commit step must leave
|
||||
* the prior identity fully live and the two stores consistent (no half-applied new identity), and
|
||||
* a later successful install must fully replace it.
|
||||
*/
|
||||
@Test
|
||||
fun failedCommit_keepsPriorIdentityLive_thenSuccessfulInstallReplacesIt() = runBlocking {
|
||||
val failing = FailingOnceCertStore(certStore)
|
||||
val repository = newRepository(failing)
|
||||
|
||||
// Install identity A (lands on the primary slot; pointer names it).
|
||||
repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
val slotA = certStore.load()!!.keyStoreAlias
|
||||
assertEquals(importer.primarySlot, slotA)
|
||||
assertNotNull("A's key present after install", importer.loadPrivateKey(slotA))
|
||||
|
||||
// Attempt to rotate to B, injecting a failure at the COMMIT (metadata save) step.
|
||||
failing.failNextSave = true
|
||||
assertThrows(InjectedCommitFailure::class.java) {
|
||||
runBlocking { repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE) }
|
||||
}
|
||||
|
||||
// (a) prior intact → the re-reading KeyManager (reads the same live view) still presents A.
|
||||
assertTrue("prior identity still live after a failed rotation", repository.hasInstalledIdentity())
|
||||
assertEquals(Fixtures.LEAF_SUBJECT_CN, repository.currentSummary()?.subjectCommonName)
|
||||
// (b) stores consistent: the pointer still names A's slot; the staged slot holds NO half-B key.
|
||||
assertEquals(slotA, certStore.load()!!.keyStoreAlias)
|
||||
assertNotNull("A's key untouched by the failed rotation", importer.loadPrivateKey(slotA))
|
||||
assertNull("no half-written B key in the staging slot", importer.loadPrivateKey(importer.secondarySlot))
|
||||
|
||||
// (c) a subsequent successful install of B fully replaces A.
|
||||
failing.failNextSave = false
|
||||
repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
assertTrue(repository.hasInstalledIdentity())
|
||||
assertEquals("pointer flipped to the other slot", importer.secondarySlot, certStore.load()!!.keyStoreAlias)
|
||||
assertNotNull("B's key now present", importer.loadPrivateKey(importer.secondarySlot))
|
||||
assertNull("A's superseded key GC'd", importer.loadPrivateKey(slotA))
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for the `currentLive()` single-source-of-truth bug: after [remove], the repository
|
||||
* must report NO identity even when the lazily-loaded `initialIdentity` was already resolved to a
|
||||
* real value at startup (the normal relaunch-then-remove flow). The old `liveOverride?.value ?:
|
||||
* initialIdentity` collapsed the post-remove `Box(null)` back to the stale cached identity, so a
|
||||
* removed cert kept being reported live and would be presented in a handshake with a dangling key.
|
||||
*/
|
||||
@Test
|
||||
fun remove_afterStartupTouch_reportsNoIdentity_notStaleCached() = runBlocking {
|
||||
// Persist identity A, then simulate an app relaunch: a FRESH repository over the same stores.
|
||||
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
val relaunched = newRepository()
|
||||
|
||||
// Startup touch: resolve the lazy `initialIdentity` to the real installed identity A.
|
||||
assertTrue("identity restored from storage on relaunch", relaunched.hasInstalledIdentity())
|
||||
assertNotNull(relaunched.currentSummary())
|
||||
|
||||
// Remove — must win over the already-cached initialIdentity.
|
||||
relaunched.remove()
|
||||
|
||||
assertFalse("removed identity must NOT still report as installed", relaunched.hasInstalledIdentity())
|
||||
assertNull("removed identity must have no summary", relaunched.currentSummary())
|
||||
assertNull("pointer cleared", certStore.load())
|
||||
// The KeyManager reads the same live view → presents nothing after removal.
|
||||
assertNull(ReReadingX509KeyManager { null }.chooseClientAlias(arrayOf("RSA"), null, null))
|
||||
}
|
||||
|
||||
/** Wraps a real [CertStore], failing the next [save] exactly once so the commit step can be forced to throw. */
|
||||
private class FailingOnceCertStore(private val delegate: CertStore) : CertStore {
|
||||
@Volatile
|
||||
var failNextSave: Boolean = false
|
||||
|
||||
override fun save(metadata: StoredIdentityMetadata) {
|
||||
if (failNextSave) {
|
||||
failNextSave = false
|
||||
throw InjectedCommitFailure()
|
||||
}
|
||||
delegate.save(metadata)
|
||||
}
|
||||
|
||||
override fun load(): StoredIdentityMetadata? = delegate.load()
|
||||
override fun clear(): Unit = delegate.clear()
|
||||
}
|
||||
|
||||
private class InjectedCommitFailure : RuntimeException("injected commit failure")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user