1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import kotlinx.coroutines.withTimeoutOrNull7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test12131415161718class VaultAdoptTest {1920 private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21 val a2b = Channel<ByteArray>(Channel.UNLIMITED)22 val b2a = Channel<ByteArray>(Channel.UNLIMITED)23 val a = object : PairingTransport {24 override suspend fun send(msg: ByteArray) { a2b.send(msg) }25 override suspend fun receive(): ByteArray = b2a.receive()26 override suspend fun close() {}27 }28 val b = object : PairingTransport {29 override suspend fun send(msg: ByteArray) { b2a.send(msg) }30 override suspend fun receive(): ByteArray = a2b.receive()31 override suspend fun close() {}32 }33 return a to b34 }3536 private fun identity(name: String) = LocalDeviceIdentity(37 deviceId = "$name-id",38 deviceName = name,39 deviceType = DeviceType.EXTENSION,40 longTermPublicKeyHex = "ab".repeat(32),41 signingPublicKeyHex = "cd".repeat(32),42 )4344 private fun vaultIdFor(rootKeyHex: String): String {45 val bytes = hexToPubkey(rootKeyHex)46 return try {47 computeVaultId(bytes)48 } finally {49 bytes.fill(0)50 }51 }5253 private class RecorderReceiver(54 override val deviceName: String,55 override val deviceType: DeviceType,56 private val vaultId: String?,57 private val createThrows: Boolean = false,58 private val derived: LocalDeviceIdentity? = null,59 private val sasConfirmed: Boolean = true,60 ) : AdoptionReceiverDeps {61 var createCalledWith: String? = null62 var licenseApplied: String? = null63 val revocationsCleared = mutableListOf<String>()64 val upserted = mutableListOf<PairedDevice>()65 val errors = mutableListOf<String>()66 override fun generateDeviceId(): String = "receiver-ephemeral"67 override suspend fun confirmSas(): Boolean = sasConfirmed68 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {69 createCalledWith = rootKeyHex70 if (createThrows) throw IllegalStateException("commit_failed")71 return vaultId ?: error("no vault id")72 }73 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")74 override suspend fun clearPairedDeviceRevocation(deviceId: String) {75 revocationsCleared += deviceId76 }77 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }78 override suspend fun applyLicense(license: String) { licenseApplied = license }79 override fun onError(error: String) { errors += error }80 }8182 private class RecorderSource(83 override val sourceIdentity: LocalDeviceIdentity,84 private val rootKeyHex: String,85 private val vaultId: String,86 private val license: String? = null,87 private val consent: Boolean = true,88 ) : AdoptionSourceDeps {89 val upserted = mutableListOf<PairedDevice>()90 var consentAsked = false91 var rootRequested = false92 override suspend fun rootKeyHex(): String {93 rootRequested = true94 return rootKeyHex95 }96 override suspend fun vaultId(): String = vaultId97 override suspend fun license(): String? = license98 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {99 consentAsked = true100 return consent101 }102 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }103 }104105 @Test fun completes_request_grant_identity_round_trip() = runBlocking {106 val (receiverCh, sourceCh) = memoryPair()107 val rootKeyHex = "11".repeat(32)108 val vaultId = vaultIdFor(rootKeyHex)109 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)110 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)111 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")112113 val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }114 val grantJob = async {115 val request = receiveFrame(sourceCh) as AdoptionFrame.Request116 grantAdoption(sourceCh, request, source) { 222L }117 }118 recvJob.await()119 grantJob.await()120121 assertEquals(rootKeyHex, receiver.createCalledWith)122 assertEquals("PPK-test", receiver.licenseApplied)123 assertEquals(listOf("source-id"), receiver.revocationsCleared)124 assertEquals("source-id", receiver.upserted.single().deviceId)125 assertEquals("receiver-id", source.upserted.single().deviceId)126 assertEquals("android", source.upserted.single().deviceType)127 }128129 130131132133134135 @Test fun request_label_is_never_what_gets_persisted() = runBlocking {136 val (receiverCh, sourceCh) = memoryPair()137 val rootKeyHex = "55".repeat(32)138 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))139 140 val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")141142 val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }143 sendFrame(receiverCh, AdoptionFrame.Confirm(true))144 receiveFrame(receiverCh) 145 sendFrame(146 receiverCh,147 AdoptionFrame.Identity(148 deviceId = "receiver-id",149 deviceName = "Receiver",150 deviceType = "extension",151 longTermPublicKeyHex = "aa".repeat(32),152 signingPublicKeyHex = "bb".repeat(32),153 ),154 )155 grantJob.await()156157 val persisted = source.upserted.single()158 assertEquals("receiver-id", persisted.deviceId)159 assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)160 assertTrue(161 "the ephemeral request label must not reach the paired-device store",162 source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },163 )164 }165166 @Test fun request_carries_receiver_device_type() = runBlocking {167 val (receiverCh, sourceCh) = memoryPair()168 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)169 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }170 val request = receiveFrame(sourceCh) as AdoptionFrame.Request171 recvJob.cancel()172173 assertEquals("android", request.deviceType)174 assertEquals("receiver-ephemeral", request.sessionRequestId)175 assertEquals("Receiver", request.deviceName)176 }177178 @Test fun denied_consent_grants_nothing() = runBlocking {179 val (receiverCh, sourceCh) = memoryPair()180 val rootKeyHex = "33".repeat(32)181 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))182 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)183184 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }185 val request = receiveFrame(sourceCh) as AdoptionFrame.Request186 val granted = grantAdoption(sourceCh, request, source) { 0L }187 recvJob.cancel()188189 assertNull(granted)190 assertTrue(source.consentAsked)191 assertFalse(source.rootRequested)192 assertTrue(source.upserted.isEmpty())193 assertNull(receiver.createCalledWith)194 }195196 @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {197 val (receiverCh, sourceCh) = memoryPair()198 val rootKeyHex = "66".repeat(32)199 val receiver = RecorderReceiver(200 "Receiver",201 DeviceType.ANDROID,202 vaultIdFor(rootKeyHex),203 sasConfirmed = false,204 )205 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))206207 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }208 val request = receiveFrame(sourceCh) as AdoptionFrame.Request209 val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }210211 assertNull(receiverJob.await())212 assertNull(sourceJob.await())213 assertFalse(source.rootRequested)214 assertNull(receiver.createCalledWith)215 assertTrue(receiver.revocationsCleared.isEmpty())216 assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)217 }218219 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {220 val (receiverCh, sourceCh) = memoryPair()221 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)222 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }223 receiveFrame(sourceCh)224 sendFrame(sourceCh, AdoptionFrame.Grant(225 rootKeyHex = "bad",226 vaultId = "00".repeat(32),227 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),228 ))229 recvJob.await()230231 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)232 assertNull(receiver.createCalledWith)233 }234235 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {236 val (receiverCh, sourceCh) = memoryPair()237 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)238 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }239 receiveFrame(sourceCh)240 sendFrame(sourceCh, AdoptionFrame.Grant(241 rootKeyHex = "22".repeat(32),242 vaultId = "00".repeat(32),243 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),244 ))245 recvJob.await()246247 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)248 assertNull(receiver.createCalledWith)249 }250251 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {252 val (receiverCh, sourceCh) = memoryPair()253 val rootKeyHex = "33".repeat(32)254 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))255 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")256257 sendFrame(receiverCh, AdoptionFrame.Confirm(true))258 val result = withTimeoutOrNull(150) {259 260 grantAdoption(sourceCh, request, source) { 0L }261 }262263 assertNull(result)264 assertTrue(source.upserted.isEmpty())265 }266267 @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {268 val (receiverCh, sourceCh) = memoryPair()269 val rootKeyHex = "44".repeat(32)270 val vaultId = vaultIdFor(rootKeyHex)271 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)272 val recvJob = async {273 runCatching { requestAdoption(receiverCh, receiver) { 0L } }274 }275 receiveFrame(sourceCh)276 sendFrame(sourceCh, AdoptionFrame.Grant(277 rootKeyHex = rootKeyHex,278 vaultId = vaultId,279 license = "PPK-test",280 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),281 ))282 val outcome = recvJob.await()283284 assertTrue(outcome.isFailure)285 assertNull(receiver.licenseApplied)286 }287288 @Test fun validator_rejects_short_and_uppercase_root_keys() {289 assertTrue(validateRootKeyHex("ab".repeat(32)))290 assertFalse(validateRootKeyHex("ab".repeat(31)))291 assertFalse(validateRootKeyHex("AB".repeat(32)))292 assertFalse(validateRootKeyHex("zz".repeat(32)))293 assertFalse(validateRootKeyHex(null))294 }295296 @Test fun grant_omits_absent_optionals_like_json_stringify() {297 val grant = AdoptionFrame.Grant(298 rootKeyHex = "11".repeat(32),299 vaultId = "22".repeat(32),300 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),301 )302 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)303304 assertFalse(json.contains("license"))305 assertFalse(json.contains("ws_port"))306 assertFalse(json.contains("signing_public_key_hex"))307 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))308 assertTrue(json.contains("\"root_key_hex\""))309 }310}