BrowserSlotTest.kt

4.8 KB · raw

1package com.peachpasswords.vault23import org.junit.Assert.assertEquals4import org.junit.Assert.assertNotNull5import org.junit.Assert.assertNull6import org.junit.Test7import java.util.Base6489/**10 * Pins the browser→Android slot-A translation that Codex restore depends on.11 *12 * The fixture is built the way the browser writes a slot: hex-encoded13 * salt/nonce/ciphertext/tag as separate fields, a space-padded JSON plaintext,14 * and the root key wrapped separately under `pw_root_v1` as lowercase hex. If15 * any of those encodings drift, a paper Codex printed on the browser stops16 * restoring on the phone — which is exactly the bug this fixes.17 */18class BrowserSlotTest {1920    private val password = "correct horse battery staple"2122    private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }23    private fun b64(b: ByteArray) = Base64.getEncoder().encodeToString(b)2425    /** Split Android's `nonce||ciphertext||tag` back into the browser's three fields. */26    private fun splitSealed(sealed: ByteArray): Triple<String, String, String> {27        val nonce = sealed.copyOfRange(0, VaultCrypto.NONCE_SIZE)28        val body = sealed.copyOfRange(VaultCrypto.NONCE_SIZE, sealed.size - 16)29        val tag = sealed.copyOfRange(sealed.size - 16, sealed.size)30        return Triple(hex(nonce), hex(body), hex(tag))31    }3233    private fun browserSlotJson(rootKey: ByteArray, entries: List<VaultEntry>): String {34        val salt = VaultCrypto.generateSalt()35        val key = VaultCrypto.deriveKey(password, salt)3637        // Browser payload: {entries, duressConfigured} with NO rootKeyB64, space-padded.38        val payloadJson = """{"entries":${entriesJson(entries)},"duressConfigured":false}"""39        val padded = payloadJson + " ".repeat(1024 - payloadJson.length)40        val (nonce, ct, tag) = splitSealed(41            VaultCrypto.encrypt(padded.toByteArray(Charsets.UTF_8), key, VaultCrypto.AAD_SLOT_A),42        )4344        // Root key travels wrapped as its own lowercase hex, AAD pw_root_v1.45        val (pn, pc, pt) = splitSealed(46            VaultCrypto.encrypt(hex(rootKey).toByteArray(Charsets.UTF_8), key, VaultCrypto.AAD_ROOT_KEY),47        )4849        return """{"version":1,"salt":"${hex(salt)}","nonce":"$nonce","ciphertext":"$ct","tag":"$tag",""" +50            """"pw_root_ciphertext":"$pc","pw_root_nonce":"$pn","pw_root_tag":"$pt"}"""51    }5253    private fun entriesJson(entries: List<VaultEntry>): String =54        entries.joinToString(",", "[", "]") {55            """{"id":"${it.id}","title":"${it.title}","username":"${it.username}",""" +56                """"password":"${it.password}","createdAt":${it.createdAt},"modifiedAt":${it.modifiedAt}}"""57        }5859    @Test60    fun `translates a browser slot and preserves entries`() {61        val rootKey = ByteArray(32) { it.toByte() }62        val entries = listOf(63            VaultEntry(64                id = "e1",65                title = "Example",66                username = "june",67                password = "hunter2",68                createdAt = 1L,69                modifiedAt = 2L,70            ),71        )7273        val slot = BrowserSlot.toAndroidSlot(BrowserSlot.parse(browserSlotJson(rootKey, entries))!!, password)74        assertNotNull("browser slot should translate", slot)7576        val key = VaultCrypto.deriveKey(password, Base64.getDecoder().decode(slot!!.salt))77        val payload = VaultCodec.decode(78            VaultCrypto.decrypt(Base64.getDecoder().decode(slot.blob), key, VaultCrypto.AAD_SLOT_A),79        )80        assertEquals(1, payload.entries.size)81        assertEquals("Example", payload.entries[0].title)82        assertEquals("hunter2", payload.entries[0].password)83    }8485    @Test86    fun `carries the browser root key across so recovery words still work`() {87        val rootKey = ByteArray(32) { (it * 7).toByte() }88        val slot = BrowserSlot.toAndroidSlot(89            BrowserSlot.parse(browserSlotJson(rootKey, emptyList()))!!,90            password,91        )!!9293        val key = VaultCrypto.deriveKey(password, Base64.getDecoder().decode(slot.salt))94        val payload = VaultCodec.decode(95            VaultCrypto.decrypt(Base64.getDecoder().decode(slot.blob), key, VaultCrypto.AAD_SLOT_A),96        )97        assertEquals(98            "root key must survive the translation or the user's written-down words break",99            b64(rootKey),100            payload.rootKeyB64,101        )102    }103104    @Test105    fun `wrong password yields null rather than a corrupt-blob error`() {106        val json = browserSlotJson(ByteArray(32), emptyList())107        assertNull(BrowserSlot.toAndroidSlot(BrowserSlot.parse(json)!!, "not the password"))108    }109110    @Test111    fun `an Android-shaped slot is not mistaken for a browser one`() {112        val android = """{"version":1,"salt":"AAAA","blob":"BBBB"}"""113        assertNull(BrowserSlot.parse(android))114    }115}