Harden panic keychain and media cleanup

This commit is contained in:
jack
2026-07-25 23:00:27 +02:00
parent d1ee7a7045
commit bfc50b65e7
5 changed files with 935 additions and 141 deletions
@@ -1,4 +1,5 @@
import Foundation
import Security
import Testing
import BitFoundation
@testable import bitchat
@@ -24,6 +25,77 @@ struct PreviewKeychainManagerTests {
containerKnowsMarker: false,
markerRead: .deviceLocked
) == .retryLater)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
cleanupPending: true,
markerRead: .itemNotFound
) == .clearStaleKeys)
}
@Test("Accessibility migration covers custom services and retries after any incomplete update")
func accessibilityMigrationCoversEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let completed = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.favorites"
? errSecInteractionNotAllowed
: errSecItemNotFound
}
#expect(!completed)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let retryCompleted = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { _ in errSecSuccess }
#expect(retryCompleted)
}
@Test("Keychain cleanup is complete only when every owned scope is clean")
func keychainCleanupRequiresEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let partialCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.outbox"
? errSecInteractionNotAllowed
: errSecSuccess
}
#expect(!partialCleanup)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let emptyCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { _ in errSecItemNotFound }
#expect(emptyCleanup)
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
#expect(!KeychainManager.completedApplicationGroupDelete(
status: errSecInteractionNotAllowed
))
}
@Test("Preview keychain manager stores identity and service-scoped data in memory")
@@ -72,4 +144,132 @@ struct PreviewKeychainManagerTests {
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
}
}
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
let gate = KeychainInstallAccessGate()
var cleanupCanComplete = false
var reconciliationAttempts = 0
var manager: PreviewKeychainManager!
manager = PreviewKeychainManager(
installAccessGate: gate
) {
reconciliationAttempts += 1
guard cleanupCanComplete else { return false }
return manager.deleteAllKeychainData()
}
let staleIdentity = Data([1, 2, 3])
let staleFavorite = Data([4, 5, 6])
let staleOutbox = Data([7, 8, 9])
let staleCustom = Data([10, 11, 12])
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "identity_noiseStaticKey"
))
#expect(manager.verifyIdentityKeyExists())
manager.save(
key: "favorite",
data: staleFavorite,
service: "chat.bitchat.favorites",
accessible: nil
)
manager.save(
key: "outbox",
data: staleOutbox,
service: "chat.bitchat.outbox",
accessible: nil
)
manager.save(
key: "custom",
data: staleCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
gate.block()
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(!manager.verifyIdentityKeyExists())
if case .accessDenied = manager.getIdentityKeyWithResult(
forKey: "noiseStaticKey"
) {
} else {
Issue.record("Expected blocked identity read to fail closed")
}
for (key, service) in [
("favorite", "chat.bitchat.favorites"),
("outbox", "chat.bitchat.outbox"),
("custom", "chat.bitchat.future-custom")
] {
#expect(manager.load(key: key, service: service) == nil)
if case .accessDenied = manager.loadWithResult(
key: key,
service: service
) {
} else {
Issue.record(
"Expected blocked \(service) read to fail closed"
)
}
}
#expect(!manager.saveIdentityKey(
Data([13]),
forKey: "replacement"
))
if case .accessDenied = manager.saveIdentityKeyWithResult(
Data([14]),
forKey: "replacement"
) {
} else {
Issue.record("Expected blocked identity save to fail closed")
}
let failedAttempts = reconciliationAttempts
#expect(failedAttempts > 0)
cleanupCanComplete = true
// The first access retries cleanup synchronously. It must not return
// any surviving value from before the reinstall.
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(reconciliationAttempts == failedAttempts + 1)
#expect(manager.load(
key: "favorite",
service: "chat.bitchat.favorites"
) == nil)
#expect(manager.load(
key: "outbox",
service: "chat.bitchat.outbox"
) == nil)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == nil)
let replacementIdentity = Data([21, 22, 23])
let replacementCustom = Data([24, 25, 26])
#expect(manager.saveIdentityKey(
replacementIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.getIdentityKey(
forKey: "noiseStaticKey"
) == replacementIdentity)
manager.save(
key: "custom",
data: replacementCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == replacementCustom)
}
}