Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions docs/desktop-closeout-mobile-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionUuid> =
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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<IllegalStateException> {
DevicePairingEnvelope.open("com.example.app", deviceID, tampered, key)
}
assertFailsWith<IllegalStateException> {
DevicePairingEnvelope.open("com.other.app", deviceID, ciphertext, key)
}
assertFailsWith<IllegalStateException> {
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<IllegalArgumentException> {
DevicePairingEnvelope.open("com.example.app", deviceID, wrongVersion, key)
}
assertFailsWith<IllegalArgumentException> {
DevicePairingEnvelope.open("com.example.app", deviceID, ciphertext.dropLast(1).toByteArray(), key)
}
val different = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
assertFailsWith<IllegalStateException> {
DevicePairingEnvelope.open("com.example.app", deviceID, ciphertext, different)
}
}

@Test
fun invalidSecretSizesAreRejected() {
assertFailsWith<IllegalArgumentException> {
DevicePairingMaterial(ByteArray(1), pin)
}
assertFailsWith<IllegalArgumentException> {
DevicePairingMaterial(credential, ByteArray(1))
}
}
}
Loading