Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/commands/qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The canonical order (from `qa/SKILL.md` "Run all tests") is:
→ 16 → 17 → 20 → 27 → 28 → 19 → 15 → 34 → 18
```

plus secondary tests `14, 22, 23, 23b, 24, 25, 26, 28b, 29, 30, 31, 32, 33, 43, 44b` slotted where they don't conflict with destructive steps (09, 18). Test 43 (long-message rendering) explodes the shared conversation in teardown, so slot it late in the sequence, after other tests that reuse `shared_conversation_id`. Test 44b briefly shuts down the primary simulator to clone it (like 03/04), so don't overlap it with tests running on the primary; it otherwise runs entirely on its own disposable clone.
plus secondary tests `14, 22, 23, 23b, 24, 25, 26, 28b, 29, 30, 31, 32, 33, 43, 44b, 45` slotted where they don't conflict with destructive steps (09, 18). Test 43 (long-message rendering) explodes the shared conversation in teardown, so slot it late in the sequence, after other tests that reuse `shared_conversation_id`. Tests 44b and 45 briefly shut down the primary simulator to clone it (like 03/04), so don't overlap them with tests running on the primary; they otherwise run entirely on their own disposable clones.

**What can actually run in parallel:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ public protocol KeychainIdentityStoreProtocol: Actor {
/// flow after the user loses their device.
func loadSyncedBackups() throws -> [KeychainIdentityBackup]

/// Reads this device's installation marker (see `InstallationMarker`).
/// Device-local like the primary slot, so it survives app deletion
/// and never syncs to other devices.
func loadInstallationMarker() throws -> InstallationMarker?

/// Writes (or overwrites) this device's installation marker.
func saveInstallationMarker(_ marker: InstallationMarker) throws

/// Reads this device's consent backup (see `ConsentBackup`).
/// Device-local like the primary slot, so it survives app deletion
/// and never syncs to other devices.
func loadConsentBackup() throws -> ConsentBackup?

/// Writes (or overwrites) this device's consent backup.
func saveConsentBackup(_ backup: ConsentBackup) throws

/// Mirrors the primary identity into the synced backup slot when its
/// backup is missing. Installs that registered before the backup
/// slot existed only ever wrote the primary slot; calling this on
Expand Down Expand Up @@ -295,6 +311,14 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol {
/// Fixed account key for the identity in the primary slot.
public static let identityAccount: String = "convos-identity"

/// Fixed account key for this device's installation marker, stored
/// device-local in `defaultService` alongside the identity.
public static let installationMarkerAccount: String = "convos-installation-marker"

/// Fixed account key for this device's consent backup, stored
/// device-local in `defaultService` alongside the identity.
public static let consentBackupAccount: String = "convos-consent-backup"

// MARK: - Initialization

/// - Parameters:
Expand Down Expand Up @@ -372,6 +396,34 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol {
}
}

public func loadInstallationMarker() throws -> InstallationMarker? {
do {
let data = try Self.loadKeychainData(with: installationMarkerQuery.toReadDictionary())
return try JSONDecoder().decode(InstallationMarker.self, from: data)
} catch KeychainIdentityStoreError.identityNotFound {
return nil
}
}

public func saveInstallationMarker(_ marker: InstallationMarker) throws {
let data = try JSONEncoder().encode(marker)
try saveData(data, with: installationMarkerQuery)
}

public func loadConsentBackup() throws -> ConsentBackup? {
do {
let data = try Self.loadKeychainData(with: consentBackupQuery.toReadDictionary())
return try JSONDecoder().decode(ConsentBackup.self, from: data)
} catch KeychainIdentityStoreError.identityNotFound {
return nil
}
}

public func saveConsentBackup(_ backup: ConsentBackup) throws {
let data = try JSONEncoder().encode(backup)
try saveData(data, with: consentBackupQuery)
}

public func backfillSyncedBackupIfNeeded() {
guard syncedBackupEnabled else { return }
do {
Expand Down Expand Up @@ -418,6 +470,22 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol {
)
}

private nonisolated var installationMarkerQuery: KeychainQuery {
KeychainQuery(
account: Self.installationMarkerAccount,
service: keychainService,
accessGroup: keychainAccessGroup
)
}

private nonisolated var consentBackupQuery: KeychainQuery {
KeychainQuery(
account: Self.consentBackupAccount,
service: keychainService,
accessGroup: keychainAccessGroup
)
}

private nonisolated func syncedBackupQuery(inboxId: String) -> KeychainQuery {
KeychainQuery(
account: inboxId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ actor MockKeychainIdentityStore: KeychainIdentityStoreProtocol {
/// inboxId — one entry per backed-up identity, like the real store's
/// per-identity accounts.
private let backupState: OSAllocatedUnfairLock<[String: KeychainIdentityBackup]> = .init(initialState: [:])
/// In-memory stand-in for the device-local installation marker slot.
private let markerState: OSAllocatedUnfairLock<InstallationMarker?> = .init(initialState: nil)
/// In-memory stand-in for the device-local consent backup slot.
private let consentBackupState: OSAllocatedUnfairLock<ConsentBackup?> = .init(initialState: nil)
/// Optional error injection for the load path. Tests simulating a
/// transient keychain daemon failure set this to a non-nil `Error`;
/// `loadSync` and `load` both throw it until the test clears it.
Expand Down Expand Up @@ -76,6 +80,22 @@ actor MockKeychainIdentityStore: KeychainIdentityStoreProtocol {
}
}

func loadInstallationMarker() throws -> InstallationMarker? {
markerState.withLock { $0 }
}

func saveInstallationMarker(_ marker: InstallationMarker) throws {
markerState.withLock { $0 = marker }
}

func loadConsentBackup() throws -> ConsentBackup? {
consentBackupState.withLock { $0 }
}

func saveConsentBackup(_ backup: ConsentBackup) throws {
consentBackupState.withLock { $0 = backup }
}

/// Test-only — inject an error for the next `loadSync`/`load` calls.
/// Pass `nil` to clear.
nonisolated func _setLoadError(_ error: (any Error)?) {
Expand Down
85 changes: 85 additions & 0 deletions ConvosCore/Sources/ConvosCore/Device/ConsentBackup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Foundation
import GRDB

/// The set of conversations this device's user has allowed, mirrored into
/// the device-local keychain so it survives app deletion (the consent
/// records themselves live in the app container's database and do not).
///
/// Consent cannot be recovered from the network after a reinstall: XMTP
/// propagates consent records as messages inside the device-sync MLS
/// group, which a new installation cannot decrypt (they predate its
/// membership), and the only installation that could re-send them died
/// with the deleted app container. Without this backup, conversations
/// recovered by re-welcome land as consent=unknown and never surface in
/// the UI. Restoring from the backup is spam-safe: it re-allows exactly
/// the conversations this device's user had allowed, nothing else.
public struct ConsentBackup: Codable, Sendable, Equatable {
public let inboxId: String
public let allowedConversationIds: [String]

public init(inboxId: String, allowedConversationIds: [String]) {
self.inboxId = inboxId
self.allowedConversationIds = allowedConversationIds
}
}

extension ConsentBackup {
/// The ids worth backing up: every non-draft conversation the user has
/// allowed. Drafts are client-local placeholders that don't exist on
/// the network, so restoring consent for them is meaningless. Sorted
/// so snapshots compare stably. Static and pure for unit testing.
static func allowedConversationIds(db: Database) throws -> [String] {
let ids = try DBConversation
.filter(DBConversation.Columns.consent == Consent.allowed)
.select(DBConversation.Columns.id, as: String.self)
.fetchAll(db)
return ids
.filter { !DBConversation.isDraft(id: $0) }
.sorted()
}
}

/// Restore helpers for backed-up consent after a reinstall resume,
/// invoked from the stale-installation reconcile (the reliable reinstall
/// signal: same inbox, different installation id). Split into Sendable-
/// only helpers because the XMTP client is not Sendable and must stay in
/// the caller's actor region - the caller sequences: load ids here, apply
/// them via `client.setConsentStates`, then flip stored rows here. The
/// keychain backup is never consumed by a restore attempt.
enum ConsentBackupRestorer {
/// The backed-up allowed conversation ids applicable to `inboxId`,
/// empty when there is no backup, the backup belongs to a replaced
/// identity, or nothing was allowed.
static func idsToRestore(
identityStore: any KeychainIdentityStoreProtocol,
inboxId: String
) async throws -> [String] {
guard let backup = try await identityStore.loadConsentBackup(),
backup.inboxId == inboxId else {
return []
}
return backup.allowedConversationIds
}

/// Flips already-stored rows for the restored ids from `.unknown` to
/// `.allowed` so the list updates without waiting for a re-sync.
/// Rows in other states are left alone - `.denied` means the user
/// deleted the conversation and it must stay hidden. Rows that don't
/// exist yet need nothing: their welcomes land already-allowed via
/// the consent records written before this.
static func flipStoredUnknownRows(
ids: [String],
databaseWriter: any DatabaseWriter
) async throws {
guard !ids.isEmpty else { return }
try await databaseWriter.write { db in
let stored = try DBConversation
.filter(ids.contains(DBConversation.Columns.id))
.filter(DBConversation.Columns.consent == Consent.unknown)
.fetchAll(db)
for conversation in stored {
try conversation.with(consent: .allowed).save(db)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation

/// Record of which XMTP installation this device most recently ran for an
/// inbox, kept in the device-local keychain so it survives app deletion
/// (the installation keys themselves live in the app container's database
/// and do not).
///
/// The iOS keychain outliving the app is what makes reinstall churn
/// possible: a reinstall resumes the identity from the keychain but must
/// mint a new installation, permanently orphaning the previous one - its
/// keys died with the deleted database, yet it stays registered on the
/// inbox and shows up as a ghost "Device <hex>" row in the devices list.
/// Comparing this marker against the live installation on launch is the
/// only way to prove an installation was this device's own dead one (and
/// not a legitimately paired device), which makes it safe to auto-revoke.
public struct InstallationMarker: Codable, Sendable, Equatable {
public let inboxId: String
public let installationId: String
/// Installations this device orphaned but has not yet successfully
/// revoked - carried across launches so an offline launch retries
/// later instead of leaking the ghost forever.
public let staleInstallationIds: [String]

public init(inboxId: String, installationId: String, staleInstallationIds: [String]) {
self.inboxId = inboxId
self.installationId = installationId
self.staleInstallationIds = staleInstallationIds
}
}

/// Pure planning logic for reconciling the installation marker against
/// the live session, split from the keychain and network plumbing so it
/// can be unit tested.
enum StaleInstallationReconciler {
struct Plan: Equatable {
/// Marker to persist for this launch (records any newly-orphaned
/// installation before the revoke attempt, so a crash or network
/// failure retries on the next launch).
let marker: InstallationMarker
/// This device's own dead installations, candidates for
/// revocation once filtered against the live installation list.
let candidateStaleIds: [String]
}

/// A nil or foreign-inbox marker carries no history worth acting on:
/// first run of a build with marker support, or the identity was
/// replaced wholesale (pairing adoption, delete-all) - in both cases
/// any previous installation belongs to an abandoned inbox that the
/// devices list never shows, so revoking it is pointless. Start
/// fresh. Otherwise every installation id this marker saw that isn't
/// the current one is provably this device's own dead installation.
static func plan(
marker: InstallationMarker?,
inboxId: String,
installationId: String
) -> Plan {
guard let marker, marker.inboxId == inboxId else {
return Plan(
marker: InstallationMarker(inboxId: inboxId, installationId: installationId, staleInstallationIds: []),
candidateStaleIds: []
)
}
var stale = marker.staleInstallationIds
if marker.installationId != installationId {
stale.append(marker.installationId)
}
var seen = Set<String>()
let filtered = stale.filter { $0 != installationId && seen.insert($0).inserted }
return Plan(
marker: InstallationMarker(inboxId: inboxId, installationId: installationId, staleInstallationIds: filtered),
candidateStaleIds: filtered
)
}
}
Loading
Loading