package com.peachpasswords.sync import com.peachpasswords.vault.Tombstone import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import kotlinx.serialization.json.Json import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.generators.HKDFBytesGenerator import org.bouncycastle.crypto.params.HKDFParameters import java.security.SecureRandom import java.util.Base64 import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec /** * Production [SyncVaultAdapter] backing the coordinator with the live * [VaultRepository]. Exact port of the browser `sync-vault-adapter.ts`: * the coordinator only ever moves opaque base64 envelopes; plaintext crosses * this boundary and nowhere else. * * Envelope cipher: AES-GCM under a PER-ENTRY key * HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\u0000" + "sync-entry-v1") * with a random 12-byte nonce and aad = "peach-sync-entry-v1". Binding the key * to the entry id means a ciphertext minted for one slot cannot be replayed * into another — [applyEntries] rejects any envelope whose decrypted id does * not match the map key it arrived under. Both paired devices hold the same * syncMasterKey (same vault root key), so keys match without negotiation; the * Noise KK reconnect session is a second, independent encryption layer on top. */ class VaultSyncAdapter( private val repo: VaultRepository, private val ourDeviceId: String, private val pairedDevices: PairedDeviceRegistry? = null, private val pendingVouched: PendingVouchedRegistry? = null, private val syncAlerts: SyncAlertRecorder? = null, private var remoteDeviceName: String = "paired device", override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null, ) : SyncVaultAdapter { override suspend fun computeVaultId(): String = repo.syncVaultId() ?: throw VaultLockedException() override suspend fun getOurDeviceId(): String = ourDeviceId override suspend fun getLocalVersion(): Long = repo.getVaultVersion() override suspend fun buildEntryManifest(): Map = repo.getEntries().associate { it.id to it.modifiedAt } override suspend fun buildTombstoneManifest(): Map = repo.getTombstones().associate { it.id to it.deletedAt } override suspend fun getEncryptedEntries(entryIds: List): Map { val master = requireMasterKey() try { val out = LinkedHashMap() for (id in entryIds) { val entry = repo.getEntry(id) ?: continue // M-05: deriveEntryKey materializes a per-entry 32-byte key that // never leaves this loop; zero it after encrypt so the GC copy // doesn't outlive the ciphertext. var entryKey: ByteArray? = null try { entryKey = deriveEntryKey(master, id) out[id] = encryptEntry(entryKey, entry) } finally { entryKey?.let(VaultCrypto::zero) } } return out } finally { master.fill(0) } } override suspend fun applyEntries(entries: Map) { val master = requireMasterKey() val decrypted = try { entries.mapNotNull { (id, envelope) -> // M-05: per-entry derived key is used exactly once below; zero it // after the decrypt so it doesn't sit on the heap alongside the // plaintext VaultEntry we're about to commit. var entryKey: ByteArray? = null val entry = try { entryKey = deriveEntryKey(master, id) decryptEntryOrNull(entryKey, envelope) } finally { entryKey?.let(VaultCrypto::zero) } // The map key is the authority on id — refuse a swapped envelope // so a ciphertext can't poison a different entry slot. if (entry != null && entry.id == id) entry else null } } finally { master.fill(0) } if (decrypted.isNotEmpty()) { repo.applyMergedRemoteEntries(decrypted, emptyList()) } } override suspend fun applyTombstones(tombstones: Map) { val cleaned = tombstones .filterValues { it > 0L } .map { (id, ts) -> Tombstone(id, ts) } if (cleaned.isNotEmpty()) { repo.applyMergedRemoteEntries(emptyList(), cleaned) } } override fun bindAuthenticatedPeer(peer: PairedDevice) { remoteDeviceName = peer.deviceName?.takeIf { it.isNotBlank() } ?: peer.deviceId.takeIf { it.isNotBlank() } ?: "paired device" } override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? { val registry = pairedDevices ?: return null val master = requireMasterKey() return try { val signing = deriveSyncSigningKeypair(master, ourDeviceId) val revoked = registry.listRevocations() val revokedIds = revoked.map { it.deviceId }.toSet() val devices = registry.getAll() .filterNot { revokedIds.contains(it.deviceId) } .mapNotNull { it.toManifestEntry() } buildSignedDeviceManifest( ourDeviceId, devices, signing.privateKey, revoked = revoked, ) } finally { master.fill(0) } } override suspend fun applyIncomingDeviceManifest( manifest: SignedDeviceManifest, authenticatedRemoteStaticPublicKey: ByteArray, ) { val registry = pairedDevices ?: return val pending = pendingVouched ?: return if (authenticatedRemoteStaticPublicKey.size != 32) return val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts): // the issuer must already be admitted locally, but it need not be the // device that performed the original pairing. Direct and vouched peers // are both Noise-authenticated same-vault members, so either may carry // the signed pool onward. Unknown and legacy-unclassified rows remain // fail-closed. if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return if (body.issuerDeviceId != issuer.deviceId) return val master = requireMasterKey() val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId) try { // Revocations are absorbed BEFORE any vouching, so a manifest that both // revokes and re-vouches the same device cannot race itself. A peer may // not revoke US — that would let one compromised device sever the whole // mesh — so our own id is filtered out first. val newlyRevoked = registry.mergeRevocations( body.revoked.filterNot { it.deviceId == ourDeviceId }, ) for (deviceId in newlyRevoked) { registry.remove(deviceId) pending.remove(deviceId) syncAlerts?.record( listOf( SyncAlert( type = SyncAlertType.DEVICE_REVOKED, deviceName = issuer.deviceName ?: "paired device", entryTitle = deviceId, timestamp = System.currentTimeMillis(), ), ), ) } for (entry in body.devices) { if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue val existing = registry.get(entry.deviceId) val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry) if (existing == null) { if (meshRoute == null) continue registry.upsert( PairedDevice( deviceId = entry.deviceId, deviceName = entry.deviceName, deviceType = entry.deviceType, longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(), signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(), trustSource = "vouched", wsPort = entry.wsPort, rendezvousSecretHex = meshRoute.first, relayUrl = meshRoute.second, pairedAt = entry.pairedAt, ), ) pending.remove(entry.deviceId) } else { deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert -> syncAlerts?.record(listOf(alert)) } registry.upsert( existing.copy( deviceName = existing.deviceName?.takeIf { it.isNotBlank() } ?: entry.deviceName, deviceType = entry.deviceType, longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(), signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(), wsPort = existing.wsPort ?: entry.wsPort, rendezvousSecretHex = if (existing.trustSource == "vouched" && meshRoute != null) { meshRoute.first } else { existing.rendezvousSecretHex }, relayUrl = if (existing.trustSource == "vouched" && meshRoute != null) { meshRoute.second } else { existing.relayUrl }, pairedAt = existing.pairedAt, trustSource = existing.trustSource ?: "direct", ), ) } } } finally { master.fill(0) localKeys.secretKey.fill(0) } } private fun deriveManifestMeshRoute( masterKey: ByteArray, localPublicKey: ByteArray, entry: ManifestDeviceEntry, ): Pair? = runCatching { val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null) val secret = Rendezvous.deriveMeshSecret( masterKey, localPublicKey, hexToPubkey(entry.longTermPublicKeyHex), ) try { Rendezvous.bytesToHex(secret) to relayUrl } finally { secret.fill(0) } }.getOrNull() private fun requireMasterKey(): ByteArray = repo.syncMasterKey() ?: throw VaultLockedException() private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray { // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so // the (entryId, suffix) boundary is unambiguous regardless of future id // formats. Coordinated wire change — peach-browser deriveEntryKey produces // the identical info bytes; the two MUST stay byte-for-byte in lockstep. val info = (entryId + "\u0000" + ENTRY_KEY_INFO_SUFFIX).toByteArray(Charsets.UTF_8) val gen = HKDFBytesGenerator(SHA256Digest()) gen.init(HKDFParameters(masterKey, ByteArray(0), info)) return ByteArray(32).also { gen.generateBytes(it, 0, it.size) } } private fun encryptEntry(key: ByteArray, entry: VaultEntry): String { // M-05: plaintext is the entry JSON (incl. password field). Zero in // finally so the serialized copy doesn't outlive the ciphertext. var plaintext: ByteArray? = null return try { plaintext = entryJson.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8) val nonce = ByteArray(12).also(rng::nextBytes) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) cipher.updateAAD(ENTRY_AAD) val ct = cipher.doFinal(plaintext) b64(nonce + ct) } finally { plaintext?.let(VaultCrypto::zero) } } private fun decryptEntryOrNull(key: ByteArray, envelopeB64: String): VaultEntry? { var pt: ByteArray? = null return try { val bytes = unb64(envelopeB64) if (bytes.size < 12 + 16) null else { val nonce = bytes.copyOfRange(0, 12) val ct = bytes.copyOfRange(12, bytes.size) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) cipher.updateAAD(ENTRY_AAD) pt = cipher.doFinal(ct) entryJson.decodeFromString(VaultEntry.serializer(), String(pt, Charsets.UTF_8)) } } catch (_: Exception) { null } finally { // M-05: pt holds the serialized entry including password until the // decoded VaultEntry above is the only remaining reference. Zero it // so the raw bytes don't outlive the structure they were parsed into. pt?.let(VaultCrypto::zero) } } private companion object { const val ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1" val ENTRY_AAD = "peach-sync-entry-v1".toByteArray(Charsets.UTF_8) val rng = SecureRandom() val entryJson = Json { encodeDefaults = false; ignoreUnknownKeys = true } fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b) fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s) } } class VaultLockedException : Exception("vault_locked")