VaultSyncAdapter.kt

14.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) : 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        repo.getEntries().associate { it.id to it.modifiedAt }5152    override suspend fun buildTombstoneManifest(): Map<String, Long> =53        repo.getTombstones().associate { it.id to it.deletedAt }5455    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {56        val master = requireMasterKey()57        try {58            val out = LinkedHashMap<String, String>()59            for (id in entryIds) {60                val entry = repo.getEntry(id) ?: continue61                // M-05: deriveEntryKey materializes a per-entry 32-byte key that62                // never leaves this loop; zero it after encrypt so the GC copy63                // doesn't outlive the ciphertext.64                var entryKey: ByteArray? = null65                try {66                    entryKey = deriveEntryKey(master, id)67                    out[id] = encryptEntry(entryKey, entry)68                } finally {69                    entryKey?.let(VaultCrypto::zero)70                }71            }72            return out73        } finally {74            master.fill(0)75        }76    }7778    override suspend fun applyEntries(entries: Map<String, String>) {79        val master = requireMasterKey()80        val decrypted = try {81            entries.mapNotNull { (id, envelope) ->82                // M-05: per-entry derived key is used exactly once below; zero it83                // after the decrypt so it doesn't sit on the heap alongside the84                // plaintext VaultEntry we're about to commit.85                var entryKey: ByteArray? = null86                val entry = try {87                    entryKey = deriveEntryKey(master, id)88                    decryptEntryOrNull(entryKey, envelope)89                } finally {90                    entryKey?.let(VaultCrypto::zero)91                }92                // The map key is the authority on id — refuse a swapped envelope93                // so a ciphertext can't poison a different entry slot.94                if (entry != null && entry.id == id) entry else null95            }96        } finally {97            master.fill(0)98        }99        if (decrypted.isNotEmpty()) {100            repo.applyMergedRemoteEntries(decrypted, emptyList())101        }102    }103104    override suspend fun applyTombstones(tombstones: Map<String, Long>) {105        val cleaned = tombstones106            .filterValues { it > 0L }107            .map { (id, ts) -> Tombstone(id, ts) }108        if (cleaned.isNotEmpty()) {109            repo.applyMergedRemoteEntries(emptyList(), cleaned)110        }111    }112113    override fun bindAuthenticatedPeer(peer: PairedDevice) {114        remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }115            ?: peer.deviceId.takeIf { it.isNotBlank() }116            ?: "paired device"117    }118119    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {120        val registry = pairedDevices ?: return null121        val master = requireMasterKey()122        return try {123            val signing = deriveSyncSigningKeypair(master, ourDeviceId)124            val revoked = registry.listRevocations()125            val revokedIds = revoked.map { it.deviceId }.toSet()126            val devices = registry.getAll()127                .filterNot { revokedIds.contains(it.deviceId) }128                .mapNotNull { it.toManifestEntry() }129            buildSignedDeviceManifest(130                ourDeviceId,131                devices,132                signing.privateKey,133                revoked = revoked,134            )135        } finally {136            master.fill(0)137        }138    }139140    override suspend fun applyIncomingDeviceManifest(141        manifest: SignedDeviceManifest,142        authenticatedRemoteStaticPublicKey: ByteArray,143    ) {144        val registry = pairedDevices ?: return145        val pending = pendingVouched ?: return146        if (authenticatedRemoteStaticPublicKey.size != 32) return147        val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return148        // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):149        // the issuer must already be admitted locally, but it need not be the150        // device that performed the original pairing. Direct and vouched peers151        // are both Noise-authenticated same-vault members, so either may carry152        // the signed pool onward. Unknown and legacy-unclassified rows remain153        // fail-closed.154        if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return155        val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return156        val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return157        if (body.issuerDeviceId != issuer.deviceId) return158        val master = requireMasterKey()159        val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)160161        try {162            // Revocations are absorbed BEFORE any vouching, so a manifest that both163            // revokes and re-vouches the same device cannot race itself. A peer may164            // not revoke US — that would let one compromised device sever the whole165            // mesh — so our own id is filtered out first.166            val newlyRevoked = registry.mergeRevocations(167                body.revoked.filterNot { it.deviceId == ourDeviceId },168            )169            for (deviceId in newlyRevoked) {170                registry.remove(deviceId)171                pending.remove(deviceId)172                syncAlerts?.record(173                    listOf(174                        SyncAlert(175                            type = SyncAlertType.DEVICE_REVOKED,176                            deviceName = issuer.deviceName ?: "paired device",177                            entryTitle = deviceId,178                            timestamp = System.currentTimeMillis(),179                        ),180                    ),181                )182            }183            for (entry in body.devices) {184                if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue185                if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue186                val existing = registry.get(entry.deviceId)187                val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)188                if (existing == null) {189                    if (meshRoute == null) continue190                    registry.upsert(191                        PairedDevice(192                            deviceId = entry.deviceId,193                            deviceName = entry.deviceName,194                            deviceType = entry.deviceType,195                            longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),196                            signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),197                            trustSource = "vouched",198                            wsPort = entry.wsPort,199                            rendezvousSecretHex = meshRoute.first,200                            relayUrl = meshRoute.second,201                            pairedAt = entry.pairedAt,202                        ),203                    )204                    pending.remove(entry.deviceId)205                } else {206                    deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->207                        syncAlerts?.record(listOf(alert))208                    }209                    registry.upsert(210                        existing.copy(211                            deviceName = existing.deviceName?.takeIf { it.isNotBlank() }212                                ?: entry.deviceName,213                            deviceType = entry.deviceType,214                            longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),215                            signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),216                            wsPort = existing.wsPort ?: entry.wsPort,217                            rendezvousSecretHex =218                                if (existing.trustSource == "vouched" && meshRoute != null) {219                                    meshRoute.first220                                } else {221                                    existing.rendezvousSecretHex222                                },223                            relayUrl =224                                if (existing.trustSource == "vouched" && meshRoute != null) {225                                    meshRoute.second226                                } else {227                                    existing.relayUrl228                                },229                            pairedAt = existing.pairedAt,230                            trustSource = existing.trustSource ?: "direct",231                        ),232                    )233                }234            }235        } finally {236            master.fill(0)237            localKeys.secretKey.fill(0)238        }239    }240241    private fun deriveManifestMeshRoute(242        masterKey: ByteArray,243        localPublicKey: ByteArray,244        entry: ManifestDeviceEntry,245    ): Pair<String, String>? = runCatching {246        val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)247        val secret = Rendezvous.deriveMeshSecret(248            masterKey,249            localPublicKey,250            hexToPubkey(entry.longTermPublicKeyHex),251        )252        try {253            Rendezvous.bytesToHex(secret) to relayUrl254        } finally {255            secret.fill(0)256        }257    }.getOrNull()258259    private fun requireMasterKey(): ByteArray =260        repo.syncMasterKey() ?: throw VaultLockedException()261262    private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {263        // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so264        // the (entryId, suffix) boundary is unambiguous regardless of future id265        // formats. Coordinated wire change — peach-browser deriveEntryKey produces266        // the identical info bytes; the two MUST stay byte-for-byte in lockstep.267        val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)268        val gen = HKDFBytesGenerator(SHA256Digest())269        gen.init(HKDFParameters(masterKey, ByteArray(0), info))270        return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }271    }272273    private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {274        // M-05: plaintext is the entry JSON (incl. password field). Zero in275        // finally so the serialized copy doesn't outlive the ciphertext.276        var plaintext: ByteArray? = null277        return try {278            plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)279            val nonce = ByteArray(12).also(rng::nextBytes)280            val cipher = Cipher.getInstance("AES/GCM/NoPadding")281            cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))282            cipher.updateAAD(ENTRY_AAD)283            val ct = cipher.doFinal(plaintext)284            b64(nonce + ct)285        } finally {286            plaintext?.let(VaultCrypto::zero)287        }288    }289290    private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? {291        var pt: ByteArray? = null292        return try {293            val bytes = unb64(envelopeB64)294            if (bytes.size < 12 + 16) null else {295                val nonce = bytes.copyOfRange(0, 12)296                val ct = bytes.copyOfRange(12, bytes.size)297                val cipher = Cipher.getInstance("AES/GCM/NoPadding")298                cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))299                cipher.updateAAD(ENTRY_AAD)300                pt = cipher.doFinal(ct)301                entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))302            }303        } catch (_: Exception) {304            null305        } finally {306            // M-05: pt holds the serialized entry including password until the307            // decoded VaultEntry above is the only remaining reference. Zero it308            // so the raw bytes don't outlive the structure they were parsed into.309            pt?.let(VaultCrypto::zero)310        }311    }312313    private companion object {314        const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"315        val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)316        val rng = SecureRandom()317        val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }318        fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)319        fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)320    }321}322323class VaultLockedException : Exception("vault_locked")