From a0a7b0ddfef8569b322570c3027b1efb98d3fea9 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 20:13:40 +0800 Subject: [PATCH 1/3] feat(android): encrypt scoped companion credentials with Android Keystore --- CHANGELOG.md | 10 ++ .../session/AndroidKeystorePairingStore.kt | 115 ++++++++++++++++++ .../rivet/session/DevicePairingEnvelope.kt | 101 +++++++++++++++ .../session/DevicePairingEnvelopeTest.kt | 75 ++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt create mode 100644 platform/android/src/main/kotlin/dev/rivet/session/DevicePairingEnvelope.kt create mode 100644 platform/android/src/test/kotlin/dev/rivet/session/DevicePairingEnvelopeTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a6e54..417f92b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Add an Android Keystore-backed, app-scoped pairing credential store for + the RVD1 persistence gate. Credentials and server SPKI pins are encrypted + together with AES-256-GCM and bound to the application and device ID; + corrupted, restored-without-key, or cross-device records fail closed. + Synchronous SharedPreferences commits and no-replacement policy prevent + a failed credential write from authorizing application traffic. JVM + envelope tests cover tampering, identity binding, and malformed records; + Android SDK compilation covers the KeyStore adapter. This does not provide + a product UI or hardware pairing acceptance. + - Bridge Android companion TLS streams to the RVD1 client through a one-shot selected-endpoint session driver. Enforce the stored SPKI pin on reconnect without implicit pairing fallback, preserve explicit pairing diff --git a/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt b/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt new file mode 100644 index 0000000..7314a61 --- /dev/null +++ b/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt @@ -0,0 +1,115 @@ +package dev.rivet.session + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import java.security.MessageDigest +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** + * Product-scoped Android pairing persistence for the RVD1 save-before-accept + * gate. An AndroidKeyStore AES-256-GCM key protects each credential/pin pair; + * only authenticated ciphertext is kept in private SharedPreferences. + * + * A missing Keystore key never causes an existing record to be regenerated + * or trusted. Products must own backup exclusions, pairing consent UI, and + * desktop-side revocation. Do not export this preferences file in backups. + */ +class AndroidKeystorePairingStore( + context: Context, + private val applicationID: String, +) : SessionCredentialStore { + init { + require(applicationID.matches(Regex("[A-Za-z0-9][A-Za-z0-9._-]{2,199}"))) { + "invalid pairing application ID" + } + } + + private val preferences = context.applicationContext.getSharedPreferences( + "dev.rivet.device.pairing.v1", Context.MODE_PRIVATE, + ) + private val prefix = applicationID + "." + private val alias = "dev.rivet.device.pairing." + MessageDigest.getInstance("SHA-256") + .digest(applicationID.encodeToByteArray()) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + + /** A duplicate pairing ID must be removed explicitly, not silently rotated. */ + @Synchronized + override fun savePairingCredential( + deviceID: SessionUuid, + credential: ByteArray, + serverFingerprint: ByteArray, + ) { + val name = recordName(deviceID) + check(!preferences.contains(name)) { "Rivet device is already paired" } + val material = DevicePairingMaterial(credential, serverFingerprint) + val sealed = DevicePairingEnvelope.seal(applicationID, deviceID, material, key(create = true)!!) + val encoded = Base64.encodeToString(sealed, Base64.NO_WRAP) + check(preferences.edit().putString(name, encoded).commit()) { + "Rivet pairing credential could not be persisted" + } + } + + /** Return a defensive copy of stored secrets, or null for an unknown ID. */ + @Synchronized + fun loadPairing(deviceID: SessionUuid): DevicePairingMaterial? { + val encoded = preferences.getString(recordName(deviceID), null) ?: return null + // Existing ciphertext with a deleted/restored Keystore key is not a + // new-pairing opportunity: fail closed and require explicit removal. + val savedKey = key(create = false) + ?: throw IllegalStateException("Rivet pairing Keystore key is unavailable") + val sealed = try { + Base64.decode(encoded, Base64.NO_WRAP) + } catch (_: IllegalArgumentException) { + throw IllegalStateException("Rivet pairing credential record is invalid") + } + return DevicePairingEnvelope.open(applicationID, deviceID, sealed, savedKey) + } + + @Synchronized + fun removePairing(deviceID: SessionUuid): Boolean { + val name = recordName(deviceID) + if (!preferences.contains(name)) return false + check(preferences.edit().remove(name).commit()) { + "Rivet pairing removal could not be persisted" + } + return true + } + + /** Non-secret local metadata. This does not query the remote gateway. */ + @Synchronized + fun savedDeviceIDs(): List = + preferences.all.keys + .filter { it.startsWith(prefix) && it.length == prefix.length + SESSION_UUID_SIZE * 2 } + .mapNotNull { name -> + val hex = name.substring(prefix.length) + if (!hex.all { it in '0'..'9' || it in 'a'..'f' }) null + else SessionUuid(ByteArray(SESSION_UUID_SIZE) { i -> + hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + }) + } + .sortedBy { it.bytes.joinToString("") { b -> "%02x".format(b.toInt() and 0xff) } } + + private fun recordName(id: SessionUuid): String = + prefix + id.bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun key(create: Boolean): SecretKey? { + val store = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } + (store.getKey(alias, null) as? SecretKey)?.let { return it } + if (!create) return null + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder( + alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(256) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + return generator.generateKey() + } +} diff --git a/platform/android/src/main/kotlin/dev/rivet/session/DevicePairingEnvelope.kt b/platform/android/src/main/kotlin/dev/rivet/session/DevicePairingEnvelope.kt new file mode 100644 index 0000000..6750447 --- /dev/null +++ b/platform/android/src/main/kotlin/dev/rivet/session/DevicePairingEnvelope.kt @@ -0,0 +1,101 @@ +package dev.rivet.session + +import java.nio.ByteBuffer +import java.security.GeneralSecurityException +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Secret-bearing pairing data: never store or log this outside encrypted storage. */ +class DevicePairingMaterial(credential: ByteArray, serverFingerprint: ByteArray) { + private val storedCredential = credential.copyOf() + private val storedFingerprint = serverFingerprint.copyOf() + + init { + require(storedCredential.size == SESSION_CREDENTIAL_SIZE) { "invalid device credential length" } + require(storedFingerprint.size == SESSION_FINGERPRINT_SIZE) { "invalid server fingerprint length" } + } + + val credential: ByteArray get() = storedCredential.copyOf() + val serverFingerprint: ByteArray get() = storedFingerprint.copyOf() + + fun reconnectIntent(applicationID: String, deviceID: SessionUuid): DeviceCompanionIntent.Reconnect = + DeviceCompanionIntent.Reconnect(applicationID, deviceID, credential, serverFingerprint) +} + +/** + * Versioned, fixed-size, authenticated envelope for an Android Keystore AES + * key. No plaintext credential or SPKI is stored in SharedPreferences. + * AES-GCM additional authenticated data binds each record to a product and + * paired device, making ciphertext swapping across records fail closed. + */ +object DevicePairingEnvelope { + private const val VERSION: Byte = 1 + private const val IV_SIZE = 12 + private const val TAG_BITS = 128 + private const val HEADER_SIZE = 1 + IV_SIZE + private const val PLAIN_SIZE = SESSION_CREDENTIAL_SIZE + SESSION_FINGERPRINT_SIZE + private const val SEALED_SIZE = HEADER_SIZE + PLAIN_SIZE + TAG_BITS / 8 + + fun seal( + applicationID: String, + deviceID: SessionUuid, + material: DevicePairingMaterial, + key: SecretKey, + ): ByteArray { + val payload = material.credential + material.serverFingerprint + try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + check(iv.size == IV_SIZE) { "unexpected Rivet pairing IV size" } + cipher.updateAAD(aad(applicationID, deviceID)) + val ciphertext = cipher.doFinal(payload) + return byteArrayOf(VERSION) + iv + ciphertext + } finally { + payload.fill(0) + } + } + + fun open( + applicationID: String, + deviceID: SessionUuid, + envelope: ByteArray, + key: SecretKey, + ): DevicePairingMaterial { + require(envelope.size == SEALED_SIZE && envelope[0] == VERSION) { + "unsupported or malformed Rivet pairing credential envelope" + } + val iv = envelope.copyOfRange(1, HEADER_SIZE) + val ciphertext = envelope.copyOfRange(HEADER_SIZE, envelope.size) + val plaintext = try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) + cipher.updateAAD(aad(applicationID, deviceID)) + cipher.doFinal(ciphertext) + } catch (_: GeneralSecurityException) { + throw IllegalStateException("Rivet pairing credential authentication failed") + } + try { + check(plaintext.size == PLAIN_SIZE) { "invalid decrypted device credential" } + return DevicePairingMaterial( + plaintext.copyOfRange(0, SESSION_CREDENTIAL_SIZE), + plaintext.copyOfRange(SESSION_CREDENTIAL_SIZE, PLAIN_SIZE), + ) + } finally { + plaintext.fill(0) + } + } + + private fun aad(applicationID: String, deviceID: SessionUuid): ByteArray { + require(applicationID.isNotBlank() && applicationID.length <= 200) { + "invalid pairing application ID" + } + val app = applicationID.encodeToByteArray() + return ByteBuffer.allocate(4 + app.size + SESSION_UUID_SIZE) + .putInt(app.size) + .put(app) + .put(deviceID.bytes) + .array() + } +} diff --git a/platform/android/src/test/kotlin/dev/rivet/session/DevicePairingEnvelopeTest.kt b/platform/android/src/test/kotlin/dev/rivet/session/DevicePairingEnvelopeTest.kt new file mode 100644 index 0000000..95bbe0b --- /dev/null +++ b/platform/android/src/test/kotlin/dev/rivet/session/DevicePairingEnvelopeTest.kt @@ -0,0 +1,75 @@ +package dev.rivet.session + +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class DevicePairingEnvelopeTest { + private val deviceID = SessionUuid(ByteArray(SESSION_UUID_SIZE) { it.toByte() }) + private val credential = ByteArray(SESSION_CREDENTIAL_SIZE) { 0x11 } + private val pin = ByteArray(SESSION_FINGERPRINT_SIZE) { 0x22 } + private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + @Test + fun authenticatedEncryptedRoundTripKeepsSecretsOffTheWire() { + val material = DevicePairingMaterial(credential, pin) + val ciphertext = DevicePairingEnvelope.seal("com.example.app", deviceID, material, key) + assertFalse(ciphertext.toList().windowed(credential.size).any { it == credential.toList() }) + val restored = DevicePairingEnvelope.open("com.example.app", deviceID, ciphertext, key) + assertContentEquals(credential, restored.credential) + assertContentEquals(pin, restored.serverFingerprint) + restored.credential.fill(0) + restored.serverFingerprint.fill(0) + assertContentEquals(credential, restored.credential) + assertContentEquals(pin, restored.serverFingerprint) + } + + @Test + fun ciphertextTamperingAndIdentitySwapAreRejected() { + val ciphertext = DevicePairingEnvelope.seal( + "com.example.app", deviceID, DevicePairingMaterial(credential, pin), key, + ) + val tampered = ciphertext.copyOf().also { it[it.lastIndex] = (it.last().toInt() xor 1).toByte() } + assertFailsWith { + DevicePairingEnvelope.open("com.example.app", deviceID, tampered, key) + } + assertFailsWith { + DevicePairingEnvelope.open("com.other.app", deviceID, ciphertext, key) + } + assertFailsWith { + DevicePairingEnvelope.open( + "com.example.app", SessionUuid(ByteArray(SESSION_UUID_SIZE) { 0x44 }), ciphertext, key, + ) + } + } + + @Test + fun malformedLengthVersionAndDifferentKeyFailClosed() { + val ciphertext = DevicePairingEnvelope.seal( + "com.example.app", deviceID, DevicePairingMaterial(credential, pin), key, + ) + val wrongVersion = ciphertext.copyOf().also { it[0] = 2 } + assertFailsWith { + DevicePairingEnvelope.open("com.example.app", deviceID, wrongVersion, key) + } + assertFailsWith { + DevicePairingEnvelope.open("com.example.app", deviceID, ciphertext.dropLast(1).toByteArray(), key) + } + val different = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + assertFailsWith { + DevicePairingEnvelope.open("com.example.app", deviceID, ciphertext, different) + } + } + + @Test + fun invalidSecretSizesAreRejected() { + assertFailsWith { + DevicePairingMaterial(ByteArray(1), pin) + } + assertFailsWith { + DevicePairingMaterial(credential, ByteArray(1)) + } + } +} From 4fb79303cf576ed5987b7aa6667294a92de34750 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Thu, 22 Oct 2026 23:50:25 +0800 Subject: [PATCH 2/3] fix(android): normalize Keystore failures into the store's error taxonomy A corrupted or removed AndroidKeyStore threw raw IOException or GeneralSecurityException out of loadPairing/savePairingCredential while the rest of the store reports IllegalStateException, so callers had to know Keystore internals to fail closed. Key access now translates those into the same unavailable-key error, and the class doc states the real invariant: one key per application ID, per-device binding via AES-GCM AAD. The plan's M3 handoff records the storage evidence it was missing. --- docs/desktop-closeout-mobile-plan.md | 14 ++++++++++--- .../session/AndroidKeystorePairingStore.kt | 21 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/desktop-closeout-mobile-plan.md b/docs/desktop-closeout-mobile-plan.md index f56f02b..15ad2c9 100644 --- a/docs/desktop-closeout-mobile-plan.md +++ b/docs/desktop-closeout-mobile-plan.md @@ -273,9 +273,17 @@ session client, surfaces explicit pairing approval, hands the accepted stream to the product's RVT1 client only after the persistence gate, and closes failed or cancelled transport attempts. Kotlin/JVM tests cover wrong pins, no reconnect-to-pairing fallback, accepted stream ownership, -and cancellation during an in-flight TLS dial. The Android `NsdManager` -driver, product credential integration, and device-to-device evidence -remain separate unfinished M3 tasks. +and cancellation during an in-flight TLS dial. The save-before-accept gate +now has a product-shaped Android persistence adapter: one Keystore AES key +per application ID seals each credential/fingerprint record into a versioned +AES-GCM envelope whose additional authenticated data binds it to the product +and paired device, SharedPreferences holds only that ciphertext, a duplicate +or unknown pairing ID never rotates silently, and a missing or corrupted +Keystore key fails closed into one stable error taxonomy instead of offering +a re-pairing path. The adapter is compile- and envelope-tested on the JVM; +Keystore-backed behavior itself requires device evidence. The Android +`NsdManager` driver, product credential integration, and device-to-device +evidence remain separate unfinished M3 tasks. M3's Windows transport slice is also implemented: a CNG-persisted P-256 self-signed identity with deletion-based rotation, an SChannel TLS server diff --git a/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt b/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt index 7314a61..e3161d8 100644 --- a/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt +++ b/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt @@ -4,6 +4,8 @@ import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import java.io.IOException +import java.security.GeneralSecurityException import java.security.KeyStore import java.security.MessageDigest import javax.crypto.KeyGenerator @@ -11,8 +13,10 @@ import javax.crypto.SecretKey /** * Product-scoped Android pairing persistence for the RVD1 save-before-accept - * gate. An AndroidKeyStore AES-256-GCM key protects each credential/pin pair; - * only authenticated ciphertext is kept in private SharedPreferences. + * gate. One AndroidKeyStore AES-256-GCM key per application ID protects every + * record; the AES-GCM additional authenticated data binds each sealed + * credential/pin pair to its product and paired device, so only + * authenticated ciphertext is kept in private SharedPreferences. * * A missing Keystore key never causes an existing record to be regenerated * or trusted. Products must own backup exclusions, pairing consent UI, and @@ -96,7 +100,12 @@ class AndroidKeystorePairingStore( private fun recordName(id: SessionUuid): String = prefix + id.bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) } - private fun key(create: Boolean): SecretKey? { + /** + * Keystore machinery failures (corrupted store, removed provider, hardware + * key loss) surface as the store's own IllegalStateException taxonomy so + * callers face one failure type per operation, never a raw KeyStore one. + */ + private fun key(create: Boolean): SecretKey? = try { val store = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } (store.getKey(alias, null) as? SecretKey)?.let { return it } if (!create) return null @@ -110,6 +119,10 @@ class AndroidKeystorePairingStore( .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .build(), ) - return generator.generateKey() + generator.generateKey() + } catch (failure: GeneralSecurityException) { + throw IllegalStateException("Rivet pairing Keystore key is unavailable", failure) + } catch (failure: IOException) { + throw IllegalStateException("Rivet pairing Keystore key is unavailable", failure) } } From 8827755b76099e8937de89c7d0e8d0597223b089 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 23 Oct 2026 06:16:23 +0800 Subject: [PATCH 3/3] fix(changelog): resolve the Keystore-store entry merge properly --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf590f3..9facd5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## Unreleased -<<<<<<< HEAD - Add an Android Keystore-backed, app-scoped pairing credential store for the RVD1 persistence gate. Credentials and server SPKI pins are encrypted together with AES-256-GCM and bound to the application and device ID; @@ -12,14 +11,13 @@ envelope tests cover tampering, identity binding, and malformed records; Android SDK compilation covers the KeyStore adapter. This does not provide a product UI or hardware pairing acceptance. -======= + - Bind Android's RVT1 stream client to the payload and pending-request budgets negotiated by an accepted RVD1 session. Reject oversized outgoing RPCs without emitting a frame or poisoning later requests; oversized incoming frames fail closed. Deterministic JVM tests cover both directions and malformed acceptance budgets. This is companion transport policy, not a claim of a complete Android app or physical-device acceptance. ->>>>>>> origin/main - Add reusable iOS/iPadOS companion orchestration and SwiftUI UI for explicit Bonjour selection, pinned reconnect, provisional RVD1 pairing, pairing-code