package com.peachpasswords.sync import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import org.junit.Assert.assertEquals /** * Bounds tests for [verifyDeviceManifest]. The arithmetic fix lives in the * verify path itself; these tests pin the inputs that used to slip past it. */ class DeviceManifestSupportTest { private val master = ByteArray(32) { it.toByte() } private val signing = deriveSyncSigningKeypair(master, "issuer") /** * Known signing keys per issuer id. Test revocations are issued by the * fixture key, so any issuer id resolves to it; a record naming a different * key must not be authorised. */ private val knownIssuerKeys: (String) -> Set = { setOf(pubkeyToHex(signing.publicKeyRaw)) } private fun validEntry(deviceId: String = "peer", pairedAt: Long = 1L) = ManifestDeviceEntry( deviceId = deviceId, deviceName = "Peer", deviceType = "android", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), pairedAt = pairedAt, ) private fun manifestWith(issuedAt: Long, devices: List = listOf(validEntry())) = buildSignedDeviceManifest( issuerDeviceId = "issuer", devices = devices, signingPrivateKey = signing.privateKey, nowMs = issuedAt, ) @Test fun rejects_issued_at_long_min_value() { // abs(Long.MIN_VALUE) is still negative, so the legacy abs()-based gate // accepted this. The new two-sided bound must reject it. val manifest = manifestWith(issuedAt = Long.MIN_VALUE) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_700_000_000_000L)) } @Test fun rejects_issued_at_too_old() { val manifest = manifestWith(issuedAt = 1L) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_700_000_000_000L)) } @Test fun rejects_issued_at_too_far_in_future() { val now = 1_700_000_000_000L // 5 min + 1 ms past the default freshness window. val manifest = manifestWith(issuedAt = now + 5 * 60_000L + 1L) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = now)) } @Test fun accepts_issued_at_within_window() { val now = 1_700_000_000_000L val manifest = manifestWith(issuedAt = now) assertNotNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = now)) } @Test fun rejects_oversized_device_id_string() { val manifest = manifestWith( issuedAt = 1_000L, devices = listOf(validEntry(deviceId = "x".repeat(10_000))), ) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L)) } @Test fun caps_oversized_device_name_at_verify() { // Sign the long name RAW (build-side normalise would cap it) so this // pins the verify-side policy: admit and truncate at the shared // 64-char bound, matching the browser — not reject. val now = 1_700_000_000_000L val body = DeviceManifestBody( manifestVersion = DEVICE_MANIFEST_VERSION, issuerDeviceId = "issuer", issuedAt = now, devices = listOf(validEntry().copy(deviceName = "N".repeat(200))), revoked = emptyList(), ) val signature = signEd25519( signing.privateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8), ) val verified = verifyDeviceManifest( SignedDeviceManifest(body, pubkeyToHex(signature)), signing.publicKeyRaw, knownIssuerKeys, nowMs = now, ) assertNotNull(verified) assertEquals("N".repeat(MAX_DEVICE_NAME_CHARS), verified!!.devices[0].deviceName) } @Test fun caps_device_name_at_build() { val manifest = manifestWith( issuedAt = 1_000L, devices = listOf(validEntry().copy(deviceName = "B".repeat(200))), ) assertEquals("B".repeat(MAX_DEVICE_NAME_CHARS), manifest.body.devices[0].deviceName) } @Test fun rejects_negative_paired_at() { val manifest = manifestWith( issuedAt = 1_000L, devices = listOf(validEntry(pairedAt = -1L)), ) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L)) } @Test fun rejects_too_many_devices() { val manifest = buildSignedDeviceManifest( issuerDeviceId = "issuer", devices = List(300) { validEntry(deviceId = "peer-$it") }, signingPrivateKey = signing.privateKey, nowMs = 1_000L, ) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L)) } @Test fun rejects_oversized_issuer_device_id() { val manifest = manifestWith(issuedAt = 1_000L) val oversized = manifest.copy(body = manifest.body.copy(issuerDeviceId = "y".repeat(2_000))) assertNull(verifyDeviceManifest(oversized, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L)) } @Test fun rejects_negative_revoked_at() { val manifest = buildSignedDeviceManifest( issuerDeviceId = "issuer", devices = emptyList(), signingPrivateKey = signing.privateKey, nowMs = 1_000L, revoked = listOf( DeviceRevocation( deviceId = "peer", longTermPublicKeyHex = "aa".repeat(32), revokedAt = -1L, revokedByDeviceId = "issuer", ), ), ) assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L)) } @Test fun revocation_signature_binds_immutable_fields() { val record = signDeviceRevocation( DeviceRevocation("peer", "aa".repeat(32), 42L, "issuer"), signing.privateKey, ) assertEquals( """{"deviceId":"peer","longTermPublicKeyHex":"${"aa".repeat(32)}","recordVersion":2,"revokedAt":42,"revokedByDeviceId":"issuer"}""", canonicalDeviceRevocation(record), ) assertEquals(true, verifyDeviceRevocation(record, signing.publicKeyRaw)) assertEquals(false, verifyDeviceRevocation(record.copy(deviceId = "attacker"), signing.publicKeyRaw)) } @Test fun forwarding_preserves_original_revocation_signature() { val original = signDeviceRevocation( DeviceRevocation("peer", "aa".repeat(32), 42L, "issuer"), signing.privateKey, ) val forwarder = deriveSyncSigningKeypair(master, "forwarder") val forwarded = buildSignedDeviceManifest( issuerDeviceId = "forwarder", devices = emptyList(), signingPrivateKey = forwarder.privateKey, nowMs = 1_000L, revoked = listOf(original), ) assertEquals(original, forwarded.body.revoked.single()) assertNotNull(verifyDeviceManifest(forwarded, forwarder.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L)) } @Test fun rejects_a_revocation_signed_by_an_untrusted_issuer_key() { // The record is internally consistent — it carries its own key and a // signature that verifies under it — which is exactly why trusting that // key let any admitted peer eject any device. Only a key the receiver // already knows for `revokedByDeviceId` may authorise a revocation. val attacker = deriveSyncSigningKeypair(ByteArray(32) { (it + 9).toByte() }, "attacker") val forged = signDeviceRevocation( DeviceRevocation("victim", "aa".repeat(32), 42L, "attacker"), attacker.privateKey, ) assertEquals(true, verifyDeviceRevocation(forged, attacker.publicKeyRaw)) val manifest = buildSignedDeviceManifest( issuerDeviceId = "issuer", devices = emptyList(), signingPrivateKey = signing.privateKey, nowMs = 1_000L, revoked = listOf(forged), ) assertNull( verifyDeviceManifest(manifest, signing.publicKeyRaw, knownIssuerKeys, nowMs = 1_000L), ) } @Test fun rejects_a_revocation_when_the_issuer_key_cannot_be_resolved() { val record = signDeviceRevocation( DeviceRevocation("peer", "aa".repeat(32), 42L, "issuer"), signing.privateKey, ) val manifest = buildSignedDeviceManifest( issuerDeviceId = "issuer", devices = emptyList(), signingPrivateKey = signing.privateKey, nowMs = 1_000L, revoked = listOf(record), ) // An empty history must fail closed: the manifest is rejected rather // than the record being quietly skipped, so a pruned history entry can // never silently un-revoke a device. assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, { emptySet() }, nowMs = 1_000L)) } }