package com.peachpasswords.sync import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Adoption frame protocol parity with the browser `chirp-adopt.test.ts`. The * receiver must verify the granted root key derives the advertised vault id * BEFORE sealing a vault, and a denied consent must hand over nothing. */ class ChirpAdoptTest { private fun memoryPair(): Pair { val a2b = Channel(Channel.UNLIMITED) val b2a = Channel(Channel.UNLIMITED) val a = object : PairingTransport { override suspend fun send(msg: ByteArray) { a2b.send(msg) } override suspend fun receive(): ByteArray = b2a.receive() override suspend fun close() {} } val b = object : PairingTransport { override suspend fun send(msg: ByteArray) { b2a.send(msg) } override suspend fun receive(): ByteArray = a2b.receive() override suspend fun close() {} } return a to b } private fun identity(name: String) = LocalChirpIdentity( deviceId = "$name-id", deviceName = name, deviceType = DeviceType.EXTENSION, longTermPublicKeyHex = "ab".repeat(32), signingPublicKeyHex = "cd".repeat(32), ) private fun vaultIdFor(rootKeyHex: String): String { val bytes = hexToPubkey(rootKeyHex) return try { computeVaultId(bytes) } finally { bytes.fill(0) } } private class RecorderReceiver( override val deviceName: String, override val deviceType: DeviceType, private val vaultId: String?, private val createThrows: Boolean = false, private val derived: LocalChirpIdentity? = null, private val sasConfirmed: Boolean = true, ) : AdoptionReceiverDeps { var createCalledWith: String? = null var licenseApplied: String? = null val upserted = mutableListOf() val errors = mutableListOf() override fun generateDeviceId(): String = "receiver-ephemeral" override suspend fun confirmSas(): Boolean = sasConfirmed override suspend fun createVaultFromRootKey(rootKeyHex: String): String { createCalledWith = rootKeyHex if (createThrows) throw IllegalStateException("commit_failed") return vaultId ?: error("no vault id") } override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity") override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device } override suspend fun applyLicense(license: String) { licenseApplied = license } override fun onError(error: String) { errors += error } } private class RecorderSource( override val sourceIdentity: LocalChirpIdentity, private val rootKeyHex: String, private val vaultId: String, private val license: String? = null, private val consent: Boolean = true, ) : AdoptionSourceDeps { val upserted = mutableListOf() var consentAsked = false var rootRequested = false override suspend fun rootKeyHex(): String { rootRequested = true return rootKeyHex } override suspend fun vaultId(): String = vaultId override suspend fun license(): String? = license override suspend fun onConsent(request: AdoptionFrame.Request): Boolean { consentAsked = true return consent } override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device } } @Test fun completes_request_grant_identity_round_trip() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "11".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID) val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity) val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test") val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } } val grantJob = async { val request = receiveFrame(sourceCh) as AdoptionFrame.Request grantAdoption(sourceCh, request, source) { 222L } } recvJob.await() grantJob.await() assertEquals(rootKeyHex, receiver.createCalledWith) assertEquals("PPK-test", receiver.licenseApplied) assertEquals("source-id", receiver.upserted.single().deviceId) assertEquals("receiver-id", source.upserted.single().deviceId) assertEquals("android", source.upserted.single().deviceType) } /** * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label * on ADOPT_REQUEST is generated before any key exists, so persisting it as * an identity would key trust on a value with no continuity between * attempts. What lands in the store must come from ADOPT_IDENTITY. */ @Test fun request_label_is_never_what_gets_persisted() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "55".repeat(32) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex)) // Deliberately unlike the identity the receiver will present. val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension") val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } } sendFrame(receiverCh, AdoptionFrame.Confirm(true)) receiveFrame(receiverCh) // the grant sendFrame( receiverCh, AdoptionFrame.Identity( deviceId = "receiver-id", deviceName = "Receiver", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), ), ) grantJob.await() val persisted = source.upserted.single() assertEquals("receiver-id", persisted.deviceId) assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex) assertTrue( "the ephemeral request label must not reach the paired-device store", source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" }, ) } @Test fun request_carries_receiver_device_type() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } val request = receiveFrame(sourceCh) as AdoptionFrame.Request recvJob.cancel() assertEquals("android", request.deviceType) assertEquals("receiver-ephemeral", request.sessionRequestId) assertEquals("Receiver", request.deviceName) } @Test fun denied_consent_grants_nothing() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "33".repeat(32) val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex)) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } val request = receiveFrame(sourceCh) as AdoptionFrame.Request val granted = grantAdoption(sourceCh, request, source) { 0L } recvJob.cancel() assertNull(granted) assertTrue(source.consentAsked) assertFalse(source.rootRequested) assertTrue(source.upserted.isEmpty()) assertNull(receiver.createCalledWith) } @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "66".repeat(32) val receiver = RecorderReceiver( "Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex), sasConfirmed = false, ) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex)) val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } } val request = receiveFrame(sourceCh) as AdoptionFrame.Request val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } } assertNull(receiverJob.await()) assertNull(sourceJob.await()) assertFalse(source.rootRequested) assertNull(receiver.createCalledWith) assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors) } @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = "bad", vaultId = "00".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) recvJob.await() assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors) assertNull(receiver.createCalledWith) } @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = "22".repeat(32), vaultId = "00".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) recvJob.await() assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors) assertNull(receiver.createCalledWith) } @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "33".repeat(32) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex)) val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension") sendFrame(receiverCh, AdoptionFrame.Confirm(true)) val result = withTimeoutOrNull(150) { // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist. grantAdoption(sourceCh, request, source) { 0L } } assertNull(result) assertTrue(source.upserted.isEmpty()) } @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "44".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true) val recvJob = async { runCatching { requestAdoption(receiverCh, receiver) { 0L } } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, license = "PPK-test", source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) val outcome = recvJob.await() assertTrue(outcome.isFailure) assertNull(receiver.licenseApplied) } @Test fun validator_rejects_short_and_uppercase_root_keys() { assertTrue(validateRootKeyHex("ab".repeat(32))) assertFalse(validateRootKeyHex("ab".repeat(31))) assertFalse(validateRootKeyHex("AB".repeat(32))) assertFalse(validateRootKeyHex("zz".repeat(32))) assertFalse(validateRootKeyHex(null)) } @Test fun grant_omits_absent_optionals_like_json_stringify() { val grant = AdoptionFrame.Grant( rootKeyHex = "11".repeat(32), vaultId = "22".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), ) val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant) assertFalse(json.contains("license")) assertFalse(json.contains("ws_port")) assertFalse(json.contains("signing_public_key_hex")) assertTrue(json.contains("\"type\":\"ADOPT_GRANT\"")) assertTrue(json.contains("\"root_key_hex\"")) } }