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, private val localCredentials: LocalIdentityCredentials? = 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 { val snapshot = requireMasterKeySnapshot() VaultCrypto.zero(snapshot.key) val manifest = repo.getEntries().associate { it.id to it.modifiedAt } if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException() return manifest } override suspend fun buildTombstoneManifest(): Map { val snapshot = requireMasterKeySnapshot() VaultCrypto.zero(snapshot.key) val manifest = repo.getTombstones().associate { it.id to it.deletedAt } if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException() return manifest } override suspend fun getEncryptedEntries(entryIds: List): Map { val snapshot = requireMasterKeySnapshot() val master = snapshot.key 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) } } if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException() return out } finally { VaultCrypto.zero(master) } } override suspend fun applyEntries( entries: Map, remoteManifest: Map, ) { val snapshot = requireMasterKeySnapshot() val master = snapshot.key 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 { VaultCrypto.zero(master) } if (decrypted.isNotEmpty()) { repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch) } } override suspend fun applyTombstones(tombstones: Map) { val cleaned = tombstones .filterValues { it > 0L } .map { (id, ts) -> Tombstone(id, ts) } if (cleaned.isNotEmpty()) { val snapshot = requireMasterKeySnapshot() VaultCrypto.zero(snapshot.key) repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch) } } 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 signing = localCredentials?.let(::localSigningKeypair) ?: throw IllegalStateException("identity_migration_required") val revoked = registry.listRevocations() val revokedIds = revoked.map { it.deviceId }.toSet() val devices = registry.getAll() .filterNot { revokedIds.contains(it.deviceId) } .mapNotNull { it.toManifestEntry() } .map { it.copy(vouchDepth = if (it.deviceId == ourDeviceId) 0 else it.vouchDepth + 1) } return buildSignedDeviceManifest(ourDeviceId, devices, signing.privateKey, revoked = revoked) } 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 // Direct and bounded-vouch issuers may propagate the signed device pool. if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return if (issuer.vouchDepth >= MAX_VOUCH_DEPTH) return val currentSigningKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return val knownIssuerKeys = registry.issuerSigningKeys(issuer.deviceId) + currentSigningKey // Historical keys and the persisted current key remain valid; mutable // device metadata never rewrites the issuer's key history. // // A revocation may name any device as its issuer — including one whose // active row is gone — so resolve that issuer's key from the registry // and its immutable history rather than from the record itself. val resolveIssuerSigningKeys: (String) -> Set = { deviceId -> val history = registry.issuerSigningKeys(deviceId) val current = registry.get(deviceId)?.signingPublicKeyHex?.lowercase() if (current != null && current.length == 64) history + current else history } val verified = knownIssuerKeys.firstNotNullOfOrNull { candidate -> runCatching { verifyDeviceManifest(manifest, hexToPubkey(candidate), resolveIssuerSigningKeys) ?.let { candidate to it } }.getOrNull() } ?: return val (signingKey, body) = verified if (body.issuerDeviceId != issuer.deviceId) return val snapshot = requireMasterKeySnapshot() val master = snapshot.key val localKeys = localCredentials?.let(::localSyncKeypair) ?: throw IllegalStateException("identity_migration_required") try { if (!repo.isSessionEpochCurrent(snapshot.epoch)) return // Stage the complete result against one read-only registry snapshot. // No registry mutation is allowed until the epoch-fenced batch below; // a per-item epoch check would still leave a prefix behind if lock() // wins between two items. val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId } val currentRevocations = registry.listRevocations() val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet() val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) } val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet() val revokedKeys = (currentRevocations + incomingRevocations) .map { it.longTermPublicKeyHex.lowercase() } .filterNot { it == NO_KEY_SENTINEL } .toSet() fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean { return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys } val stagedUpserts = LinkedHashMap() val stagedAlerts = ArrayList() newlyRevoked.forEach { revocation -> stagedAlerts += SyncAlert( type = SyncAlertType.DEVICE_REVOKED, deviceName = issuer.deviceName ?: "paired device", entryTitle = revocation.deviceId, timestamp = System.currentTimeMillis(), ) } for (entry in body.devices) { if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) { continue } if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) { continue } if (entry.vouchDepth > issuer.vouchDepth + 1) continue val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId) val meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry) if (existing == null) { if (meshRoute == null) continue stagedUpserts[entry.deviceId] = PairedDevice( deviceId = entry.deviceId, deviceName = entry.deviceName, deviceType = entry.deviceType, longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(), signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(), trustSource = "vouched", vouchDepth = issuer.vouchDepth + 1, wsPort = entry.wsPort, rendezvousSecretHex = meshRoute.first, relayUrl = meshRoute.second, pairedAt = entry.pairedAt, ) } else { val incomingLongTermKey = entry.longTermPublicKeyHex.lowercase() val incomingSigningKey = entry.signingPublicKeyHex.lowercase() // Direct pairing is SAS-pinned. A manifest issuer is never // authorized to rotate either identity key, even if the // entry is correctly vouched and the manifest verifies. // Drop the entry wholesale rather than retaining mutable // metadata alongside an untrusted identity transition. val longTermChanged = existing.longTermPublicKeyHex?.let { !CryptoCompare.hex(it, incomingLongTermKey) } == true val signingChanged = existing.signingPublicKeyHex?.let { !CryptoCompare.hex(it, incomingSigningKey) } == true if (longTermChanged || signingChanged) { deviceKeyRotationAlert(existing, entry, System.currentTimeMillis()) ?.let(stagedAlerts::add) continue } deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add) stagedUpserts[entry.deviceId] = existing.copy( deviceName = existing.deviceName?.takeIf { it.isNotBlank() } ?: entry.deviceName, deviceType = entry.deviceType, // Every admitted identity is immutable. Only an explicit, // physically confirmed re-pair may replace these keys. longTermPublicKeyHex = existing.longTermPublicKeyHex ?: incomingLongTermKey, signingPublicKeyHex = existing.signingPublicKeyHex ?: incomingSigningKey, vouchDepth = entry.vouchDepth, 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, ) } } val batch = DeviceManifestRegistryBatch( incomingRevocations = incomingRevocations, removals = newlyRevoked.map { it.deviceId }.toSet(), upserts = stagedUpserts.values.toList(), ) // Remove any matching pending introductions synchronously BEFORE // installing trust. If the registry commit fails, restoring this // exact pending subset is safe; if restoration also fails, the // fail-closed outcome is a lost introduction, never stale trust. val pendingIds = batch.removals + stagedUpserts.keys val priorPending = pending.getAll().filter { it.deviceId in pendingIds } val committed = repo.withSessionEpochCommit(snapshot.epoch) { if (!registry.appendIssuerSigningKey(issuer.deviceId, signingKey)) return@withSessionEpochCommit false val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false) if (!pendingRemoved) { pending.restoreBatch(priorPending) false } else { val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false) if (registryCommitted) { // Pending entries and alerts are kept inside the same // epoch fence. Alerts follow the trust commit and never // participate in its semantics. if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts) true } else { pending.restoreBatch(priorPending) false } } } if (committed != true) return } finally { VaultCrypto.zero(master) VaultCrypto.zero(localKeys.secretKey) } } private fun deriveManifestMeshRoute( localPrivateKey: ByteArray, localPublicKey: ByteArray, entry: ManifestDeviceEntry, ): Pair? = runCatching { val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null) val secret = Rendezvous.deriveMeshSecret( localPrivateKey, localPublicKey, hexToPubkey(entry.longTermPublicKeyHex), ) try { Rendezvous.bytesToHex(secret) to relayUrl } finally { VaultCrypto.zero(secret) } }.getOrNull() private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot = repo.syncMasterKeySnapshot() ?: throw VaultLockedException() internal companion object { internal 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) } } internal 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) } } internal fun encryptEntryWithMasterKey(masterKey: ByteArray, entry: VaultEntry): String { val key = deriveEntryKey(masterKey, entry.id) return try { encryptEntry(key, entry) } finally { VaultCrypto.zero(key) } } internal 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) } } 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")