diff --git a/hush-ios/README.md b/hush-ios/README.md index 73fc2ed..17c7f9b 100644 --- a/hush-ios/README.md +++ b/hush-ios/README.md @@ -19,7 +19,7 @@ Add a secret with a key, value, and project. Notes are optional. A definite reje ## Privacy -The catalog holds metadata only. Values and notes are fetched on demand and are not cached to disk. The app covers its content when inactive and locks in the background. Details, drafts, and an app-owned clipboard entry are cleared when locked. The app does not clear text another app has since copied. +The catalog holds metadata only. Values and notes are fetched on demand and are not cached to disk. The app covers its content when inactive and locks in the background. Details, drafts, and an app-owned clipboard entry are cleared when locked. The background lock is the exception for the clipboard: a copied value stays so you can paste it in another app, and iOS removes it 30 seconds after the copy. Back in the app, Lock clears it again. The app does not clear text another app has since copied. Credentials use `WhenPasscodeSetThisDeviceOnly` and user presence. The Rust session and in-flight work use a serial queue. Lock invalidates the session immediately, so a late response cannot unlock it or restore its contents. This reduces retention but does not promise that Swift strings can be securely erased from process memory. diff --git a/hush-ios/Sources/App/AppModel.swift b/hush-ios/Sources/App/AppModel.swift index 6f6bf20..48daea7 100644 --- a/hush-ios/Sources/App/AppModel.swift +++ b/hush-ios/Sources/App/AppModel.swift @@ -172,8 +172,10 @@ extension SessionPhase { clearVault() } - /// Scene went to the background. Lock before anything else, then clear every secret on screen. + /// Scene went to the background. Release the copied value to its system expiry, so another app + /// can paste it. Then lock and clear every secret on screen. func enterBackground() { + clipboard.release() lock() tokenDraft = "" isConnecting = false @@ -181,6 +183,11 @@ extension SessionPhase { connectFailure = nil } + /// Scene is active again. Take back a copied value that is still on the pasteboard, so Lock clears it. + func enterForeground() { + clipboard.reclaim() + } + /// Drops everything that came from an unlocked session. Also called when the store locks itself. func clearVault() { clipboard.clearIfOwned() diff --git a/hush-ios/Sources/App/HushApp.swift b/hush-ios/Sources/App/HushApp.swift index e44f0ba..a17756e 100644 --- a/hush-ios/Sources/App/HushApp.swift +++ b/hush-ios/Sources/App/HushApp.swift @@ -20,6 +20,7 @@ struct HushApp: App { .onChange(of: scenePhase) { _, phase in // Inactive only covers the screen, so a Face ID prompt never locks the app it is unlocking. if phase == .background { model.enterBackground() } + if phase == .active { model.enterForeground() } } } diff --git a/hush-ios/Sources/Core/API.md b/hush-ios/Sources/Core/API.md index e5a67f1..7acac98 100644 --- a/hush-ios/Sources/Core/API.md +++ b/hush-ios/Sources/Core/API.md @@ -145,7 +145,7 @@ Raw bridge, SDK, and Keychain errors never reach the views. A malformed bridge r } ``` -`copy` also schedules `clearIfOwned` after the lifetime. Call `clearIfOwned` on lock if you want the value gone before the system expiry. +`copy` also schedules `clearIfOwned` after the lifetime. Call `clearIfOwned` on lock if you want the value gone before the system expiry. Call `release` when the app leaves the foreground, so another app can paste the value until the system expiry. After `release`, `clearIfOwned` skips the value. Call `reclaim` when the app is active again, so the next `clearIfOwned` clears it if nothing was copied after it. ## Bridge wire format (Core side) diff --git a/hush-ios/Sources/Core/Clipboard.swift b/hush-ios/Sources/Core/Clipboard.swift index 6ed2aa2..69e9111 100644 --- a/hush-ios/Sources/Core/Clipboard.swift +++ b/hush-ios/Sources/Core/Clipboard.swift @@ -6,6 +6,8 @@ import UniformTypeIdentifiers private let pasteboard: UIPasteboard private let lifetime: Duration private var ownedChangeCount: Int? + /// Our value while another app may paste it. `reclaim` makes it ours again. + private var releasedChangeCount: Int? private var expiry: Task? init(pasteboard: UIPasteboard = .general, lifetime: Duration = .seconds(30)) { @@ -19,6 +21,7 @@ import UniformTypeIdentifiers options: Self.options(now: .now, lifetime: lifetime) ) ownedChangeCount = pasteboard.changeCount + releasedChangeCount = nil expiry?.cancel() expiry = Task { [weak self, lifetime] in do { @@ -41,6 +44,23 @@ import UniformTypeIdentifiers pasteboard.items = [] } + /// Lets another app paste our value. `clearIfOwned` skips it until `reclaim`. + /// The system expiry set in `copy` still removes it. + func release() { + expiry?.cancel() + expiry = nil + if let owned = ownedChangeCount { releasedChangeCount = owned } + ownedChangeCount = nil + } + + /// Makes a released value ours again if nothing was copied after it, so a lock clears it. + func reclaim() { + guard let released = releasedChangeCount else { return } + releasedChangeCount = nil + guard pasteboard.changeCount == released else { return } + ownedChangeCount = released + } + static func options(now: Date, lifetime: Duration) -> [UIPasteboard.OptionsKey: Any] { let seconds = Double(lifetime.components.seconds) + Double(lifetime.components.attoseconds) / 1e18 return [.localOnly: true, .expirationDate: now.addingTimeInterval(seconds)] diff --git a/hush-ios/Tests/CoreTests/AppModelTests.swift b/hush-ios/Tests/CoreTests/AppModelTests.swift index e89f35f..c8f5d63 100644 --- a/hush-ios/Tests/CoreTests/AppModelTests.swift +++ b/hush-ios/Tests/CoreTests/AppModelTests.swift @@ -47,6 +47,39 @@ import UIKit } } + @Test func leavingTheAppKeepsTheCopiedValueForPasting() async throws { + let pasteboard = UIPasteboard.general + let previous = pasteboard.items + defer { pasteboard.items = previous } + let backend = FakeBackend() + let store = VaultStore(credentials: FakeCredentialStore(), + deviceAuthenticator: FakeDeviceAuthenticator(), makeConnection: backend.factory) + let model = AppModel(store: store) + try await store.connect(token: "0.test", region: .us) + model.selection = Fixture.stripe.id + await model.loadDetail(Fixture.stripe.id) + model.copyValue() + + let controller = UIHostingController(rootView: RootView(model: model)) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.view.layoutIfNeeded() + defer { window.isHidden = true; window.rootViewController = nil } + + model.enterBackground() + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(100)) + + #expect(store.phase == .locked) + #expect(model.detail == .idle) + #expect(pasteboard.string == Fixture.stripeDetail.value) + + model.enterForeground() + model.lock() + #expect(!pasteboard.hasStrings) + } + @Test(arguments: [false, true]) func refreshedProjectsKeepOnlyAnExistingFilter(removed: Bool) async throws { let refreshed = OSAllocatedUnfairLock(initialState: false)