VaultSyncAdapter.kt

13.2 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 master = requireMasterKey()70        val decrypted = try {71            entries.mapNotNull { (id, envelope) ->72                val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)73                // The map key is the authority on id — refuse a swapped envelope74                // so a ciphertext can't poison a different entry slot.75                if (entry != null && entry.id == id) entry else null76            }77        } finally {78            master.fill(0)79        }80        if (decrypted.isNotEmpty()) {81            repo.applyMergedRemoteEntries(decrypted, emptyList())82        }83    }8485    override suspend fun applyTombstones(tombstones: Map<String, Long>) {86        val cleaned = tombstones87            .filterValues { it > 0L }88            .map { (id, ts) -> Tombstone(id, ts) }89        if (cleaned.isNotEmpty()) {90            repo.applyMergedRemoteEntries(emptyList(), cleaned)91        }92    }9394    override fun bindAuthenticatedPeer(peer: PairedDevice) {95        remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }96            ?: peer.deviceId.takeIf { it.isNotBlank() }97            ?: "paired device"98    }99100    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {101        val registry = pairedDevices ?: return null102        val master = requireMasterKey()103        return try {104            val signing = deriveSyncSigningKeypair(master, ourDeviceId)105            val revoked = registry.listRevocations()106            val revokedIds = revoked.map { it.deviceId }.toSet()107            val devices = registry.getAll()108                .filterNot { revokedIds.contains(it.deviceId) }109                .mapNotNull { it.toManifestEntry() }110            buildSignedDeviceManifest(111                ourDeviceId,112                devices,113                signing.privateKey,114                revoked = revoked,115            )116        } finally {117            master.fill(0)118        }119    }120121    override suspend fun applyIncomingDeviceManifest(122        manifest: SignedDeviceManifest,123        authenticatedRemoteStaticPublicKey: ByteArray,124    ) {125        val registry = pairedDevices ?: return126        val pending = pendingVouched ?: return127        if (authenticatedRemoteStaticPublicKey.size != 32) return128        val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return129        // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):130        // trust a manifest only from a DIRECTLY paired issuer. This previously131        // keyed on deviceType == "extension" instead, which got both halves132        // wrong: it ignored manifests from other phones/tablets, so mesh trust133        // could never propagate between native devices (the whole point of the134        // mesh now that phones are the listening side), and it never checked135        // trustSource at all — so a VOUCHED device could introduce further136        // devices, chaining trust transitively. A direct issuer's valid v3137        // routes are auto-admitted; vouched peers remain unable to vouch onward.138        if (issuer.trustSource != "direct") return139        val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return140        val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return141        if (body.issuerDeviceId != issuer.deviceId) return142        val master = requireMasterKey()143        val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)144145        try {146            // Revocations are absorbed BEFORE any vouching, so a manifest that both147            // revokes and re-vouches the same device cannot race itself. A peer may148            // not revoke US — that would let one compromised device sever the whole149            // mesh — so our own id is filtered out first.150            val newlyRevoked = registry.mergeRevocations(151                body.revoked.filterNot { it.deviceId == ourDeviceId },152            )153            for (deviceId in newlyRevoked) {154                registry.remove(deviceId)155                pending.remove(deviceId)156                syncAlerts?.record(157                    listOf(158                        SyncAlert(159                            type = SyncAlertType.DEVICE_REVOKED,160                            deviceName = issuer.deviceName ?: "paired device",161                            entryTitle = deviceId,162                            timestamp = System.currentTimeMillis(),163                        ),164                    ),165                )166            }167            for (entry in body.devices) {168                if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue169                if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue170                val existing = registry.get(entry.deviceId)171                val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)172                if (existing == null) {173                    if (meshRoute == null) continue174                    registry.upsert(175                        PairedDevice(176                            deviceId = entry.deviceId,177                            deviceName = entry.deviceName,178                            deviceType = entry.deviceType,179                            longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),180                            signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),181                            trustSource = "vouched",182                            wsPort = entry.wsPort,183                            rendezvousSecretHex = meshRoute.first,184                            relayUrl = meshRoute.second,185                            pairedAt = entry.pairedAt,186                        ),187                    )188                    pending.remove(entry.deviceId)189                } else {190                    deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->191                        syncAlerts?.record(listOf(alert))192                    }193                    registry.upsert(194                        existing.copy(195                            deviceName = existing.deviceName?.takeIf { it.isNotBlank() }196                                ?: entry.deviceName,197                            deviceType = entry.deviceType,198                            longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),199                            signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),200                            wsPort = existing.wsPort ?: entry.wsPort,201                            rendezvousSecretHex =202                                if (existing.trustSource == "vouched" && meshRoute != null) {203                                    meshRoute.first204                                } else {205                                    existing.rendezvousSecretHex206                                },207                            relayUrl =208                                if (existing.trustSource == "vouched" && meshRoute != null) {209                                    meshRoute.second210                                } else {211                                    existing.relayUrl212                                },213                            pairedAt = existing.pairedAt,214                            trustSource = existing.trustSource ?: "direct",215                        ),216                    )217                }218            }219        } finally {220            master.fill(0)221            localKeys.secretKey.fill(0)222        }223    }224225    private fun deriveManifestMeshRoute(226        masterKey: ByteArray,227        localPublicKey: ByteArray,228        entry: ManifestDeviceEntry,229    ): Pair<String, String>? = runCatching {230        val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)231        val secret = Rendezvous.deriveMeshSecret(232            masterKey,233            localPublicKey,234            hexToPubkey(entry.longTermPublicKeyHex),235        )236        try {237            Rendezvous.bytesToHex(secret) to relayUrl238        } finally {239            secret.fill(0)240        }241    }.getOrNull()242243    private fun requireMasterKey(): ByteArray =244        repo.syncMasterKey() ?: throw VaultLockedException()245246    private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {247        // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so248        // the (entryId, suffix) boundary is unambiguous regardless of future id249        // formats. Coordinated wire change — peach-browser deriveEntryKey produces250        // the identical info bytes; the two MUST stay byte-for-byte in lockstep.251        val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)252        val gen = HKDFBytesGenerator(SHA256Digest())253        gen.init(HKDFParameters(masterKey, ByteArray(0), info))254        return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }255    }256257    private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {258        val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)259        val nonce = ByteArray(12).also(rng::nextBytes)260        val cipher = Cipher.getInstance("AES/GCM/NoPadding")261        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))262        cipher.updateAAD(ENTRY_AAD)263        val ct = cipher.doFinal(plaintext)264        return b64(nonce + ct)265    }266267    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {268        val bytes = unb64(envelopeB64)269        if (bytes.size < 12 + 16) null else {270            val nonce = bytes.copyOfRange(0, 12)271            val ct = bytes.copyOfRange(12, bytes.size)272            val cipher = Cipher.getInstance("AES/GCM/NoPadding")273            cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))274            cipher.updateAAD(ENTRY_AAD)275            val pt = cipher.doFinal(ct)276            entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))277        }278    } catch (_: Exception) {279        null280    }281282    private companion object {283        const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"284        val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)285        val rng = SecureRandom()286        val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }287        fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)288        fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)289    }290}291292class VaultLockedException : Exception("vault_locked")