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.SecretKeySpec1516171819202122232425262728293031class 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) : ChirpSyncVaultAdapter {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 before = if (syncAlerts != null) repo.getEntries() else emptyList()70 val master = requireMasterKey()71 val decrypted = try {72 entries.mapNotNull { (id, envelope) ->73 val entry = decryptEntryOrNull(deriveEntryKey(master, id), envelope)74 75 76 if (entry != null && entry.id == id) entry else null77 }78 } finally {79 master.fill(0)80 }81 if (decrypted.isNotEmpty()) {82 val applied = repo.applyMergedRemoteEntries(decrypted, emptyList())83 if (applied > 0) {84 syncAlerts?.record(85 remoteEntrySyncAlerts(86 localEntries = before,87 remoteEntries = decrypted,88 deviceName = remoteDeviceName,89 nowMs = System.currentTimeMillis(),90 ),91 )92 }93 }94 }9596 override suspend fun applyTombstones(tombstones: Map<String, Long>) {97 val before = if (syncAlerts != null) repo.getEntries() else emptyList()98 val cleaned = tombstones99 .filterValues { it > 0L }100 .map { (id, ts) -> Tombstone(id, ts) }101 if (cleaned.isNotEmpty()) {102 val applied = repo.applyMergedRemoteEntries(emptyList(), cleaned)103 if (applied > 0) {104 syncAlerts?.record(105 remoteTombstoneSyncAlerts(106 localEntries = before,107 remoteTombstones = cleaned,108 deviceName = remoteDeviceName,109 nowMs = System.currentTimeMillis(),110 ),111 )112 }113 }114 }115116 override fun bindAuthenticatedPeer(peer: PairedDevice) {117 remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() }118 ?: peer.deviceId.takeIf { it.isNotBlank() }119 ?: "paired device"120 }121122 override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {123 val registry = pairedDevices ?: return null124 val master = requireMasterKey()125 return try {126 val signing = deriveSyncSigningKeypair(master, ourDeviceId)127 val revoked = registry.listRevocations()128 val revokedIds = revoked.map { it.deviceId }.toSet()129 val devices = registry.getAll()130 .filterNot { revokedIds.contains(it.deviceId) }131 .mapNotNull { it.toManifestEntry() }132 buildSignedDeviceManifest(133 ourDeviceId,134 devices,135 signing.privateKey,136 revoked = revoked,137 )138 } finally {139 master.fill(0)140 }141 }142143 override suspend fun applyIncomingDeviceManifest(144 manifest: SignedDeviceManifest,145 authenticatedRemoteStaticPublicKey: ByteArray,146 ) {147 val registry = pairedDevices ?: return148 val pending = pendingVouched ?: return149 if (authenticatedRemoteStaticPublicKey.size != 32) return150 val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return151 152 153 154 155 156 157 158 159 160 if (issuer.trustSource != "direct") return161 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return162 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return163 if (body.issuerDeviceId != issuer.deviceId) return164165 166 167 168 169 val newlyRevoked = registry.mergeRevocations(170 body.revoked.filterNot { it.deviceId == ourDeviceId },171 )172 for (deviceId in newlyRevoked) {173 registry.remove(deviceId)174 pending.remove(deviceId)175 syncAlerts?.record(176 listOf(177 SyncAlert(178 type = SyncAlertType.DEVICE_REVOKED,179 deviceName = issuer.deviceName ?: "paired device",180 entryTitle = deviceId,181 timestamp = System.currentTimeMillis(),182 ),183 ),184 )185 }186 val revokedIds = registry.listRevocations().map { it.deviceId }.toSet()187188 for (entry in body.devices) {189 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue190 191 if (revokedIds.contains(entry.deviceId)) continue192 val existing = registry.get(entry.deviceId)193 if (existing == null) {194 pending.upsert(195 PendingVouchedDevice(196 deviceId = entry.deviceId,197 deviceName = entry.deviceName,198 deviceType = entry.deviceType,199 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),200 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),201 wsPort = entry.wsPort,202 pairedAt = entry.pairedAt,203 receivedAt = System.currentTimeMillis(),204 vouchedByDeviceId = issuer.deviceId,205 ),206 )207 } else {208 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->209 syncAlerts?.record(listOf(alert))210 }211 212 213 214 registry.upsert(215 existing.copy(216 deviceName = existing.deviceName?.takeIf { it.isNotBlank() }217 ?: entry.deviceName,218 deviceType = entry.deviceType,219 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),220 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),221 wsPort = existing.wsPort ?: entry.wsPort,222 pairedAt = existing.pairedAt,223 trustSource = existing.trustSource ?: "direct",224 ),225 )226 }227 }228 }229230 private fun requireMasterKey(): ByteArray =231 repo.syncMasterKey() ?: throw VaultLockedException()232233 private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {234 235 236 237 238 val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8)239 val gen = HKDFBytesGenerator(SHA256Digest())240 gen.init(HKDFParameters(masterKey, ByteArray(0), info))241 return ByteArray(32).also { gen.generateBytes(it, 0, it.size) }242 }243244 private fun encryptEntry(key: ByteArray, entry: VaultEntry): String {245 val plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)246 val nonce = ByteArray(12).also(rng::nextBytes)247 val cipher = Cipher.getInstance("AES/GCM/NoPadding")248 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))249 cipher.updateAAD(ENTRY_AAD)250 val ct = cipher.doFinal(plaintext)251 return b64(nonce + ct)252 }253254 private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? = try {255 val bytes = unb64(envelopeB64)256 if (bytes.size < 12 + 16) null else {257 val nonce = bytes.copyOfRange(0, 12)258 val ct = bytes.copyOfRange(12, bytes.size)259 val cipher = Cipher.getInstance("AES/GCM/NoPadding")260 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))261 cipher.updateAAD(ENTRY_AAD)262 val pt = cipher.doFinal(ct)263 entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8))264 }265 } catch (_: Exception) {266 null267 }268269 private companion object {270 const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"271 val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8)272 val rng = SecureRandom()273 val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true }274 fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)275 fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)276 }277}278279class VaultLockedException : Exception("vault_locked")