DeviceManifestSupportTest.kt

9.1 KB · raw

1package com.peachpasswords.sync23import org.junit.Assert.assertNotNull4import org.junit.Assert.assertNull5import org.junit.Test6import org.junit.Assert.assertEquals78/**9 * Bounds tests for [verifyDeviceManifest]. The arithmetic fix lives in the10 * verify path itself; these tests pin the inputs that used to slip past it.11 */12class DeviceManifestSupportTest {13    private val master = ByteArray(32) { it.toByte() }14    private val signing = deriveSyncSigningKeypair(master, "issuer")1516    /**17     * Known signing keys per issuer id. Test revocations are issued by the18     * fixture key, so any issuer id resolves to it; a record naming a different19     * key must not be authorised.20     */21    private val knownIssuerKeys: (String) -> Set<String> = { setOf(pubkeyToHex(signing.publicKeyRaw)) }2223    private fun validEntry(deviceId: String = "peer", pairedAt: Long = 1L) = ManifestDeviceEntry(24        deviceId = deviceId,25        deviceName = "Peer",26        deviceType = "android",27        longTermPublicKeyHex = "aa".repeat(32),28        signingPublicKeyHex = "bb".repeat(32),29        pairedAt = pairedAt,30    )3132    private fun manifestWith(issuedAt: Long, devices: List<ManifestDeviceEntry> = listOf(validEntry())) =33        buildSignedDeviceManifest(34            issuerDeviceId = "issuer",35            devices = devices,36            signingPrivateKey = signing.privateKey,37            nowMs = issuedAt,38        )3940    @Test fun rejects_issued_at_long_min_value() {41        // abs(Long.MIN_VALUE) is still negative, so the legacy abs()-based gate42        // accepted this. The new two-sided bound must reject it.43        val manifest = manifestWith(issuedAt = Long.MIN_VALUE)44        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_700_000_000_000L))45    }4647    @Test fun rejects_issued_at_too_old() {48        val manifest = manifestWith(issuedAt = 1L)49        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_700_000_000_000L))50    }5152    @Test fun rejects_issued_at_too_far_in_future() {53        val now = 1_700_000_000_000L54        // 5 min + 1 ms past the default freshness window.55        val manifest = manifestWith(issuedAt = now + 5 * 60_000L + 1L)56        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = now))57    }5859    @Test fun accepts_issued_at_within_window() {60        val now = 1_700_000_000_000L61        val manifest = manifestWith(issuedAt = now)62        assertNotNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = now))63    }6465    @Test fun rejects_oversized_device_id_string() {66        val manifest = manifestWith(67            issuedAt = 1_000L,68            devices = listOf(validEntry(deviceId = "x".repeat(10_000))),69        )70        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L))71    }7273    @Test fun caps_oversized_device_name_at_verify() {74        // Sign the long name RAW (build-side normalise would cap it) so this75        // pins the verify-side policy: admit and truncate at the shared76        // 64-char bound, matching the browser — not reject.77        val now = 1_700_000_000_000L78        val body = DeviceManifestBody(79            manifestVersion = DEVICE_MANIFEST_VERSION,80            issuerDeviceId = "issuer",81            issuedAt = now,82            devices = listOf(validEntry().copy(deviceName = "N".repeat(200))),83            revoked = emptyList(),84        )85        val signature = signEd25519(86            signing.privateKey,87            canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8),88        )89        val verified = verifyDeviceManifest(90            SignedDeviceManifest(body, pubkeyToHex(signature)),91            signing.publicKeyRaw,92            knownIssuerKeys,93            nowMs = now,94        )95        assertNotNull(verified)96        assertEquals("N".repeat(MAX_DEVICE_NAME_CHARS), verified!!.devices[0].deviceName)97    }9899    @Test fun caps_device_name_at_build() {100        val manifest = manifestWith(101            issuedAt = 1_000L,102            devices = listOf(validEntry().copy(deviceName = "B".repeat(200))),103        )104        assertEquals("B".repeat(MAX_DEVICE_NAME_CHARS), manifest.body.devices[0].deviceName)105    }106107    @Test fun rejects_negative_paired_at() {108        val manifest = manifestWith(109            issuedAt = 1_000L,110            devices = listOf(validEntry(pairedAt = -1L)),111        )112        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L))113    }114115    @Test fun rejects_too_many_devices() {116        val manifest = buildSignedDeviceManifest(117            issuerDeviceId = "issuer",118            devices = List(300) { validEntry(deviceId = "peer-$it") },119            signingPrivateKey = signing.privateKey,120            nowMs = 1_000L,121        )122        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L))123    }124125    @Test fun rejects_oversized_issuer_device_id() {126        val manifest = manifestWith(issuedAt = 1_000L)127        val oversized = manifest.copy(body = manifest.body.copy(issuerDeviceId = "y".repeat(2_000)))128        assertNull(verifyDeviceManifest(oversized, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L))129    }130131    @Test fun rejects_negative_revoked_at() {132        val manifest = buildSignedDeviceManifest(133            issuerDeviceId = "issuer",134            devices = emptyList(),135            signingPrivateKey = signing.privateKey,136            nowMs = 1_000L,137            revoked = listOf(138                DeviceRevocation(139                    deviceId = "peer",140                    longTermPublicKeyHex = "aa".repeat(32),141                    revokedAt = -1L,142                    revokedByDeviceId = "issuer",143                ),144            ),145        )146        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L))147    }148    @Test fun revocation_signature_binds_immutable_fields() {149        val record = signDeviceRevocation(150            DeviceRevocation("peer", "aa".repeat(32), 42L, "issuer"),151            signing.privateKey,152        )153        assertEquals(154            """{"deviceId":"peer","longTermPublicKeyHex":"${"aa".repeat(32)}","recordVersion":2,"revokedAt":42,"revokedByDeviceId":"issuer"}""",155            canonicalDeviceRevocation(record),156        )157        assertEquals(true, verifyDeviceRevocation(record, signing.publicKeyRaw))158        assertEquals(false, verifyDeviceRevocation(record.copy(deviceId = "attacker"), signing.publicKeyRaw))159    }160    @Test fun forwarding_preserves_original_revocation_signature() {161        val original = signDeviceRevocation(162            DeviceRevocation("peer", "aa".repeat(32), 42L, "issuer"),163            signing.privateKey,164        )165        val forwarder = deriveSyncSigningKeypair(master, "forwarder")166        val forwarded = buildSignedDeviceManifest(167            issuerDeviceId = "forwarder",168            devices = emptyList(),169            signingPrivateKey = forwarder.privateKey,170            nowMs = 1_000L,171            revoked = listOf(original),172        )173        assertEquals(original, forwarded.body.revoked.single())174        assertNotNull(verifyDeviceManifest(forwarded, forwarder.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L))175    }176177    @Test fun rejects_a_revocation_signed_by_an_untrusted_issuer_key() {178        // The record is internally consistent — it carries its own key and a179        // signature that verifies under it — which is exactly why trusting that180        // key let any admitted peer eject any device. Only a key the receiver181        // already knows for `revokedByDeviceId` may authorise a revocation.182        val attacker = deriveSyncSigningKeypair(ByteArray(32) { (it + 9).toByte() }, "attacker")183        val forged = signDeviceRevocation(184            DeviceRevocation("victim", "aa".repeat(32), 42L, "attacker"),185            attacker.privateKey,186        )187        assertEquals(true, verifyDeviceRevocation(forged, attacker.publicKeyRaw))188189        val manifest = buildSignedDeviceManifest(190            issuerDeviceId = "issuer",191            devices = emptyList(),192            signingPrivateKey = signing.privateKey,193            nowMs = 1_000L,194            revoked = listOf(forged),195        )196        assertNull(197            verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L),198        )199    }200201    @Test fun rejects_a_revocation_when_the_issuer_key_cannot_be_resolved() {202        val record = signDeviceRevocation(203            DeviceRevocation("peer", "aa".repeat(32), 42L, "issuer"),204            signing.privateKey,205        )206        val manifest = buildSignedDeviceManifest(207            issuerDeviceId = "issuer",208            devices = emptyList(),209            signingPrivateKey = signing.privateKey,210            nowMs = 1_000L,211            revoked = listOf(record),212        )213        // An empty history must fail closed: the manifest is rejected rather214        // than the record being quietly skipped, so a pruned history entry can215        // never silently un-revoke a device.216        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, { emptySet() }, nowMs = 1_000L))217    }218}