From 1e7dfa9a5c1fc733af3ab339024fc2952b243a5a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Mon, 26 Oct 2026 11:32:25 -0500 Subject: [PATCH 1/3] fix(hush-ios): a copied value survives leaving the app, so it can be pasted elsewhere Going to the background locked the app and cleared the pasteboard before any other app could paste. Background now releases the copy to the 30-second system expiry. Manual lock and a self-locked session still clear it at once. Closes #103 --- hush-ios/README.md | 2 +- hush-ios/Sources/App/AppModel.swift | 2 ++ hush-ios/Sources/Core/API.md | 2 +- hush-ios/Sources/Core/Clipboard.swift | 12 ++++++-- hush-ios/Tests/CoreTests/AppModelTests.swift | 29 ++++++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/hush-ios/README.md b/hush-ios/README.md index 73fc2ed..29490d3 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. Leaving the app keeps a copied value so you can paste it elsewhere, and iOS removes it after 30 seconds. 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..dab83a3 100644 --- a/hush-ios/Sources/App/AppModel.swift +++ b/hush-ios/Sources/App/AppModel.swift @@ -173,7 +173,9 @@ extension SessionPhase { } /// Scene went to the background. Lock before anything else, then clear every secret on screen. + /// The copied value stays for pasting in another app until its system expiry. func enterBackground() { + clipboard.release() lock() tokenDraft = "" isConnecting = false diff --git a/hush-ios/Sources/Core/API.md b/hush-ios/Sources/Core/API.md index e5a67f1..6528b2e 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. ## Bridge wire format (Core side) diff --git a/hush-ios/Sources/Core/Clipboard.swift b/hush-ios/Sources/Core/Clipboard.swift index 6ed2aa2..d1a35c5 100644 --- a/hush-ios/Sources/Core/Clipboard.swift +++ b/hush-ios/Sources/Core/Clipboard.swift @@ -33,12 +33,18 @@ import UniformTypeIdentifiers /// Clears the pasteboard only if nothing was copied after our value. func clearIfOwned() { + let owned = ownedChangeCount + release() + guard let owned, pasteboard.changeCount == owned else { return } + pasteboard.items = [] + } + + /// Stops tracking our value without clearing it, so another app can paste it. + /// The system expiry set in `copy` still removes it. + func release() { expiry?.cancel() expiry = nil - guard let owned = ownedChangeCount else { return } ownedChangeCount = nil - guard pasteboard.changeCount == owned else { return } - pasteboard.items = [] } static func options(now: Date, lifetime: Duration) -> [UIPasteboard.OptionsKey: Any] { diff --git a/hush-ios/Tests/CoreTests/AppModelTests.swift b/hush-ios/Tests/CoreTests/AppModelTests.swift index e89f35f..fe0c7f7 100644 --- a/hush-ios/Tests/CoreTests/AppModelTests.swift +++ b/hush-ios/Tests/CoreTests/AppModelTests.swift @@ -47,6 +47,35 @@ 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) + } + @Test(arguments: [false, true]) func refreshedProjectsKeepOnlyAnExistingFilter(removed: Bool) async throws { let refreshed = OSAllocatedUnfairLock(initialState: false) From 31abc49a051e614453bde5b31bf5aefe7a5afeba Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Mon, 26 Oct 2026 11:36:17 -0500 Subject: [PATCH 2/3] docs(hush-ios): say the background lock is the clipboard exception --- hush-ios/README.md | 2 +- hush-ios/Sources/App/AppModel.swift | 4 ++-- hush-ios/Sources/Core/API.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hush-ios/README.md b/hush-ios/README.md index 29490d3..c15540a 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. Leaving the app keeps a copied value so you can paste it elsewhere, and iOS removes it after 30 seconds. 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. 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 dab83a3..130220e 100644 --- a/hush-ios/Sources/App/AppModel.swift +++ b/hush-ios/Sources/App/AppModel.swift @@ -172,8 +172,8 @@ extension SessionPhase { clearVault() } - /// Scene went to the background. Lock before anything else, then clear every secret on screen. - /// The copied value stays for pasting in another app until its system expiry. + /// 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() diff --git a/hush-ios/Sources/Core/API.md b/hush-ios/Sources/Core/API.md index 6528b2e..2ed7da5 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. Call `release` when the app leaves the foreground, so another app can paste the value until 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` does nothing and only the system expiry removes the value. ## Bridge wire format (Core side) From 97e5ce55ca563da452095465b3c78696b6db89c9 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Mon, 26 Oct 2026 11:59:56 -0500 Subject: [PATCH 3/3] fix(hush-ios): Lock clears a copied value again after you return to the app --- hush-ios/README.md | 2 +- hush-ios/Sources/App/AppModel.swift | 5 +++++ hush-ios/Sources/App/HushApp.swift | 1 + hush-ios/Sources/Core/API.md | 2 +- hush-ios/Sources/Core/Clipboard.swift | 22 ++++++++++++++++---- hush-ios/Tests/CoreTests/AppModelTests.swift | 4 ++++ 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/hush-ios/README.md b/hush-ios/README.md index c15540a..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 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. 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 130220e..48daea7 100644 --- a/hush-ios/Sources/App/AppModel.swift +++ b/hush-ios/Sources/App/AppModel.swift @@ -183,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 2ed7da5..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. Call `release` when the app leaves the foreground, so another app can paste the value until the system expiry. After `release`, `clearIfOwned` does nothing and only the system expiry removes the value. +`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 d1a35c5..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 { @@ -33,20 +36,31 @@ import UniformTypeIdentifiers /// Clears the pasteboard only if nothing was copied after our value. func clearIfOwned() { - let owned = ownedChangeCount - release() - guard let owned, pasteboard.changeCount == owned else { return } + expiry?.cancel() + expiry = nil + guard let owned = ownedChangeCount else { return } + ownedChangeCount = nil + guard pasteboard.changeCount == owned else { return } pasteboard.items = [] } - /// Stops tracking our value without clearing it, so another app can paste it. + /// 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 fe0c7f7..c8f5d63 100644 --- a/hush-ios/Tests/CoreTests/AppModelTests.swift +++ b/hush-ios/Tests/CoreTests/AppModelTests.swift @@ -74,6 +74,10 @@ import UIKit #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])