VaultSyncAdapter.kt

5.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 [ChirpSyncVaultAdapter] backing the coordinator with the live18 * [VaultRepository]. Exact port of the browser `chirp-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 XX session is a second, independent encryption layer on top.30 */31class VaultSyncAdapter(32    private val repo: VaultRepository,33    private val ourDeviceId: String,34    override val supportsDeviceManifest: Boolean = false,35) : ChirpSyncVaultAdapter {3637    override suspend fun computeVaultId(): String =38        repo.syncVaultId() ?: throw VaultLockedException()3940    override suspend fun getOurDeviceId(): String = ourDeviceId4142    override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4344    override suspend fun buildEntryManifest(): Map<String, Long> =45        repo.getEntries().associate { it.id to it.modifiedAt }4647    override suspend fun buildTombstoneManifest(): Map<String, Long> =48        repo.getTombstones().associate { it.id to it.deletedAt }4950    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {51        val master = requireMasterKey()52        try {53            val out = LinkedHashMap<String, String>()54            for (id in entryIds) {55                val entry = repo.getEntry(id) ?: continue56                out[id] = encryptEntry(deriveEntryKey(master, id), entry)57            }58            return out59        } finally {60            master.fill(0)61        }62    }6364    override suspend fun applyEntries(entries: Map<String, String>) {65        val master = requireMasterKey()66        val decrypted = try {67            entries.mapNotNull { (id, envelope) ->68                val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)69                // The map key is the authority on id — refuse a swapped envelope70                // so a ciphertext can't poison a different entry slot.71                if (entry != null && entry.id == id) entry else null72            }73        } finally {74            master.fill(0)75        }76        if (decrypted.isNotEmpty()) repo.applyMergedRemoteEntries(decrypted, emptyList())77    }7879    override suspend fun applyTombstones(tombstones: Map<String, Long>) {80        val cleaned = tombstones81            .filterValues { it > 0L }82            .map { (id, ts) -> Tombstone(id, ts) }83        if (cleaned.isNotEmpty()) repo.applyMergedRemoteEntries(emptyList(), cleaned)84    }8586    private fun requireMasterKey(): ByteArray =87        repo.syncMasterKey() ?: throw VaultLockedException()8889    private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {90        // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so91        // the (entryId, suffix) boundary is unambiguous regardless of future id92        // formats. Coordinated wire change — peach-browser deriveEntryKey produces93        // the identical info bytes; the two MUST stay byte-for-byte in lockstep.94        val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)95        val gen = HKDFBytesGenerator(SHA256Digest())96        gen.init(HKDFParameters(masterKey, ByteArray(0), info))97        return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }98    }99100    private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {101        val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)102        val nonce = ByteArray(12).also(rng::nextBytes)103        val cipher = Cipher.getInstance("AES/GCM/NoPadding")104        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))105        cipher.updateAAD(ENTRY_AAD)106        val ct = cipher.doFinal(plaintext)107        return b64(nonce + ct)108    }109110    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {111        val bytes = unb64(envelopeB64)112        if (bytes.size < 12 + 16) null else {113            val nonce = bytes.copyOfRange(0, 12)114            val ct = bytes.copyOfRange(12, bytes.size)115            val cipher = Cipher.getInstance("AES/GCM/NoPadding")116            cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))117            cipher.updateAAD(ENTRY_AAD)118            val pt = cipher.doFinal(ct)119            entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))120        }121    } catch (_: Exception) {122        null123    }124125    private companion object {126        const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"127        val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)128        val rng = SecureRandom()129        val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }130        fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)131        fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)132    }133}134135class VaultLockedException : Exception("vault_locked")