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 open 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_through_mutual_completion_acknowledgements() = 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 receiveFrame(receiverCh) 156 sendFrame(receiverCh, AdoptionFrame.Applied)157 grantJob.await()158159 val persisted = source.upserted.single()160 assertEquals("receiver-id", persisted.deviceId)161 assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)162 assertTrue(163 "the ephemeral request label must not reach the paired-device store",164 source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },165 )166 }167168 @Test fun request_carries_receiver_device_type() = runBlocking {169 val (receiverCh, sourceCh) = memoryPair()170 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)171 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }172 val request = receiveFrame(sourceCh) as AdoptionFrame.Request173 recvJob.cancel()174175 assertEquals("android", request.deviceType)176 assertEquals("receiver-ephemeral", request.sessionRequestId)177 assertEquals("Receiver", request.deviceName)178 }179180 @Test fun denied_consent_grants_nothing() = runBlocking {181 val (receiverCh, sourceCh) = memoryPair()182 val rootKeyHex = "33".repeat(32)183 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))184 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)185186 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }187 val request = receiveFrame(sourceCh) as AdoptionFrame.Request188 val granted = grantAdoption(sourceCh, request, source) { 0L }189 recvJob.cancel()190191 assertNull(granted)192 assertTrue(source.consentAsked)193 assertFalse(source.rootRequested)194 assertTrue(source.upserted.isEmpty())195 assertNull(receiver.createCalledWith)196 }197198 @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {199 val (receiverCh, sourceCh) = memoryPair()200 val rootKeyHex = "66".repeat(32)201 val receiver = RecorderReceiver(202 "Receiver",203 DeviceType.ANDROID,204 vaultIdFor(rootKeyHex),205 sasConfirmed = false,206 )207 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))208209 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }210 val request = receiveFrame(sourceCh) as AdoptionFrame.Request211 val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }212213 assertNull(receiverJob.await())214 assertNull(sourceJob.await())215 assertFalse(source.rootRequested)216 assertNull(receiver.createCalledWith)217 assertTrue(receiver.revocationsCleared.isEmpty())218 assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)219 }220221 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {222 val (receiverCh, sourceCh) = memoryPair()223 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)224 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }225 receiveFrame(sourceCh)226 sendFrame(sourceCh, AdoptionFrame.Grant(227 rootKeyHex = "bad",228 vaultId = "00".repeat(32),229 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),230 ))231 recvJob.await()232233 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)234 assertNull(receiver.createCalledWith)235 }236237 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {238 val (receiverCh, sourceCh) = memoryPair()239 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)240 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }241 receiveFrame(sourceCh)242 sendFrame(sourceCh, AdoptionFrame.Grant(243 rootKeyHex = "22".repeat(32),244 vaultId = "00".repeat(32),245 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),246 ))247 recvJob.await()248249 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)250 assertNull(receiver.createCalledWith)251 }252253 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {254 val (receiverCh, sourceCh) = memoryPair()255 val rootKeyHex = "33".repeat(32)256 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))257 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")258259 sendFrame(receiverCh, AdoptionFrame.Confirm(true))260 val result = withTimeoutOrNull(150) {261 262 grantAdoption(sourceCh, request, source) { 0L }263 }264265 assertNull(result)266 assertTrue(source.upserted.isEmpty())267 }268269 @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {270 val (receiverCh, sourceCh) = memoryPair()271 val rootKeyHex = "77".repeat(32)272 val vaultId = vaultIdFor(rootKeyHex)273 val receiver = RecorderReceiver(274 "Receiver",275 DeviceType.ANDROID,276 vaultId,277 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),278 )279 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }280281 receiveFrame(sourceCh) 282 receiveFrame(sourceCh) 283 sendFrame(284 sourceCh,285 AdoptionFrame.Grant(286 rootKeyHex = rootKeyHex,287 vaultId = vaultId,288 source = AdoptionFrame.Source(289 "source-id",290 "Source",291 "extension",292 "ab".repeat(32),293 ),294 ),295 )296 receiveFrame(sourceCh) 297 sendFrame(sourceCh, AdoptionFrame.Confirm(true))298299 assertNull(receiverJob.await())300 assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)301 assertTrue(receiver.revocationsCleared.isEmpty())302 assertTrue(receiver.upserted.isEmpty())303 }304305 @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {306 val (receiverCh, sourceCh) = memoryPair()307 val rootKeyHex = "78".repeat(32)308 val vaultId = vaultIdFor(rootKeyHex)309 val receiver = object : RecorderReceiver(310 "Receiver",311 DeviceType.ANDROID,312 vaultId,313 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),314 ) {315 override suspend fun validateSource(source: AdoptionFrame.Source) {316 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")317 }318 }319 val receiverJob = async {320 runCatching { requestAdoption(receiverCh, receiver) { 0L } }321 }322323 receiveFrame(sourceCh) 324 receiveFrame(sourceCh) 325 sendFrame(326 sourceCh,327 AdoptionFrame.Grant(328 rootKeyHex = rootKeyHex,329 vaultId = vaultId,330 source = AdoptionFrame.Source(331 "new-source",332 "Source",333 "extension",334 "ab".repeat(32),335 ),336 ),337 )338339 val outcome = receiverJob.await()340 assertTrue(outcome.isFailure)341 assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)342 assertNull(receiver.createCalledWith)343 }344345 @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {346 val (receiverCh, sourceCh) = memoryPair()347 val rootKeyHex = "44".repeat(32)348 val vaultId = vaultIdFor(rootKeyHex)349 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)350 val recvJob = async {351 runCatching { requestAdoption(receiverCh, receiver) { 0L } }352 }353 receiveFrame(sourceCh)354 sendFrame(sourceCh, AdoptionFrame.Grant(355 rootKeyHex = rootKeyHex,356 vaultId = vaultId,357 license = "PPK-test",358 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),359 ))360 val outcome = recvJob.await()361362 assertTrue(outcome.isFailure)363 assertNull(receiver.licenseApplied)364 }365366 @Test fun validator_rejects_short_and_uppercase_root_keys() {367 assertTrue(validateRootKeyHex("ab".repeat(32)))368 assertFalse(validateRootKeyHex("ab".repeat(31)))369 assertFalse(validateRootKeyHex("AB".repeat(32)))370 assertFalse(validateRootKeyHex("zz".repeat(32)))371 assertFalse(validateRootKeyHex(null))372 }373374 @Test fun grant_omits_absent_optionals_like_json_stringify() {375 val grant = AdoptionFrame.Grant(376 rootKeyHex = "11".repeat(32),377 vaultId = "22".repeat(32),378 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),379 )380 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)381382 assertFalse(json.contains("license"))383 assertFalse(json.contains("ws_port"))384 assertFalse(json.contains("signing_public_key_hex"))385 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))386 assertTrue(json.contains("\"root_key_hex\""))387 }388}