Merge ab7ec2be89cff6cd26d1c561179f1e62335ecb3a into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
AmirHossein Rezaei 2026-08-02 12:00:53 +02:00 committed by GitHub
commit bd4077296f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1616 additions and 1 deletions

View File

@ -117,6 +117,22 @@ final class AppChromeModel: ObservableObject {
chatViewModel.panicClearAllData()
}
/// Fingerprint of the live Noise static identity (for the backup sheet).
func identityFingerprint() -> String {
chatViewModel.meshService.noiseIdentityFingerprint()
}
func exportEncryptedIdentityBackup(passphrase: String, confirm: String) throws -> String {
try chatViewModel.exportEncryptedIdentityBackup(
passphrase: passphrase,
confirm: confirm
)
}
func restoreIdentityFromBackup(_ backup: String, passphrase: String) throws -> String {
try chatViewModel.restoreIdentityFromBackup(backup, passphrase: passphrase)
}
private func bind(privateInboxModel: PrivateInboxModel) {
privateInboxModel.$unreadPeerIDs
.receive(on: DispatchQueue.main)

View File

@ -64,6 +64,80 @@ final class NostrIdentityBridge {
cacheLock.unlock()
}
// MARK: - Identity Backup (export / restore)
/// Current device seed used for per-geohash derivation (creates one if missing).
func exportDeviceSeed() -> Data {
getOrCreateDeviceSeed()
}
/// Replace the primary Nostr identity and device seed with restored material.
/// Clears derived-identity caches so geohash keys recompute from the new seed.
///
/// `keychain.save` is fire-and-forget, so this method always read-backs both
/// values and throws if either write did not stick. On failure it attempts
/// to restore the previous primary/seed snapshot so Noise and Nostr cannot
/// diverge after a half-applied restore.
@discardableResult
func installRestoredIdentity(privateKey: Data, deviceSeed: Data) throws -> NostrIdentity {
guard deviceSeed.count == 32 else {
throw IdentityBackupError.invalidKeyMaterial
}
let identity = try NostrIdentity(privateKeyData: privateKey)
let encoded = try JSONEncoder().encode(identity)
// Snapshot whatever is currently durable so a failed write after wipe
// can be rolled back instead of leaving an empty Nostr identity.
let previousIdentity = keychain.load(key: currentIdentityKey, service: keychainService)
let previousSeed = keychain.load(key: deviceSeedKey, service: keychainService)
// Drop prior associations/caches first so a partial failure cannot leave
// a mix of old seed + new primary (or vice versa).
clearAllAssociations()
keychain.save(
key: currentIdentityKey,
data: encoded,
service: keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
keychain.save(
key: deviceSeedKey,
data: deviceSeed,
service: keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
let storedIdentity = keychain.load(key: currentIdentityKey, service: keychainService)
let storedSeed = keychain.load(key: deviceSeedKey, service: keychainService)
guard storedIdentity == encoded, storedSeed == deviceSeed else {
// Best-effort rollback to the pre-restore snapshot.
if let previousIdentity {
keychain.save(
key: currentIdentityKey,
data: previousIdentity,
service: keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
}
if let previousSeed {
keychain.save(
key: deviceSeedKey,
data: previousSeed,
service: keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
deviceSeedCache = previousSeed
} else {
deviceSeedCache = nil
}
throw IdentityBackupError.persistenceFailed
}
deviceSeedCache = deviceSeed
return identity
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.

View File

@ -3,6 +3,7 @@ import BitFoundation
import Foundation
import CoreBluetooth
import Combine
import CryptoKit
#if os(iOS)
import UIKit
#endif
@ -841,6 +842,131 @@ final class BLEService: NSObject {
sendAnnounce(forceSend: true)
}
}
/// Export Noise static + Ed25519 signing private keys for an encrypted
/// identity backup. Secrets clear when finished.
func exportNoisePrivateKeysForBackup() -> (noiseStatic: Data, ed25519Signing: Data) {
noiseService.exportPersistentPrivateKeys()
}
/// Install restored Noise keys from an identity backup, then rebuild the
/// encryption service the same way panic reset does without regenerating
/// fresh keys. Caller must have already written Nostr material.
func installRestoredNoiseIdentity(
noiseStaticPrivateKey: Data,
ed25519SigningPrivateKey: Data,
currentNickname: String,
restartServices: Bool = true
) throws {
guard noiseStaticPrivateKey.count == 32,
ed25519SigningPrivateKey.count == 32 else {
throw IdentityBackupError.invalidKeyMaterial
}
// Validate before touching the live identity.
_ = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: noiseStaticPrivateKey)
_ = try Curve25519.Signing.PrivateKey(rawRepresentation: ed25519SigningPrivateKey)
gossipSyncManager?.stop()
gossipSyncManager = nil
messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
}
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.removeAll()
}
let panicReset = collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.removeAll()
pendingNotifications.removeAll()
let transfers = outboundFragmentTransfers.removeAll()
fragmentAssemblyBuffer.removeAll()
pendingDirectedRelays.removeAll()
ingressLinks.removeAll()
recentTrafficTracker.removeAll()
scheduledRelays.cancelAll()
pendingPrivateMediaPolicyResolutions.removeAll()
privateMediaSessionGenerations.removeAll()
authenticatedPeerStates.removeAll()
privateMediaProofTimeoutMarkers.removeAll()
privateMediaProofWatchdogs.removeAll()
authenticatedPeerStateSendProgress.removeAll()
lastPrekeyBundleSentAt = nil
return transfers
}
for entry in panicReset {
entry.workItems.forEach { $0.cancel() }
TransferProgressManager.shared.cancel(id: entry.id)
}
bleQueue.sync {
pendingWriteBuffers.removeAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
}
disconnectNotifyDebouncer.removeAll()
messageQueue.sync(flags: .barrier) {
noiseService.clearEphemeralStateForPanic()
// Drop the previous identity (and its prekeys), then install the
// restored material before constructing the replacement service so
// init loads the imported keys instead of minting new ones.
noiseService.clearPersistentIdentity()
let noiseSave = keychain.saveIdentityKeyWithResult(
noiseStaticPrivateKey,
forKey: "noiseStaticKey"
)
let signingSave = keychain.saveIdentityKeyWithResult(
ed25519SigningPrivateKey,
forKey: "ed25519SigningKey"
)
if case .success = noiseSave, case .success = signingSave {
// Persisted.
} else {
SecureLogger.error(
"Failed to persist restored identity keys (noise=\(String(describing: noiseSave)), signing=\(String(describing: signingSave)))",
category: .security
)
// Fall through: NoiseEncryptionService will mint ephemeral keys
// if persistence failed surface as error to the caller below.
}
let newNoise = NoiseEncryptionService(
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
}
// Confirm the live fingerprint matches what we intended to install.
let expected = try Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: noiseStaticPrivateKey
).publicKey.rawRepresentation.sha256Fingerprint()
let actual = noiseService.getIdentityFingerprint()
guard expected == actual else {
throw IdentityBackupError.invalidKeyMaterial
}
guard keychain.getIdentityKey(forKey: "noiseStaticKey") == noiseStaticPrivateKey,
keychain.getIdentityKey(forKey: "ed25519SigningKey") == ed25519SigningPrivateKey else {
throw IdentityBackupError.persistenceFailed
}
localIdentityState.setNickname(currentNickname)
messageDeduplicator.reset()
messageQueue.async(flags: .barrier) { [weak self] in
self?.selfBroadcastTracker.removeAll()
}
requestPeerDataPublish()
if restartServices {
restartGossipManager()
startServices()
sendAnnounce(forceSend: true)
}
}
// Ensure this runs on message queue to avoid main thread blocking
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) {

View File

@ -0,0 +1,369 @@
//
// IdentityBackupService.swift
// bitchat
//
// Passphrase-encrypted export/import of the long-lived cryptographic identity.
// Scope matches issue #183 v1: migrate Noise static + Ed25519 signing + Nostr
// primary (+ device seed) to another device. Concurrent multi-device use of the
// same backup is unsupported and warned about in the UI.
//
import BitFoundation
import CommonCrypto
import CryptoKit
import Foundation
/// Raw private key material that makes one BitChat identity portable.
struct IdentityKeyMaterial: Equatable, Sendable {
/// Curve25519.KeyAgreement private key (32 bytes).
var noiseStaticPrivateKey: Data
/// Curve25519.Signing private key (32 bytes).
var ed25519SigningPrivateKey: Data
/// secp256k1 Schnorr private key for the primary Nostr identity (32 bytes).
var nostrPrivateKey: Data
/// HMAC seed used to derive per-geohash Nostr identities (32 bytes).
var nostrDeviceSeed: Data
static let keyByteCount = 32
static let plaintextByteCount = keyByteCount * 4
func validated() throws -> IdentityKeyMaterial {
guard noiseStaticPrivateKey.count == Self.keyByteCount,
ed25519SigningPrivateKey.count == Self.keyByteCount,
nostrPrivateKey.count == Self.keyByteCount,
nostrDeviceSeed.count == Self.keyByteCount else {
throw IdentityBackupError.invalidKeyMaterial
}
// Reject all-zero seeds / keys (almost certainly corruption).
let parts = [
noiseStaticPrivateKey,
ed25519SigningPrivateKey,
nostrPrivateKey,
nostrDeviceSeed
]
for part in parts where part.allSatisfy({ $0 == 0 }) {
throw IdentityBackupError.invalidKeyMaterial
}
// CryptoKit / P256K will throw if the curve points are invalid.
_ = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: noiseStaticPrivateKey)
_ = try Curve25519.Signing.PrivateKey(rawRepresentation: ed25519SigningPrivateKey)
_ = try NostrIdentity(privateKeyData: nostrPrivateKey)
return self
}
func encodePlaintext() -> Data {
var data = Data(capacity: Self.plaintextByteCount)
data.append(noiseStaticPrivateKey)
data.append(ed25519SigningPrivateKey)
data.append(nostrPrivateKey)
data.append(nostrDeviceSeed)
return data
}
static func decodePlaintext(_ data: Data) throws -> IdentityKeyMaterial {
guard data.count == plaintextByteCount else {
throw IdentityBackupError.invalidPayload
}
let n = keyByteCount
return try IdentityKeyMaterial(
noiseStaticPrivateKey: data.subdata(in: 0..<n),
ed25519SigningPrivateKey: data.subdata(in: n..<(2 * n)),
nostrPrivateKey: data.subdata(in: (2 * n)..<(3 * n)),
nostrDeviceSeed: data.subdata(in: (3 * n)..<(4 * n))
).validated()
}
}
enum IdentityBackupError: Error, Equatable, LocalizedError {
case weakPassphrase
case passphraseMismatch
case invalidPayload
case invalidKeyMaterial
case decryptionFailed
case unsupportedVersion
case encodingFailed
case persistenceFailed
var errorDescription: String? {
switch self {
case .weakPassphrase:
return String(
localized: "identity_backup.error.weak_passphrase",
defaultValue: "passphrase must be at least 12 characters",
comment: "Error when the backup passphrase is too short"
)
case .passphraseMismatch:
return String(
localized: "identity_backup.error.passphrase_mismatch",
defaultValue: "passphrases do not match",
comment: "Error when confirm-passphrase field differs from the first"
)
case .invalidPayload:
return String(
localized: "identity_backup.error.invalid_payload",
defaultValue: "that doesn't look like an identity backup",
comment: "Error when pasted/scanned backup text cannot be parsed"
)
case .invalidKeyMaterial:
return String(
localized: "identity_backup.error.invalid_keys",
defaultValue: "backup contains invalid key material",
comment: "Error when decrypted backup keys fail validation"
)
case .decryptionFailed:
return String(
localized: "identity_backup.error.decryption_failed",
defaultValue: "could not decrypt — check the passphrase",
comment: "Error when AES-GCM open fails (wrong passphrase or tampered backup)"
)
case .unsupportedVersion:
return String(
localized: "identity_backup.error.unsupported_version",
defaultValue: "this backup was made by a newer bitchat; update the app",
comment: "Error when backup envelope version is newer than we support"
)
case .encodingFailed:
return String(
localized: "identity_backup.error.encoding_failed",
defaultValue: "failed to build the encrypted backup",
comment: "Error when encrypting or encoding the backup envelope fails"
)
case .persistenceFailed:
return String(
localized: "identity_backup.error.persistence_failed",
defaultValue: "could not save the restored identity to the keychain",
comment: "Error when keychain write/read-back of restored identity keys fails"
)
}
}
}
/// Builds and opens passphrase-encrypted identity backup envelopes.
enum IdentityBackupService {
static let uriScheme = "bitchat"
static let uriHost = "identity-backup"
static let uriVersionPath = "v1"
/// Compact pasteable prefix (analogous to `bitchat1:` for Nostr packets).
static let tokenPrefix = "bitchat1id:"
static let minimumPassphraseLength = 12
static let pbkdf2Iterations: UInt32 = 600_000
static let saltByteCount = 16
static let currentVersion: UInt8 = 1
private static let magic = Data("BCID".utf8)
private static let kdfPBKDF2SHA256: UInt8 = 1
// MARK: - Fingerprint
/// SHA-256 hex of the Noise static *public* key matches live identity fingerprint.
static func fingerprint(of material: IdentityKeyMaterial) throws -> String {
let validated = try material.validated()
let privateKey = try Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: validated.noiseStaticPrivateKey
)
return privateKey.publicKey.rawRepresentation.sha256Fingerprint()
}
static func nostrNpub(of material: IdentityKeyMaterial) throws -> String {
try NostrIdentity(privateKeyData: material.validated().nostrPrivateKey).npub
}
// MARK: - Passphrase helpers
static func validatePassphrase(_ passphrase: String, confirm: String?) throws {
let trimmed = passphrase
guard trimmed.count >= minimumPassphraseLength else {
throw IdentityBackupError.weakPassphrase
}
if let confirm, confirm != trimmed {
throw IdentityBackupError.passphraseMismatch
}
}
/// Suggests a high-entropy passphrase the user can write down (groups of base32).
static func suggestPassphrase() -> String {
var bytes = Data(count: 16)
bytes.withUnsafeMutableBytes { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
}
let alphabet = Array("abcdefghijklmnopqrstuvwxyz234567")
var chars: [Character] = []
chars.reserveCapacity(26)
for (index, byte) in bytes.enumerated() {
chars.append(alphabet[Int(byte % 32)])
if index % 4 == 3, index != bytes.count - 1 {
chars.append("-")
}
}
return String(chars)
}
// MARK: - Encrypt / decrypt
static func encrypt(
_ material: IdentityKeyMaterial,
passphrase: String
) throws -> String {
try validatePassphrase(passphrase, confirm: nil)
let plaintext = try material.validated().encodePlaintext()
var salt = Data(count: saltByteCount)
let saltStatus = salt.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, saltByteCount, ptr.baseAddress!)
}
guard saltStatus == errSecSuccess else {
throw IdentityBackupError.encodingFailed
}
let key = try deriveKey(passphrase: passphrase, salt: salt, iterations: pbkdf2Iterations)
let sealed: AES.GCM.SealedBox
do {
sealed = try AES.GCM.seal(plaintext, using: key)
} catch {
throw IdentityBackupError.encodingFailed
}
guard let combined = sealed.combined else {
throw IdentityBackupError.encodingFailed
}
var envelope = Data()
envelope.append(magic)
envelope.append(currentVersion)
envelope.append(kdfPBKDF2SHA256)
var iterationsBE = pbkdf2Iterations.bigEndian
withUnsafeBytes(of: &iterationsBE) { envelope.append(contentsOf: $0) }
envelope.append(salt)
envelope.append(combined)
let token = Base64URLCoding.encode(envelope)
return "\(uriScheme)://\(uriHost)/\(uriVersionPath)/\(token)"
}
static func decrypt(_ backup: String, passphrase: String) throws -> IdentityKeyMaterial {
try validatePassphrase(passphrase, confirm: nil)
let envelope = try decodeEnvelopeData(from: backup)
guard envelope.count > magic.count + 1 + 1 + 4 + saltByteCount + 12 + 16 else {
throw IdentityBackupError.invalidPayload
}
guard envelope.prefix(magic.count) == magic else {
throw IdentityBackupError.invalidPayload
}
var offset = magic.count
let version = envelope[offset]
offset += 1
guard version == currentVersion else {
throw IdentityBackupError.unsupportedVersion
}
let kdf = envelope[offset]
offset += 1
guard kdf == kdfPBKDF2SHA256 else {
throw IdentityBackupError.unsupportedVersion
}
let iterations: UInt32 = envelope.subdata(in: offset..<(offset + 4)).withUnsafeBytes {
UInt32(bigEndian: $0.load(as: UInt32.self))
}
offset += 4
// Envelope fields before the AEAD tag are unauthenticated. Bound the
// iteration count before PBKDF2 so a crafted backup cannot force
// billions of derivation rounds on the main actor (DoS).
guard iterations == pbkdf2Iterations else {
throw IdentityBackupError.invalidPayload
}
let salt = envelope.subdata(in: offset..<(offset + saltByteCount))
offset += saltByteCount
let combined = envelope.subdata(in: offset..<envelope.count)
let key = try deriveKey(passphrase: passphrase, salt: salt, iterations: iterations)
let sealed: AES.GCM.SealedBox
do {
sealed = try AES.GCM.SealedBox(combined: combined)
} catch {
throw IdentityBackupError.invalidPayload
}
let plaintext: Data
do {
plaintext = try AES.GCM.open(sealed, using: key)
} catch {
throw IdentityBackupError.decryptionFailed
}
return try IdentityKeyMaterial.decodePlaintext(plaintext)
}
/// Normalizes pasted/scanned text into the canonical URI string when possible.
static func normalizeBackupString(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix(tokenPrefix) {
let token = String(trimmed.dropFirst(tokenPrefix.count))
return "\(uriScheme)://\(uriHost)/\(uriVersionPath)/\(token)"
}
return trimmed
}
/// Compact token form for copy/share (shorter than the URI, same payload).
static func compactToken(fromURI uri: String) throws -> String {
let data = try decodeEnvelopeData(from: uri)
return tokenPrefix + Base64URLCoding.encode(data)
}
// MARK: - Private
private static func decodeEnvelopeData(from backup: String) throws -> Data {
let normalized = normalizeBackupString(backup)
let token: String
if let url = URL(string: normalized),
url.scheme == uriScheme,
url.host == uriHost {
let parts = url.pathComponents.filter { $0 != "/" }
guard parts.count >= 2, parts[0] == uriVersionPath else {
throw IdentityBackupError.invalidPayload
}
token = parts[1]
} else if normalized.hasPrefix(tokenPrefix) {
token = String(normalized.dropFirst(tokenPrefix.count))
} else {
// Bare base64url envelope.
token = normalized
}
guard let data = Base64URLCoding.decode(token), !data.isEmpty else {
throw IdentityBackupError.invalidPayload
}
return data
}
private static func deriveKey(
passphrase: String,
salt: Data,
iterations: UInt32
) throws -> SymmetricKey {
let passwordData = Data(passphrase.utf8)
var derived = Data(count: 32)
let status = derived.withUnsafeMutableBytes { derivedPtr in
salt.withUnsafeBytes { saltPtr in
passwordData.withUnsafeBytes { passwordPtr in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordPtr.bindMemory(to: Int8.self).baseAddress,
passwordData.count,
saltPtr.bindMemory(to: UInt8.self).baseAddress,
salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
iterations,
derivedPtr.bindMemory(to: UInt8.self).baseAddress,
32
)
}
}
}
guard status == kCCSuccess else {
throw IdentityBackupError.encodingFailed
}
return SymmetricKey(data: derived)
}
}

View File

@ -557,6 +557,15 @@ final class MessageRouter {
outboxStore?.wipe()
}
/// Point the Nostr transport at a new mesh peer ID after identity restore.
func updateNostrSenderPeerID(_ peerID: PeerID) {
for transport in transports {
if let nostr = transport as? NostrTransport {
nostr.senderPeerID = peerID
}
}
}
/// Returns true only when the receipt was handed to a reachable transport.
/// A false result means it was dropped (no route) and must NOT be recorded
/// as sent, or the sender's message would stay unread forever the receipt

View File

@ -425,6 +425,16 @@ final class NoiseEncryptionService {
func getIdentityFingerprint() -> String {
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
}
/// Export the long-lived Noise static + Ed25519 signing private keys for a
/// passphrase-encrypted identity backup (issue #183). Callers must treat
/// the returned bytes as secret and clear them when finished.
func exportPersistentPrivateKeys() -> (noiseStatic: Data, ed25519Signing: Data) {
(
staticIdentityKey.rawRepresentation,
signingKey.rawRepresentation
)
}
/// Get peer's public key data
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {

View File

@ -1774,6 +1774,116 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
return true
}
// MARK: - Identity Backup (export / restore)
/// Assemble the passphrase-encryptable identity material from the live
/// Noise service + Nostr bridge.
@MainActor
func exportIdentityKeyMaterial() throws -> IdentityKeyMaterial {
guard let bleService = meshService as? BLEService else {
throw IdentityBackupError.encodingFailed
}
let noiseKeys = bleService.exportNoisePrivateKeysForBackup()
guard let nostr = try idBridge.getCurrentNostrIdentity() else {
throw IdentityBackupError.invalidKeyMaterial
}
let seed = idBridge.exportDeviceSeed()
return try IdentityKeyMaterial(
noiseStaticPrivateKey: noiseKeys.noiseStatic,
ed25519SigningPrivateKey: noiseKeys.ed25519Signing,
nostrPrivateKey: nostr.privateKey,
nostrDeviceSeed: seed
).validated()
}
/// Encrypt the live identity into a `bitchat://identity-backup/v1/` URI.
@MainActor
func exportEncryptedIdentityBackup(passphrase: String, confirm: String) throws -> String {
try IdentityBackupService.validatePassphrase(passphrase, confirm: confirm)
let material = try exportIdentityKeyMaterial()
return try IdentityBackupService.encrypt(material, passphrase: passphrase)
}
/// Decrypt a backup and replace the on-device cryptographic identity.
/// Does not wipe messages or favorites only keys and Noise sessions.
@MainActor
@discardableResult
func restoreIdentityFromBackup(
_ backup: String,
passphrase: String,
restartServices: Bool = true
) throws -> String {
let material = try IdentityBackupService.decrypt(backup, passphrase: passphrase)
let fingerprint = try IdentityBackupService.fingerprint(of: material)
guard let bleService = meshService as? BLEService else {
throw IdentityBackupError.encodingFailed
}
// Pause internet work so in-flight Nostr decrypts cannot land under the
// old identity while we swap keys.
panicNetworkLifecycle.stop()
nostrCoordinator.inbound.invalidateInFlightDecrypts()
bleService.suspendForPanicReset()
// Always reopen admission + Nostr after a suspend, even when install
// throws otherwise a failed restore leaves BLE/internet dead until
// relaunch.
do {
try idBridge.installRestoredIdentity(
privateKey: material.nostrPrivateKey,
deviceSeed: material.nostrDeviceSeed
)
try bleService.installRestoredNoiseIdentity(
noiseStaticPrivateKey: material.noiseStaticPrivateKey,
ed25519SigningPrivateKey: material.ed25519SigningPrivateKey,
currentNickname: nickname,
restartServices: false
)
// Nostr transport caches the previous sender peer ID; point it at the
// restored mesh identity.
messageRouter.updateNostrSenderPeerID(meshService.myPeerID)
resumeTransportsAfterIdentityRestore(
bleService: bleService,
restartServices: restartServices
)
SecureLogger.info(
"Identity restored from backup; fingerprint=\(fingerprint.prefix(16))",
category: .security
)
return fingerprint
} catch {
resumeTransportsAfterIdentityRestore(
bleService: bleService,
restartServices: restartServices
)
SecureLogger.error(
"Identity restore failed after transport suspend; services reopened: \(error)",
category: .security
)
throw error
}
}
@MainActor
private func resumeTransportsAfterIdentityRestore(
bleService: BLEService,
restartServices: Bool
) {
bleService.completePanicReset(restartServices: restartServices)
guard restartServices else { return }
if !TestEnvironment.isRunningTests {
nostrRelayManager = NostrRelayManager.shared
setupNostrMessageHandling()
}
panicNetworkLifecycle.restart()
}
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
/// iOS stores preview screenshots in Library/Caches/Snapshots/<bundle_id>/
/// These could reveal sensitive information visible in the app at the time

View File

@ -17,6 +17,9 @@ struct AppInfoView: View {
/// Wipes all local data. Nil (previews, missing wiring) hides the danger
/// zone entirely.
var onPanicWipe: (@MainActor () -> Void)?
/// Passphrase-encrypted identity export/restore. Nil hides the IDENTITY
/// section (previews, missing wiring).
var identityBackupActions: IdentityBackupActions?
@State private var showTopology = false
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
@ -30,6 +33,7 @@ struct AppInfoView: View {
/// introduction), and afterwards the sheet reopens wherever it was left.
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
@State private var showPanicConfirmation = false
@State private var identityBackupMode: IdentityBackupMode?
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
/// The override changed this session; localization resolves at process
/// start, so surface the restart hint.
@ -118,6 +122,11 @@ struct AppInfoView: View {
static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications")
static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does")
static let identityTitle = String(localized: "app_info.settings.identity.title", defaultValue: "IDENTITY", comment: "Section header (uppercase) for identity export/restore in settings")
static let identitySubtitle = String(localized: "app_info.settings.identity.subtitle", defaultValue: "keys stay on this device by design. export an encrypted backup when you switch phones — never run the same backup on two devices at once.", comment: "Subtitle under the identity section explaining export/restore")
static let identityExport = String(localized: "app_info.settings.identity.export", defaultValue: "export encrypted backup", comment: "Button that opens the identity export sheet")
static let identityRestore = String(localized: "app_info.settings.identity.restore", defaultValue: "restore from backup", comment: "Button that opens the identity restore sheet")
static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings")
static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation")
static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut")
@ -279,6 +288,18 @@ struct AppInfoView: View {
MeshTopologyView(provider: topologyProvider)
}
}
.sheet(item: $identityBackupMode) { mode in
if let identityBackupActions {
IdentityBackupSheet(
mode: mode,
actions: identityBackupActions,
isPresented: Binding(
get: { identityBackupMode != nil },
set: { if !$0 { identityBackupMode = nil } }
)
)
}
}
#else
NavigationView {
VStack(spacing: 0) {
@ -302,6 +323,18 @@ struct AppInfoView: View {
MeshTopologyView(provider: topologyProvider)
}
}
.sheet(item: $identityBackupMode) { mode in
if let identityBackupActions {
IdentityBackupSheet(
mode: mode,
actions: identityBackupActions,
isPresented: Binding(
get: { identityBackupMode != nil },
set: { if !$0 { identityBackupMode = nil } }
)
)
}
}
#endif
}
@ -543,6 +576,44 @@ struct AppInfoView: View {
}
}
// Identity migration: passphrase-encrypted export/restore. Keys are
// ThisDeviceOnly in the keychain, so this is the only supported
// way to move an identity onto a new phone (#183).
if identityBackupActions != nil {
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.identityTitle)
settingsCard {
Text(verbatim: Strings.Settings.identitySubtitle)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Button(action: { identityBackupMode = .export }) {
Text(verbatim: Strings.Settings.identityExport)
.bitchatFont(size: 12)
.foregroundColor(palette.accent)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(palette.accent.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
Button(action: { identityBackupMode = .restore }) {
Text(verbatim: Strings.Settings.identityRestore)
.bitchatFont(size: 12)
.foregroundColor(textColor)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(palette.secondary.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
}
// Danger zone
if onPanicWipe != nil {
VStack(alignment: .leading, spacing: 12) {

View File

@ -341,7 +341,24 @@ struct ContentView: View {
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
AppInfoView(
topologyProvider: { appChromeModel.meshTopologyDisplayModel() },
onPanicWipe: { appChromeModel.panicClearAllData() }
onPanicWipe: { appChromeModel.panicClearAllData() },
identityBackupActions: IdentityBackupActions(
currentFingerprint: {
appChromeModel.identityFingerprint()
},
exportBackup: { passphrase, confirm in
try appChromeModel.exportEncryptedIdentityBackup(
passphrase: passphrase,
confirm: confirm
)
},
restoreBackup: { backup, passphrase in
try appChromeModel.restoreIdentityFromBackup(
backup,
passphrase: passphrase
)
}
)
)
.environmentObject(locationChannelsModel)
}

View File

@ -0,0 +1,563 @@
//
// IdentityBackupViews.swift
// bitchat
//
// Settings sheet for passphrase-encrypted identity export / restore (#183).
// Visual language matches AppInfoView + VerificationSheetView: themed palette,
// bitchatFont, secondary-opacity cards, SheetCloseButton.
//
import BitFoundation
import SwiftUI
#if os(iOS)
import UIKit
#else
import AppKit
#endif
enum IdentityBackupMode: String, Identifiable, Equatable {
case export
case restore
var id: String { rawValue }
}
/// Closures AppInfoView needs without taking a ChatViewModel dependency.
struct IdentityBackupActions {
var currentFingerprint: () -> String
var exportBackup: (_ passphrase: String, _ confirm: String) throws -> String
var restoreBackup: (_ backup: String, _ passphrase: String) throws -> String
}
struct IdentityBackupSheet: View {
let mode: IdentityBackupMode
let actions: IdentityBackupActions
@Binding var isPresented: Bool
@ThemedPalette private var palette
@Environment(\.dismiss) private var dismiss
@State private var passphrase = ""
@State private var confirmPassphrase = ""
@State private var backupText = ""
@State private var exportedURI: String?
@State private var compactToken: String?
@State private var exportedFingerprint: String?
@State private var restoredFingerprint: String?
@State private var errorMessage: String?
@State private var infoMessage: String?
@State private var showRestoreConfirm = false
@State private var showScanner = false
@State private var isBusy = false
@State private var revealPassphrase = false
private var accent: Color { palette.accent }
private var textColor: Color { palette.primary }
private var secondaryText: Color { palette.secondary }
private var boxColor: Color { palette.secondary.opacity(0.12) }
private enum Strings {
static let exportTitle = String(
localized: "identity_backup.sheet.export_title",
defaultValue: "export identity",
comment: "Title of the identity export sheet"
)
static let restoreTitle = String(
localized: "identity_backup.sheet.restore_title",
defaultValue: "restore identity",
comment: "Title of the identity restore sheet"
)
static let exportIntro = String(
localized: "identity_backup.sheet.export_intro",
defaultValue: "encrypts your Noise, signing, and Nostr keys so you can move them to another phone. anyone with this backup and the passphrase becomes you on the network — keep both secret.",
comment: "Intro copy on the identity export sheet"
)
static let restoreIntro = String(
localized: "identity_backup.sheet.restore_intro",
defaultValue: "replaces the cryptographic identity on this device with the one in the backup. do not run the same backup on two devices at once — sessions will collide.",
comment: "Intro copy on the identity restore sheet"
)
static let passphrase = String(
localized: "identity_backup.sheet.passphrase",
defaultValue: "passphrase",
comment: "Label for the backup passphrase field"
)
static let confirmPassphrase = String(
localized: "identity_backup.sheet.confirm_passphrase",
defaultValue: "confirm passphrase",
comment: "Label for the confirm-passphrase field on export"
)
static let suggest = String(
localized: "identity_backup.sheet.suggest",
defaultValue: "suggest",
comment: "Button that fills a high-entropy suggested passphrase"
)
static let createBackup = String(
localized: "identity_backup.sheet.create_backup",
defaultValue: "create encrypted backup",
comment: "Primary button that builds the encrypted identity backup"
)
static let restoreAction = String(
localized: "identity_backup.sheet.restore_action",
defaultValue: "restore identity",
comment: "Primary button that decrypts and installs the backup"
)
static let backupPlaceholder = String(
localized: "identity_backup.sheet.backup_placeholder",
defaultValue: "paste bitchat://identity-backup/… or bitchat1id:…",
comment: "Placeholder for the restore backup text field"
)
static let copyURI = String(
localized: "identity_backup.sheet.copy_uri",
defaultValue: "copy backup",
comment: "Button that copies the encrypted backup string"
)
static let share = String(
localized: "identity_backup.sheet.share",
defaultValue: "share",
comment: "Button that opens the system share sheet for the backup"
)
static let copied = String(
localized: "identity_backup.sheet.copied",
defaultValue: "copied to clipboard",
comment: "Status shown after copying the backup string"
)
static let fingerprintLabel = String(
localized: "identity_backup.sheet.fingerprint",
defaultValue: "fingerprint",
comment: "Label above the identity fingerprint shown after export/restore"
)
static let currentFingerprint = String(
localized: "identity_backup.sheet.current_fingerprint",
defaultValue: "this device",
comment: "Caption above the fingerprint of the live device identity"
)
static let scanQR = String(
localized: "identity_backup.sheet.scan_qr",
defaultValue: "scan backup qr",
comment: "Button that opens the camera scanner for an identity backup QR"
)
static let hideScanner = String(
localized: "identity_backup.sheet.hide_scanner",
defaultValue: "enter backup text",
comment: "Button that closes the camera scanner and returns to paste field"
)
static let restoreConfirmTitle = String(
localized: "identity_backup.sheet.restore_confirm_title",
defaultValue: "replace this device's identity?",
comment: "Title of the confirmation dialog before restoring an identity backup"
)
static let restoreConfirmAction = String(
localized: "identity_backup.sheet.restore_confirm_action",
defaultValue: "restore and replace",
comment: "Destructive confirmation button that performs identity restore"
)
static let restoreSuccess = String(
localized: "identity_backup.sheet.restore_success",
defaultValue: "identity restored. mesh sessions will re-handshake as this fingerprint.",
comment: "Status shown after a successful identity restore"
)
static let exportDoneHint = String(
localized: "identity_backup.sheet.export_done_hint",
defaultValue: "write down the passphrase separately from the QR. the backup alone is useless without it — and dangerous with it.",
comment: "Caption under a successfully created identity backup"
)
static let showPassphrase = String(
localized: "identity_backup.sheet.show_passphrase",
defaultValue: "show",
comment: "Toggle label to reveal the passphrase field as plain text"
)
static let hidePassphrase = String(
localized: "identity_backup.sheet.hide_passphrase",
defaultValue: "hide",
comment: "Toggle label to mask the passphrase field"
)
}
var body: some View {
VStack(spacing: 0) {
header
Divider().background(palette.divider)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
introCard
fingerprintCard(
title: Strings.currentFingerprint,
value: actions.currentFingerprint()
)
if mode == .export {
exportForm
if let exportedURI {
exportResult(uri: exportedURI)
}
} else {
restoreForm
if let restoredFingerprint {
fingerprintCard(
title: Strings.fingerprintLabel,
value: restoredFingerprint
)
Text(verbatim: Strings.restoreSuccess)
.bitchatFont(size: 11)
.foregroundColor(accent)
.fixedSize(horizontal: false, vertical: true)
}
}
if let errorMessage {
Text(verbatim: errorMessage)
.bitchatFont(size: 11)
.foregroundColor(palette.alertRed)
.fixedSize(horizontal: false, vertical: true)
}
if let infoMessage {
Text(verbatim: infoMessage)
.bitchatFont(size: 11)
.foregroundColor(secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(16)
}
}
.themedSheetBackground()
.confirmationDialog(
Strings.restoreConfirmTitle,
isPresented: $showRestoreConfirm,
titleVisibility: .visible
) {
Button(Strings.restoreConfirmAction, role: .destructive) {
performRestore()
}
Button("common.cancel", role: .cancel) {}
}
}
private var header: some View {
HStack {
Text(verbatim: mode == .export ? Strings.exportTitle : Strings.restoreTitle)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accent)
Spacer()
SheetCloseButton {
isPresented = false
dismiss()
}
.foregroundColor(accent)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
}
private var introCard: some View {
Text(verbatim: mode == .export ? Strings.exportIntro : Strings.restoreIntro)
.bitchatFont(size: 11)
.foregroundColor(secondaryText)
.fixedSize(horizontal: false, vertical: true)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(boxColor)
.cornerRadius(8)
}
private func fingerprintCard(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: title)
.bitchatFont(size: 10, weight: .semibold)
.foregroundColor(secondaryText)
Text(verbatim: formatFingerprint(value))
.bitchatFont(size: 12, weight: .medium)
.foregroundColor(textColor)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(boxColor)
.cornerRadius(8)
}
private var exportForm: some View {
VStack(alignment: .leading, spacing: 10) {
passphraseFields(includeConfirm: true)
Button(action: createBackup) {
Text(verbatim: Strings.createBackup)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(accent)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(accent.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(isBusy || passphrase.isEmpty || confirmPassphrase.isEmpty)
}
}
private var restoreForm: some View {
VStack(alignment: .leading, spacing: 10) {
#if os(iOS)
if showScanner {
IdentityBackupScanner { code in
backupText = code
showScanner = false
infoMessage = nil
errorMessage = nil
}
.frame(height: 240)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button(action: { showScanner = false }) {
Text(verbatim: Strings.hideScanner)
.bitchatFont(size: 12)
.foregroundColor(secondaryText)
}
.buttonStyle(.plain)
} else {
backupTextEditor
Button(action: { showScanner = true }) {
Label(Strings.scanQR, systemImage: "camera.viewfinder")
.bitchatFont(size: 12)
.foregroundColor(accent)
}
.buttonStyle(.plain)
}
#else
backupTextEditor
#endif
passphraseFields(includeConfirm: false)
Button(action: { showRestoreConfirm = true }) {
Text(verbatim: Strings.restoreAction)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(isBusy || passphrase.isEmpty || backupText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private var backupTextEditor: some View {
VStack(alignment: .leading, spacing: 4) {
TextEditor(text: $backupText)
.frame(minHeight: 88)
.bitchatFont(size: 11)
.padding(8)
.background(boxColor)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(palette.secondary.opacity(0.25), lineWidth: 1)
)
if backupText.isEmpty {
Text(verbatim: Strings.backupPlaceholder)
.bitchatFont(size: 10)
.foregroundColor(secondaryText.opacity(0.7))
}
}
}
private func passphraseFields(includeConfirm: Bool) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(verbatim: Strings.passphrase)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(textColor)
Spacer()
Button(revealPassphrase ? Strings.hidePassphrase : Strings.showPassphrase) {
revealPassphrase.toggle()
}
.buttonStyle(.plain)
.bitchatFont(size: 10)
.foregroundColor(secondaryText)
if mode == .export {
Button(Strings.suggest) {
let suggested = IdentityBackupService.suggestPassphrase()
passphrase = suggested
confirmPassphrase = suggested
revealPassphrase = true
}
.buttonStyle(.plain)
.bitchatFont(size: 10, weight: .semibold)
.foregroundColor(accent)
}
}
secureField($passphrase)
if includeConfirm {
Text(verbatim: Strings.confirmPassphrase)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(textColor)
secureField($confirmPassphrase)
}
}
.padding(12)
.background(boxColor)
.cornerRadius(8)
}
@ViewBuilder
private func secureField(_ binding: Binding<String>) -> some View {
Group {
if revealPassphrase {
TextField("", text: binding)
} else {
SecureField("", text: binding)
}
}
.textFieldStyle(.plain)
.bitchatFont(size: 12)
.foregroundColor(textColor)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(palette.secondary.opacity(0.08))
.cornerRadius(6)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
private func exportResult(uri: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
if let exportedFingerprint {
fingerprintCard(title: Strings.fingerprintLabel, value: exportedFingerprint)
}
QRCodeImage(data: uri, size: 220)
.frame(maxWidth: .infinity)
Text(verbatim: compactToken ?? uri)
.bitchatFont(size: 10)
.foregroundColor(secondaryText)
.textSelection(.enabled)
.lineLimit(4)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(boxColor)
.cornerRadius(8)
HStack(spacing: 10) {
Button(action: { copyToClipboard(compactToken ?? uri) }) {
Text(verbatim: Strings.copyURI)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(accent)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(accent.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
ShareLink(item: compactToken ?? uri) {
Text(verbatim: Strings.share)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(textColor)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(boxColor)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
Text(verbatim: Strings.exportDoneHint)
.bitchatFont(size: 11)
.foregroundColor(secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.background(boxColor.opacity(0.5))
.cornerRadius(8)
}
// MARK: - Actions
private func createBackup() {
errorMessage = nil
infoMessage = nil
isBusy = true
defer { isBusy = false }
do {
let uri = try actions.exportBackup(passphrase, confirmPassphrase)
exportedURI = uri
compactToken = try? IdentityBackupService.compactToken(fromURI: uri)
exportedFingerprint = actions.currentFingerprint()
// Clear passphrase fields after success so screenshots are less risky.
passphrase = ""
confirmPassphrase = ""
revealPassphrase = false
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
exportedURI = nil
compactToken = nil
exportedFingerprint = nil
}
}
private func performRestore() {
errorMessage = nil
infoMessage = nil
isBusy = true
defer { isBusy = false }
do {
let fingerprint = try actions.restoreBackup(backupText, passphrase)
restoredFingerprint = fingerprint
passphrase = ""
revealPassphrase = false
infoMessage = nil
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
restoredFingerprint = nil
}
}
private func copyToClipboard(_ string: String) {
#if os(iOS)
UIPasteboard.general.string = string
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
#endif
infoMessage = Strings.copied
}
private func formatFingerprint(_ hex: String) -> String {
let clean = hex.lowercased().filter(\.isHexDigit)
guard !clean.isEmpty else { return hex }
var groups: [String] = []
var index = clean.startIndex
while index < clean.endIndex {
let end = clean.index(index, offsetBy: 4, limitedBy: clean.endIndex) ?? clean.endIndex
groups.append(String(clean[index..<end]))
index = end
}
return groups.joined(separator: " ")
}
}
#if os(iOS)
/// Camera scanner that returns any QR payload string (identity backup URI/token).
private struct IdentityBackupScanner: View {
var onCode: (String) -> Void
@State private var lastCode = ""
var body: some View {
CameraScannerView(isActive: true) { code in
guard code != lastCode else { return }
lastCode = code
onCode(code)
}
}
}
#endif

View File

@ -0,0 +1,250 @@
//
// IdentityBackupServiceTests.swift
// bitchatTests
//
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct IdentityBackupServiceTests {
private func sampleMaterial(keychain: MockKeychain = MockKeychain()) throws -> IdentityKeyMaterial {
let noise = NoiseEncryptionService(keychain: keychain)
let keys = noise.exportPersistentPrivateKeys()
let nostr = try NostrIdentity.generate()
var seed = Data(count: 32)
seed.withUnsafeMutableBytes { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
return try IdentityKeyMaterial(
noiseStaticPrivateKey: keys.noiseStatic,
ed25519SigningPrivateKey: keys.ed25519Signing,
nostrPrivateKey: nostr.privateKey,
nostrDeviceSeed: seed
).validated()
}
@Test func roundTripEncryptDecryptPreservesKeys() throws {
let material = try sampleMaterial()
let passphrase = "correct-horse-battery-staple-extra"
let uri = try IdentityBackupService.encrypt(material, passphrase: passphrase)
#expect(uri.hasPrefix("bitchat://identity-backup/v1/"))
#expect(uri.utf8.count < 600)
let restored = try IdentityBackupService.decrypt(uri, passphrase: passphrase)
#expect(restored == material)
let restoredFP = try IdentityBackupService.fingerprint(of: restored)
let originalFP = try IdentityBackupService.fingerprint(of: material)
#expect(restoredFP == originalFP)
#expect(restoredFP.count == 64)
}
@Test func compactTokenRoundTrip() throws {
let material = try sampleMaterial()
let passphrase = "twelve-chars-min"
let uri = try IdentityBackupService.encrypt(material, passphrase: passphrase)
let token = try IdentityBackupService.compactToken(fromURI: uri)
#expect(token.hasPrefix("bitchat1id:"))
let restored = try IdentityBackupService.decrypt(token, passphrase: passphrase)
#expect(restored == material)
}
@Test func wrongPassphraseFails() throws {
let material = try sampleMaterial()
let uri = try IdentityBackupService.encrypt(material, passphrase: "right-passphrase-ok")
#expect(throws: IdentityBackupError.decryptionFailed) {
_ = try IdentityBackupService.decrypt(uri, passphrase: "wrong-passphrase-ok")
}
}
@Test func weakPassphraseRejected() {
#expect(throws: IdentityBackupError.weakPassphrase) {
try IdentityBackupService.validatePassphrase("short", confirm: "short")
}
}
@Test func passphraseMismatchRejected() {
#expect(throws: IdentityBackupError.passphraseMismatch) {
try IdentityBackupService.validatePassphrase(
"long-enough-pass",
confirm: "different-pass-1"
)
}
}
@Test func garbagePayloadRejected() {
#expect(throws: IdentityBackupError.invalidPayload) {
_ = try IdentityBackupService.decrypt("not-a-backup", passphrase: "long-enough-pass")
}
}
@Test func fingerprintMatchesNoiseService() throws {
let keychain = MockKeychain()
let noise = NoiseEncryptionService(keychain: keychain)
let keys = noise.exportPersistentPrivateKeys()
let nostr = try NostrIdentity.generate()
var seed = Data(count: 32)
seed.withUnsafeMutableBytes { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let fromLive = try IdentityKeyMaterial(
noiseStaticPrivateKey: keys.noiseStatic,
ed25519SigningPrivateKey: keys.ed25519Signing,
nostrPrivateKey: nostr.privateKey,
nostrDeviceSeed: seed
).validated()
let backupFP = try IdentityBackupService.fingerprint(of: fromLive)
#expect(backupFP == noise.getIdentityFingerprint())
}
@Test func nostrBridgeInstallRestoresPrimaryAndSeed() throws {
let keychain = MockKeychain()
let bridge = NostrIdentityBridge(keychain: keychain)
let original = try #require(try bridge.getCurrentNostrIdentity())
let seed = bridge.exportDeviceSeed()
let otherKeychain = MockKeychain()
let other = NostrIdentityBridge(keychain: otherKeychain)
let installed = try other.installRestoredIdentity(
privateKey: original.privateKey,
deviceSeed: seed
)
#expect(installed.npub == original.npub)
#expect(other.exportDeviceSeed() == seed)
let again = try #require(try other.getCurrentNostrIdentity())
#expect(again.privateKey == original.privateKey)
}
@Test func suggestPassphraseIsLongEnough() {
let suggested = IdentityBackupService.suggestPassphrase()
#expect(suggested.count >= IdentityBackupService.minimumPassphraseLength)
#expect(suggested.contains("-"))
}
@Test func normalizeAcceptsBareBase64URL() throws {
let material = try sampleMaterial()
let passphrase = "twelve-chars-min"
let uri = try IdentityBackupService.encrypt(material, passphrase: passphrase)
let token = try #require(URL(string: uri)?.pathComponents.last)
let restored = try IdentityBackupService.decrypt(token, passphrase: passphrase)
#expect(restored == material)
}
@Test func rejectsUnauthenticatedPBKDFIterationCount() throws {
let material = try sampleMaterial()
let passphrase = "twelve-chars-min"
let uri = try IdentityBackupService.encrypt(material, passphrase: passphrase)
let token = try #require(URL(string: uri)?.pathComponents.last)
let envelope = try #require(Base64URLCoding.decode(token))
// Flip the big-endian iteration field (bytes after magic+version+kdf)
// to UInt32.max without touching the AEAD ciphertext the point is
// that we must reject before running PBKDF2.
var poisoned = envelope
let iterationsOffset = 4 /* BCID */ + 1 /* version */ + 1 /* kdf */
poisoned.replaceSubrange(
iterationsOffset..<(iterationsOffset + 4),
with: Data([0xFF, 0xFF, 0xFF, 0xFF])
)
let poisonedURI =
"bitchat://identity-backup/v1/\(Base64URLCoding.encode(poisoned))"
#expect(throws: IdentityBackupError.invalidPayload) {
_ = try IdentityBackupService.decrypt(poisonedURI, passphrase: passphrase)
}
}
@Test func nostrInstallFailsWhenKeychainWriteDoesNotStick() throws {
let keychain = DropWritesKeychain()
let bridge = NostrIdentityBridge(keychain: keychain)
let nostr = try NostrIdentity.generate()
var seed = Data(count: 32)
seed.withUnsafeMutableBytes { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
#expect(throws: IdentityBackupError.persistenceFailed) {
_ = try bridge.installRestoredIdentity(
privateKey: nostr.privateKey,
deviceSeed: seed
)
}
}
}
struct IdentityBackupInstallTests {
@Test func noiseServiceLoadsImportedKeys() throws {
let keychain = MockKeychain()
let original = NoiseEncryptionService(keychain: keychain)
let exported = original.exportPersistentPrivateKeys()
let fingerprint = original.getIdentityFingerprint()
let staticPub = original.getStaticPublicKeyData()
let signingPub = original.getSigningPublicKeyData()
// Simulate clear + reinstall on a fresh service lifetime.
#expect(keychain.deleteIdentityKey(forKey: "noiseStaticKey"))
#expect(keychain.deleteIdentityKey(forKey: "ed25519SigningKey"))
let noiseSave = keychain.saveIdentityKeyWithResult(exported.noiseStatic, forKey: "noiseStaticKey")
let signingSave = keychain.saveIdentityKeyWithResult(exported.ed25519Signing, forKey: "ed25519SigningKey")
guard case .success = noiseSave, case .success = signingSave else {
Issue.record("Failed to save restored keys")
return
}
let restored = NoiseEncryptionService(keychain: keychain)
#expect(restored.getIdentityFingerprint() == fingerprint)
#expect(restored.getStaticPublicKeyData() == staticPub)
#expect(restored.getSigningPublicKeyData() == signingPub)
}
}
/// Keychain that accepts writes but never retains them models a failed
/// `SecItemAdd` after `clearAllAssociations` so restore must throw instead of
/// reporting success with an empty Nostr identity.
private final class DropWritesKeychain: KeychainManagerProtocol {
private var serviceStorage: [String: [String: Data]] = [:]
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { true }
func getIdentityKey(forKey key: String) -> Data? { nil }
func deleteIdentityKey(forKey key: String) -> Bool { true }
func deleteAllKeychainData() -> Bool {
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) { data = Data() }
func secureClear(_ string: inout String) { string = "" }
func verifyIdentityKeyExists() -> Bool { false }
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { .itemNotFound }
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { .success }
func save(key: String, data: Data, service: String, accessible: CFString?) {
// Pretend to succeed; do not retain read-back must fail.
_ = (key, data, service, accessible)
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func loadWithResult(key: String, service: String) -> KeychainReadResult {
if let data = serviceStorage[service]?[key] {
return .success(data)
}
return .itemNotFound
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
func deleteAll(service: String) {
serviceStorage.removeValue(forKey: service)
}
}