package com.peachpasswords.sync import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.bouncycastle.crypto.params.X25519PrivateKeyParameters import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton @Serializable internal data class LockedSyncCachedEntry(val modifiedAt: Long, val ciphertext: String) @Serializable internal data class LockedSyncSpool( val version: Int = 1, val vaultId: String, val deviceId: String, val deviceName: String, val localVersion: Long, val longTermPublicKeyHex: String, val longTermPrivateKeyHex: String? = null, val signingPublicKeyHex: String, val entries: Map, val tombstones: Map, val inboxEntries: Map = emptyMap(), val inboxTombstones: Map = emptyMap(), ) { init { require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" } require(inboxEntries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" } require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" } require(inboxTombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" } } } /** A Keystore-encrypted cache of opaque entry envelopes and transport identity. */ @Singleton class LockedSyncSpoolStore @Inject constructor( @ApplicationContext private val context: Context, ) { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val prefs = EncryptedSharedPreferences.create( context, PREFS_FILE, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) @Synchronized internal fun refresh(repo: VaultRepository) { val snapshot = repo.syncMasterKeySnapshot() ?: return val root = snapshot.key val credentials = loadOrCreateLocalIdentity(context) val deviceId = credentials.deviceId val keys = localSyncKeypair(credentials) try { val prior = read() val entries = repo.getEntries().associate { entry -> entry.id to LockedSyncCachedEntry( entry.modifiedAt, VaultSyncAdapter.encryptEntryWithMasterKey(root, entry), ) } if (!repo.isSessionEpochCurrent(snapshot.epoch)) return val signing = localSigningKeypair(credentials) write( LockedSyncSpool( vaultId = computeVaultId(root), deviceId = deviceId, deviceName = getDeviceName(context), localVersion = repo.getVaultVersion(), longTermPublicKeyHex = pubkeyToHex(keys.publicKey), longTermPrivateKeyHex = pubkeyToHex(keys.secretKey), signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw), entries = entries, tombstones = repo.getTombstones().associate { it.id to it.deletedAt }, inboxEntries = prior?.inboxEntries ?: emptyMap(), inboxTombstones = prior?.inboxTombstones ?: emptyMap(), ), ) } finally { VaultCrypto.zero(root) VaultCrypto.zero(keys.secretKey) } } internal fun identity(): Pair? { val state = read() ?: return null return lockedSyncIdentity(state, RelaySettingsStore.readRelayUrl(context)) } internal fun adapter(): SyncVaultAdapter? = read()?.let { state -> LockedSyncVaultAdapter( vaultId = state.vaultId, readSnapshot = { snapshot(state.vaultId) }, stageEntriesInto = { entries, manifest -> stageEntries(state.vaultId, entries, manifest) }, stageTombstonesInto = { tombstones -> stageTombstones(state.vaultId, tombstones) }, ) } internal suspend fun drainInto(repo: VaultRepository) { val staged = read() ?: return if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) { val live = VaultSyncAdapter(repo, staged.deviceId) live.applyEntries( staged.inboxEntries.mapValues { it.value.ciphertext }, staged.inboxEntries.mapValues { it.value.modifiedAt }, ) live.applyTombstones(staged.inboxTombstones) synchronized(this) { val current = read() ?: return@synchronized write( current.copy( inboxEntries = current.inboxEntries.filter { (id, value) -> staged.inboxEntries[id]?.ciphertext != value.ciphertext }, inboxTombstones = current.inboxTombstones.filter { (id, value) -> staged.inboxTombstones[id] != value }, ), ) } } refresh(repo) } @Synchronized internal fun stageEntries(vaultId: String, entries: Map, manifest: Map) { val current = read()?.takeIf { it.vaultId == vaultId } ?: return require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" } val now = System.currentTimeMillis() val inbox = current.inboxEntries.toMutableMap() entries.forEach { (id, ciphertext) -> val claimed = manifest[id] ?: return@forEach if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now) if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) { inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext) } } write(current.copy(inboxEntries = inbox)) } @Synchronized internal fun stageTombstones(vaultId: String, tombstones: Map) { val current = read()?.takeIf { it.vaultId == vaultId } ?: return require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" } val now = System.currentTimeMillis() val inbox = current.inboxTombstones.toMutableMap() tombstones.forEach { (id, timestamp) -> if (timestamp <= 0L) return@forEach val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now) if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt } write(current.copy(inboxTombstones = inbox)) } internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId } fun clear() { prefs.edit().remove(KEY_STATE).commit() } private fun read(): LockedSyncSpool? { val raw = prefs.getString(KEY_STATE, null) ?: return null return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) } .getOrNull() ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) } } private fun write(state: LockedSyncSpool) { check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) { "locked_sync_spool_write_failed" } } private companion object { const val PREFS_FILE = "peach_locked_sync_spool_v1" const val KEY_STATE = "state" val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } } } internal fun lockedSyncIdentity( state: LockedSyncSpool, relayUrl: String, ): Pair? { val privateHex = state.longTermPrivateKeyHex ?: return null val secret = runCatching { hexToBytes(privateHex) }.getOrNull() ?: return null if (secret.size != 32) { VaultCrypto.zero(secret) return null } return try { val public = X25519PrivateKeyParameters(secret, 0).generatePublicKey().encoded val expectedPublic = hexToPubkey(state.longTermPublicKeyHex) if (!MessageDigest.isEqual(public, expectedPublic)) { VaultCrypto.zero(public) VaultCrypto.zero(secret) null } else { SyncIdentityKeypair(public, secret) to LocalDeviceIdentity( deviceId = state.deviceId, deviceName = state.deviceName, deviceType = DeviceType.ANDROID, longTermPublicKeyHex = state.longTermPublicKeyHex, signingPublicKeyHex = state.signingPublicKeyHex, relayUrl = relayUrl, ) } } catch (_: RuntimeException) { VaultCrypto.zero(secret) null } } /** * The sync adapter a fully locked session may use. * * It can do exactly two things: report the cached snapshot, and STAGE incoming * ciphertext for the next unlock to drain into the vault. It deliberately * cannot author or accept a device manifest. * * That boundary is load-bearing for PB-26-112. The Keystore-wrapped key this * session runs on is the X25519 TRANSPORT key only — the Ed25519 signing key is * never written to the spool — so a locked device holds no key that can * authorise a manifest or a revocation. Admission, vouching, revocation, and * identity rotation all require an unlocked vault. * * The refusals below are explicit instead of inherited from the interface's * silent defaults, so a future wiring change cannot quietly turn a locked * session into a trust-mutating one. */ internal class LockedSyncVaultAdapter( private val vaultId: String, private val readSnapshot: () -> LockedSyncSpool?, private val stageEntriesInto: (Map, Map) -> Unit, private val stageTombstonesInto: (Map) -> Unit, ) : SyncVaultAdapter { private fun state() = readSnapshot() ?: throw VaultLockedException() override suspend fun computeVaultId() = vaultId override suspend fun getOurDeviceId() = state().deviceId override suspend fun getLocalVersion() = state().localVersion override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt } override suspend fun buildTombstoneManifest() = state().tombstones override suspend fun getEncryptedEntries(entryIds: List): Map { val entries = state().entries return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap() } // Staging only — the vault is not touched until the next unlock drains it. override suspend fun applyEntries(entries: Map, remoteManifest: Map) = stageEntriesInto(entries, remoteManifest) override suspend fun applyTombstones(tombstones: Map) = stageTombstonesInto(tombstones) override val supportsDeviceManifest: Boolean = false override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest = throw VaultLockedException() override suspend fun applyIncomingDeviceManifest( manifest: SignedDeviceManifest, authenticatedRemoteStaticPublicKey: ByteArray, ): Unit = throw VaultLockedException() } internal object LockedSyncSpoolHooks { @Volatile var refresh: (VaultRepository) -> Unit = {} }