package com.peachpasswords.vault import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import java.util.Base64 /** * Pins the browser→Android slot-A translation that Codex restore depends on. * * The fixture is built the way the browser writes a slot: hex-encoded * salt/nonce/ciphertext/tag as separate fields, a space-padded JSON plaintext, * and the root key wrapped separately under `pw_root_v1` as lowercase hex. If * any of those encodings drift, a paper Codex printed on the browser stops * restoring on the phone — which is exactly the bug this fixes. */ class BrowserSlotTest { private val password = "correct horse battery staple" private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } private fun b64(b: ByteArray) = Base64.getEncoder().encodeToString(b) /** Split Android's `nonce||ciphertext||tag` back into the browser's three fields. */ private fun splitSealed(sealed: ByteArray): Triple { val nonce = sealed.copyOfRange(0, VaultCrypto.NONCE_SIZE) val body = sealed.copyOfRange(VaultCrypto.NONCE_SIZE, sealed.size - 16) val tag = sealed.copyOfRange(sealed.size - 16, sealed.size) return Triple(hex(nonce), hex(body), hex(tag)) } private fun browserSlotJson(rootKey: ByteArray, entries: List): String { val salt = VaultCrypto.generateSalt() val key = VaultCrypto.deriveKey(password, salt) // Browser payload: {entries, duressConfigured} with NO rootKeyB64, space-padded. val payloadJson = """{"entries":${entriesJson(entries)},"duressConfigured":false}""" val padded = payloadJson + " ".repeat(1024 - payloadJson.length) val (nonce, ct, tag) = splitSealed( VaultCrypto.encrypt(padded.toByteArray(Charsets.UTF_8), key, VaultCrypto.AAD_SLOT_A), ) // Root key travels wrapped as its own lowercase hex, AAD pw_root_v1. val (pn, pc, pt) = splitSealed( VaultCrypto.encrypt(hex(rootKey).toByteArray(Charsets.UTF_8), key, VaultCrypto.AAD_ROOT_KEY), ) return """{"version":1,"salt":"${hex(salt)}","nonce":"$nonce","ciphertext":"$ct","tag":"$tag",""" + """"pw_root_ciphertext":"$pc","pw_root_nonce":"$pn","pw_root_tag":"$pt"}""" } private fun entriesJson(entries: List): String = entries.joinToString(",", "[", "]") { """{"id":"${it.id}","title":"${it.title}","username":"${it.username}",""" + """"password":"${it.password}","createdAt":${it.createdAt},"modifiedAt":${it.modifiedAt}}""" } @Test fun `translates a browser slot and preserves entries`() { val rootKey = ByteArray(32) { it.toByte() } val entries = listOf( VaultEntry( id = "e1", title = "Example", username = "june", password = "hunter2", createdAt = 1L, modifiedAt = 2L, ), ) val slot = BrowserSlot.toAndroidSlot(BrowserSlot.parse(browserSlotJson(rootKey, entries))!!, password) assertNotNull("browser slot should translate", slot) val key = VaultCrypto.deriveKey(password, Base64.getDecoder().decode(slot!!.salt)) val payload = VaultCodec.decode( VaultCrypto.decrypt(Base64.getDecoder().decode(slot.blob), key, VaultCrypto.AAD_SLOT_A), ) assertEquals(1, payload.entries.size) assertEquals("Example", payload.entries[0].title) assertEquals("hunter2", payload.entries[0].password) } @Test fun `carries the browser root key across so recovery words still work`() { val rootKey = ByteArray(32) { (it * 7).toByte() } val slot = BrowserSlot.toAndroidSlot( BrowserSlot.parse(browserSlotJson(rootKey, emptyList()))!!, password, )!! val key = VaultCrypto.deriveKey(password, Base64.getDecoder().decode(slot.salt)) val payload = VaultCodec.decode( VaultCrypto.decrypt(Base64.getDecoder().decode(slot.blob), key, VaultCrypto.AAD_SLOT_A), ) assertEquals( "root key must survive the translation or the user's written-down words break", b64(rootKey), payload.rootKeyB64, ) } @Test fun `wrong password yields null rather than a corrupt-blob error`() { val json = browserSlotJson(ByteArray(32), emptyList()) assertNull(BrowserSlot.toAndroidSlot(BrowserSlot.parse(json)!!, "not the password")) } @Test fun `an Android-shaped slot is not mistaken for a browser one`() { val android = """{"version":1,"salt":"AAAA","blob":"BBBB"}""" assertNull(BrowserSlot.parse(android)) } }