From dce1b7dc21a219ce287beb8bbb8310ee1c0fb04b Mon Sep 17 00:00:00 2001 From: Gustavo Schonarth Date: Wed, 4 Nov 2026 20:58:54 -0300 Subject: [PATCH] feat: accept authorized paste stack batches Signed-off-by: Gustavo Schonarth --- .github/workflows/release.yml | 15 +++ Copy/App/AppCoordinator.swift | 6 + Copy/App/AppDelegate.swift | 98 ++++++++++++++ Copy/Copy.entitlements | 12 ++ Copy/Info.plist | 2 + Copy/Intents/CopyShortcuts.swift | 19 +++ .../PasteStack/PasteStackAccessKeychain.swift | 38 ++++++ .../PasteStackBatchConfirmation.swift | 123 ++++++++++++++++++ .../PasteStack/PasteStackCallerIdentity.swift | 41 ++++++ Copy/PasteStack/PasteStackModel.swift | 11 ++ Copy/Settings/PermissionsSettings.swift | 107 +++++++++++++++ Copy/Settings/SettingsSection.swift | 5 + Copy/Settings/SettingsStore.swift | 34 +++++ Copy/Settings/SettingsView.swift | 2 + .../Paste/PasteStackBatchRequest.swift | 25 ++++ .../Paste/PasteStackExternalAccess.swift | 54 ++++++++ .../CopyCore/Paste/PasteStackPermission.swift | 32 +++++ .../CopyCore/Paste/PasteStackQueue.swift | 13 ++ .../PasteStackBatchRequestTests.swift | 50 +++++++ .../PasteStackExternalAccessTests.swift | 59 +++++++++ .../PasteStackPermissionTests.swift | 43 ++++++ .../PasteStackTemporaryTests.swift | 37 ++++++ CopyTests/PasteStackModelTests.swift | 104 +++++++++++++++ Scripts/release.sh | 9 +- docs/paste-stack-integration.md | 61 +++++++++ project.yml | 3 + 26 files changed, 1002 insertions(+), 1 deletion(-) create mode 100644 Copy/Copy.entitlements create mode 100644 Copy/PasteStack/PasteStackAccessKeychain.swift create mode 100644 Copy/PasteStack/PasteStackBatchConfirmation.swift create mode 100644 Copy/PasteStack/PasteStackCallerIdentity.swift create mode 100644 Copy/Settings/PermissionsSettings.swift create mode 100644 CopyCore/Sources/CopyCore/Paste/PasteStackBatchRequest.swift create mode 100644 CopyCore/Sources/CopyCore/Paste/PasteStackExternalAccess.swift create mode 100644 CopyCore/Sources/CopyCore/Paste/PasteStackPermission.swift create mode 100644 CopyCore/Tests/CopyCoreTests/PasteStackBatchRequestTests.swift create mode 100644 CopyCore/Tests/CopyCoreTests/PasteStackExternalAccessTests.swift create mode 100644 CopyCore/Tests/CopyCoreTests/PasteStackPermissionTests.swift create mode 100644 docs/paste-stack-integration.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3530722..396e37b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: # round-trip. - name: Verify required secrets are configured env: + SENTINEL_PROFILE: ${{ secrets.COPY_PROVISIONING_PROFILE_BASE64 }} SENTINEL_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12_BASE64 }} SENTINEL_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} SENTINEL_SPARKLE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -36,6 +37,7 @@ jobs: SENTINEL_NOTARY_ISSUER_ID: ${{ secrets.NOTARY_API_ISSUER_ID }} run: | missing=() + [[ -z "$SENTINEL_PROFILE" ]] && missing+=("COPY_PROVISIONING_PROFILE_BASE64") [[ -z "$SENTINEL_CERT_P12" ]] && missing+=("DEVELOPER_ID_CERT_P12_BASE64") [[ -z "$SENTINEL_CERT_PASSWORD" ]] && missing+=("DEVELOPER_ID_CERT_PASSWORD") [[ -z "$SENTINEL_SPARKLE_KEY" ]] && missing+=("SPARKLE_PRIVATE_KEY") @@ -93,6 +95,19 @@ jobs: rm -f "$CERT_PATH" + - name: Install Developer ID provisioning profile + env: + PROFILE_BASE64: ${{ secrets.COPY_PROVISIONING_PROFILE_BASE64 }} + run: | + PROFILE_PATH="$RUNNER_TEMP/copy.provisionprofile" + echo "$PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH" + PROFILE_PLIST="$RUNNER_TEMP/copy-profile.plist" + security cms -D -i "$PROFILE_PATH" -o "$PROFILE_PLIST" + PROFILE_UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST") + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.provisionprofile" + echo "COPY_PROVISIONING_PROFILE=$PROFILE_UUID" >> "$GITHUB_ENV" + - name: Write Sparkle private key env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 4eaea49..21f1af8 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -755,6 +755,12 @@ final class AppCoordinator { /// `AppDelegate` to reflect a checkmark on the "Paste Stack" menu item. var isPasteStackActive: Bool { pasteStackModel.isActive } + var pasteStackSnapshot: PasteStackQueue { pasteStackModel.queue } + + func replacePasteStack(with texts: [String], ifUnchanged previous: PasteStackQueue) -> Bool { + pasteStackModel.replaceWithBatch(texts, ifUnchanged: previous) + } + /// Flips the Paste Stack on or off. All activation/deactivation — the palette, the /// CGEvent tap, and the `.pasteNextFromStack` fallback hotkey — fans out from /// `pasteStackModel.isActive`'s `didSet`, wired to `onActiveChange` in `init()`. diff --git a/Copy/App/AppDelegate.swift b/Copy/App/AppDelegate.swift index cdf36df..e888d35 100644 --- a/Copy/App/AppDelegate.swift +++ b/Copy/App/AppDelegate.swift @@ -22,10 +22,108 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// `applyHideMenuBarIconSetting`. `nil` means no status item exists right now. private var statusItem: NSStatusItem? private var coordinator: AppCoordinator! + private var handlingExternalBatch = false private let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + func applicationWillFinishLaunching(_ notification: Notification) { + NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(pasteStackPing(_:withReplyEvent:)), + forEventClass: 0x43705374, andEventID: 0x70696e67) + NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(pasteStackBatch(_:withReplyEvent:)), + forEventClass: 0x43705374, andEventID: 0x62617463) + } + + @objc private func pasteStackPing(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { + pasteStackReply(coordinator?.settings.allowsExternalPasteStackRequests() == true ? "capable" : "disabled", to: reply) + } + + @objc private func pasteStackBatch(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { + let wallTime = Date().timeIntervalSince1970 + let uptime = ProcessInfo.processInfo.systemUptime + guard let payload = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject)), + payload.descriptorType == DescType(typeUTF8Text), + let descriptor = payload.aeDesc, AEGetDescDataSize(descriptor) <= 6_295_552, + let request = PasteStackBatchRequest.decode(payload.data, now: wallTime) else { + pasteStackReply("invalid", to: reply) + return + } + guard !request.isExpired(at: wallTime) else { + pasteStackReply("expired", to: reply) + return + } + pasteStackReply(acceptPasteStackBatch(request.items, caller: pasteStackCaller(from: event), + deadline: request.monotonicDeadline(wallTime: wallTime, uptime: uptime)), to: reply) + } + + private func pasteStackReply(_ status: String, to reply: NSAppleEventDescriptor) { + let data = Data("{\"version\":1,\"status\":\"\(status)\"}".utf8) + guard let value = NSAppleEventDescriptor(descriptorType: DescType(typeUTF8Text), data: data) else { return } + reply.setParam(value, forKeyword: AEKeyword(keyDirectObject)) + } + + func requestPasteStackBatch(_ texts: [String]) -> Bool { + acceptPasteStackBatch(texts, caller: nil) == "accepted" + } + + private func acceptPasteStackBatch(_ texts: [String], caller: PasteStackCaller?, + deadline: TimeInterval? = nil) -> String { + func expired() -> Bool { + deadline.map { ProcessInfo.processInfo.systemUptime >= $0 } ?? false + } + guard !expired() else { return "expired" } + guard !handlingExternalBatch, coordinator != nil else { return "busy" } + guard coordinator.settings.allowsExternalPasteStackRequests() else { return "disabled" } + var probe = PasteStackQueue() + guard probe.replaceWithBatch(texts) else { return "invalid" } + handlingExternalBatch = true + defer { handlingExternalBatch = false } + while !expired() { + let trusted = caller.map { caller in + coordinator.settings.pasteStackPermissions.contains { $0.permits(caller) } + } ?? false + let previous = coordinator.pasteStackSnapshot + var rememberPermission: PasteStackPermission? + if !trusted || !previous.isEmpty { + let signed: PasteStackPermission? + if let caller, let signingID = caller.signingID, let certificate = caller.certificateSHA256 { + signed = .signed(bundleID: caller.bundleID, signingID: signingID, certificateSHA256: certificate) + } else { + signed = nil + } + let alert = pasteStackBatchConfirmation(texts, hideSecrets: coordinator.settings.hideSecrets, + hideDuringScreenSharing: coordinator.settings.hideDuringScreenSharing, + caller: pasteStackCallerDescription(caller), + canRemember: !trusted && signed != nil, + hasExistingItems: !previous.isEmpty) + NSApp.activate(ignoringOtherApps: true) + let decision = alert.runModal() + guard decision == .alertSecondButtonReturn || (signed != nil && decision == .alertThirdButtonReturn) else { + return "cancelled" + } + guard !expired() else { return "expired" } + if decision == .alertThirdButtonReturn { rememberPermission = signed } + } + guard !expired() else { return "expired" } + guard coordinator.settings.allowsExternalPasteStackRequests() else { return "disabled" } + if trusted, let caller, + !coordinator.settings.pasteStackPermissions.contains(where: { $0.permits(caller) }) { continue } + guard coordinator.replacePasteStack(with: texts, ifUnchanged: previous) else { continue } + if let permission = rememberPermission, + !coordinator.settings.pasteStackPermissions.contains(permission) { + if !coordinator.settings.setPasteStackPermissions(coordinator.settings.pasteStackPermissions + [permission]) { + let alert = NSAlert() + alert.messageText = "Could not remember this app" + alert.informativeText = "The request was accepted, but Keychain access was unavailable. Future requests will ask for approval." + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + return "accepted" + } + return "expired" + } + func applicationDidFinishLaunching(_ notification: Notification) { do { coordinator = try AppCoordinator() diff --git a/Copy/Copy.entitlements b/Copy/Copy.entitlements new file mode 100644 index 0000000..1fa912c --- /dev/null +++ b/Copy/Copy.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.application-identifier + $(AppIdentifierPrefix)$(CFBundleIdentifier) + keychain-access-groups + + $(AppIdentifierPrefix)$(CFBundleIdentifier) + + + diff --git a/Copy/Info.plist b/Copy/Info.plist index 03e225c..38377b5 100644 --- a/Copy/Info.plist +++ b/Copy/Info.plist @@ -6,6 +6,8 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Copy + CopyPasteStackProtocolVersion + 1 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/Copy/Intents/CopyShortcuts.swift b/Copy/Intents/CopyShortcuts.swift index 0e32ff7..66b7601 100644 --- a/Copy/Intents/CopyShortcuts.swift +++ b/Copy/Intents/CopyShortcuts.swift @@ -142,3 +142,22 @@ struct CopyShortcuts: AppShortcutsProvider { ) } } + +struct AddTextToPasteStackIntent: AppIntent { + static var title: LocalizedStringResource = "Add Text to Paste Stack" + static var description: IntentDescription? = IntentDescription( + "Requests permission to replace the Paste Stack with text items.") + static var openAppWhenRun = true + + @Parameter(title: "Text Items") + var texts: [String] + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let delegate = NSApp.delegate as? AppDelegate, + delegate.requestPasteStackBatch(texts) else { + return .result(dialog: "The Paste Stack was not changed.") + } + return .result(dialog: "Added text to Paste Stack.") + } +} diff --git a/Copy/PasteStack/PasteStackAccessKeychain.swift b/Copy/PasteStack/PasteStackAccessKeychain.swift new file mode 100644 index 0000000..a35c0b0 --- /dev/null +++ b/Copy/PasteStack/PasteStackAccessKeychain.swift @@ -0,0 +1,38 @@ +import CopyCore +import Foundation +import Security + +struct PasteStackAccessKeychain { + private static let service = "com.tarikbc.Copy.paste-stack-access" + private static let account = "external-apps" + + private static var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true] + } + + static func read() -> PasteStackExternalAccess.State? { + var query = query + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return try? JSONDecoder().decode(PasteStackExternalAccess.State.self, from: data) + } + + static func write(_ state: PasteStackExternalAccess.State) -> Bool { + guard let data = try? JSONEncoder().encode(state) else { return false } + let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + return SecItemAdd(item as CFDictionary, nil) == errSecSuccess + } + return status == errSecSuccess + } +} diff --git a/Copy/PasteStack/PasteStackBatchConfirmation.swift b/Copy/PasteStack/PasteStackBatchConfirmation.swift new file mode 100644 index 0000000..3165ff0 --- /dev/null +++ b/Copy/PasteStack/PasteStackBatchConfirmation.swift @@ -0,0 +1,123 @@ +import AppKit +import CopyCore +import SwiftUI + +func pasteStackCallerDescription(_ caller: PasteStackCaller?) -> String? { + guard let caller else { return nil } + let verified = caller.signingID?.isEmpty == false && caller.certificateSHA256?.isEmpty == false + let description = String((caller.bundleID + " (" + caller.canonicalPath + ")").prefix(500)) + let safe = description.unicodeScalars.map { scalar in + CharacterSet.controlCharacters.contains(scalar) || scalar.properties.generalCategory == .format + ? String(format: "⟦U+%04X⟧", scalar.value) : String(scalar) + }.joined() + return verified ? safe : "Unverified app: " + safe +} + +private let batchPreviewLimit = 2_000 +private let secretScanLimit = 250_000 + +private func batchPreviewRanges(_ text: String) -> [NSRange] { + let source = text as NSString + var ranges: [NSRange] = [] + var start = 0 + while start < source.length { + var end = min(start + batchPreviewLimit, source.length) + if end < source.length, (0xD800...0xDBFF).contains(source.character(at: end - 1)), + (0xDC00...0xDFFF).contains(source.character(at: end)) { + end -= 1 + } + ranges.append(NSRange(location: start, length: end - start)) + start = end + } + return ranges.isEmpty ? [NSRange(location: 0, length: 0)] : ranges +} + +func pasteStackPreviewPageCount(_ text: String) -> Int { batchPreviewRanges(text).count } + +func pasteStackPreviewText(_ text: String, hideSecrets: Bool, revealed: Bool, page: Int = 0) -> String { + let ranges = batchPreviewRanges(text) + guard ranges.indices.contains(page) else { return "" } + let oversized = (text as NSString).length > secretScanLimit + if hideSecrets && !revealed && oversized { + return "This item is too long to scan for secrets. Select Show content to review it." + } + let display = hideSecrets && !revealed ? SecretDetector.masked(text) : text + let chunk = (display as NSString).substring(with: ranges[page]) + return chunk.unicodeScalars.map { scalar in + if CharacterSet.controlCharacters.contains(scalar) || scalar.properties.generalCategory == .format { + return String(format: "⟦U+%04X⟧", scalar.value) + } + return String(scalar) + }.joined() +} + +struct BatchReviewList: View { + let texts: [String] + let hideSecrets: Bool + @State private var revealed: Set = [] + @State private var pages: [Int: Int] = [:] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(texts.indices, id: \.self) { index in + let text = texts[index] + let oversized = (text as NSString).length > secretScanLimit + let hasSecret = hideSecrets && (oversized || SecretDetector.masked(text) != text) + let total = pasteStackPreviewPageCount(text) + let page = min(pages[index] ?? 0, total - 1) + VStack(alignment: .leading, spacing: 5) { + HStack { + Text("Item \(index + 1)").font(.caption.weight(.semibold)) + Spacer() + if hasSecret { + Button(revealed.contains(index) ? "Hide content" : "Show content") { + if revealed.contains(index) { revealed.remove(index) } + else { revealed.insert(index) } + } + .buttonStyle(.link) + } + } + Text(verbatim: pasteStackPreviewText(text, hideSecrets: hideSecrets, + revealed: revealed.contains(index), page: page)) + .textSelection(.enabled) + if total > 1 && (!oversized || !hideSecrets || revealed.contains(index)) { + HStack { + Button("Previous") { pages[index] = page - 1 }.disabled(page == 0) + Text("Page \(page + 1) of \(total)").font(.caption) + Button("Next") { pages[index] = page + 1 }.disabled(page + 1 == total) + } + .controlSize(.small) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(4) + } + .frame(width: 440, height: 220) + } +} + +@MainActor +func pasteStackBatchConfirmation(_ texts: [String], hideSecrets: Bool, hideDuringScreenSharing: Bool, + caller: String? = nil, canRemember: Bool = false, + hasExistingItems: Bool = true) -> NSAlert { + let alert = NSAlert() + alert.messageText = "Add items to Paste Stack?" + let source = caller ?? "An action with an unverified origin" + let action = hasExistingItems ? "replace your Paste Stack with" : "add to your Paste Stack" + alert.informativeText = "\(source) wants to \(action) \(texts.count) text items. Review the items below." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: hasExistingItems ? "Replace Stack" : "Allow Once") + if canRemember { alert.addButton(withTitle: "Allow and Remember") } + alert.buttons[0].keyEquivalent = "\r" + for button in alert.buttons.dropFirst() { button.keyEquivalent = "" } + let preview = NSHostingView(rootView: BatchReviewList(texts: texts, hideSecrets: hideSecrets)) + preview.frame = NSRect(x: 0, y: 0, width: 440, height: 220) + alert.accessoryView = preview + alert.window.sharingType = hideDuringScreenSharing ? .none : .readOnly + return alert +} diff --git a/Copy/PasteStack/PasteStackCallerIdentity.swift b/Copy/PasteStack/PasteStackCallerIdentity.swift new file mode 100644 index 0000000..68cc3d6 --- /dev/null +++ b/Copy/PasteStack/PasteStackCallerIdentity.swift @@ -0,0 +1,41 @@ +import AppKit +import CopyCore +import CryptoKit +import Security + +func pasteStackCaller(from event: NSAppleEventDescriptor) -> PasteStackCaller? { + guard let token = event.attributeDescriptor(forKeyword: AEKeyword(keyActualSenderAuditToken)), + token.descriptorType == DescType(typeAuditToken), + token.data.count == MemoryLayout.size else { return nil } + var code: SecCode? + let attributes = [kSecGuestAttributeAudit: token.data] as CFDictionary + guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) == errSecSuccess, + let code else { return nil } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, + let staticCode else { return nil } + var codeURL: CFURL? + guard SecCodeCopyPath(staticCode, [], &codeURL) == errSecSuccess, + let codeURL else { return nil } + let actualURL = (codeURL as URL).standardizedFileURL.resolvingSymlinksInPath() + var bundleURL = actualURL + while bundleURL.pathExtension.lowercased() != "app" && bundleURL.path != "/" { + bundleURL.deleteLastPathComponent() + } + guard bundleURL.pathExtension.lowercased() == "app", let bundle = Bundle(url: bundleURL), + let bundleID = bundle.bundleIdentifier, !bundleID.isEmpty, + let executable = bundle.executableURL?.standardizedFileURL.resolvingSymlinksInPath(), + actualURL == bundleURL || actualURL == executable else { return nil } + var caller = PasteStackCaller(bundleID: bundleID, canonicalPath: bundleURL.path) + guard SecCodeCheckValidity(code, [], nil) == errSecSuccess else { return caller } + var rawInfo: CFDictionary? + guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &rawInfo) == errSecSuccess, + let info = rawInfo as? [CFString: Any], let signingID = info[kSecCodeInfoIdentifier] as? String, + !signingID.isEmpty, let certificates = info[kSecCodeInfoCertificates] as? [SecCertificate], + let certificate = certificates.first else { return caller } + let fingerprint = SHA256.hash(data: SecCertificateCopyData(certificate) as Data) + .map { String(format: "%02x", $0) }.joined() + caller.signingID = signingID + caller.certificateSHA256 = fingerprint + return caller +} diff --git a/Copy/PasteStack/PasteStackModel.swift b/Copy/PasteStack/PasteStackModel.swift index f080103..0bacd79 100644 --- a/Copy/PasteStack/PasteStackModel.swift +++ b/Copy/PasteStack/PasteStackModel.swift @@ -109,6 +109,17 @@ final class PasteStackModel { if !isActive { isActive = true } } + func replaceWithBatch(_ texts: [String]) -> Bool { + guard queue.replaceWithBatch(texts) else { return false } + if !isActive { isActive = true } + return true + } + + func replaceWithBatch(_ texts: [String], ifUnchanged previous: PasteStackQueue) -> Bool { + guard queue == previous else { return false } + return replaceWithBatch(texts) + } + /// Advances the queue and resolves representations for the next item, skipping /// (and retrying) any uuid whose item or representations have gone missing since /// being queued. Returns `nil` once the queue is exhausted. Unlike `items()`, this diff --git a/Copy/Settings/PermissionsSettings.swift b/Copy/Settings/PermissionsSettings.swift new file mode 100644 index 0000000..2489304 --- /dev/null +++ b/Copy/Settings/PermissionsSettings.swift @@ -0,0 +1,107 @@ +import AppKit +import CopyCore +import SwiftUI +import UniformTypeIdentifiers + +struct PermissionsSettings: View { + @Bindable var settings: SettingsStore + @State private var selected: PasteStackPermission? + @State private var accessError = false + + var body: some View { + Form { + Section { + Toggle("Allow requests from external apps", isOn: Binding( + get: { settings.pasteStackExternalAccessEnabled }, + set: { if !settings.setExternalPasteStackAccess($0) { accessError = true } } + )) + } footer: { + Text("Enabling this may ask for authorization to access the Keychain.") + .font(.footnote).foregroundStyle(.secondary) + } + Section { + if settings.pasteStackPermissions.isEmpty { + Text("No authorized apps").foregroundStyle(.secondary) + } else { + List(settings.pasteStackPermissions, id: \.self, selection: $selected) { permission in + permissionRow(permission).tag(permission) + } + .frame(minHeight: 160) + } + HStack { + Button { addDeveloperException() } label: { Image(systemName: "plus") } + .accessibilityLabel("Add Developer Exception") + .disabled(!settings.pasteStackExternalAccessEnabled) + Button { + guard let selected else { return } + if settings.setPasteStackPermissions(settings.pasteStackPermissions.filter { $0 != selected }) { + self.selected = nil + } else { + accessError = true + } + } label: { Image(systemName: "minus") } + .accessibilityLabel("Forget App") + .disabled(selected == nil || !settings.pasteStackExternalAccessEnabled) + Spacer() + } + .buttonStyle(.bordered) + .controlSize(.small) + } header: { + Text("Paste Stack Access") + } footer: { + Text("Apps listed here can send items to Paste Stack. Existing items still require confirmation before replacement.") + .font(.footnote).foregroundStyle(.secondary) + } + Section { + Text("Unsafe developer exceptions match an app's name and location without verifying its signing certificate.") + Text("Only enable this for apps you are developing.") + .font(.footnote).foregroundStyle(.secondary) + } header: { + Text("Developer Exceptions") + } + } + .formStyle(.grouped) + .alert("Keychain access unavailable", isPresented: $accessError) { + Button("OK", role: .cancel) { } + } message: { + Text("Copy could not securely save this setting. Check Keychain access and try again.") + } + } + + @ViewBuilder + private func permissionRow(_ permission: PasteStackPermission) -> some View { + switch permission { + case let .signed(bundleID, _, _): + VStack(alignment: .leading) { + Text(bundleID) + Text("Verified signature").font(.caption).foregroundStyle(.secondary) + } + case let .developer(bundleID, canonicalPath): + VStack(alignment: .leading) { + Text(bundleID) + Text("Unsafe developer exception").font(.caption).foregroundStyle(.secondary) + Text(canonicalPath).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + } + } + + private func addDeveloperException() { + guard settings.allowsExternalPasteStackRequests() else { return } + let panel = NSOpenPanel() + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url?.standardizedFileURL.resolvingSymlinksInPath(), + let bundleID = Bundle(url: url)?.bundleIdentifier, !bundleID.isEmpty else { return } + let alert = NSAlert() + alert.messageText = "Add unsafe developer exception?" + alert.informativeText = "Copy will trust any app at \(url.path) with the identifier \(bundleID), even if its signing certificate changes. Only enable this for apps you are developing." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Add Exception") + guard alert.runModal() == .alertSecondButtonReturn else { return } + let permission = PasteStackPermission.developer(bundleID: bundleID, canonicalPath: url.path) + if !settings.pasteStackPermissions.contains(permission) { + if !settings.setPasteStackPermissions(settings.pasteStackPermissions + [permission]) { accessError = true } + } + } +} diff --git a/Copy/Settings/SettingsSection.swift b/Copy/Settings/SettingsSection.swift index 9253112..0fde332 100644 --- a/Copy/Settings/SettingsSection.swift +++ b/Copy/Settings/SettingsSection.swift @@ -8,6 +8,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case shortcuts case history case privacy + case permissions case about var id: String { rawValue } @@ -18,6 +19,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .shortcuts: return "Shortcuts" case .history: return "History" case .privacy: return "Privacy" + case .permissions: return "Permissions" case .about: return "About" } } @@ -28,6 +30,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .shortcuts: return "keyboard" case .history: return "clock" case .privacy: return "hand.raised" + case .permissions: return "lock.shield" case .about: return "info.circle" } } @@ -40,6 +43,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .shortcuts: return .blue case .history: return .orange case .privacy: return .red + case .permissions: return .purple case .about: return Tokens.electricBlue } } @@ -51,6 +55,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .shortcuts: return "Set the keys that summon Copy and paste for you." case .history: return "How long items are kept and what Copy enriches." case .privacy: return "Keep sensitive apps and shared screens out of history." + case .permissions: return "Manage apps allowed to send items to Paste Stack." case .about: return "Version, updates, and project links." } } diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index f979bd1..46aee17 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -1,4 +1,5 @@ import AppKit +import CopyCore import Foundation import Observation @@ -157,6 +158,7 @@ final class SettingsStore { static let hapticFeedbackKey = "hapticFeedback" static let hideSecretsKey = "hideSecrets" static let secretLifetimeKey = "secretLifetime" + static let pasteStackExternalAccessKey = "pasteStackExternalAccessEnabled" var retention: RetentionPeriod { didSet { @@ -179,6 +181,34 @@ final class SettingsStore { } } + var pasteStackExternalAccessEnabled: Bool { + didSet { defaults.set(pasteStackExternalAccessEnabled, forKey: Self.pasteStackExternalAccessKey) } + } + + @ObservationIgnored private var pasteStackAccess: PasteStackExternalAccess! + private var pasteStackPermissionsRevision = 0 + + var pasteStackPermissions: [PasteStackPermission] { + _ = pasteStackPermissionsRevision + return pasteStackAccess.permissions() + } + + func setPasteStackPermissions(_ permissions: [PasteStackPermission]) -> Bool { + guard pasteStackAccess.setPermissions(permissions) else { return false } + pasteStackPermissionsRevision += 1 + return true + } + + func allowsExternalPasteStackRequests() -> Bool { + pasteStackExternalAccessEnabled && pasteStackAccess.permitsRequests() + } + + func setExternalPasteStackAccess(_ enabled: Bool) -> Bool { + let success = enabled ? pasteStackAccess.enable() : pasteStackAccess.disable() + if success { pasteStackExternalAccessEnabled = enabled } + return success + } + var excludedBundleIDs: [String] { didSet { guard excludedBundleIDs != oldValue else { return } @@ -348,6 +378,7 @@ final class SettingsStore { } else { retention = .unlimited } + pasteStackExternalAccessEnabled = defaults.bool(forKey: Self.pasteStackExternalAccessKey) fetchLinkPreviews = (defaults.object(forKey: Self.fetchLinkPreviewsKey) as? Bool) ?? true recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false @@ -382,6 +413,9 @@ final class SettingsStore { } else { excludedBundleIDs = [] } + pasteStackAccess = PasteStackExternalAccess(enabled: pasteStackExternalAccessEnabled, + read: PasteStackAccessKeychain.read, + write: PasteStackAccessKeychain.write) } func addExcludedApp(bundleID: String) { diff --git a/Copy/Settings/SettingsView.swift b/Copy/Settings/SettingsView.swift index a39050f..774b22d 100644 --- a/Copy/Settings/SettingsView.swift +++ b/Copy/Settings/SettingsView.swift @@ -51,6 +51,8 @@ struct SettingsView: View { pane(.history) { HistorySettings(settings: settings, store: store) } case .privacy: pane(.privacy) { PrivacySettings(settings: settings) } + case .permissions: + pane(.permissions) { PermissionsSettings(settings: settings) } case .about: AboutSettings(settings: settings) } diff --git a/CopyCore/Sources/CopyCore/Paste/PasteStackBatchRequest.swift b/CopyCore/Sources/CopyCore/Paste/PasteStackBatchRequest.swift new file mode 100644 index 0000000..a43defb --- /dev/null +++ b/CopyCore/Sources/CopyCore/Paste/PasteStackBatchRequest.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct PasteStackBatchRequest: Decodable, Equatable, Sendable { + public let version: Int + public let items: [String] + public let expiresAt: TimeInterval + + public func isExpired(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + expiresAt <= now + } + + public func monotonicDeadline(wallTime: TimeInterval = Date().timeIntervalSince1970, + uptime: TimeInterval = ProcessInfo.processInfo.systemUptime) -> TimeInterval { + uptime + max(0, expiresAt - wallTime) + } + + public static func decode(_ data: Data, now: TimeInterval = Date().timeIntervalSince1970) -> PasteStackBatchRequest? { + guard data.count <= 6_295_552, + let request = try? JSONDecoder().decode(Self.self, from: data), + request.version == 1, request.expiresAt.isFinite, now.isFinite, + request.expiresAt <= now + 300 else { return nil } + var queue = PasteStackQueue() + return queue.replaceWithBatch(request.items) ? request : nil + } +} diff --git a/CopyCore/Sources/CopyCore/Paste/PasteStackExternalAccess.swift b/CopyCore/Sources/CopyCore/Paste/PasteStackExternalAccess.swift new file mode 100644 index 0000000..a80bb9c --- /dev/null +++ b/CopyCore/Sources/CopyCore/Paste/PasteStackExternalAccess.swift @@ -0,0 +1,54 @@ +import Foundation + +public final class PasteStackExternalAccess { + public struct State: Codable { + public var enabled: Bool + public var permissions: [PasteStackPermission] + + public init(enabled: Bool, permissions: [PasteStackPermission] = []) { + self.enabled = enabled + self.permissions = permissions + } + } + + private var enabled: Bool + private let read: () -> State? + private let write: (State) -> Bool + + public init(enabled: Bool, read: @escaping () -> State?, write: @escaping (State) -> Bool) { + self.enabled = enabled + self.read = read + self.write = write + } + + public func permitsRequests() -> Bool { + enabled && (read()?.enabled == true) + } + + public func enable() -> Bool { + var state = read() ?? State(enabled: false) + state.enabled = true + guard write(state) else { return false } + enabled = true + return true + } + + public func disable() -> Bool { + guard var state = read() else { return false } + state.enabled = false + guard write(state) else { return false } + enabled = false + return true + } + + public func permissions() -> [PasteStackPermission] { + guard enabled, let state = read(), state.enabled else { return [] } + return state.permissions + } + + public func setPermissions(_ permissions: [PasteStackPermission]) -> Bool { + guard enabled, var state = read(), state.enabled else { return false } + state.permissions = permissions + return write(state) + } +} diff --git a/CopyCore/Sources/CopyCore/Paste/PasteStackPermission.swift b/CopyCore/Sources/CopyCore/Paste/PasteStackPermission.swift new file mode 100644 index 0000000..ab52882 --- /dev/null +++ b/CopyCore/Sources/CopyCore/Paste/PasteStackPermission.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct PasteStackCaller: Equatable, Sendable { + public var bundleID: String + public var canonicalPath: String + public var signingID: String? + public var certificateSHA256: String? + + public init(bundleID: String, canonicalPath: String, signingID: String? = nil, certificateSHA256: String? = nil) { + self.bundleID = bundleID + self.canonicalPath = canonicalPath + self.signingID = signingID + self.certificateSHA256 = certificateSHA256 + } +} + +public enum PasteStackPermission: Codable, Hashable, Sendable { + case signed(bundleID: String, signingID: String, certificateSHA256: String) + case developer(bundleID: String, canonicalPath: String) + + public func permits(_ caller: PasteStackCaller) -> Bool { + switch self { + case let .signed(bundleID, signingID, certificateSHA256): + return !bundleID.isEmpty && !signingID.isEmpty && !certificateSHA256.isEmpty + && caller.bundleID == bundleID && caller.signingID == signingID + && caller.certificateSHA256 == certificateSHA256 + case let .developer(bundleID, canonicalPath): + return !bundleID.isEmpty && !canonicalPath.isEmpty + && caller.bundleID == bundleID && caller.canonicalPath == canonicalPath + } + } +} diff --git a/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift b/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift index c6612ec..f6a1da6 100644 --- a/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift +++ b/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift @@ -15,6 +15,19 @@ public struct PasteStackQueue: Equatable, Sendable { } } + public mutating func replaceWithBatch(_ texts: [String]) -> Bool { + guard !texts.isEmpty, texts.count <= 100 else { return false } + var bytes = 0 + for text in texts { + guard !text.isEmpty, text.utf8.count <= 1_048_576 - bytes else { return false } + bytes += text.utf8.count + } + clear() + isLIFO = false + enqueueTexts(texts) + return true + } + public func temporaryEntry(for uuid: String) -> PasteStackTextEntry? { temporaryEntries[uuid] } diff --git a/CopyCore/Tests/CopyCoreTests/PasteStackBatchRequestTests.swift b/CopyCore/Tests/CopyCoreTests/PasteStackBatchRequestTests.swift new file mode 100644 index 0000000..85ef701 --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/PasteStackBatchRequestTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import CopyCore + +final class PasteStackBatchRequestTests: XCTestCase { + func testValidRequestPreservesDuplicateEntriesAndWhitespace() throws { + let data = Data(#"{"version":1,"items":[" one ","same","same"],"expiresAt":1240}"#.utf8) + let request = try XCTUnwrap(PasteStackBatchRequest.decode(data, now: 1000)) + XCTAssertEqual(request.version, 1) + XCTAssertEqual(request.items, [" one ", "same", "same"]) + XCTAssertFalse(request.isExpired(at: 1239)) + XCTAssertTrue(request.isExpired(at: 1240)) + XCTAssertTrue(request.isExpired(at: 1241)) + } + + func testDeadlineUsesMonotonicTimeAfterReceipt() throws { + let data = Data(#"{"version":1,"items":["a"],"expiresAt":1240}"#.utf8) + let request = try XCTUnwrap(PasteStackBatchRequest.decode(data, now: 1000)) + let deadline = request.monotonicDeadline(wallTime: 1000, uptime: 500) + XCTAssertEqual(deadline, 740) + XCTAssertLessThan(739, deadline) + XCTAssertGreaterThanOrEqual(740, deadline) + } + + func testMissingOrDistantExpiryIsRejected() throws { + let missing = Data(#"{"version":1,"items":["a"]}"#.utf8) + XCTAssertNil(PasteStackBatchRequest.decode(missing, now: 1000)) + let tooFar = Data(#"{"version":1,"items":["a"],"expiresAt":1301}"#.utf8) + XCTAssertNil(PasteStackBatchRequest.decode(tooFar, now: 1000)) + let boundary = Data(#"{"version":1,"items":["a"],"expiresAt":1300}"#.utf8) + XCTAssertNotNil(PasteStackBatchRequest.decode(boundary, now: 1000)) + let nonfinite = Data(#"{"version":1,"items":["a"],"expiresAt":1e999}"#.utf8) + XCTAssertNil(PasteStackBatchRequest.decode(nonfinite, now: 1000)) + let expired = Data(#"{"version":1,"items":["a"],"expiresAt":999}"#.utf8) + XCTAssertTrue(try XCTUnwrap(PasteStackBatchRequest.decode(expired, now: 1000)).isExpired(at: 1000)) + } + + func testInvalidVersionShapeAndSizeAreRejected() throws { + for text in [#"{"version":2,"items":["a"],"expiresAt":1240}"#, + #"{"version":1,"items":[],"expiresAt":1240}"#, + #"{"version":1,"items":[""],"expiresAt":1240}"#, + #"{"version":1,"items":[3],"expiresAt":1240}"#] { + XCTAssertNil(PasteStackBatchRequest.decode(Data(text.utf8), now: 1000)) + } + let tooMany = try JSONSerialization.data(withJSONObject: ["version": 1, "items": Array(repeating: "a", count: 101), "expiresAt": 1240]) + XCTAssertNil(PasteStackBatchRequest.decode(tooMany, now: 1000)) + let tooMuch = try JSONSerialization.data(withJSONObject: ["version": 1, "items": [String(repeating: "a", count: 1_048_577)], "expiresAt": 1240]) + XCTAssertNil(PasteStackBatchRequest.decode(tooMuch, now: 1000)) + XCTAssertNil(PasteStackBatchRequest.decode(Data(repeating: 0, count: 6_295_553), now: 1000)) + } +} diff --git a/CopyCore/Tests/CopyCoreTests/PasteStackExternalAccessTests.swift b/CopyCore/Tests/CopyCoreTests/PasteStackExternalAccessTests.swift new file mode 100644 index 0000000..1f76178 --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/PasteStackExternalAccessTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import CopyCore + +final class PasteStackExternalAccessTests: XCTestCase { + func testDisabledDoesNotReadProtectedStorage() { + var reads = 0 + let access = PasteStackExternalAccess(enabled: false, read: { reads += 1; return nil }, write: { _ in true }) + XCTAssertFalse(access.permitsRequests()) + XCTAssertEqual(reads, 0) + } + + func testModifiedPreferenceCannotEnableWithoutProtectedConsent() { + let access = PasteStackExternalAccess(enabled: true, read: { nil }, write: { _ in true }) + XCTAssertFalse(access.permitsRequests()) + } + + func testEnablePersistsConsentAndPermissions() { + var protected: PasteStackExternalAccess.State? + let access = PasteStackExternalAccess(enabled: false, read: { protected }, write: { protected = $0; return true }) + XCTAssertTrue(access.enable()) + XCTAssertTrue(access.permitsRequests()) + XCTAssertEqual(protected?.enabled, true) + } + + func testFailedConsentWriteStaysDisabled() { + let access = PasteStackExternalAccess(enabled: false, read: { nil }, write: { _ in false }) + XCTAssertFalse(access.enable()) + XCTAssertFalse(access.permitsRequests()) + } + + func testPermissionChangesAreProtectedAndRevocable() { + var protected: PasteStackExternalAccess.State? + let access = PasteStackExternalAccess(enabled: false, read: { protected }, write: { protected = $0; return true }) + let permission = PasteStackPermission.developer(bundleID: "org.example.app", canonicalPath: "/Applications/Test.app") + XCTAssertFalse(access.setPermissions([permission])) + XCTAssertTrue(access.enable()) + XCTAssertTrue(access.setPermissions([permission])) + XCTAssertEqual(access.permissions(), [permission]) + XCTAssertTrue(access.setPermissions([])) + XCTAssertTrue(access.permissions().isEmpty) + XCTAssertTrue(access.disable()) + XCTAssertFalse(access.permitsRequests()) + XCTAssertFalse(protected!.enabled) + } + + func testFailedDisableDoesNotReportSuccess() { + var canWrite = true + var protected: PasteStackExternalAccess.State? + let access = PasteStackExternalAccess(enabled: false, read: { protected }, write: { state in + guard canWrite else { return false } + protected = state + return true + }) + XCTAssertTrue(access.enable()) + canWrite = false + XCTAssertFalse(access.disable()) + XCTAssertTrue(access.permitsRequests()) + } +} diff --git a/CopyCore/Tests/CopyCoreTests/PasteStackPermissionTests.swift b/CopyCore/Tests/CopyCoreTests/PasteStackPermissionTests.swift new file mode 100644 index 0000000..6d8d1f6 --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/PasteStackPermissionTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import CopyCore + +final class PasteStackPermissionTests: XCTestCase { + func testVerifiedPermissionRequiresBundleSigningIDAndCertificate() { + let entry = PasteStackPermission.signed(bundleID: "org.example.app", signingID: "org.example.app", certificateSHA256: "ABC") + let caller = PasteStackCaller(bundleID: "org.example.app", canonicalPath: "/Applications/Example.app", signingID: "org.example.app", certificateSHA256: "ABC") + XCTAssertTrue(entry.permits(caller)) + var changed = caller + changed.bundleID = "org.example.other" + XCTAssertFalse(entry.permits(changed)) + changed = caller + changed.signingID = "org.example.other" + XCTAssertFalse(entry.permits(changed)) + changed = caller + changed.certificateSHA256 = "DEF" + XCTAssertFalse(entry.permits(changed)) + changed = caller + changed.certificateSHA256 = nil + XCTAssertFalse(entry.permits(changed)) + } + + func testDeveloperExceptionRequiresExactCanonicalPathAndBundleID() { + let entry = PasteStackPermission.developer(bundleID: "org.example.app", canonicalPath: "/Applications/Example.app") + XCTAssertTrue(entry.permits(PasteStackCaller(bundleID: "org.example.app", canonicalPath: "/Applications/Example.app"))) + XCTAssertFalse(entry.permits(PasteStackCaller(bundleID: "org.example.app", canonicalPath: "/tmp/Example.app"))) + XCTAssertFalse(entry.permits(PasteStackCaller(bundleID: "org.example.other", canonicalPath: "/Applications/Example.app"))) + } + + func testEmptyIdentityNeverMatchesAnEntry() { + let caller = PasteStackCaller(bundleID: "", canonicalPath: "", signingID: "", certificateSHA256: "") + XCTAssertFalse(PasteStackPermission.signed(bundleID: "", signingID: "", certificateSHA256: "").permits(caller)) + XCTAssertFalse(PasteStackPermission.developer(bundleID: "", canonicalPath: "").permits(caller)) + } + + func testPermissionsRoundTrip() throws { + let entries: [PasteStackPermission] = [ + .signed(bundleID: "org.example.app", signingID: "org.example.app", certificateSHA256: "ABC"), + .developer(bundleID: "org.example.app", canonicalPath: "/Applications/Example.app") + ] + XCTAssertEqual(try JSONDecoder().decode([PasteStackPermission].self, from: JSONEncoder().encode(entries)), entries) + } +} diff --git a/CopyCore/Tests/CopyCoreTests/PasteStackTemporaryTests.swift b/CopyCore/Tests/CopyCoreTests/PasteStackTemporaryTests.swift index 1d77c3d..65a876f 100644 --- a/CopyCore/Tests/CopyCoreTests/PasteStackTemporaryTests.swift +++ b/CopyCore/Tests/CopyCoreTests/PasteStackTemporaryTests.swift @@ -2,6 +2,43 @@ import XCTest @testable import CopyCore final class PasteStackTemporaryTests: XCTestCase { + func testBatchReplacesExistingQueueAndPastesDuplicateTextsInFIFOOrder() throws { + var queue = PasteStackQueue() + queue.enqueue("history") + queue.isLIFO = true + XCTAssertTrue(queue.replaceWithBatch(["one", "same", "same"])) + XCTAssertFalse(queue.isLIFO) + XCTAssertEqual(queue.count, 3) + XCTAssertEqual(queue.itemUUIDs.compactMap { queue.temporaryEntry(for: $0)?.text }, ["one", "same", "same"]) + XCTAssertEqual(Set(queue.itemUUIDs).count, 3) + for text in ["one", "same", "same"] { + let next = try XCTUnwrap(queue.next) + XCTAssertEqual(queue.temporaryEntry(for: next)?.text, text) + queue.advance() + } + } + + func testInvalidBatchDoesNotAlterQueue() { + var queue = PasteStackQueue() + queue.enqueue("existing") + for batch in [[], [""], Array(repeating: "x", count: 101), [String(repeating: "a", count: 1_048_577)]] { + XCTAssertFalse(queue.replaceWithBatch(batch)) + XCTAssertEqual(queue.itemUUIDs, ["existing"]) + } + } + + func testBatchBoundsUseUTF8BytesAndAllowExactLimits() { + var queue = PasteStackQueue() + XCTAssertTrue(queue.replaceWithBatch(Array(repeating: "x", count: 100))) + XCTAssertEqual(queue.count, 100) + XCTAssertTrue(queue.replaceWithBatch([String(repeating: "é", count: 524_288)])) + let previous = queue.itemUUIDs + XCTAssertFalse(queue.replaceWithBatch([String(repeating: "é", count: 524_289)])) + XCTAssertEqual(queue.itemUUIDs, previous) + XCTAssertFalse(queue.replaceWithBatch([String(repeating: "x", count: 1_048_576), "x"])) + XCTAssertEqual(queue.itemUUIDs, previous) + } + func testTemporaryDuplicatesHaveSeparateIdentitiesAndNoHistoryID() throws { var queue = PasteStackQueue() queue.enqueueTexts(["same", "same"]) diff --git a/CopyTests/PasteStackModelTests.swift b/CopyTests/PasteStackModelTests.swift index 3f0016a..1d800a5 100644 --- a/CopyTests/PasteStackModelTests.swift +++ b/CopyTests/PasteStackModelTests.swift @@ -1,8 +1,75 @@ import AppKit import CopyCore import XCTest +import SwiftUI final class PasteStackModelTests: XCTestCase { + func testCallerDescriptionIdentifiesUnverifiedApps() { + let unsigned = PasteStackCaller(bundleID: "example.app", canonicalPath: "/Applications/Example.app") + XCTAssertEqual(pasteStackCallerDescription(unsigned), "Unverified app: example.app (/Applications/Example.app)") + let partiallySigned = PasteStackCaller(bundleID: "example.app", canonicalPath: "/Applications/Example.app", signingID: "example.app") + XCTAssertTrue(pasteStackCallerDescription(partiallySigned)?.hasPrefix("Unverified app: ") == true) + let signed = PasteStackCaller(bundleID: "example.app", canonicalPath: "/Applications/Example.app", signingID: "example.app", certificateSHA256: "fingerprint") + XCTAssertEqual(pasteStackCallerDescription(signed), "example.app (/Applications/Example.app)") + XCTAssertNil(pasteStackCallerDescription(nil)) + let suspicious = PasteStackCaller(bundleID: "example\u{202E}app", canonicalPath: "/Applications/Example\n.app") + let description = pasteStackCallerDescription(suspicious) ?? "" + XCTAssertTrue(description.contains("⟦U+202E⟧")) + XCTAssertTrue(description.contains("⟦U+000A⟧")) + } + + @MainActor + func testIncomingBatchConfirmationRequiresExplicitReplacementAndProtectsScreenSharing() throws { + let alert = pasteStackBatchConfirmation(["first\nline", " second "], hideSecrets: true, hideDuringScreenSharing: true) + XCTAssertEqual(alert.buttons.map(\.title), ["Cancel", "Replace Stack"]) + XCTAssertEqual(alert.buttons.map(\.keyEquivalent), ["\r", ""]) + XCTAssertEqual(alert.window.sharingType, .none) + let view = try XCTUnwrap(alert.accessoryView as? NSHostingView) + XCTAssertEqual(view.rootView.texts, ["first\nline", " second "]) + XCTAssertTrue(view.rootView.hideSecrets) + } + + func testBatchPreviewMasksSecretsAndExposesControlCharacters() { + let token = "AKIA" + "IOSFODNN7EXAMPLE" + let value = "name\u{202E}\n" + token + let hidden = pasteStackPreviewText(value, hideSecrets: true, revealed: false) + XCTAssertFalse(hidden.contains(token)) + XCTAssertTrue(hidden.contains("⟦U+202E⟧")) + XCTAssertTrue(hidden.contains("⟦U+000A⟧")) + let revealed = pasteStackPreviewText(value, hideSecrets: true, revealed: true) + XCTAssertTrue(revealed.contains(token)) + XCTAssertFalse(revealed.contains("\u{202E}")) + } + + func testBatchPreviewBoundsLongTextBeforeMasking() { + let token = "AKIA" + "IOSFODNN7EXAMPLE" + let value = String(repeating: "x", count: 260_000) + token + XCTAssertFalse(pasteStackPreviewText(value, hideSecrets: true, revealed: false).contains(token)) + XCTAssertFalse(pasteStackPreviewText(value, hideSecrets: false, revealed: false).contains(token)) + } + + func testPathologicalGraphemeIsBoundedAndLaterPagesRequireExplicitReveal() { + let token = "AKIA" + "IOSFODNN7EXAMPLE" + let value = "a" + String(repeating: "\u{0301}", count: 260_000) + token + let count = pasteStackPreviewPageCount(value) + XCTAssertGreaterThan(count, 1) + XCTAssertLessThanOrEqual(pasteStackPreviewText(value, hideSecrets: false, revealed: false).utf16.count, 2_000) + XCTAssertFalse(pasteStackPreviewText(value, hideSecrets: true, revealed: false, page: count - 1).contains(token)) + XCTAssertTrue(pasteStackPreviewText(value, hideSecrets: true, revealed: true, page: count - 1).contains(token)) + } + + func testPagedPreviewDoesNotSplitSurrogatePairsAndMasksCrossPageSecrets() { + let value = String(repeating: "x", count: 1_999) + "😀" + " " + "AKIA" + "IOSFODNN7EXAMPLE" + XCTAssertEqual(pasteStackPreviewPageCount(value), 2) + XCTAssertFalse(pasteStackPreviewText(value, hideSecrets: false, revealed: true).contains("�")) + XCTAssertTrue(pasteStackPreviewText(value, hideSecrets: false, revealed: true, page: 1).hasPrefix("😀")) + let splitSecret = String(repeating: "x", count: 1_990) + " " + "AKIA" + "IOSFODNN7EXAMPLE" + let rendered = (0.. "$EXPORT_OPTIONS_PLIST" <${TEAM_ID} signingStyle manual + provisioningProfiles + + com.tarikbc.Copy + ${COPY_PROVISIONING_PROFILE} + signingCertificate ${SIGN_IDENTITY} @@ -188,7 +195,7 @@ sign_one() { local path="$1" echo " codesign: $path" codesign --force --sign "$SIGN_IDENTITY" \ - --options runtime --timestamp \ + --preserve-metadata=entitlements --options runtime --timestamp \ "$path" } diff --git a/docs/paste-stack-integration.md b/docs/paste-stack-integration.md new file mode 100644 index 0000000..2e7ad38 --- /dev/null +++ b/docs/paste-stack-integration.md @@ -0,0 +1,61 @@ +# Paste Stack integrations + +Copy offers two ways to request a batch. Both validate 1 to 100 nonempty text +items, with at most 1 MiB of UTF-8 text in total, and keep duplicate items. On +acceptance, Copy replaces the stack with temporary entries, selects FIFO order, +and activates the stack. A rejected or invalid request leaves the stack alone. +Replacing a nonempty stack always needs confirmation. + +## System actions + +The Add Text to Paste Stack App Intent exposes the operation to Shortcuts and +other system experiences. The system does not provide this action with a +verified originating app, so Copy asks for confirmation on every request and +never remembers its source. The confirmation defaults to Cancel, shows a +separate, pageable preview of each item, and follows the secret-masking and +screen-sharing settings. An item beyond the secret scanner's limit remains +hidden until explicitly revealed. + +## Direct app-to-app requests + +The signed app advertises `CopyPasteStackProtocolVersion` 1 in its Info.plist. +This lets another app detect a compatible installation without sending an +Apple event or triggering macOS Automation permission. It is an advertisement, +not proof of the running recipient: a client must verify Copy's signing +identity and the reply to a payload-free handshake before sending private text. + +Copy handles Apple events in class `CpSt`: `ping` returns a versioned +capability reply without modifying the stack; `batc` accepts a UTF-8 JSON +object containing `version: 1`, an `items` array, and `expiresAt` as Unix +seconds. The expiry can be at most 300 seconds from receipt. Copy checks it +again after any consent prompt, before changing the stack or remembering a +caller. Replies contain version 1 and a status, never stack contents. Requests have a 6,295,552-byte encoded +limit before decoding, followed by the item and UTF-8 limits above. + +The App Intent and Apple events both use Copy's stack validation and consent +flow. They are separate interfaces because an App Intent invoked through a +system experience cannot establish the identity of the originating app. An +Apple event supplies a read-only actual-sender audit token; Copy resolves its +code identity from that token, never from a name or path in the request. + +## Access levels + +A verified signed caller can be authorized once and remembered by its bundle +identifier, signing identifier, and certificate fingerprint. Copy still asks +before replacing existing entries. A caller whose identity or signing cannot +be verified is offered one-time approval only; Copy never remembers it +implicitly. + +Settings, Permissions lists authorized apps and lets the user forget them. It +also lets a developer explicitly add an unsafe exception for an app bundle's +canonical path and identifier. This exception ignores its signing certificate: +a process able to replace that app inherits its access. It is never created +automatically and is intended only for apps the user is developing. The +operating system's Automation permission is separate and can be revoked in +System Settings. + +## Opt-in and signing requirements + +External requests are disabled by default. Enable them under Settings > Permissions. The setting and remembered callers are protected by an app-specific data-protection Keychain access group. If Keychain access is unavailable, requests fail closed. A non-authoritative preference avoids accessing the Keychain while disabled; changing that preference alone cannot authorize requests. Requests use noninteractive Keychain reads, so they do not trigger Keychain authorization dialogs. Enabling access can require Keychain authorization depending on macOS and Keychain state. No previously stored unprotected approval is migrated. + +A Developer ID provisioning profile must authorize the app identifier and Keychain access group for `com.tarikbc.Copy`. Install the profile locally and set `COPY_PROVISIONING_PROFILE` to its UUID before running `Scripts/release.sh`. CI requires the profile encoded as the `COPY_PROVISIONING_PROFILE_BASE64` secret. The release script preserves entitlements when re-signing the application. Verify the exported, notarized build with the actual Developer ID profile, including opt-in, remembered caller, revocation, and disabled requests on macOS 14 before release. Unsigned or ad-hoc builds cannot validate this Keychain boundary. diff --git a/project.yml b/project.yml index f0c7c62..a795756 100644 --- a/project.yml +++ b/project.yml @@ -30,6 +30,7 @@ targets: path: Copy/Info.plist properties: CFBundleDisplayName: Copy + CopyPasteStackProtocolVersion: 1 CFBundleShortVersionString: "0.4.0" CFBundleVersion: "11" LSUIElement: true @@ -60,6 +61,7 @@ targets: MARKETING_VERSION: "0.4.0" SWIFT_VERSION: "5.0" ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_ENTITLEMENTS: Copy/Copy.entitlements CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: P7V47BUA2B ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon @@ -69,6 +71,7 @@ targets: sources: - CopyTests - Copy/PasteStack/PasteStackModel.swift + - Copy/PasteStack/PasteStackBatchConfirmation.swift dependencies: - package: CopyCore settings: