package com.peachpasswords.sync import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters /** 0x05 adds signed per-issuer revocation records to the vouch graph. */ const val DEVICE_MANIFEST_VERSION = 0x05 const val MAX_VOUCH_DEPTH = 3 private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L /** * Upper bounds on what verifyDeviceManifest will accept. A pathological peer * could otherwise craft a manifest that consumes unbounded memory or skips * sanity checks (e.g. `issuedAt = Long.MIN_VALUE` bypasses `abs(...) > window` * because `abs(Long.MIN_VALUE)` is still negative). These caps match the * realistic limits of the pairing protocol: a handful of paired devices, a * bounded revocation list, and string fields long enough for any honest use. */ private const val MAX_MANIFEST_DEVICES = 256 private const val MAX_MANIFEST_REVOCATIONS = 500 private const val MAX_MANIFEST_STRING = 256 /** * Display/storage bound for device names, matching the browser's * MAX_DEVICE_NAME_CHARS (shared/sync-limits.ts) and both platforms' local * setDeviceName bound. Inbound seams cap rather than reject so old peers * with long-but-honest names keep syncing; a device name was never a * 256-char string. */ internal const val MAX_DEVICE_NAME_CHARS = 64 /** Truncate a peer- or user-supplied device name to the shared bound. */ internal fun capDeviceName(name: String): String = if (name.length > MAX_DEVICE_NAME_CHARS) name.take(MAX_DEVICE_NAME_CHARS) else name internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000" /** * Fully staged membership change. Implementations must linearize the whole * batch; callers hold the vault session epoch fence while invoking it, so a * lock/re-unlock cannot observe or leave behind a half-applied manifest. */ data class DeviceManifestRegistryBatch( val incomingRevocations: List, val removals: Set, val upserts: List, ) interface PairedDeviceRegistry { fun getAll(): List fun get(id: String): PairedDevice? fun getByPubkey(pubkeyHex: String): PairedDevice? = getAll().firstOrNull { it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true } fun upsert(device: PairedDevice) fun remove(deviceId: String) {} fun listRevocations(): List = emptyList() /** * Immutable issuer signing-key history. Implementations must append only; * the default keeps lightweight test registries source-compatible. */ fun issuerSigningKeys(issuerDeviceId: String): Set = emptySet() fun appendIssuerSigningKey(issuerDeviceId: String, signingKeyHex: String): Boolean = true /** Commit a complete staged manifest as one logical registry mutation. */ fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean /** Returns the ids that were not already revoked. */ fun mergeRevocations(incoming: List): List = emptyList() /** * True when either the id or the long-term key is under a terminal * revocation. `deviceId` is a self-asserted string in a manifest, so * keying terminality on it alone is bypassed by re-vouching a revoked * device's key under a fresh id. The key is what a revoked device still * holds, so it is what terminality has to follow. `0`×64 is the "no key was * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing. */ fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean { val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL } return listRevocations().any { it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key) } } /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */ fun clearRevocation(deviceId: String) {} } fun buildSignedDeviceManifest( issuerDeviceId: String, devices: List, signingPrivateKey: Ed25519PrivateKeyParameters, nowMs: Long = System.currentTimeMillis(), revoked: List = emptyList(), ): SignedDeviceManifest { require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" } val body = DeviceManifestBody( manifestVersion = DEVICE_MANIFEST_VERSION, issuerDeviceId = issuerDeviceId, issuedAt = nowMs, devices = devices.map { normaliseManifestEntry(it) ?: it }, revoked = revoked.map { record -> // A forwarded revocation is an immutable issuer statement. Preserve // its original issuer and signature; only sign locally-created // records that have not been signed yet. if (record.signatureHex.isNotBlank()) record else signDeviceRevocation(record, signingPrivateKey) }, ) val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)) return SignedDeviceManifest(body, pubkeyToHex(signature)) } /** Sign the immutable record, excluding its signature field from canonical bytes. */ fun signDeviceRevocation( record: DeviceRevocation, signingPrivateKey: Ed25519PrivateKeyParameters, ): DeviceRevocation { val unsigned = record.copy(signatureHex = "") val signature = signEd25519( signingPrivateKey, canonicalDeviceRevocation(unsigned).toByteArray(Charsets.UTF_8), ) return unsigned.copy(signatureHex = pubkeyToHex(signature)) } /** The exact bytes an issuer signs; identical in Browser and Android. */ fun canonicalDeviceRevocation(record: DeviceRevocation): String = canonicalise( mapOf( "deviceId" to record.deviceId, "longTermPublicKeyHex" to record.longTermPublicKeyHex.lowercase(), "recordVersion" to record.recordVersion, "revokedAt" to record.revokedAt, "revokedByDeviceId" to record.revokedByDeviceId, ), ) fun verifyDeviceRevocation(record: DeviceRevocation, issuerSigningPublicKey: ByteArray): Boolean { if (!isHex(record.signatureHex, 128)) return false val signature = runCatching { hexToBytes(record.signatureHex) }.getOrNull() ?: return false return verifyEd25519( issuerSigningPublicKey, canonicalDeviceRevocation(record.copy(signatureHex = "")).toByteArray(Charsets.UTF_8), signature, ) } /** * A relayed revocation counts only when its signature verifies under a key the * receiver already trusts for `revokedByDeviceId` — that device's current * signing key or an entry in its immutable known-key history. The record names * no key of its own, so a relaying peer cannot authorise anything by supplying * one. * * An unresolvable issuer fails closed: the manifest is rejected rather than the * record being skipped, so a dropped history entry can never silently * un-revoke a device. */ private fun isAuthorisedRevocation( record: DeviceRevocation, resolveIssuerSigningKeys: (String) -> Set, ): Boolean { val signature = runCatching { hexToBytes(record.signatureHex) }.getOrNull() ?: return false val payload = canonicalDeviceRevocation(record.copy(signatureHex = "")).toByteArray(Charsets.UTF_8) val trusted = resolveIssuerSigningKeys(record.revokedByDeviceId) if (trusted.isEmpty()) return false return trusted.any { key -> val hex = key.lowercase() isHex(hex, 64) && verifyEd25519(hexToPubkey(hex), payload, signature) } } fun verifyDeviceManifest( manifest: SignedDeviceManifest, issuerSigningPublicKey: ByteArray, resolveIssuerSigningKeys: (String) -> Set, nowMs: Long = System.currentTimeMillis(), freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS, ): DeviceManifestBody? { val body = manifest.body if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null // Two-sided bounds — avoids `abs(...) > window`, which overflows for // `Long.MIN_VALUE` and lets a peer-presented timestamp bypass the // freshness gate. Reject negative `issuedAt` outright so the bounds // arithmetic can't be steered into a wrap-around by the manifest. if (body.issuedAt < 0L) return null if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null if (body.devices.size > MAX_MANIFEST_DEVICES) return null if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null // Validate every entry before the normalised return mapping below. Without // this guard, malformed but correctly signed manifests reached `!!` and // crashed the sync path instead of being rejected. if (body.devices.any { normaliseManifestEntry(it) == null }) return null if (!isHex(manifest.signatureHex, 128)) return null if (!body.revoked.all { isValidRevocation(it) }) return null val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8) if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null // Forwarded tombstones retain the original issuer's immutable signature. // The enclosing manifest signature authenticates the forwarder only, so the // revocation's own issuer key MUST be one the receiver already knows for // `revokedByDeviceId` — never the key carried inside the record. Trusting // the record's own claim would let any admitted peer that can issue a // manifest eject any third device. if (!body.revoked.all { record -> isAuthorisedRevocation(record, resolveIssuerSigningKeys) }) return null // Return entries with the display name capped so every consumer (registry // upserts, pending-vouch queue, key-rotation alerts) stores the bounded // name. The signature above was verified against the raw body. return body.copy(devices = body.devices.map { normaliseManifestEntry(it)!! }) } fun canonicalDeviceManifestBody(body: DeviceManifestBody): String = canonicalise( mapOf( "devices" to body.devices.map { manifestEntryMap(it) }, "issuedAt" to body.issuedAt, "issuerDeviceId" to body.issuerDeviceId, "manifestVersion" to body.manifestVersion, "revoked" to body.revoked.map { revocationMap(it) }, ), ) fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? { val id = deviceId.takeIf { it.isNotBlank() } ?: return null val name = deviceName?.takeIf { it.isNotBlank() }?.let(::capDeviceName) ?: return null val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null return ManifestDeviceEntry( deviceId = id, deviceName = name, deviceType = type, longTermPublicKeyHex = longTerm, signingPublicKeyHex = signing, wsPort = wsPort, relayUrl = relayUrl, pairedAt = pairedAt, vouchDepth = 0, ) } private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? { if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null if (entry.deviceId.length > MAX_MANIFEST_STRING) return null if (entry.deviceType !in setOf("extension", "android", "ios")) return null if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null if (entry.pairedAt < 0L || entry.vouchDepth !in 0..MAX_VOUCH_DEPTH) return null if ( entry.relayUrl != null && ( entry.relayUrl.length > MAX_MANIFEST_STRING || runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure ) ) return null return entry.copy( deviceName = capDeviceName(entry.deviceName), longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(), signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(), ) } private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation = r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase()) private fun isValidRevocation(r: DeviceRevocation): Boolean = r.recordVersion == DEVICE_REVOCATION_RECORD_VERSION && r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING && isHex(r.longTermPublicKeyHex.lowercase(), 64) && r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING && isHex(r.signatureHex, 128) && r.revokedAt >= 0L private fun revocationMap(r: DeviceRevocation): Map = linkedMapOf( "deviceId" to r.deviceId, "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(), "recordVersion" to r.recordVersion, "revokedAt" to r.revokedAt, "revokedByDeviceId" to r.revokedByDeviceId, "signatureHex" to r.signatureHex.lowercase(), ) private fun manifestEntryMap(entry: ManifestDeviceEntry): Map { val normalised = normaliseManifestEntry(entry) ?: entry val map = linkedMapOf( "deviceId" to normalised.deviceId, "deviceName" to normalised.deviceName, "deviceType" to normalised.deviceType, "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(), "pairedAt" to normalised.pairedAt, "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(), "vouchDepth" to normalised.vouchDepth, ) normalised.wsPort?.let { map["wsPort"] = it } normalised.relayUrl?.let { map["relayUrl"] = it } return map } private fun canonicalise(value: Any?): String = when (value) { null -> "null" is Number -> { val d = value.toDouble() require(d.isFinite()) { "non-finite number" } if (d % 1.0 == 0.0) value.toLong().toString() else value.toString() } is String -> Json.encodeToString(String.serializer(), value) is Boolean -> if (value) "true" else "false" is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) } is Map<*, *> -> value.entries .map { (k, v) -> k.toString() to v } .sortedBy { it.first } .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) -> Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v) } else -> error("cannot canonicalise ${value::class.simpleName}") } private fun isHex(value: String, length: Int): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }