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 private val localLicense: String? = "PPK-test",61 ) : AdoptionReceiverDeps {62 var createCalledWith: String? = null63 var licenseApplied: String? = null64 val revocationsCleared = mutableListOf<String>()65 val upserted = mutableListOf<PairedDevice>()66 val errors = mutableListOf<String>()67 override fun generateDeviceId(): String = "receiver-ephemeral"68 override suspend fun confirmSas(): Boolean = sasConfirmed69 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {70 createCalledWith = rootKeyHex71 if (createThrows) throw IllegalStateException("commit_failed")72 return vaultId ?: error("no vault id")73 }74 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")75 override suspend fun clearPairedDeviceRevocation(deviceId: String) {76 revocationsCleared += deviceId77 }78 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }79 override suspend fun applyLicense(license: String) { licenseApplied = license }80 override suspend fun license(): String? = localLicense81 override fun onError(error: String) { errors += error }82 }8384 private class RecorderSource(85 override val sourceIdentity: LocalDeviceIdentity,86 private val rootKeyHex: String,87 private val vaultId: String,88 license: String? = "PPK-test",89 private val consent: Boolean = true,90 ) : AdoptionSourceDeps {91 private var storedLicense: String? = license92 val upserted = mutableListOf<PairedDevice>()93 var licenseApplied: String? = null94 var consentAsked = false95 var rootRequested = false96 override suspend fun rootKeyHex(): String {97 rootRequested = true98 return rootKeyHex99 }100 override suspend fun vaultId(): String = vaultId101 override suspend fun license(): String? = storedLicense102 override suspend fun applyLicense(license: String) {103 licenseApplied = license104 storedLicense = license105 }106 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {107 consentAsked = true108 return consent109 }110 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }111 }112113 @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking {114 val (receiverCh, sourceCh) = memoryPair()115 val rootKeyHex = "11".repeat(32)116 val vaultId = vaultIdFor(rootKeyHex)117 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)118 val receiver = RecorderReceiver(119 "Receiver",120 DeviceType.ANDROID,121 vaultId,122 derived = receiverIdentity,123 localLicense = null,124 )125 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")126127 val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }128 val grantJob = async {129 val request = receiveFrame(sourceCh) as AdoptionFrame.Request130 grantAdoption(sourceCh, request, source) { 222L }131 }132 recvJob.await()133 grantJob.await()134135 assertEquals(rootKeyHex, receiver.createCalledWith)136 assertEquals("PPK-test", receiver.licenseApplied)137 assertEquals(listOf("source-id"), receiver.revocationsCleared)138 assertEquals("source-id", receiver.upserted.single().deviceId)139 assertEquals("receiver-id", source.upserted.single().deviceId)140 assertEquals("android", source.upserted.single().deviceType)141 }142143 @Test fun rejects_without_creating_or_pairing_when_neither_peer_has_pro() = runBlocking {144 val (receiverCh, sourceCh) = memoryPair()145 val rootKeyHex = "12".repeat(32)146 val vaultId = vaultIdFor(rootKeyHex)147 val receiver = RecorderReceiver(148 "Free receiver",149 DeviceType.ANDROID,150 vaultId,151 localLicense = null,152 )153 val source = RecorderSource(154 identity("free-source"),155 rootKeyHex,156 vaultId,157 license = null,158 )159160 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }161 val sourceJob = async {162 val request = receiveFrame(sourceCh) as AdoptionFrame.Request163 runCatching { grantAdoption(sourceCh, request, source) { 0L } }164 }165166 assertNull(receiverJob.await())167 val sourceOutcome = sourceJob.await()168 assertTrue(sourceOutcome.isFailure)169 assertEquals("ADOPT_PRO_REQUIRED", sourceOutcome.exceptionOrNull()?.message)170 assertEquals(listOf("ADOPT_PRO_REQUIRED"), receiver.errors)171 assertNull(receiver.createCalledWith)172 assertTrue(receiver.upserted.isEmpty())173 assertTrue(source.upserted.isEmpty())174 }175176 @Test fun receiver_pro_license_is_transferred_to_a_free_source() = runBlocking {177 val (receiverCh, sourceCh) = memoryPair()178 val rootKeyHex = "13".repeat(32)179 val vaultId = vaultIdFor(rootKeyHex)180 val receiver = RecorderReceiver(181 "Pro receiver",182 DeviceType.ANDROID,183 vaultId,184 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),185 localLicense = "PPK-receiver",186 )187 val source = RecorderSource(188 identity("free-source"),189 rootKeyHex,190 vaultId,191 license = null,192 )193194 val receiverJob = async { requestAdoption(receiverCh, receiver) { 1L } }195 val sourceJob = async {196 val request = receiveFrame(sourceCh) as AdoptionFrame.Request197 grantAdoption(sourceCh, request, source) { 2L }198 }199200 assertEquals("free-source-id", receiverJob.await()?.deviceId)201 assertEquals("receiver-id", sourceJob.await()?.deviceId)202 assertEquals("PPK-receiver", source.licenseApplied)203 }204205 206207208209210211 @Test fun request_label_is_never_what_gets_persisted() = runBlocking {212 val (receiverCh, sourceCh) = memoryPair()213 val rootKeyHex = "55".repeat(32)214 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))215 216 val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")217218 val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }219 sendFrame(receiverCh, AdoptionFrame.Confirm(true))220 receiveFrame(receiverCh) 221 sendFrame(222 receiverCh,223 AdoptionFrame.Identity(224 deviceId = "receiver-id",225 deviceName = "Receiver",226 deviceType = "extension",227 longTermPublicKeyHex = "aa".repeat(32),228 signingPublicKeyHex = "bb".repeat(32),229 ),230 )231 receiveFrame(receiverCh) 232 sendFrame(receiverCh, AdoptionFrame.Applied)233 grantJob.await()234235 val persisted = source.upserted.single()236 assertEquals("receiver-id", persisted.deviceId)237 assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)238 assertTrue(239 "the ephemeral request label must not reach the paired-device store",240 source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },241 )242 }243244 @Test fun request_carries_receiver_device_type() = runBlocking {245 val (receiverCh, sourceCh) = memoryPair()246 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)247 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }248 val request = receiveFrame(sourceCh) as AdoptionFrame.Request249 recvJob.cancel()250251 assertEquals("android", request.deviceType)252 assertEquals("receiver-ephemeral", request.sessionRequestId)253 assertEquals("Receiver", request.deviceName)254 }255256 @Test fun denied_consent_grants_nothing() = runBlocking {257 val (receiverCh, sourceCh) = memoryPair()258 val rootKeyHex = "33".repeat(32)259 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))260 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)261262 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }263 val request = receiveFrame(sourceCh) as AdoptionFrame.Request264 val granted = grantAdoption(sourceCh, request, source) { 0L }265 recvJob.cancel()266267 assertNull(granted)268 assertTrue(source.consentAsked)269 assertFalse(source.rootRequested)270 assertTrue(source.upserted.isEmpty())271 assertNull(receiver.createCalledWith)272 }273274 @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {275 val (receiverCh, sourceCh) = memoryPair()276 val rootKeyHex = "66".repeat(32)277 val receiver = RecorderReceiver(278 "Receiver",279 DeviceType.ANDROID,280 vaultIdFor(rootKeyHex),281 sasConfirmed = false,282 )283 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))284285 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }286 val request = receiveFrame(sourceCh) as AdoptionFrame.Request287 val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }288289 assertNull(receiverJob.await())290 assertNull(sourceJob.await())291 assertFalse(source.rootRequested)292 assertNull(receiver.createCalledWith)293 assertTrue(receiver.revocationsCleared.isEmpty())294 assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)295 }296297 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {298 val (receiverCh, sourceCh) = memoryPair()299 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)300 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }301 receiveFrame(sourceCh)302 sendFrame(sourceCh, AdoptionFrame.Grant(303 rootKeyHex = "bad",304 vaultId = "00".repeat(32),305 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),306 ))307 recvJob.await()308309 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)310 assertNull(receiver.createCalledWith)311 }312313 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {314 val (receiverCh, sourceCh) = memoryPair()315 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)316 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }317 receiveFrame(sourceCh)318 sendFrame(sourceCh, AdoptionFrame.Grant(319 rootKeyHex = "22".repeat(32),320 vaultId = "00".repeat(32),321 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),322 ))323 recvJob.await()324325 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)326 assertNull(receiver.createCalledWith)327 }328329 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {330 val (receiverCh, sourceCh) = memoryPair()331 val rootKeyHex = "33".repeat(32)332 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))333 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")334335 sendFrame(receiverCh, AdoptionFrame.Confirm(true))336 val result = withTimeoutOrNull(150) {337 338 grantAdoption(sourceCh, request, source) { 0L }339 }340341 assertNull(result)342 assertTrue(source.upserted.isEmpty())343 }344345 @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {346 val (receiverCh, sourceCh) = memoryPair()347 val rootKeyHex = "77".repeat(32)348 val vaultId = vaultIdFor(rootKeyHex)349 val receiver = RecorderReceiver(350 "Receiver",351 DeviceType.ANDROID,352 vaultId,353 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),354 )355 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }356357 receiveFrame(sourceCh) 358 receiveFrame(sourceCh) 359 sendFrame(360 sourceCh,361 AdoptionFrame.Grant(362 rootKeyHex = rootKeyHex,363 vaultId = vaultId,364 source = AdoptionFrame.Source(365 "source-id",366 "Source",367 "extension",368 "ab".repeat(32),369 ),370 ),371 )372 receiveFrame(sourceCh) 373 sendFrame(sourceCh, AdoptionFrame.Confirm(true))374375 assertNull(receiverJob.await())376 assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)377 assertTrue(receiver.revocationsCleared.isEmpty())378 assertTrue(receiver.upserted.isEmpty())379 }380381 @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {382 val (receiverCh, sourceCh) = memoryPair()383 val rootKeyHex = "78".repeat(32)384 val vaultId = vaultIdFor(rootKeyHex)385 val receiver = object : RecorderReceiver(386 "Receiver",387 DeviceType.ANDROID,388 vaultId,389 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),390 ) {391 override suspend fun validateSource(source: AdoptionFrame.Source) {392 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")393 }394 }395 val receiverJob = async {396 runCatching { requestAdoption(receiverCh, receiver) { 0L } }397 }398399 receiveFrame(sourceCh) 400 receiveFrame(sourceCh) 401 sendFrame(402 sourceCh,403 AdoptionFrame.Grant(404 rootKeyHex = rootKeyHex,405 vaultId = vaultId,406 source = AdoptionFrame.Source(407 "new-source",408 "Source",409 "extension",410 "ab".repeat(32),411 ),412 ),413 )414415 val outcome = receiverJob.await()416 assertTrue(outcome.isFailure)417 assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)418 assertNull(receiver.createCalledWith)419 }420421 @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {422 val (receiverCh, sourceCh) = memoryPair()423 val rootKeyHex = "44".repeat(32)424 val vaultId = vaultIdFor(rootKeyHex)425 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)426 val recvJob = async {427 runCatching { requestAdoption(receiverCh, receiver) { 0L } }428 }429 receiveFrame(sourceCh)430 sendFrame(sourceCh, AdoptionFrame.Grant(431 rootKeyHex = rootKeyHex,432 vaultId = vaultId,433 license = "PPK-test",434 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),435 ))436 val outcome = recvJob.await()437438 assertTrue(outcome.isFailure)439 assertNull(receiver.licenseApplied)440 }441442 @Test fun validator_rejects_short_and_uppercase_root_keys() {443 assertTrue(validateRootKeyHex("ab".repeat(32)))444 assertFalse(validateRootKeyHex("ab".repeat(31)))445 assertFalse(validateRootKeyHex("AB".repeat(32)))446 assertFalse(validateRootKeyHex("zz".repeat(32)))447 assertFalse(validateRootKeyHex(null))448 }449450 @Test fun grant_omits_absent_optionals_like_json_stringify() {451 val grant = AdoptionFrame.Grant(452 rootKeyHex = "11".repeat(32),453 vaultId = "22".repeat(32),454 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),455 )456 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)457458 assertFalse(json.contains("license"))459 assertFalse(json.contains("ws_port"))460 assertFalse(json.contains("signing_public_key_hex"))461 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))462 assertTrue(json.contains("\"root_key_hex\""))463 }464465 @Test fun grant_json_carries_snapshot_capable_true_when_request_advertised_it() {466 val grant = AdoptionFrame.Grant(467 rootKeyHex = "11".repeat(32),468 vaultId = "22".repeat(32),469 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),470 snapshotCapable = true,471 )472 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)473 assertTrue(474 "explicit snapshot_capable: true must serialize",475 json.contains("\"snapshot_capable\":true"),476 )477 }478479 @Test fun grant_decodes_snapshot_capable_false_when_field_absent() {480 481 482 483 484 485 486 487 488 489 490 val withoutField = AdoptJson.decodeFromString(491 AdoptionFrame.serializer(),492 "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}",493 ) as AdoptionFrame.Grant494 assertFalse(495 "missing field must decode to default false (receiver skips 15s wait)",496 withoutField.snapshotCapable,497 )498 499 500 val withFieldFalse = AdoptJson.decodeFromString(501 AdoptionFrame.serializer(),502 "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"snapshot_capable\":false,\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}",503 ) as AdoptionFrame.Grant504 assertFalse(505 "explicit false must decode to false",506 withFieldFalse.snapshotCapable,507 )508 }509510 @Test fun requestAdoption_invokes_onGrant_with_decoded_grant() = runBlocking {511 512 513 514 515 val (receiverCh, sourceCh) = memoryPair()516 val rootKeyHex = "55".repeat(32)517 val vaultId = vaultIdFor(rootKeyHex)518 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)519 val receiver = RecorderReceiver(520 "Receiver",521 DeviceType.ANDROID,522 vaultId,523 derived = receiverIdentity,524 localLicense = null,525 )526 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")527 val seenGrants = mutableListOf<AdoptionFrame.Grant>()528529 val recvJob = async {530 requestAdoption(531 receiverCh,532 receiver,533 onGrant = { grant -> seenGrants += grant },534 )535 }536 val grantJob = async {537 val request = receiveFrame(sourceCh) as AdoptionFrame.Request538 assertTrue(539 "receiver must advertise snapshot_capable in ADOPT_REQUEST",540 request.snapshotCapable == true,541 )542 grantAdoption(sourceCh, request, source) { 0L }543 }544 recvJob.await()545 grantJob.await()546547 assertEquals(1, seenGrants.size)548 assertEquals(rootKeyHex, seenGrants.single().rootKeyHex)549 550 551 552 553 assertTrue(554 "grant must echo request.snapshot_capable === true",555 seenGrants.single().snapshotCapable,556 )557 }558}