1package com.peachpasswords23import com.peachpasswords.sync.DEVICE_MANIFEST_VERSION4import com.peachpasswords.sync.DeviceManifestBody5import com.peachpasswords.sync.DeviceRevocation6import com.peachpasswords.sync.ManifestDeviceEntry7import com.peachpasswords.sync.SASDerivation8import com.peachpasswords.sync.canonicalDeviceManifestBody9import com.peachpasswords.sync.computeVaultId10import com.peachpasswords.sync.noiseNKHkdf2ForTest11import com.peachpasswords.sync.noiseNKInitHashForTest12import com.peachpasswords.vault.VaultCrypto13import org.junit.Assert.assertArrayEquals14import org.junit.Assert.assertEquals15import org.junit.Assert.assertFalse16import org.junit.Assert.assertNotEquals17import org.junit.Test18import java.text.Normalizer19202122232425262728class CrossPlatformKATTest {2930 private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }3132 33 private val argonSalt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }3435 @Test fun argon2id_non_bmp_password_vector() {36 37 38 39 val key = VaultCrypto.deriveKey("test🔑", argonSalt)40 assertEquals("a519dd4e695d25c6cd69a2f89d51ddcabd952c979f5714ab29fb77f6d1ad3728", hex(key))41 }4243 @Test fun nfc_normalization_makes_composed_and_decomposed_match() {44 45 46 val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)47 val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)48 assertNotEquals(nfd, nfc)49 assertArrayEquals(VaultCrypto.deriveKey(nfd, argonSalt), VaultCrypto.deriveKey(nfc, argonSalt))50 }5152 @Test fun deriveKeyRaw_does_not_normalize() {53 val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)54 val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)55 assertFalse(56 VaultCrypto.deriveKeyRaw(nfd, argonSalt).contentEquals(VaultCrypto.deriveKeyRaw(nfc, argonSalt)),57 )58 }5960 @Test fun compute_vault_id_vector() {61 62 val masterKey = ByteArray(32) { it.toByte() }63 assertEquals("ae0ec54041c9ba3e20c0c2d33bdeb2552f3b9a3fb4be3286ec0ef0a61be2fc9c", computeVaultId(masterKey))64 }6566 @Test fun device_manifest_v5_canonical_form_vector() {67 68 69 70 71 val body = DeviceManifestBody(72 manifestVersion = DEVICE_MANIFEST_VERSION,73 issuerDeviceId = "issuer-1",74 issuedAt = 1_700_000_000_000L,75 devices = listOf(76 ManifestDeviceEntry(77 deviceId = "peer-1",78 deviceName = "Peer One",79 deviceType = "android",80 longTermPublicKeyHex = "aa".repeat(32),81 signingPublicKeyHex = "bb".repeat(32),82 wsPort = 49142,83 relayUrl = "wss://relay.peachpasswords.com",84 pairedAt = 1_700_000_000_001L,85 ),86 ),87 revoked = listOf(88 DeviceRevocation(89 deviceId = "gone-1",90 longTermPublicKeyHex = "cc".repeat(32),91 revokedAt = 1_700_000_000_002L,92 revokedByDeviceId = "issuer-1",93 ),94 ),95 )96 val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +97 "\"deviceType\":\"android\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +98 "\"pairedAt\":1700000000001,\"relayUrl\":\"wss://relay.peachpasswords.com\"," +99 "\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\"," +100 "\"vouchDepth\":0,\"wsPort\":49142}],\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +101 "\"manifestVersion\":5,\"revoked\":[{\"deviceId\":\"gone-1\"," +102 "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"recordVersion\":2," +103 "\"revokedAt\":1700000000002,\"revokedByDeviceId\":\"issuer-1\",\"signatureHex\":\"\"}]}"104 assertEquals(expected, canonicalDeviceManifestBody(body))105 }106107 @Test fun sas_derivation_vector() {108 109 val handshakeHash = byteArrayOf(0x12, 0x34, 0x56, 0x78) + ByteArray(28)110 assertEquals("67721216", SASDerivation.derive(handshakeHash))111 }112113 114 115 116 117 118 @Test fun noise_nk_init_hash_is_blake2s_vector() {119 assertEquals(120 "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e",121 hex(noiseNKInitHashForTest()),122 )123 }124125 @Test fun noise_hkdf2_blake2s_vector() {126 127 val ck = ByteArray(32) { it.toByte() }128 val ikm = ByteArray(32) { (it + 32).toByte() }129 val (nk1, nk2) = noiseNKHkdf2ForTest(ck, ikm)130 assertEquals("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0", hex(nk1))131 assertEquals("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5", hex(nk2))132 }133134 @Test fun device_manifest_v5_canonical_form_omits_null_ws_port_and_relay_url() {135 136 137 138 139 140 141 val body = DeviceManifestBody(142 manifestVersion = DEVICE_MANIFEST_VERSION,143 issuerDeviceId = "issuer-1",144 issuedAt = 1_700_000_000_000L,145 devices = listOf(146 ManifestDeviceEntry(147 deviceId = "peer-1",148 deviceName = "Peer One",149 deviceType = "extension",150 longTermPublicKeyHex = "aa".repeat(32),151 signingPublicKeyHex = "bb".repeat(32),152 wsPort = null,153 relayUrl = null,154 pairedAt = 1_700_000_000_001L,155 ),156 ),157 revoked = listOf(158 DeviceRevocation(159 deviceId = "gone-1",160 longTermPublicKeyHex = "cc".repeat(32),161 revokedAt = 1_700_000_000_002L,162 revokedByDeviceId = "issuer-1",163 ),164 ),165 )166 val canonical = canonicalDeviceManifestBody(body)167 168 assertFalse("wsPort must be omitted when null but got: $canonical", canonical.contains("wsPort"))169 assertFalse("relayUrl must be omitted when null but got: $canonical", canonical.contains("relayUrl"))170 171 val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +172 "\"deviceType\":\"extension\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +173 "\"pairedAt\":1700000000001," +174 "\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\",\"vouchDepth\":0}]," +175 "\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +176 "\"manifestVersion\":5," +177 "\"revoked\":[{\"deviceId\":\"gone-1\"," +178 "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"recordVersion\":2," +179 "\"revokedAt\":1700000000002,\"revokedByDeviceId\":\"issuer-1\",\"signatureHex\":\"\"}]}"180 assertEquals(expected, canonical)181 }182}