VaultSyncAdapter.kt

12.9 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultEntry5import com.peachpasswords.vault.VaultRepository6import kotlinx.serialization.json.Json7import org.bouncycastle.crypto.digests.SHA256Digest8import org.bouncycastle.crypto.generators.HKDFBytesGenerator9import org.bouncycastle.crypto.params.HKDFParameters10import java.security.SecureRandom11import java.util.Base6412import javax.crypto.Cipher13import javax.crypto.spec.GCMParameterSpec14import javax.crypto.spec.SecretKeySpec1516/**17 * Production [SyncVaultAdapter] backing the coordinator with the live18 * [VaultRepository]. Exact port of the browser `sync-vault-adapter.ts`:19 * the coordinator only ever moves opaque base64 envelopes; plaintext crosses20 * this boundary and nowhere else.21 *22 * Envelope cipher: AES-GCM under a PER-ENTRY key23 *   HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\u0000" + "sync-entry-v1")24 * with a random 12-byte nonce and aad = "peach-sync-entry-v1". Binding the key25 * to the entry id means a ciphertext minted for one slot cannot be replayed26 * into another — [applyEntries] rejects any envelope whose decrypted id does27 * not match the map key it arrived under. Both paired devices hold the same28 * syncMasterKey (same vault root key), so keys match without negotiation; the29 * Noise KK reconnect session is a second, independent encryption layer on top.30 */31class VaultSyncAdapter(32    private val repo: VaultRepository,33    private val ourDeviceId: String,34    private val pairedDevices: PairedDeviceRegistry? = null,35    private val pendingVouched: PendingVouchedRegistry? = null,36    private val syncAlerts: SyncAlertRecorder? = null,37    private var remoteDeviceName: String = "paired device",38    override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,39) : SyncVaultAdapter {4041    override suspend fun computeVaultId(): String =42        repo.syncVaultId() ?: throw VaultLockedException()4344    override suspend fun getOurDeviceId(): String = ourDeviceId4546    override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4748    override suspend fun buildEntryManifest(): Map<String, Long> =49        repo.getEntries().associate { it.id to it.modifiedAt }5051    override suspend fun buildTombstoneManifest(): Map<String, Long> =52        repo.getTombstones().associate { it.id to it.deletedAt }5354    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {55        val master = requireMasterKey()56        try {57            val out = LinkedHashMap<String, String>()58            for (id in entryIds) {59                val entry = repo.getEntry(id) ?: continue60                out[id] = encryptEntry(deriveEntryKey(master, id), entry)61            }62            return out63        } finally {64            master.fill(0)65        }66    }6768    override suspend fun applyEntries(entries: Map<String, String>) {69        val before = if (syncAlerts != null) repo.getEntries() else emptyList()70        val master = requireMasterKey()71        val decrypted = try {72            entries.mapNotNull { (id, envelope) ->73                val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)74                // The map key is the authority on id — refuse a swapped envelope75                // so a ciphertext can't poison a different entry slot.76                if (entry != null && entry.id == id) entry else null77            }78        } finally {79            master.fill(0)80        }81        if (decrypted.isNotEmpty()) {82            val applied = repo.applyMergedRemoteEntries(decrypted, emptyList())83            if (applied > 0) {84                syncAlerts?.record(85                    remoteEntrySyncAlerts(86                        localEntries = before,87                        remoteEntries = decrypted,88                        deviceName = remoteDeviceName,89                        nowMs = System.currentTimeMillis(),90                    ),91                )92            }93        }94    }9596    override suspend fun applyTombstones(tombstones: Map<String, Long>) {97        val before = if (syncAlerts != null) repo.getEntries() else emptyList()98        val cleaned = tombstones99            .filterValues { it > 0L }100            .map { (id, ts) -> Tombstone(id, ts) }101        if (cleaned.isNotEmpty()) {102            val applied = repo.applyMergedRemoteEntries(emptyList(), cleaned)103            if (applied > 0) {104                syncAlerts?.record(105                    remoteTombstoneSyncAlerts(106                        localEntries = before,107                        remoteTombstones = cleaned,108                        deviceName = remoteDeviceName,109                        nowMs = System.currentTimeMillis(),110                    ),111                )112            }113        }114    }115116    override fun bindAuthenticatedPeer(peer: PairedDevice) {117        remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }118            ?: peer.deviceId.takeIf { it.isNotBlank() }119            ?: "paired device"120    }121122    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {123        val registry = pairedDevices ?: return null124        val master = requireMasterKey()125        return try {126            val signing = deriveSyncSigningKeypair(master, ourDeviceId)127            val revoked = registry.listRevocations()128            val revokedIds = revoked.map { it.deviceId }.toSet()129            val devices = registry.getAll()130                .filterNot { revokedIds.contains(it.deviceId) }131                .mapNotNull { it.toManifestEntry() }132            buildSignedDeviceManifest(133                ourDeviceId,134                devices,135                signing.privateKey,136                revoked = revoked,137            )138        } finally {139            master.fill(0)140        }141    }142143    override suspend fun applyIncomingDeviceManifest(144        manifest: SignedDeviceManifest,145        authenticatedRemoteStaticPublicKey: ByteArray,146    ) {147        val registry = pairedDevices ?: return148        val pending = pendingVouched ?: return149        if (authenticatedRemoteStaticPublicKey.size != 32) return150        val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return151        // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):152        // trust a manifest only from a DIRECTLY paired issuer. This previously153        // keyed on deviceType == "extension" instead, which got both halves154        // wrong: it ignored manifests from other phones/tablets, so mesh trust155        // could never propagate between native devices (the whole point of the156        // mesh now that phones are the listening side), and it never checked157        // trustSource at all — so a merely VOUCHED device could introduce158        // further devices, chaining trust transitively in a way the browser159        // deliberately refuses.160        if (issuer.trustSource != "direct") return161        val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return162        val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return163        if (body.issuerDeviceId != issuer.deviceId) return164165        // Revocations are absorbed BEFORE any vouching, so a manifest that both166        // revokes and re-vouches the same device cannot race itself. A peer may167        // not revoke US — that would let one compromised device sever the whole168        // mesh — so our own id is filtered out first.169        val newlyRevoked = registry.mergeRevocations(170            body.revoked.filterNot { it.deviceId == ourDeviceId },171        )172        for (deviceId in newlyRevoked) {173            registry.remove(deviceId)174            pending.remove(deviceId)175            syncAlerts?.record(176                listOf(177                    SyncAlert(178                        type = SyncAlertType.DEVICE_REVOKED,179                        deviceName = issuer.deviceName ?: "paired device",180                        entryTitle = deviceId,181                        timestamp = System.currentTimeMillis(),182                    ),183                ),184            )185        }186        for (entry in body.devices) {187            if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue188            // Terminal: no manifest re-admits a revoked device. Matched on the189            // key as well as the id, because the id is the issuer's to choose —190            // checking only the id let a revoked key walk back in under a fresh191            // one. The check sits ahead of the existing-device lookup so it also192            // refuses rotating a device we still trust onto a revoked key.193            if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue194            val existing = registry.get(entry.deviceId)195            if (existing == null) {196                pending.upsert(197                    PendingVouchedDevice(198                        deviceId = entry.deviceId,199                        deviceName = entry.deviceName,200                        deviceType = entry.deviceType,201                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),202                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),203                        wsPort = entry.wsPort,204                        pairedAt = entry.pairedAt,205                        receivedAt = System.currentTimeMillis(),206                        vouchedByDeviceId = issuer.deviceId,207                    ),208                )209            } else {210                deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->211                    syncAlerts?.record(listOf(alert))212                }213                // S5: keys and ports propagate through the mesh, the name does214                // not. A device names itself at pairing; letting a third-party215                // issuer rewrite that is the rename-then-impersonate vector.216                registry.upsert(217                    existing.copy(218                        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }219                            ?: entry.deviceName,220                        deviceType = entry.deviceType,221                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),222                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),223                        wsPort = existing.wsPort ?: entry.wsPort,224                        pairedAt = existing.pairedAt,225                        trustSource = existing.trustSource ?: "direct",226                    ),227                )228            }229        }230    }231232    private fun requireMasterKey(): ByteArray =233        repo.syncMasterKey() ?: throw VaultLockedException()234235    private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {236        // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so237        // the (entryId, suffix) boundary is unambiguous regardless of future id238        // formats. Coordinated wire change — peach-browser deriveEntryKey produces239        // the identical info bytes; the two MUST stay byte-for-byte in lockstep.240        val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)241        val gen = HKDFBytesGenerator(SHA256Digest())242        gen.init(HKDFParameters(masterKey, ByteArray(0), info))243        return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }244    }245246    private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {247        val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)248        val nonce = ByteArray(12).also(rng::nextBytes)249        val cipher = Cipher.getInstance("AES/GCM/NoPadding")250        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))251        cipher.updateAAD(ENTRY_AAD)252        val ct = cipher.doFinal(plaintext)253        return b64(nonce + ct)254    }255256    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {257        val bytes = unb64(envelopeB64)258        if (bytes.size < 12 + 16) null else {259            val nonce = bytes.copyOfRange(0, 12)260            val ct = bytes.copyOfRange(12, bytes.size)261            val cipher = Cipher.getInstance("AES/GCM/NoPadding")262            cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))263            cipher.updateAAD(ENTRY_AAD)264            val pt = cipher.doFinal(ct)265            entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))266        }267    } catch (_: Exception) {268        null269    }270271    private companion object {272        const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"273        val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)274        val rng = SecureRandom()275        val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }276        fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)277        fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)278    }279}280281class VaultLockedException : Exception("vault_locked")