mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
276 lines
10 KiB
Swift
276 lines
10 KiB
Swift
import Foundation
|
|
import Security
|
|
import Testing
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
@Suite("PreviewKeychainManager Tests")
|
|
struct PreviewKeychainManagerTests {
|
|
|
|
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
|
func installLifecycleDecision() {
|
|
#expect(KeychainManager.installLifecycleAction(
|
|
containerKnowsMarker: true,
|
|
markerRead: .success(Data([1]))
|
|
) == .markerPresent)
|
|
#expect(KeychainManager.installLifecycleAction(
|
|
containerKnowsMarker: false,
|
|
markerRead: .success(Data([1]))
|
|
) == .clearStaleKeys)
|
|
#expect(KeychainManager.installLifecycleAction(
|
|
containerKnowsMarker: false,
|
|
markerRead: .itemNotFound
|
|
) == .bootstrapMarker)
|
|
#expect(KeychainManager.installLifecycleAction(
|
|
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")
|
|
func previewKeychainManagerRoundTripsData() {
|
|
let manager = PreviewKeychainManager()
|
|
let identityKey = Data([1, 2, 3, 4])
|
|
let serviceKey = "preview-service"
|
|
let scopedData = Data([9, 8, 7, 6])
|
|
|
|
#expect(!manager.verifyIdentityKeyExists())
|
|
#expect(manager.saveIdentityKey(identityKey, forKey: "noiseStaticKey"))
|
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == identityKey)
|
|
#expect(manager.saveIdentityKey(identityKey, forKey: "identity_noiseStaticKey"))
|
|
#expect(manager.verifyIdentityKeyExists())
|
|
|
|
if case .success(let stored) = manager.getIdentityKeyWithResult(forKey: "noiseStaticKey") {
|
|
#expect(stored == identityKey)
|
|
} else {
|
|
Issue.record("Expected stored preview identity key")
|
|
}
|
|
|
|
if case .success = manager.saveIdentityKeyWithResult(Data([5, 6, 7]), forKey: "ed25519SigningKey") {
|
|
} else {
|
|
Issue.record("Expected preview keychain save to succeed")
|
|
}
|
|
|
|
manager.save(key: "blob", data: scopedData, service: serviceKey, accessible: nil)
|
|
#expect(manager.load(key: "blob", service: serviceKey) == scopedData)
|
|
manager.delete(key: "blob", service: serviceKey)
|
|
#expect(manager.load(key: "blob", service: serviceKey) == nil)
|
|
|
|
var secretData = Data([4, 3, 2, 1])
|
|
var secretString = "secret"
|
|
manager.secureClear(&secretData)
|
|
manager.secureClear(&secretString)
|
|
#expect(secretData == Data([4, 3, 2, 1]))
|
|
#expect(secretString == "secret")
|
|
|
|
#expect(manager.deleteIdentityKey(forKey: "noiseStaticKey"))
|
|
#expect(manager.deleteIdentityKey(forKey: "identity_noiseStaticKey"))
|
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
|
#expect(manager.deleteAllKeychainData())
|
|
|
|
if case .itemNotFound = manager.getIdentityKeyWithResult(forKey: "ed25519SigningKey") {
|
|
} else {
|
|
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)
|
|
}
|
|
}
|