RelayAdoptionInitiatorSourceTest.kt

27.8 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.withTimeoutOrNull5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Assert.fail12import org.junit.Test1314/**15 * Source-initiator counterpart to [RelayAdoptionHostTest]: the phone dials the16 * relay room, runs Noise as initiator, and sends the grant to the (empty)17 * responder. The responder side runs the real [requestAdoption] receiver18 * pipeline so this test exercises both directions of the protocol over a19 * real Noise channel.20 */21class RelayAdoptionInitiatorSourceTest {2223    /**24     * Time-boxed so these adversarial tests fail rather than hang. Without the25     * pin check the phone completes a handshake against the substituted key and26     * then waits on a protocol step the attacker never performs, so a plain27     * await() would never return.28     */29    private val HANDSHAKE_TIMEOUT_MS = 5_000L3031    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(32        deviceId = "$name-id",33        deviceName = name,34        deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION,35        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),36        signingPublicKeyHex = "cd".repeat(32),37        wsPort = 49142,38        relayUrl = "wss://relay.example.test",39    )4041    private class MemoryRelaySocket(42        private val inbound: kotlinx.coroutines.channels.Channel<ByteArray>,43        private val outbound: kotlinx.coroutines.channels.Channel<ByteArray>,44    ) : RelayBinarySocket {45        override fun send(frame: ByteArray) {46            check(outbound.trySend(frame.copyOf()).isSuccess)47        }4849        override suspend fun receive(): ByteArray = inbound.receive()50        override fun close(code: Int, reason: String) {51            outbound.close()52        }53    }5455    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {56        val leftToRight = kotlinx.coroutines.channels.Channel<ByteArray>(57            kotlinx.coroutines.channels.Channel.UNLIMITED,58        )59        val rightToLeft = kotlinx.coroutines.channels.Channel<ByteArray>(60            kotlinx.coroutines.channels.Channel.UNLIMITED,61        )62        return MemoryRelaySocket(rightToLeft, leftToRight) to63            MemoryRelaySocket(leftToRight, rightToLeft)64    }6566    @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking {67        val (phoneSocket, responderSocket) = socketPair()68        val token = "AAECAwQFBgcICQoLDA0ODw"69        val relay = "wss://relay.example.test"70        val root = ByteArray(32) { (it + 5).toByte() }71        val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id")72        val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")73        val phoneIdentity = identity("phone", phoneKeys.publicKey)74        val responderIdentity = identity("responder", responderKeys.publicKey)75        var phoneStored: PairedDevice? = null7677        val phoneJob = async {78            runRelayAdoptionInitiatorSourceSession(79                socket = phoneSocket,80                invitation = PairingInvitation(81                    relay,82                    token,83                    VaultStatus.EMPTY,84                    Rendezvous.bytesToHex(responderKeys.publicKey),85                ),86                sourceDeps = object : AdoptionSourceV2Deps {87        override suspend fun rotateSourceIdentity() = sourceIdentity88        override fun hasDeviceIdentityCollision(device: PairedDevice) = false89        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true90        override suspend fun completePendingIdentity(device: PairedDevice) = true91        override fun snapshotCapable() = true9293                    override val sourceIdentity = phoneIdentity94                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)95                    override suspend fun vaultId() = computeVaultId(root)96                    override suspend fun license() = "PPK-test"97                    override suspend fun onConsent(request: AdoptionFrame.Request) = true98                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true99                    override suspend fun upsertPairedDevice(device: PairedDevice) {100                        phoneStored = device101                    }102                },103                confirm = { sas, request ->104                    assertEquals(responderIdentity.deviceName, request.deviceName)105                    assertEquals("extension", request.deviceType)106                    assertEquals(8, sas.length)107                    true108                },109            )110        }111112        // Responder (empty browser) handshake: receive HELLO, send ACK, run Noise.113        val hello = org.json.JSONObject(String(responderSocket.receive()))114        assertEquals("PEACH_HELLO", hello.optString("type"))115        assertEquals(token, hello.optString("token"))116        responderSocket.send(117            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")118                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))119                .toString().toByteArray(),120        )121        val noise = noiseNKResponder(122            responderKeys.secretKey,123            responderKeys.publicKey,124            responderSocket::send,125            responderSocket::receive,126        )127        var responderStored: PairedDevice? = null128        try {129            val channel = object : PairingTransport {130                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))131                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())132                override suspend fun close() = responderSocket.close()133            }134            // Run the real receiver pipeline on the responder side so the test135            // exercises both protocol directions end-to-end.136            val responderJob = async {137                requestAdoption(138                    channel = channel,139                    deps = object : AdoptionReceiverV2Deps {140        override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")141        override suspend fun rotateLocalIdentity() = localIdentity()142        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}143        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false144145                        override val deviceName = responderIdentity.deviceName146                        override val deviceType = DeviceType.EXTENSION147                        override fun generateDeviceId() = "session-only"148                        override suspend fun confirmSas() = true149                        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {150                            assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)151                            return computeVaultId(hexToPubkey(rootKeyHex))152                        }153                        override suspend fun deriveLocalIdentity() = responderIdentity154                        override suspend fun upsertPairedDevice(device: PairedDevice) {155                            responderStored = device156                        }157                        override fun onError(error: String) = throw AssertionError(error)158                    },159                )160            }161            assertNotNull(responderJob.await())162        } finally {163            noise.close()164        }165166        assertNotNull(phoneJob.await())167        assertNotNull(phoneStored)168        assertNotNull(responderStored)169        assertEquals("responder-id", phoneStored?.deviceId)170        assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)171        assertEquals(relay, phoneStored?.relayUrl)172        assertEquals(64, phoneStored?.rendezvousSecretHex?.length)173        // The responder stores the phone's identity (from the ADOPT_GRANT source).174        assertEquals("phone-id", responderStored?.deviceId)175        assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)176    }177178    @Test fun `phone source rotates after browser rejects its revoked identity`() = runBlocking {179        val (phoneSocket, responderSocket) = socketPair()180        val token = "AAECAwQFBgcICQoLDA0ODw"181        val relay = "wss://relay.example.test"182        val root = ByteArray(32) { (it + 19).toByte() }183        val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id")184        val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")185        val phoneIdentity = identity("phone", phoneKeys.publicKey)186        val rotatedPhoneIdentity = identity(187            "phone-fresh",188            deriveSyncIdentityKeypair(root, "phone-fresh-id").publicKey,189        ).copy(deviceId = "phone-fresh-id")190        val responderIdentity = identity("responder", responderKeys.publicKey)191        var rotations = 0192193        val phoneJob = async {194            runRelayAdoptionInitiatorSourceSession(195                socket = phoneSocket,196                invitation = PairingInvitation(197                    relay,198                    token,199                    VaultStatus.EMPTY,200                    Rendezvous.bytesToHex(responderKeys.publicKey),201                ),202                sourceDeps = object : AdoptionSourceV2Deps {203        override fun hasDeviceIdentityCollision(device: PairedDevice) = false204        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true205        override suspend fun completePendingIdentity(device: PairedDevice) = true206        override fun snapshotCapable() = true207208                    override val sourceIdentity = phoneIdentity209                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)210                    override suspend fun vaultId() = computeVaultId(root)211                    override suspend fun license() = "PPK-test"212                    override suspend fun onConsent(request: AdoptionFrame.Request) = true213                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true214                    override suspend fun rotateSourceIdentity(): LocalDeviceIdentity {215                        rotations++216                        return rotatedPhoneIdentity217                    }218                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit219                },220                confirm = { _, _ -> true },221            )222        }223224        val hello = org.json.JSONObject(String(responderSocket.receive()))225        assertEquals("PEACH_HELLO", hello.optString("type"))226        responderSocket.send(227            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")228                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))229                .toString().toByteArray(),230        )231        val noise = noiseNKResponder(232            responderKeys.secretKey,233            responderKeys.publicKey,234            responderSocket::send,235            responderSocket::receive,236        )237        try {238            val channel = object : PairingTransport {239                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))240                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())241                override suspend fun close() = responderSocket.close()242            }243            val responderJob = async {244                requestAdoption(245                    channel,246                    object : AdoptionReceiverV2Deps {247        override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")248        override suspend fun rotateLocalIdentity() = localIdentity()249        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}250        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false251252                        override val deviceName = responderIdentity.deviceName253                        override val deviceType = DeviceType.EXTENSION254                        override fun generateDeviceId() = "session-only"255                        override suspend fun confirmSas() = true256                        override suspend fun createVaultFromRootKey(rootKeyHex: String) =257                            computeVaultId(hexToPubkey(rootKeyHex))258                        override suspend fun deriveLocalIdentity() = responderIdentity259                        override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source) =260                            source.deviceId == phoneIdentity.deviceId261                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit262                        override fun onError(error: String) = throw AssertionError(error)263                    },264                )265            }266            assertNotNull(withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { responderJob.await() })267        } finally {268            noise.close()269        }270271        assertNotNull(withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() })272        assertEquals(1, rotations)273    }274275    // PB-26-103: this is the root-key-bearing direction. The phone grants its276    // vault to an empty browser, so if the relay can substitute the responder's277    // static in the cleartext ACK the phone completes an NK handshake with the278    // attacker and hands over the root key. The invitation pin is the only279    // machine-checkable defence — the SAS on the source side is a human step280    // the attack assumes is skipped.281    /**282     * The attacker's half of the adoption protocol once it has substituted its283     * static: complete Noise as responder, ask for the vault, and read whatever284     * the phone sends back. Fire-and-forget — with the pin check in place the285     * phone aborts before the handshake and this never gets a peer.286     */287    private suspend fun attackerReceivesGrant(288        socket: RelayBinarySocket,289        keys: SyncIdentityKeypair,290    ) {291        val noise = noiseNKResponder(keys.secretKey, keys.publicKey, socket::send, socket::receive)292        try {293            val channel = object : PairingTransport {294                override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))295                override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())296                override suspend fun close() = socket.close()297            }298            sendFrame(channel, AdoptionFrame.Request("attacker-session", "Empty Browser", "extension"))299            receiveFrame(channel)300        } finally {301            noise.close()302        }303    }304305    @Test fun `phone source rejects a substituted ACK static`() = runBlocking {306        val (phoneSocket, mitmSocket) = socketPair()307        val token = "AAECAwQFBgcICQoLDA0ODw"308        val root = ByteArray(32) { (it + 7).toByte() }309        val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")310        val attackerKeys = deriveSyncIdentityKeypair(root, "attacker-id")311        var rootRequested = false312313        // The result is captured inside the job: an exception escaping a child314        // coroutine cancels the parent scope before await() can observe it.315        val phoneJob = async {316            try {317                runRelayAdoptionInitiatorSourceSession(318                    socket = phoneSocket,319                    invitation = PairingInvitation(320                        "wss://relay.example.test",321                        token,322                        VaultStatus.EMPTY,323                        Rendezvous.bytesToHex(responderKeys.publicKey),324                    ),325                    sourceDeps = object : AdoptionSourceV2Deps {326        override suspend fun rotateSourceIdentity() = sourceIdentity327        override fun hasDeviceIdentityCollision(device: PairedDevice) = false328        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true329        override suspend fun completePendingIdentity(device: PairedDevice) = true330        override fun snapshotCapable() = true331332                        override val sourceIdentity =333                            identity("phone", deriveSyncIdentityKeypair(root, "phone-id").publicKey)334                        override suspend fun rootKeyHex(): String {335                            rootRequested = true336                            return Rendezvous.bytesToHex(root)337                        }338                        override suspend fun vaultId() = computeVaultId(root)339                        override suspend fun onConsent(request: AdoptionFrame.Request) = true340                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true341                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit342                    },343                    confirm = { _, _ -> true },344                )345                "completed"346            } catch (expected: IllegalArgumentException) {347                expected.message348            }349        }350351        mitmSocket.receive() // PEACH_HELLO352        // The relay terminates the session itself: a different static in the ACK.353        mitmSocket.send(354            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")355                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))356                .toString().toByteArray(),357        )358        // The attacker then plays the receiver through a complete handshake so359        // the pre-fix code path runs to the grant instead of stalling.360        val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }361362        // Pre-fix the phone completes the handshake against the attacker's key363        // and stalls waiting on the protocol; either way it is not this error.364        assertEquals(365            "pairing_static_key_mismatch",366            withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },367        )368        attackerJob.cancel()369        assertFalse("root key must never be read against a substituted static", rootRequested)370    }371372    @Test fun `phone source rejects an invitation that pins no static key`() = runBlocking {373        val (phoneSocket, mitmSocket) = socketPair()374        val root = ByteArray(32) { (it + 23).toByte() }375        val attackerKeys = deriveSyncIdentityKeypair(root, "attacker-id")376377        val phoneJob = async {378            try {379                runRelayAdoptionInitiatorSourceSession(380                    socket = phoneSocket,381                    // Pre-fix invitations carried no static; accepting one here382                    // is the same hole as accepting a mismatch.383                    invitation = PairingInvitation(384                        "wss://relay.example.test",385                        "AAECAwQFBgcICQoLDA0ODw",386                        VaultStatus.EMPTY,387                    ),388                    sourceDeps = object : AdoptionSourceV2Deps {389        override suspend fun rotateSourceIdentity() = sourceIdentity390        override fun hasDeviceIdentityCollision(device: PairedDevice) = false391        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true392        override suspend fun completePendingIdentity(device: PairedDevice) = true393        override fun snapshotCapable() = true394395                        override val sourceIdentity =396                            identity("phone", deriveSyncIdentityKeypair(root, "phone-id").publicKey)397                        override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)398                        override suspend fun vaultId() = computeVaultId(root)399                        override suspend fun onConsent(request: AdoptionFrame.Request) = true400                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true401                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit402                    },403                    confirm = { _, _ -> true },404                )405                "completed"406            } catch (expected: IllegalArgumentException) {407                expected.message408            }409        }410411        mitmSocket.receive() // PEACH_HELLO412        mitmSocket.send(413            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")414                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))415                .toString().toByteArray(),416        )417        val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }418419        assertEquals(420            "pairing_invitation_missing_static_key",421            withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },422        )423        attackerJob.cancel()424    }425426    @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {427        val (phoneSocket, responderSocket) = socketPair()428        val token = "AAECAwQFBgcICQoLDA0ODw"429        val root = ByteArray(32) { (it + 11).toByte() }430        val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id")431        val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")432        var rootRequested = false433        var upserted = false434435        val phoneJob = async {436            runRelayAdoptionInitiatorSourceSession(437                socket = phoneSocket,438                invitation = PairingInvitation(439                    "wss://relay.example.test",440                    token,441                    VaultStatus.EMPTY,442                    Rendezvous.bytesToHex(responderKeys.publicKey),443                ),444                sourceDeps = object : AdoptionSourceV2Deps {445        override suspend fun rotateSourceIdentity() = sourceIdentity446        override fun hasDeviceIdentityCollision(device: PairedDevice) = false447        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true448        override suspend fun completePendingIdentity(device: PairedDevice) = true449        override fun snapshotCapable() = true450451                    override val sourceIdentity = identity("phone", phoneKeys.publicKey)452                    override suspend fun rootKeyHex(): String {453                        rootRequested = true454                        return Rendezvous.bytesToHex(root)455                    }456                    override suspend fun vaultId() = computeVaultId(root)457                    override suspend fun onConsent(request: AdoptionFrame.Request) = true458                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true459                    override suspend fun upsertPairedDevice(device: PairedDevice) {460                        upserted = true461                    }462                },463                confirm = { _, _ -> false },464            )465        }466467        // Responder handshake468        responderSocket.receive() // PEACH_HELLO469        responderSocket.send(470            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")471                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))472                .toString().toByteArray(),473        )474        val noise = noiseNKResponder(475            responderKeys.secretKey,476            responderKeys.publicKey,477            responderSocket::send,478            responderSocket::receive,479        )480        try {481            val channel = object : PairingTransport {482                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))483                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())484                override suspend fun close() = responderSocket.close()485            }486            // Send only the request. The phone's source flow must reject before487            // it ever reads the root key or sends the grant.488            sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))489        } finally {490            noise.close()491        }492493        assertNull(phoneJob.await())494        assertFalse("root key must stay on this device until SAS matches", rootRequested)495        assertFalse("paired-device store must not be touched on rejection", upserted)496    }497}498499/**500 * Dispatch tests: each receiver entry point rejects the wrong vaultStatus, so501 * the ViewModel's `when (parsed.vaultStatus)` is forced to call the matching502 * path. Combined with the parser tests this is the flow-level guarantee that503 * an empty invitation triggers grantAdoption and a populated one triggers504 * requestAdoption.505 */506class RelayAdoptionReceiverDispatchTest {507508    @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {509        val receiver = RelayAdoptionReceiver(FakeSyncGeneration(), EmptyRegistry)510        val empty = createPairingInvitation(511            "wss://relay.example.test",512            "AAECAwQFBgcICQoLDA0ODw",513            VaultStatus.EMPTY,514        )515        try {516            receiver.adopt(517                encodedInvitation = empty,518                deps = object : AdoptionReceiverV2Deps {519        override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")520        override suspend fun rotateLocalIdentity() = localIdentity()521        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}522        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false523524                    override val deviceName = "phone"525                    override val deviceType = DeviceType.ANDROID526                    override fun generateDeviceId() = "x"527                    override suspend fun confirmSas() = true528                    override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"529                    override suspend fun deriveLocalIdentity() = error("unused")530                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit531                    override fun onError(error: String) = Unit532                },533                confirm = { true },534            )535            fail("adopt must reject empty invitations")536        } catch (expected: IllegalArgumentException) {537            assertTrue(538                "expected vaultStatus in the error, got: ${expected.message}",539                expected.message!!.contains("EMPTY", ignoreCase = true),540            )541        }542    }543544    @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {545        val receiver = RelayAdoptionReceiver(FakeSyncGeneration(), EmptyRegistry)546        val populated = createPairingInvitation(547            "wss://relay.example.test",548            "AAECAwQFBgcICQoLDA0ODw",549            VaultStatus.POPULATED,550        )551        try {552            receiver.grantFromInvitation(553                encodedInvitation = populated,554                sourceDeps = object : AdoptionSourceV2Deps {555        override suspend fun rotateSourceIdentity() = sourceIdentity556        override fun hasDeviceIdentityCollision(device: PairedDevice) = false557        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true558        override suspend fun completePendingIdentity(device: PairedDevice) = true559        override fun snapshotCapable() = true560561                    override val sourceIdentity = LocalDeviceIdentity(562                        "phone", "phone", DeviceType.ANDROID,563                        "ab".repeat(32), "cd".repeat(32),564                    )565                    override suspend fun rootKeyHex() = "ab".repeat(32)566                    override suspend fun vaultId() = "cd".repeat(32)567                    override suspend fun onConsent(request: AdoptionFrame.Request) = true568                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true569                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit570                },571                confirm = { _, _ -> true },572            )573            fail("grantFromInvitation must reject populated invitations")574        } catch (expected: IllegalArgumentException) {575            assertTrue(576                "expected vaultStatus in the error, got: ${expected.message}",577                expected.message!!.contains("POPULATED", ignoreCase = true),578            )579        }580    }581}