VaultSyncAdapter.kt

20.6 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    private val localCredentials: LocalIdentityCredentials? = null,41) : SyncVaultAdapter {4243    override suspend fun computeVaultId(): String =44        repo.syncVaultId() ?: throw VaultLockedException()4546    override suspend fun getOurDeviceId(): String = ourDeviceId4748    override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4950    override suspend fun buildEntryManifest(): Map<String, Long> {51        val snapshot = requireMasterKeySnapshot()52        VaultCrypto.zero(snapshot.key)53        val manifest = repo.getEntries().associate { it.id to it.modifiedAt }54        if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()55        return manifest56    }5758    override suspend fun buildTombstoneManifest(): Map<String, Long> {59        val snapshot = requireMasterKeySnapshot()60        VaultCrypto.zero(snapshot.key)61        val manifest = repo.getTombstones().associate { it.id to it.deletedAt }62        if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()63        return manifest64    }6566    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {67        val snapshot = requireMasterKeySnapshot()68        val master = snapshot.key69        try {70            val out = LinkedHashMap<String, String>()71            for (id in entryIds) {72                val entry = repo.getEntry(id) ?: continue73                // M-05: deriveEntryKey materializes a per-entry 32-byte key that74                // never leaves this loop; zero it after encrypt so the GC copy75                // doesn't outlive the ciphertext.76                var entryKey: ByteArray? = null77                try {78                    entryKey = deriveEntryKey(master, id)79                    out[id] = encryptEntry(entryKey, entry)80                } finally {81                    entryKey?.let(VaultCrypto::zero)82                }83            }84            if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()85            return out86        } finally {87            VaultCrypto.zero(master)88        }89    }9091    override suspend fun applyEntries(92        entries: Map<String, String>,93        remoteManifest: Map<String, Long>,94    ) {95        val snapshot = requireMasterKeySnapshot()96        val master = snapshot.key97        val decrypted = try {98            entries.mapNotNull { (id, envelope) ->99                // M-05: per-entry derived key is used exactly once below; zero it100                // after the decrypt so it doesn't sit on the heap alongside the101                // plaintext VaultEntry we're about to commit.102                var entryKey: ByteArray? = null103                val entry = try {104                    entryKey = deriveEntryKey(master, id)105                    decryptEntryOrNull(entryKey, envelope)106                } finally {107                    entryKey?.let(VaultCrypto::zero)108                }109                // The map key is the authority on id — refuse a swapped envelope110                // so a ciphertext can't poison a different entry slot.111                if (entry != null && entry.id == id) entry else null112            }113        } finally {114            VaultCrypto.zero(master)115        }116        if (decrypted.isNotEmpty()) {117            repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch)118        }119    }120121    override suspend fun applyTombstones(tombstones: Map<String, Long>) {122        val cleaned = tombstones123            .filterValues { it > 0L }124            .map { (id, ts) -> Tombstone(id, ts) }125        if (cleaned.isNotEmpty()) {126            val snapshot = requireMasterKeySnapshot()127            VaultCrypto.zero(snapshot.key)128            repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch)129        }130    }131132    override fun bindAuthenticatedPeer(peer: PairedDevice) {133        remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }134            ?: peer.deviceId.takeIf { it.isNotBlank() }135            ?: "paired device"136    }137138    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {139        val registry = pairedDevices ?: return null140        val signing = localCredentials?.let(::localSigningKeypair)141            ?: throw IllegalStateException("identity_migration_required")142        val revoked = registry.listRevocations()143        val revokedIds = revoked.map { it.deviceId }.toSet()144        val devices = registry.getAll()145            .filterNot { revokedIds.contains(it.deviceId) }146            .mapNotNull { it.toManifestEntry() }147            .map { it.copy(vouchDepth = if (it.deviceId == ourDeviceId) 0 else it.vouchDepth + 1) }148        return buildSignedDeviceManifest(ourDeviceId, devices, signing.privateKey, revoked = revoked)149    }150151    override suspend fun applyIncomingDeviceManifest(152        manifest: SignedDeviceManifest,153        authenticatedRemoteStaticPublicKey: ByteArray,154    ) {155        val registry = pairedDevices ?: return156        val pending = pendingVouched ?: return157        if (authenticatedRemoteStaticPublicKey.size != 32) return158        val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return159        // Direct and bounded-vouch issuers may propagate the signed device pool.160        if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return161        if (issuer.vouchDepth >= MAX_VOUCH_DEPTH) return162        val currentSigningKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return163        val knownIssuerKeys = registry.issuerSigningKeys(issuer.deviceId) + currentSigningKey164        // Historical keys and the persisted current key remain valid; mutable165        // device metadata never rewrites the issuer's key history.166        //167        // A revocation may name any device as its issuer — including one whose168        // active row is gone — so resolve that issuer's key from the registry169        // and its immutable history rather than from the record itself.170        val resolveIssuerSigningKeys: (String) -> Set<String> = { deviceId ->171            val history = registry.issuerSigningKeys(deviceId)172            val current = registry.get(deviceId)?.signingPublicKeyHex?.lowercase()173            if (current != null && current.length == 64) history + current else history174        }175        val verified = knownIssuerKeys.firstNotNullOfOrNull { candidate ->176            runCatching {177                verifyDeviceManifest(manifest, hexToPubkey(candidate), resolveIssuerSigningKeys)178                    ?.let { candidate to it }179            }.getOrNull()180        } ?: return181        val (signingKey, body) = verified182        if (body.issuerDeviceId != issuer.deviceId) return183        val snapshot = requireMasterKeySnapshot()184        val master = snapshot.key185        val localKeys = localCredentials?.let(::localSyncKeypair)186            ?: throw IllegalStateException("identity_migration_required")187        try {188            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return189            // Stage the complete result against one read-only registry snapshot.190            // No registry mutation is allowed until the epoch-fenced batch below;191            // a per-item epoch check would still leave a prefix behind if lock()192            // wins between two items.193            val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId }194            val currentRevocations = registry.listRevocations()195            val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()196            val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }197            val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet()198            val revokedKeys = (currentRevocations + incomingRevocations)199                .map { it.longTermPublicKeyHex.lowercase() }200                .filterNot { it == NO_KEY_SENTINEL }201                .toSet()202            fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean {203                return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys204            }205206            val stagedUpserts = LinkedHashMap<String, PairedDevice>()207            val stagedAlerts = ArrayList<SyncAlert>()208            newlyRevoked.forEach { revocation ->209                stagedAlerts += SyncAlert(210                    type = SyncAlertType.DEVICE_REVOKED,211                    deviceName = issuer.deviceName ?: "paired device",212                    entryTitle = revocation.deviceId,213                    timestamp = System.currentTimeMillis(),214                )215            }216            for (entry in body.devices) {217                if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) {218                    continue219                }220                if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) {221                    continue222                }223                if (entry.vouchDepth > issuer.vouchDepth + 1) continue224                val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)225                val meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry)226                if (existing == null) {227                    if (meshRoute == null) continue228                    stagedUpserts[entry.deviceId] = PairedDevice(229                        deviceId = entry.deviceId,230                        deviceName = entry.deviceName,231                        deviceType = entry.deviceType,232                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),233                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),234                        trustSource = "vouched",235                        vouchDepth = issuer.vouchDepth + 1,236                        wsPort = entry.wsPort,237                        rendezvousSecretHex = meshRoute.first,238                        relayUrl = meshRoute.second,239                        pairedAt = entry.pairedAt,240                    )241                } else {242                    val incomingLongTermKey = entry.longTermPublicKeyHex.lowercase()243                    val incomingSigningKey = entry.signingPublicKeyHex.lowercase()244                    // Direct pairing is SAS-pinned. A manifest issuer is never245                    // authorized to rotate either identity key, even if the246                    // entry is correctly vouched and the manifest verifies.247                    // Drop the entry wholesale rather than retaining mutable248                    // metadata alongside an untrusted identity transition.249                    val longTermChanged = existing.longTermPublicKeyHex?.let {250                        !CryptoCompare.hex(it, incomingLongTermKey)251                    } == true252                    val signingChanged = existing.signingPublicKeyHex?.let {253                        !CryptoCompare.hex(it, incomingSigningKey)254                    } == true255                    if (longTermChanged || signingChanged) {256                        deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())257                            ?.let(stagedAlerts::add)258                        continue259                    }260                    deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add)261                    stagedUpserts[entry.deviceId] = existing.copy(262                        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }263                            ?: entry.deviceName,264                        deviceType = entry.deviceType,265                        // Every admitted identity is immutable. Only an explicit,266                        // physically confirmed re-pair may replace these keys.267                        longTermPublicKeyHex = existing.longTermPublicKeyHex ?: incomingLongTermKey,268                        signingPublicKeyHex = existing.signingPublicKeyHex ?: incomingSigningKey,269                        vouchDepth = entry.vouchDepth,270                        wsPort = existing.wsPort ?: entry.wsPort,271                        rendezvousSecretHex =272                            if (existing.trustSource == "vouched" && meshRoute != null) {273                                meshRoute.first274                            } else {275                                existing.rendezvousSecretHex276                            },277                        relayUrl =278                            if (existing.trustSource == "vouched" && meshRoute != null) {279                                meshRoute.second280                            } else {281                                existing.relayUrl282                            },283                        pairedAt = existing.pairedAt,284                        trustSource = existing.trustSource,285                    )286                }287            }288289            val batch = DeviceManifestRegistryBatch(290                incomingRevocations = incomingRevocations,291                removals = newlyRevoked.map { it.deviceId }.toSet(),292                upserts = stagedUpserts.values.toList(),293            )294            // Remove any matching pending introductions synchronously BEFORE295            // installing trust. If the registry commit fails, restoring this296            // exact pending subset is safe; if restoration also fails, the297            // fail-closed outcome is a lost introduction, never stale trust.298            val pendingIds = batch.removals + stagedUpserts.keys299            val priorPending = pending.getAll().filter { it.deviceId in pendingIds }300            val committed = repo.withSessionEpochCommit(snapshot.epoch) {301                if (!registry.appendIssuerSigningKey(issuer.deviceId, signingKey)) return@withSessionEpochCommit false302                val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false)303                if (!pendingRemoved) {304                    pending.restoreBatch(priorPending)305                    false306                } else {307                    val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false)308                    if (registryCommitted) {309                        // Pending entries and alerts are kept inside the same310                        // epoch fence. Alerts follow the trust commit and never311                        // participate in its semantics.312                        if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts)313                        true314                    } else {315                        pending.restoreBatch(priorPending)316                        false317                    }318                }319            }320            if (committed != true) return321        } finally {322            VaultCrypto.zero(master)323            VaultCrypto.zero(localKeys.secretKey)324        }325    }326327    private fun deriveManifestMeshRoute(328        localPrivateKey: ByteArray,329        localPublicKey: ByteArray,330        entry: ManifestDeviceEntry,331    ): Pair<String, String>? = runCatching {332        val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)333        val secret = Rendezvous.deriveMeshSecret(334            localPrivateKey,335            localPublicKey,336            hexToPubkey(entry.longTermPublicKeyHex),337        )338        try {339            Rendezvous.bytesToHex(secret) to relayUrl340        } finally {341            VaultCrypto.zero(secret)342        }343    }.getOrNull()344345    private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =346        repo.syncMasterKeySnapshot() ?: throw VaultLockedException()347348    internal companion object {349        internal fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {350            // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so351            // the (entryId, suffix) boundary is unambiguous regardless of future id352            // formats. Coordinated wire change — peach-browser deriveEntryKey produces353            // the identical info bytes; the two MUST stay byte-for-byte in lockstep.354            val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)355            val gen = HKDFBytesGenerator(SHA256Digest())356            gen.init(HKDFParameters(masterKey, ByteArray(0), info))357            return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }358        }359360        internal fun encryptEntry(key: ByteArray, entry: VaultEntry): String {361            // M-05: plaintext is the entry JSON (incl. password field). Zero in362            // finally so the serialized copy doesn't outlive the ciphertext.363            var plaintext: ByteArray? = null364            return try {365                plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)366                val nonce = ByteArray(12).also(rng::nextBytes)367                val cipher = Cipher.getInstance("AES/GCM/NoPadding")368                cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))369                cipher.updateAAD(ENTRY_AAD)370                val ct = cipher.doFinal(plaintext)371                b64(nonce + ct)372            } finally {373                plaintext?.let(VaultCrypto::zero)374            }375        }376377        internal fun encryptEntryWithMasterKey(masterKey: ByteArray, entry: VaultEntry): String {378            val key = deriveEntryKey(masterKey, entry.id)379            return try { encryptEntry(key, entry) } finally { VaultCrypto.zero(key) }380        }381382        internal fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {383            var pt: ByteArray? = null384            return try {385                val bytes = unb64(envelopeB64)386                if (bytes.size < 12 + 16) null else {387                    val nonce = bytes.copyOfRange(0, 12)388                    val ct = bytes.copyOfRange(12, bytes.size)389                    val cipher = Cipher.getInstance("AES/GCM/NoPadding")390                    cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))391                    cipher.updateAAD(ENTRY_AAD)392                    pt = cipher.doFinal(ct)393                    entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))394                }395            } catch (_: Exception) {396                null397            } finally {398                // M-05: pt holds the serialized entry including password until the399                // decoded VaultEntry above is the only remaining reference. Zero it400                // so the raw bytes don't outlive the structure they were parsed into.401                pt?.let(VaultCrypto::zero)402            }403        }404405        const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"406        val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)407        val rng = SecureRandom()408        val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }409        fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)410        fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)411    }412}413414class VaultLockedException : Exception("vault_locked")