VaultSyncAdapter.kt

18.3 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultCrypto5import com.peachpasswords.vault.VaultEntry6import com.peachpasswords.vault.VaultRepository7import kotlinx.serialization.json.Json8import org.bouncycastle.crypto.digests.SHA256Digest9import org.bouncycastle.crypto.generators.HKDFBytesGenerator10import org.bouncycastle.crypto.params.HKDFParameters11import java.security.SecureRandom12import java.util.Base6413import javax.crypto.Cipher14import javax.crypto.spec.GCMParameterSpec15import javax.crypto.spec.SecretKeySpec1617/**18 * Production [SyncVaultAdapter] backing the coordinator with the live19 * [VaultRepository]. Exact port of the browser `sync-vault-adapter.ts`:20 * the coordinator only ever moves opaque base64 envelopes; plaintext crosses21 * this boundary and nowhere else.22 *23 * Envelope cipher: AES-GCM under a PER-ENTRY key24 *   HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\u0000" + "sync-entry-v1")25 * with a random 12-byte nonce and aad = "peach-sync-entry-v1". Binding the key26 * to the entry id means a ciphertext minted for one slot cannot be replayed27 * into another — [applyEntries] rejects any envelope whose decrypted id does28 * not match the map key it arrived under. Both paired devices hold the same29 * syncMasterKey (same vault root key), so keys match without negotiation; the30 * Noise KK reconnect session is a second, independent encryption layer on top.31 */32class VaultSyncAdapter(33    private val repo: VaultRepository,34    private val ourDeviceId: String,35    private val pairedDevices: PairedDeviceRegistry? = null,36    private val pendingVouched: PendingVouchedRegistry? = null,37    private val syncAlerts: SyncAlertRecorder? = null,38    private var remoteDeviceName: String = "paired device",39    override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,40) : SyncVaultAdapter {4142    override suspend fun computeVaultId(): String =43        repo.syncVaultId() ?: throw VaultLockedException()4445    override suspend fun getOurDeviceId(): String = ourDeviceId4647    override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4849    override suspend fun buildEntryManifest(): Map<String, Long> {50        val snapshot = requireMasterKeySnapshot()51        VaultCrypto.zero(snapshot.key)52        val manifest = repo.getEntries().associate { it.id to it.modifiedAt }53        if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()54        return manifest55    }5657    override suspend fun buildTombstoneManifest(): Map<String, Long> {58        val snapshot = requireMasterKeySnapshot()59        VaultCrypto.zero(snapshot.key)60        val manifest = repo.getTombstones().associate { it.id to it.deletedAt }61        if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()62        return manifest63    }6465    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {66        val snapshot = requireMasterKeySnapshot()67        val master = snapshot.key68        try {69            val out = LinkedHashMap<String, String>()70            for (id in entryIds) {71                val entry = repo.getEntry(id) ?: continue72                // M-05: deriveEntryKey materializes a per-entry 32-byte key that73                // never leaves this loop; zero it after encrypt so the GC copy74                // doesn't outlive the ciphertext.75                var entryKey: ByteArray? = null76                try {77                    entryKey = deriveEntryKey(master, id)78                    out[id] = encryptEntry(entryKey, entry)79                } finally {80                    entryKey?.let(VaultCrypto::zero)81                }82            }83            if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()84            return out85        } finally {86            VaultCrypto.zero(master)87        }88    }8990    override suspend fun applyEntries(91        entries: Map<String, String>,92        remoteManifest: Map<String, Long>,93    ) {94        val snapshot = requireMasterKeySnapshot()95        val master = snapshot.key96        val decrypted = try {97            entries.mapNotNull { (id, envelope) ->98                // M-05: per-entry derived key is used exactly once below; zero it99                // after the decrypt so it doesn't sit on the heap alongside the100                // plaintext VaultEntry we're about to commit.101                var entryKey: ByteArray? = null102                val entry = try {103                    entryKey = deriveEntryKey(master, id)104                    decryptEntryOrNull(entryKey, envelope)105                } finally {106                    entryKey?.let(VaultCrypto::zero)107                }108                // The map key is the authority on id — refuse a swapped envelope109                // so a ciphertext can't poison a different entry slot.110                if (entry != null && entry.id == id) entry else null111            }112        } finally {113            VaultCrypto.zero(master)114        }115        if (decrypted.isNotEmpty()) {116            repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch)117        }118    }119120    override suspend fun applyTombstones(tombstones: Map<String, Long>) {121        val cleaned = tombstones122            .filterValues { it > 0L }123            .map { (id, ts) -> Tombstone(id, ts) }124        if (cleaned.isNotEmpty()) {125            val snapshot = requireMasterKeySnapshot()126            VaultCrypto.zero(snapshot.key)127            repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch)128        }129    }130131    override fun bindAuthenticatedPeer(peer: PairedDevice) {132        remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }133            ?: peer.deviceId.takeIf { it.isNotBlank() }134            ?: "paired device"135    }136137    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {138        val registry = pairedDevices ?: return null139        val snapshot = requireMasterKeySnapshot()140        val master = snapshot.key141        return try {142            val signing = deriveSyncSigningKeypair(master, ourDeviceId)143            val revoked = registry.listRevocations()144            val revokedIds = revoked.map { it.deviceId }.toSet()145            val devices = registry.getAll()146                .filterNot { revokedIds.contains(it.deviceId) }147                .mapNotNull { it.toManifestEntry() }148            val manifest = buildSignedDeviceManifest(149                ourDeviceId,150                devices,151                signing.privateKey,152                revoked = revoked,153            )154            if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()155            manifest156        } finally {157            VaultCrypto.zero(master)158        }159    }160161    override suspend fun applyIncomingDeviceManifest(162        manifest: SignedDeviceManifest,163        authenticatedRemoteStaticPublicKey: ByteArray,164    ) {165        val registry = pairedDevices ?: return166        val pending = pendingVouched ?: return167        if (authenticatedRemoteStaticPublicKey.size != 32) return168        val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return169        // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):170        // the issuer must already be admitted locally, but it need not be the171        // device that performed the original pairing. Direct and vouched peers172        // are both Noise-authenticated same-vault members, so either may carry173        // the signed pool onward. Unknown and legacy-unclassified rows remain174        // fail-closed.175        if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return176        val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return177        val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return178        if (body.issuerDeviceId != issuer.deviceId) return179        val snapshot = requireMasterKeySnapshot()180        val master = snapshot.key181        val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)182183        try {184            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return185            // Stage the complete result against one read-only registry snapshot.186            // No registry mutation is allowed until the epoch-fenced batch below;187            // a per-item epoch check would still leave a prefix behind if lock()188            // wins between two items.189            val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId }190            val currentRevocations = registry.listRevocations()191            val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()192            val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }193            val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet()194            val revokedKeys = (currentRevocations + incomingRevocations)195                .map { it.longTermPublicKeyHex.lowercase() }196                .filterNot { it == NO_KEY_SENTINEL }197                .toSet()198            fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean {199                return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys200            }201202            val stagedUpserts = LinkedHashMap<String, PairedDevice>()203            val stagedAlerts = ArrayList<SyncAlert>()204            newlyRevoked.forEach { revocation ->205                stagedAlerts += SyncAlert(206                    type = SyncAlertType.DEVICE_REVOKED,207                    deviceName = issuer.deviceName ?: "paired device",208                    entryTitle = revocation.deviceId,209                    timestamp = System.currentTimeMillis(),210                )211            }212            for (entry in body.devices) {213                if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) {214                    continue215                }216                if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) {217                    continue218                }219                val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)220                val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)221                if (existing == null) {222                    if (meshRoute == null) continue223                    stagedUpserts[entry.deviceId] = PairedDevice(224                        deviceId = entry.deviceId,225                        deviceName = entry.deviceName,226                        deviceType = entry.deviceType,227                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),228                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),229                        trustSource = "vouched",230                        wsPort = entry.wsPort,231                        rendezvousSecretHex = meshRoute.first,232                        relayUrl = meshRoute.second,233                        pairedAt = entry.pairedAt,234                    )235                } else {236                    deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add)237                    stagedUpserts[entry.deviceId] = existing.copy(238                        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }239                            ?: entry.deviceName,240                        deviceType = entry.deviceType,241                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),242                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),243                        wsPort = existing.wsPort ?: entry.wsPort,244                        rendezvousSecretHex =245                            if (existing.trustSource == "vouched" && meshRoute != null) {246                                meshRoute.first247                            } else {248                                existing.rendezvousSecretHex249                            },250                        relayUrl =251                            if (existing.trustSource == "vouched" && meshRoute != null) {252                                meshRoute.second253                            } else {254                                existing.relayUrl255                            },256                        pairedAt = existing.pairedAt,257                        trustSource = existing.trustSource,258                    )259                }260            }261262            val batch = DeviceManifestRegistryBatch(263                incomingRevocations = incomingRevocations,264                removals = newlyRevoked.map { it.deviceId }.toSet(),265                upserts = stagedUpserts.values.toList(),266            )267            // Remove any matching pending introductions synchronously BEFORE268            // installing trust. If the registry commit fails, restoring this269            // exact pending subset is safe; if restoration also fails, the270            // fail-closed outcome is a lost introduction, never stale trust.271            val pendingIds = batch.removals + stagedUpserts.keys272            val priorPending = pending.getAll().filter { it.deviceId in pendingIds }273            val committed = repo.withSessionEpochCommit(snapshot.epoch) {274                val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false)275                if (!pendingRemoved) {276                    pending.restoreBatch(priorPending)277                    false278                } else {279                    val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false)280                    if (registryCommitted) {281                        // Pending entries and alerts are kept inside the same282                        // epoch fence. Alerts follow the trust commit and never283                        // participate in its semantics.284                        if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts)285                        true286                    } else {287                        pending.restoreBatch(priorPending)288                        false289                    }290                }291            }292            if (committed != true) return293        } finally {294            VaultCrypto.zero(master)295            VaultCrypto.zero(localKeys.secretKey)296        }297    }298299    private fun deriveManifestMeshRoute(300        masterKey: ByteArray,301        localPublicKey: ByteArray,302        entry: ManifestDeviceEntry,303    ): Pair<String, String>? = runCatching {304        val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)305        val secret = Rendezvous.deriveMeshSecret(306            masterKey,307            localPublicKey,308            hexToPubkey(entry.longTermPublicKeyHex),309        )310        try {311            Rendezvous.bytesToHex(secret) to relayUrl312        } finally {313            secret.fill(0)314        }315    }.getOrNull()316317    private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =318        repo.syncMasterKeySnapshot() ?: throw VaultLockedException()319320    internal companion object {321        internal fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {322            // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so323            // the (entryId, suffix) boundary is unambiguous regardless of future id324            // formats. Coordinated wire change — peach-browser deriveEntryKey produces325            // the identical info bytes; the two MUST stay byte-for-byte in lockstep.326            val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)327            val gen = HKDFBytesGenerator(SHA256Digest())328            gen.init(HKDFParameters(masterKey, ByteArray(0), info))329            return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }330        }331332        internal fun encryptEntry(key: ByteArray, entry: VaultEntry): String {333            // M-05: plaintext is the entry JSON (incl. password field). Zero in334            // finally so the serialized copy doesn't outlive the ciphertext.335            var plaintext: ByteArray? = null336            return try {337                plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)338                val nonce = ByteArray(12).also(rng::nextBytes)339                val cipher = Cipher.getInstance("AES/GCM/NoPadding")340                cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))341                cipher.updateAAD(ENTRY_AAD)342                val ct = cipher.doFinal(plaintext)343                b64(nonce + ct)344            } finally {345                plaintext?.let(VaultCrypto::zero)346            }347        }348349        internal fun encryptEntryWithMasterKey(masterKey: ByteArray, entry: VaultEntry): String {350            val key = deriveEntryKey(masterKey, entry.id)351            return try { encryptEntry(key, entry) } finally { VaultCrypto.zero(key) }352        }353354        internal fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {355            var pt: ByteArray? = null356            return try {357                val bytes = unb64(envelopeB64)358                if (bytes.size < 12 + 16) null else {359                    val nonce = bytes.copyOfRange(0, 12)360                    val ct = bytes.copyOfRange(12, bytes.size)361                    val cipher = Cipher.getInstance("AES/GCM/NoPadding")362                    cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))363                    cipher.updateAAD(ENTRY_AAD)364                    pt = cipher.doFinal(ct)365                    entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))366                }367            } catch (_: Exception) {368                null369            } finally {370                // M-05: pt holds the serialized entry including password until the371                // decoded VaultEntry above is the only remaining reference. Zero it372                // so the raw bytes don't outlive the structure they were parsed into.373                pt?.let(VaultCrypto::zero)374            }375        }376377        const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"378        val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)379        val rng = SecureRandom()380        val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }381        fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)382        fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)383    }384}385386class VaultLockedException : Exception("vault_locked")