LockedSyncSpoolStore.kt

11.6 KB · raw

1package com.peachpasswords.sync23import android.content.Context4import androidx.security.crypto.EncryptedSharedPreferences5import androidx.security.crypto.MasterKey6import com.peachpasswords.vault.VaultCrypto7import com.peachpasswords.vault.VaultRepository8import dagger.hilt.android.qualifiers.ApplicationContext9import kotlinx.serialization.Serializable10import kotlinx.serialization.json.Json11import org.bouncycastle.crypto.params.X25519PrivateKeyParameters12import java.security.MessageDigest13import javax.inject.Inject14import javax.inject.Singleton1516@Serializable17internal data class LockedSyncCachedEntry(val modifiedAt: Long, val ciphertext: String)1819@Serializable20internal data class LockedSyncSpool(21    val version: Int = 1,22    val vaultId: String,23    val deviceId: String,24    val deviceName: String,25    val localVersion: Long,26    val longTermPublicKeyHex: String,27    val longTermPrivateKeyHex: String? = null,28    val signingPublicKeyHex: String,29    val entries: Map<String, LockedSyncCachedEntry>,30    val tombstones: Map<String, Long>,31    val inboxEntries: Map<String, LockedSyncCachedEntry> = emptyMap(),32    val inboxTombstones: Map<String, Long> = emptyMap(),33) {34    init {35        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }36        require(inboxEntries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }37        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }38        require(inboxTombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }39    }40}4142/** A Keystore-encrypted cache of opaque entry envelopes and transport identity. */43@Singleton44class LockedSyncSpoolStore @Inject constructor(45    @ApplicationContext private val context: Context,46) {47    private val masterKey = MasterKey.Builder(context)48        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)49        .build()50    private val prefs = EncryptedSharedPreferences.create(51        context,52        PREFS_FILE,53        masterKey,54        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,55        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,56    )5758    @Synchronized59    internal fun refresh(repo: VaultRepository) {60        val snapshot = repo.syncMasterKeySnapshot() ?: return61        val root = snapshot.key62        val credentials = loadOrCreateLocalIdentity(context)63        val deviceId = credentials.deviceId64        val keys = localSyncKeypair(credentials)65        try {66            val prior = read()67            val entries = repo.getEntries().associate { entry ->68                entry.id to LockedSyncCachedEntry(69                    entry.modifiedAt,70                    VaultSyncAdapter.encryptEntryWithMasterKey(root, entry),71                )72            }73            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return74            val signing = localSigningKeypair(credentials)75            write(76                LockedSyncSpool(77                    vaultId = computeVaultId(root),78                    deviceId = deviceId,79                    deviceName = getDeviceName(context),80                    localVersion = repo.getVaultVersion(),81                    longTermPublicKeyHex = pubkeyToHex(keys.publicKey),82                    longTermPrivateKeyHex = pubkeyToHex(keys.secretKey),83                    signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),84                    entries = entries,85                    tombstones = repo.getTombstones().associate { it.id to it.deletedAt },86                    inboxEntries = prior?.inboxEntries ?: emptyMap(),87                    inboxTombstones = prior?.inboxTombstones ?: emptyMap(),88                ),89            )90        } finally {91            VaultCrypto.zero(root)92            VaultCrypto.zero(keys.secretKey)93        }94    }9596    internal fun identity(): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {97        val state = read() ?: return null98        return lockedSyncIdentity(state, RelaySettingsStore.readRelayUrl(context))99    }100101    internal fun adapter(): SyncVaultAdapter? = read()?.let { state ->102        LockedSyncVaultAdapter(103            vaultId = state.vaultId,104            readSnapshot = { snapshot(state.vaultId) },105            stageEntriesInto = { entries, manifest -> stageEntries(state.vaultId, entries, manifest) },106            stageTombstonesInto = { tombstones -> stageTombstones(state.vaultId, tombstones) },107        )108    }109110    internal suspend fun drainInto(repo: VaultRepository) {111        val staged = read() ?: return112        if (staged.inboxEntries.isNotEmpty() || staged.inboxTombstones.isNotEmpty()) {113            val live = VaultSyncAdapter(repo, staged.deviceId)114            live.applyEntries(115                staged.inboxEntries.mapValues { it.value.ciphertext },116                staged.inboxEntries.mapValues { it.value.modifiedAt },117            )118            live.applyTombstones(staged.inboxTombstones)119            synchronized(this) {120                val current = read() ?: return@synchronized121                write(122                    current.copy(123                        inboxEntries = current.inboxEntries.filter { (id, value) ->124                            staged.inboxEntries[id]?.ciphertext != value.ciphertext125                        },126                        inboxTombstones = current.inboxTombstones.filter { (id, value) ->127                            staged.inboxTombstones[id] != value128                        },129                    ),130                )131            }132        }133        refresh(repo)134    }135136    @Synchronized137    internal fun stageEntries(vaultId: String, entries: Map<String, String>, manifest: Map<String, Long>) {138        val current = read()?.takeIf { it.vaultId == vaultId } ?: return139        require(entries.size <= SyncLimits.MAX_ENTRY_COUNT) { "sync_entry_count_exceeded" }140        val now = System.currentTimeMillis()141        val inbox = current.inboxEntries.toMutableMap()142        entries.forEach { (id, ciphertext) ->143            val claimed = manifest[id] ?: return@forEach144            if (claimed <= 0L || ciphertext.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES) return@forEach145            val modifiedAt = SyncLimits.clampSyncTimestamp(claimed, now)146            if ((inbox[id]?.modifiedAt ?: 0L) < modifiedAt) {147                inbox[id] = LockedSyncCachedEntry(modifiedAt, ciphertext)148            }149        }150        write(current.copy(inboxEntries = inbox))151    }152153    @Synchronized154    internal fun stageTombstones(vaultId: String, tombstones: Map<String, Long>) {155        val current = read()?.takeIf { it.vaultId == vaultId } ?: return156        require(tombstones.size <= SyncLimits.MAX_TOMBSTONE_COUNT) { "sync_tombstone_count_exceeded" }157        val now = System.currentTimeMillis()158        val inbox = current.inboxTombstones.toMutableMap()159        tombstones.forEach { (id, timestamp) ->160            if (timestamp <= 0L) return@forEach161            val deletedAt = SyncLimits.clampSyncTimestamp(timestamp, now)162            if ((inbox[id] ?: 0L) < deletedAt) inbox[id] = deletedAt163        }164        write(current.copy(inboxTombstones = inbox))165    }166167    internal fun snapshot(vaultId: String): LockedSyncSpool? = read()?.takeIf { it.vaultId == vaultId }168    fun clear() { prefs.edit().remove(KEY_STATE).commit() }169170    private fun read(): LockedSyncSpool? {171        val raw = prefs.getString(KEY_STATE, null) ?: return null172        return runCatching { json.decodeFromString(LockedSyncSpool.serializer(), raw) }173            .getOrNull()174            ?.takeIf { it.version == 1 && it.vaultId.matches(Regex("^[0-9a-f]{64}$")) }175    }176177    private fun write(state: LockedSyncSpool) {178        check(prefs.edit().putString(KEY_STATE, json.encodeToString(LockedSyncSpool.serializer(), state)).commit()) {179            "locked_sync_spool_write_failed"180        }181    }182183    private companion object {184        const val PREFS_FILE = "peach_locked_sync_spool_v1"185        const val KEY_STATE = "state"186        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }187    }188}189190internal fun lockedSyncIdentity(191    state: LockedSyncSpool,192    relayUrl: String,193): Pair<SyncIdentityKeypair, LocalDeviceIdentity>? {194    val privateHex = state.longTermPrivateKeyHex ?: return null195    val secret = runCatching { hexToBytes(privateHex) }.getOrNull() ?: return null196    if (secret.size != 32) {197        VaultCrypto.zero(secret)198        return null199    }200    return try {201        val public = X25519PrivateKeyParameters(secret, 0).generatePublicKey().encoded202        val expectedPublic = hexToPubkey(state.longTermPublicKeyHex)203        if (!MessageDigest.isEqual(public, expectedPublic)) {204            VaultCrypto.zero(public)205            VaultCrypto.zero(secret)206            null207        } else {208            SyncIdentityKeypair(public, secret) to LocalDeviceIdentity(209                deviceId = state.deviceId,210                deviceName = state.deviceName,211                deviceType = DeviceType.ANDROID,212                longTermPublicKeyHex = state.longTermPublicKeyHex,213                signingPublicKeyHex = state.signingPublicKeyHex,214                relayUrl = relayUrl,215            )216        }217    } catch (_: RuntimeException) {218        VaultCrypto.zero(secret)219        null220    }221}222223/**224 * The sync adapter a fully locked session may use.225 *226 * It can do exactly two things: report the cached snapshot, and STAGE incoming227 * ciphertext for the next unlock to drain into the vault. It deliberately228 * cannot author or accept a device manifest.229 *230 * That boundary is load-bearing for PB-26-112. The Keystore-wrapped key this231 * session runs on is the X25519 TRANSPORT key only — the Ed25519 signing key is232 * never written to the spool — so a locked device holds no key that can233 * authorise a manifest or a revocation. Admission, vouching, revocation, and234 * identity rotation all require an unlocked vault.235 *236 * The refusals below are explicit instead of inherited from the interface's237 * silent defaults, so a future wiring change cannot quietly turn a locked238 * session into a trust-mutating one.239 */240internal class LockedSyncVaultAdapter(241    private val vaultId: String,242    private val readSnapshot: () -> LockedSyncSpool?,243    private val stageEntriesInto: (Map<String, String>, Map<String, Long>) -> Unit,244    private val stageTombstonesInto: (Map<String, Long>) -> Unit,245) : SyncVaultAdapter {246    private fun state() = readSnapshot() ?: throw VaultLockedException()247    override suspend fun computeVaultId() = vaultId248    override suspend fun getOurDeviceId() = state().deviceId249    override suspend fun getLocalVersion() = state().localVersion250    override suspend fun buildEntryManifest() = state().entries.mapValues { it.value.modifiedAt }251    override suspend fun buildTombstoneManifest() = state().tombstones252    override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {253        val entries = state().entries254        return entryIds.mapNotNull { id -> entries[id]?.ciphertext?.let { id to it } }.toMap()255    }256257    // Staging only — the vault is not touched until the next unlock drains it.258    override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) =259        stageEntriesInto(entries, remoteManifest)260261    override suspend fun applyTombstones(tombstones: Map<String, Long>) =262        stageTombstonesInto(tombstones)263264    override val supportsDeviceManifest: Boolean = false265266    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest =267        throw VaultLockedException()268269    override suspend fun applyIncomingDeviceManifest(270        manifest: SignedDeviceManifest,271        authenticatedRemoteStaticPublicKey: ByteArray,272    ): Unit = throw VaultLockedException()273}274275internal object LockedSyncSpoolHooks {276    @Volatile var refresh: (VaultRepository) -> Unit = {}277}