DeviceManifestSupport.kt

14.7 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.builtins.serializer4import kotlinx.serialization.json.Json5import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters67/** 0x05 adds signed per-issuer revocation records to the vouch graph. */8const val DEVICE_MANIFEST_VERSION = 0x059const val MAX_VOUCH_DEPTH = 310private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L11/**12 * Upper bounds on what verifyDeviceManifest will accept. A pathological peer13 * could otherwise craft a manifest that consumes unbounded memory or skips14 * sanity checks (e.g. `issuedAt = Long.MIN_VALUE` bypasses `abs(...) > window`15 * because `abs(Long.MIN_VALUE)` is still negative). These caps match the16 * realistic limits of the pairing protocol: a handful of paired devices, a17 * bounded revocation list, and string fields long enough for any honest use.18 */19private const val MAX_MANIFEST_DEVICES = 25620private const val MAX_MANIFEST_REVOCATIONS = 50021private const val MAX_MANIFEST_STRING = 25622/**23 * Display/storage bound for device names, matching the browser's24 * MAX_DEVICE_NAME_CHARS (shared/sync-limits.ts) and both platforms' local25 * setDeviceName bound. Inbound seams cap rather than reject so old peers26 * with long-but-honest names keep syncing; a device name was never a27 * 256-char string.28 */29internal const val MAX_DEVICE_NAME_CHARS = 643031/** Truncate a peer- or user-supplied device name to the shared bound. */32internal fun capDeviceName(name: String): String =33    if (name.length > MAX_DEVICE_NAME_CHARS) name.take(MAX_DEVICE_NAME_CHARS) else name34internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"3536/**37 * Fully staged membership change. Implementations must linearize the whole38 * batch; callers hold the vault session epoch fence while invoking it, so a39 * lock/re-unlock cannot observe or leave behind a half-applied manifest.40 */41data class DeviceManifestRegistryBatch(42    val incomingRevocations: List<DeviceRevocation>,43    val removals: Set<String>,44    val upserts: List<PairedDevice>,45)4647interface PairedDeviceRegistry {48    fun getAll(): List<PairedDevice>49    fun get(id: String): PairedDevice?50    fun getByPubkey(pubkeyHex: String): PairedDevice? =51        getAll().firstOrNull {52            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true53        }54    fun upsert(device: PairedDevice)55    fun remove(deviceId: String) {}56    fun listRevocations(): List<DeviceRevocation> = emptyList()5758    /**59     * Immutable issuer signing-key history. Implementations must append only;60     * the default keeps lightweight test registries source-compatible.61     */62    fun issuerSigningKeys(issuerDeviceId: String): Set<String> = emptySet()63    fun appendIssuerSigningKey(issuerDeviceId: String, signingKeyHex: String): Boolean = true6465    /** Commit a complete staged manifest as one logical registry mutation. */66    fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean6768    /** Returns the ids that were not already revoked. */69    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()7071    /**72     * True when either the id or the long-term key is under a terminal73     * revocation. `deviceId` is a self-asserted string in a manifest, so74     * keying terminality on it alone is bypassed by re-vouching a revoked75     * device's key under a fresh id. The key is what a revoked device still76     * holds, so it is what terminality has to follow. `0`×64 is the "no key was77     * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing.78     */79    fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {80        val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }81        return listRevocations().any {82            it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)83        }84    }8586    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */87    fun clearRevocation(deviceId: String) {}88}8990fun buildSignedDeviceManifest(91    issuerDeviceId: String,92    devices: List<ManifestDeviceEntry>,93    signingPrivateKey: Ed25519PrivateKeyParameters,94    nowMs: Long = System.currentTimeMillis(),95    revoked: List<DeviceRevocation> = emptyList(),96): SignedDeviceManifest {97    require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }98    val body = DeviceManifestBody(99        manifestVersion = DEVICE_MANIFEST_VERSION,100        issuerDeviceId = issuerDeviceId,101        issuedAt = nowMs,102        devices = devices.map { normaliseManifestEntry(it) ?: it },103        revoked = revoked.map { record ->104            // A forwarded revocation is an immutable issuer statement. Preserve105            // its original issuer and signature; only sign locally-created106            // records that have not been signed yet.107            if (record.signatureHex.isNotBlank()) record else signDeviceRevocation(record, signingPrivateKey)108        },109    )110    val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))111    return SignedDeviceManifest(body, pubkeyToHex(signature))112}113114/** Sign the immutable record, excluding its signature field from canonical bytes. */115fun signDeviceRevocation(116    record: DeviceRevocation,117    signingPrivateKey: Ed25519PrivateKeyParameters,118): DeviceRevocation {119    val unsigned = record.copy(signatureHex = "")120    val signature = signEd25519(121        signingPrivateKey,122        canonicalDeviceRevocation(unsigned).toByteArray(Charsets.UTF_8),123    )124    return unsigned.copy(signatureHex = pubkeyToHex(signature))125}126127/** The exact bytes an issuer signs; identical in Browser and Android. */128fun canonicalDeviceRevocation(record: DeviceRevocation): String =129    canonicalise(130        mapOf(131            "deviceId" to record.deviceId,132            "longTermPublicKeyHex" to record.longTermPublicKeyHex.lowercase(),133            "recordVersion" to record.recordVersion,134            "revokedAt" to record.revokedAt,135            "revokedByDeviceId" to record.revokedByDeviceId,136        ),137    )138139fun verifyDeviceRevocation(record: DeviceRevocation, issuerSigningPublicKey: ByteArray): Boolean {140    if (!isHex(record.signatureHex, 128)) return false141    val signature = runCatching { hexToBytes(record.signatureHex) }.getOrNull() ?: return false142    return verifyEd25519(143        issuerSigningPublicKey,144        canonicalDeviceRevocation(record.copy(signatureHex = "")).toByteArray(Charsets.UTF_8),145        signature,146    )147}148149/**150 * A relayed revocation counts only when its signature verifies under a key the151 * receiver already trusts for `revokedByDeviceId` — that device's current152 * signing key or an entry in its immutable known-key history. The record names153 * no key of its own, so a relaying peer cannot authorise anything by supplying154 * one.155 *156 * An unresolvable issuer fails closed: the manifest is rejected rather than the157 * record being skipped, so a dropped history entry can never silently158 * un-revoke a device.159 */160private fun isAuthorisedRevocation(161    record: DeviceRevocation,162    resolveIssuerSigningKeys: (String) -> Set<String>,163): Boolean {164    val signature = runCatching { hexToBytes(record.signatureHex) }.getOrNull() ?: return false165    val payload = canonicalDeviceRevocation(record.copy(signatureHex = "")).toByteArray(Charsets.UTF_8)166    val trusted = resolveIssuerSigningKeys(record.revokedByDeviceId)167    if (trusted.isEmpty()) return false168    return trusted.any { key ->169        val hex = key.lowercase()170        isHex(hex, 64) && verifyEd25519(hexToPubkey(hex), payload, signature)171    }172}173174fun verifyDeviceManifest(175    manifest: SignedDeviceManifest,176    issuerSigningPublicKey: ByteArray,177    resolveIssuerSigningKeys: (String) -> Set<String>,178    nowMs: Long = System.currentTimeMillis(),179    freshnessMs: Long = DEVICE_MANIFEST_FRESHNESS_MS,180): DeviceManifestBody? {181    val body = manifest.body182    if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null183    // Two-sided bounds — avoids `abs(...) > window`, which overflows for184    // `Long.MIN_VALUE` and lets a peer-presented timestamp bypass the185    // freshness gate. Reject negative `issuedAt` outright so the bounds186    // arithmetic can't be steered into a wrap-around by the manifest.187    if (body.issuedAt < 0L) return null188    if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null189    if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null190    if (body.devices.size > MAX_MANIFEST_DEVICES) return null191    if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null192    // Validate every entry before the normalised return mapping below. Without193    // this guard, malformed but correctly signed manifests reached `!!` and194    // crashed the sync path instead of being rejected.195    if (body.devices.any { normaliseManifestEntry(it) == null }) return null196    if (!isHex(manifest.signatureHex, 128)) return null197    if (!body.revoked.all { isValidRevocation(it) }) return null198    val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null199    val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)200    if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null201    // Forwarded tombstones retain the original issuer's immutable signature.202    // The enclosing manifest signature authenticates the forwarder only, so the203    // revocation's own issuer key MUST be one the receiver already knows for204    // `revokedByDeviceId` — never the key carried inside the record. Trusting205    // the record's own claim would let any admitted peer that can issue a206    // manifest eject any third device.207    if (!body.revoked.all { record -> isAuthorisedRevocation(record, resolveIssuerSigningKeys) }) return null208    // Return entries with the display name capped so every consumer (registry209    // upserts, pending-vouch queue, key-rotation alerts) stores the bounded210    // name. The signature above was verified against the raw body.211    return body.copy(devices = body.devices.map { normaliseManifestEntry(it)!! })212}213214fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =215    canonicalise(216        mapOf(217            "devices" to body.devices.map { manifestEntryMap(it) },218            "issuedAt" to body.issuedAt,219            "issuerDeviceId" to body.issuerDeviceId,220            "manifestVersion" to body.manifestVersion,221            "revoked" to body.revoked.map { revocationMap(it) },222        ),223    )224225fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {226    val id = deviceId.takeIf { it.isNotBlank() } ?: return null227    val name = deviceName?.takeIf { it.isNotBlank() }?.let(::capDeviceName) ?: return null228    val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null229    val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null230    val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null231    return ManifestDeviceEntry(232        deviceId = id,233        deviceName = name,234        deviceType = type,235        longTermPublicKeyHex = longTerm,236        signingPublicKeyHex = signing,237        wsPort = wsPort,238        relayUrl = relayUrl,239        pairedAt = pairedAt,240        vouchDepth = 0,241    )242}243244private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {245    if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null246    if (entry.deviceId.length > MAX_MANIFEST_STRING) return null247    if (entry.deviceType !in setOf("extension", "android", "ios")) return null248    if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null249    if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null250    if (entry.pairedAt < 0L || entry.vouchDepth !in 0..MAX_VOUCH_DEPTH) return null251    if (252        entry.relayUrl != null && (253            entry.relayUrl.length > MAX_MANIFEST_STRING ||254            runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure255        )256    ) return null257    return entry.copy(258        deviceName = capDeviceName(entry.deviceName),259        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),260        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),261    )262}263264private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =265    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())266267private fun isValidRevocation(r: DeviceRevocation): Boolean =268    r.recordVersion == DEVICE_REVOCATION_RECORD_VERSION &&269        r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&270        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&271        r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&272        isHex(r.signatureHex, 128) &&273        r.revokedAt >= 0L274 275276private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(277    "deviceId" to r.deviceId,278    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),279    "recordVersion" to r.recordVersion,280    "revokedAt" to r.revokedAt,281    "revokedByDeviceId" to r.revokedByDeviceId,282    "signatureHex" to r.signatureHex.lowercase(),283)284285private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {286    val normalised = normaliseManifestEntry(entry) ?: entry287    val map = linkedMapOf<String, Any>(288        "deviceId" to normalised.deviceId,289        "deviceName" to normalised.deviceName,290        "deviceType" to normalised.deviceType,291        "longTermPublicKeyHex" to normalised.longTermPublicKeyHex.lowercase(),292        "pairedAt" to normalised.pairedAt,293        "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),294        "vouchDepth" to normalised.vouchDepth,295    )296    normalised.wsPort?.let { map["wsPort"] = it }297    normalised.relayUrl?.let { map["relayUrl"] = it }298    return map299}300301private fun canonicalise(value: Any?): String = when (value) {302    null -> "null"303    is Number -> {304        val d = value.toDouble()305        require(d.isFinite()) { "non-finite number" }306        if (d % 1.0 == 0.0) value.toLong().toString() else value.toString()307    }308    is String -> Json.encodeToString(String.serializer(), value)309    is Boolean -> if (value) "true" else "false"310    is List<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalise(it) }311    is Map<*, *> -> value.entries312        .map { (k, v) -> k.toString() to v }313        .sortedBy { it.first }314        .joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) ->315            Json.encodeToString(String.serializer(), k) + ":" + canonicalise(v)316        }317    else -> error("cannot canonicalise ${value::class.simpleName}")318}319320private fun isHex(value: String, length: Int): Boolean =321    value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }