diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c25b0..9facd5b 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. + - 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 diff --git a/docs/desktop-closeout-mobile-plan.md b/docs/desktop-closeout-mobile-plan.md index e24dd11..4050621 100644 --- a/docs/desktop-closeout-mobile-plan.md +++ b/docs/desktop-closeout-mobile-plan.md @@ -278,9 +278,17 @@ applies the payload and pending-request budgets negotiated by its RVD1 session: oversized outgoing RPCs are rejected before any frame is emitted, oversized incoming frames fail closed, and malformed acceptance budgets are rejected during session establishment, all pinned by deterministic JVM -tests. The Android `NsdManager` -driver, product credential integration, and device-to-device evidence -remain separate unfinished M3 tasks. +tests. 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 new file mode 100644 index 0000000..e3161d8 --- /dev/null +++ b/platform/android/android-nsd/src/main/kotlin/dev/rivet/session/AndroidKeystorePairingStore.kt @@ -0,0 +1,128 @@ +package dev.rivet.session + +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 +import javax.crypto.SecretKey + +/** + * Product-scoped Android pairing persistence for the RVD1 save-before-accept + * 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 + * 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) } + + /** + * 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 + 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(), + ) + 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) + } +} 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)) + } + } +}