diff --git a/docs/NATIVE_ONLY_REDESIGN.md b/docs/NATIVE_ONLY_REDESIGN.md index 070fb33..e8129d6 100644 --- a/docs/NATIVE_ONLY_REDESIGN.md +++ b/docs/NATIVE_ONLY_REDESIGN.md @@ -222,6 +222,9 @@ Deliverables: - `BrokerCoreTests` exercises the broker outcome paths via `StubCredentialBroker` for `success` / `denied` / `canceled` / `invalidResponse`, and asserts the broker error code mapping. +- `BrokerAutomation` exposes a narrow Shortcuts / AppleScript request surface + for `login` and `fill`. Automation builds the same broker envelope as the CLI + path and still routes credential material through the user-mediated broker. **Phase 3 exit blockers still open**: @@ -239,12 +242,16 @@ Deliverables: - Map `pw get ` to the new login flow where appropriate - Remove unsupported commands from primary docs - Preserve a migration guide for operators moving from parity APW to native-only +- Expose only mediated automation entrypoints for `login` and `fill` so + scripts can initiate broker requests without bypassing credential approval Deliverables: - clear CLI help text - explicit migration notices - browser/runtime code marked legacy +- Shortcuts actions and an AppleScript dictionary for the supported broker + operations ### Phase 5: packaging and release diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index 5c3c4cb..9b6b460 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -40,6 +40,9 @@ Release reference version: `v2.0.0` - the v2 app broker uses a same-user local UNIX socket under `~/.apw/native-app/` - `status --json` exposes app/broker readiness while retaining legacy daemon diagnostics - requests and responses use typed JSON envelopes with bounded payload sizes +- Shortcuts and AppleScript automation entrypoints build the same broker + request envelope as CLI `apw login` / `apw fill`; they do not read credential + material directly or bypass the broker's user-mediated response path - bootstrap credentials are read from a local runtime file for the supported demo domain only; the app does not create that plaintext file on default launch @@ -124,6 +127,7 @@ The Rust test suite covers: - native app socket timeout handling - native app diagnostics and `APW_DEMO=1` bootstrap credential file initialization - end-to-end v2 app install, launch, status, doctor, and login flows +- Shortcuts / AppleScript automation envelope parity for `login` and `fill` - direct-exec fallback, unsupported-domain handling, denial handling, and malformed broker response mapping - a manual notarized-hardware validation contract for the Phase 3 AuthenticationServices broker flow diff --git a/native-app/Resources/APW.sdef b/native-app/Resources/APW.sdef new file mode 100644 index 0000000..80c1c7e --- /dev/null +++ b/native-app/Resources/APW.sdef @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/native-app/Sources/APW/APWAppleScriptCommands.swift b/native-app/Sources/APW/APWAppleScriptCommands.swift new file mode 100644 index 0000000..5790d8d --- /dev/null +++ b/native-app/Sources/APW/APWAppleScriptCommands.swift @@ -0,0 +1,85 @@ +import AppKit +import Foundation +import NativeAppLib + +enum APWAppleScriptCommandBridge { + static func perform( + operation: BrokerAutomationOperation, + command: NSScriptCommand + ) -> Any? { + guard let url = command.directParameter as? String, !url.isEmpty else { + command.scriptErrorNumber = 1 + command.scriptErrorString = "APW \(operation.rawValue) requires an HTTPS URL direct parameter." + return nil + } + + let request = { + try BrokerAutomation.performResponseData( + operation: operation, + url: url, + requestId: "applescript-\(operation.rawValue)" + ) + } + + do { + let data: Data + if Thread.isMainThread { + data = try performOffMainThreadWhilePumpingRunLoop(request) + } else { + data = try request() + } + return String(decoding: data, as: UTF8.self) + } catch { + command.scriptErrorNumber = 1 + command.scriptErrorString = "\(error)" + return nil + } + } + + private static func performOffMainThreadWhilePumpingRunLoop( + _ request: @escaping () throws -> Data + ) throws -> Data { + let result = AppleScriptCommandResultBox() + + DispatchQueue.global(qos: .userInitiated).async { + result.set(Result(catching: request)) + } + + while result.value == nil { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + } + + return try result.value!.get() + } +} + +private final class AppleScriptCommandResultBox { + private let lock = NSLock() + private var storage: Result? + + var value: Result? { + lock.lock() + defer { lock.unlock() } + return storage + } + + func set(_ value: Result) { + lock.lock() + storage = value + lock.unlock() + } +} + +@objc(APWRequestLoginCommand) +final class APWRequestLoginCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + APWAppleScriptCommandBridge.perform(operation: .login, command: self) + } +} + +@objc(APWRequestFillCommand) +final class APWRequestFillCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + APWAppleScriptCommandBridge.perform(operation: .fill, command: self) + } +} diff --git a/native-app/Sources/APW/APWAutomationIntents.swift b/native-app/Sources/APW/APWAutomationIntents.swift new file mode 100644 index 0000000..5c05bed --- /dev/null +++ b/native-app/Sources/APW/APWAutomationIntents.swift @@ -0,0 +1,83 @@ +#if canImport(AppIntents) + import AppIntents + import Foundation + import NativeAppLib + + @available(macOS 13.0, *) + struct APWLoginIntent: AppIntent { + static var title: LocalizedStringResource = "Request APW Login" + static var description = IntentDescription( + "Requests a login credential through the APW broker. APW still requires user mediation before returning credential material." + ) + static var openAppWhenRun = true + + @Parameter(title: "URL") + var url: String + + init() {} + + init(url: String) { + self.url = url + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let data = try await BrokerAutomation.performResponseDataAsync( + operation: .login, + url: url, + requestId: "shortcuts-login" + ) + return .result(value: String(decoding: data, as: UTF8.self)) + } + } + + @available(macOS 13.0, *) + struct APWFillIntent: AppIntent { + static var title: LocalizedStringResource = "Request APW Fill" + static var description = IntentDescription( + "Requests a fill credential through the APW broker. APW still requires user mediation before returning credential material." + ) + static var openAppWhenRun = true + + @Parameter(title: "URL") + var url: String + + init() {} + + init(url: String) { + self.url = url + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let data = try await BrokerAutomation.performResponseDataAsync( + operation: .fill, + url: url, + requestId: "shortcuts-fill" + ) + return .result(value: String(decoding: data, as: UTF8.self)) + } + } + + @available(macOS 13.0, *) + struct APWShortcutsProvider: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: APWLoginIntent(), + phrases: [ + "Request APW login with \(.applicationName)", + "Get APW login credential with \(.applicationName)", + ], + shortTitle: "APW Login", + systemImageName: "key.fill" + ) + AppShortcut( + intent: APWFillIntent(), + phrases: [ + "Request APW fill with \(.applicationName)", + "Fill with APW using \(.applicationName)", + ], + shortTitle: "APW Fill", + systemImageName: "text.cursor" + ) + } + } +#endif diff --git a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift index 76f28fe..405ce6f 100644 --- a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift +++ b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift @@ -81,10 +81,7 @@ public protocol CredentialBroker { let semaphore = DispatchSemaphore(value: 0) var captured: CredentialBrokerResult = .failure(.unknown, "broker did not complete") - // ASAuthorizationController must be driven on the main thread. The - // broker server runs accept() on a worker thread, so we hop to - // main and block on a semaphore until the delegate fires. - DispatchQueue.main.async { + let startRequest = { let request = ASAuthorizationPasswordProvider().createRequest() let controller = ASAuthorizationController(authorizationRequests: [request]) @@ -111,9 +108,22 @@ public protocol CredentialBroker { // Bound the sync wait so a hung credential picker cannot hold the // broker forever. `brokerRequestTimeoutMs` is the same constant // used for IPC. (issue #2) - let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs) - if semaphore.wait(timeout: timeout) == .timedOut { + if Thread.isMainThread { + startRequest() + let deadline = Date().addingTimeInterval(TimeInterval(brokerRequestTimeoutMs) / 1000.0) + while Date() < deadline { + if semaphore.wait(timeout: .now()) == .success { + return captured + } + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) + } return .failure(.failed, "AuthenticationServices request timed out.") + } else { + DispatchQueue.main.async(execute: startRequest) + let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs) + if semaphore.wait(timeout: timeout) == .timedOut { + return .failure(.failed, "AuthenticationServices request timed out.") + } } return captured } diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index d04e461..3ab40cf 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -200,6 +200,105 @@ struct ResponseEnvelope: Codable { let requestId: String? } +public enum BrokerAutomationOperation: String, CaseIterable, Sendable { + case login + case fill +} + +public enum BrokerAutomationError: Error, CustomStringConvertible { + case invalidURL(String) + + public var description: String { + switch self { + case .invalidURL(let value): + return "Invalid APW automation URL: \(value)" + } + } +} + +public struct BrokerAutomation { + public static func requestEnvelopeData( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil + ) throws -> Data { + try validateURL(url) + return try JSONEncoder().encode(requestEnvelope( + operation: operation, + url: url, + requestId: requestId + )) + } + + public static func performResponseData( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil + ) throws -> Data { + let server = BrokerServer(paths: AppPaths.resolve()) + return try responseData( + operation: operation, + url: url, + requestId: requestId, + server: server + ) + } + + public static func performResponseDataAsync( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil + ) async throws -> Data { + try await Task.detached(priority: .userInitiated) { + try performResponseData( + operation: operation, + url: url, + requestId: requestId + ) + }.value + } + + static func responseData( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil, + server: BrokerServer + ) throws -> Data { + let response = try server.dispatch(request: requestEnvelope( + operation: operation, + url: url, + requestId: requestId + )) + return try JSONEncoder().encode(response) + } + + private static func requestEnvelope( + operation: BrokerAutomationOperation, + url: String, + requestId: String? + ) throws -> RequestEnvelope { + try validateURL(url) + return RequestEnvelope( + requestId: requestId, + command: operation.rawValue, + payload: [ + "url": url, + "intent": operation.rawValue, + "automation": "true", + ] + ) + } + + private static func validateURL(_ rawURL: String) throws { + guard let parsed = URL(string: rawURL), + parsed.scheme == "https", + parsed.host?.isEmpty == false + else { + throw BrokerAutomationError.invalidURL(rawURL) + } + } +} + struct AppPaths { let runtimeRoot: URL let socketPath: URL @@ -372,9 +471,14 @@ final class BrokerServer { error: nil, requestId: request.requestId ) - case "login": + case "login", "fill": let url = request.payload?["url"] ?? "" - return try loginResponse(for: url, requestId: request.requestId) + let operation = BrokerAutomationOperation(rawValue: request.command) ?? .login + return try credentialResponse( + for: url, + operation: operation, + requestId: request.requestId + ) default: return ResponseEnvelope( ok: false, @@ -413,13 +517,17 @@ final class BrokerServer { ] } - private func loginResponse(for rawURL: String, requestId: String?) throws -> ResponseEnvelope { + private func credentialResponse( + for rawURL: String, + operation: BrokerAutomationOperation, + requestId: String? + ) throws -> ResponseEnvelope { guard let url = URL(string: rawURL), let host = url.host?.lowercased(), !host.isEmpty else { return ResponseEnvelope( ok: false, code: 1, payload: nil, - error: "Invalid URL for native app login.", + error: "Invalid URL for native app \(operation.rawValue).", requestId: requestId ) } @@ -448,6 +556,7 @@ final class BrokerServer { "status": AnyCodable("approved"), "url": AnyCodable(credential.url), "domain": AnyCodable(credential.domain), + "intent": AnyCodable(operation.rawValue), "username": AnyCodable(credential.username), "password": AnyCodable(credential.password), "transport": AnyCodable("authentication_services"), @@ -515,6 +624,7 @@ final class BrokerServer { "status": AnyCodable("approved"), "url": AnyCodable(credential.url), "domain": AnyCodable(credential.domain), + "intent": AnyCodable(operation.rawValue), "username": AnyCodable(credential.username), "password": AnyCodable(credential.password), "transport": AnyCodable("unix_socket"), @@ -644,6 +754,38 @@ final class BrokerServer { } } +private final class BrokerApplicationDelegate: NSObject, NSApplicationDelegate { + private let server: BrokerServer + + init(server: BrokerServer) { + self.server = server + } + + func applicationDidFinishLaunching(_ notification: Notification) { + Thread.detachNewThread { [server] in + do { + try server.run() + } catch { + fputs("APW app failed: \(error)\n", stderr) + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + } + } + } +} + +private var brokerApplicationDelegate: BrokerApplicationDelegate? + +private func runScriptableBrokerApp(server: BrokerServer) -> Never { + let application = NSApplication.shared + application.setActivationPolicy(.accessory) + brokerApplicationDelegate = BrokerApplicationDelegate(server: server) + application.delegate = brokerApplicationDelegate + application.run() + exit(0) +} + func bundleVersion() -> String { if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, @@ -662,6 +804,9 @@ public func runBrokerAppMain() -> Never { do { switch command { case "serve": + if Bundle.main.object(forInfoDictionaryKey: "NSAppleScriptEnabled") as? Bool == true { + runScriptableBrokerApp(server: server) + } try server.run() case "doctor": let payload = server.doctorPayload() diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index ad42d85..3f2a160 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -272,9 +272,91 @@ final class BrokerCoreTests: XCTestCase { XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") XCTAssertEqual(response.payload?["username"]?.value as? String, "alice@example.com") XCTAssertEqual(response.payload?["domain"]?.value as? String, "vault.example.com") + XCTAssertEqual(response.payload?["intent"]?.value as? String, "login") XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) } + func testFillRoutesThroughSameBrokerEnvelopeOnSuccess() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "fill", + command: "fill", + payload: ["url": "https://vault.example.com", "intent": "fill"] + )) + + XCTAssertEqual(response.ok, true) + XCTAssertEqual(response.code, 0) + XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") + XCTAssertEqual(response.payload?["intent"]?.value as? String, "fill") + XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) + } + + func testAutomationEnvelopeMatchesBrokerRequestContract() throws { + let data = try BrokerAutomation.requestEnvelopeData( + operation: .fill, + url: "https://vault.example.com", + requestId: "automation-fill" + ) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let payload = json?["payload"] as? [String: String] + + XCTAssertEqual(json?["requestId"] as? String, "automation-fill") + XCTAssertEqual(json?["command"] as? String, "fill") + XCTAssertEqual(payload?["url"], "https://vault.example.com") + XCTAssertEqual(payload?["intent"], "fill") + XCTAssertEqual(payload?["automation"], "true") + } + + func testAutomationResponseUsesInjectedBrokerServer() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let data = try BrokerAutomation.responseData( + operation: .login, + url: "https://vault.example.com", + requestId: "automation-login", + server: server + ) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let payload = json?["payload"] as? [String: Any] + + XCTAssertEqual(json?["ok"] as? Bool, true) + XCTAssertEqual(json?["requestId"] as? String, "automation-login") + XCTAssertEqual(payload?["intent"] as? String, "login") + XCTAssertEqual(payload?["transport"] as? String, "authentication_services") + } + + func testAutomationRejectsNonHTTPSURLsBeforeBrokerDispatch() { + XCTAssertThrowsError(try BrokerAutomation.requestEnvelopeData( + operation: .login, + url: "http://vault.example.com", + requestId: "bad" + )) + } + func testLoginRoutesToCredentialBrokerOnDeny() throws { unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/scripts/build-native-app.sh b/scripts/build-native-app.sh index a91bf6d..d90aa3a 100755 --- a/scripts/build-native-app.sh +++ b/scripts/build-native-app.sh @@ -55,6 +55,7 @@ rm -rf "$APP_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" cp "$EXECUTABLE_PATH" "$MACOS_DIR/$EXECUTABLE_NAME" chmod 0755 "$MACOS_DIR/$EXECUTABLE_NAME" +cp "$PACKAGE_DIR/Resources/APW.sdef" "$RESOURCES_DIR/APW.sdef" RESOURCE_BUNDLE="$(find "$PACKAGE_DIR/.build" \( -path '*/release/*.bundle' -o -path '*/Release/*.bundle' \) -type d -name '*NativeAppLib*.bundle' | head -n 1 || true)" if [[ -n "$RESOURCE_BUNDLE" ]]; then @@ -84,6 +85,10 @@ cat >"$PLIST_PATH" <$VERSION LSUIElement + NSAppleScriptEnabled + + OSAScriptingDefinition + APW.sdef EOF diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 564b845..915adb0 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -44,5 +44,6 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-extended-validation-config.sh ./scripts/test-verify-universal-binaries.sh ./scripts/test-universal-release-config.sh +./scripts/test-native-automation-config.sh echo "APW fast checks passed." diff --git a/scripts/test-native-automation-config.sh b/scripts/test-native-automation-config.sh new file mode 100755 index 0000000..81f227b --- /dev/null +++ b/scripts/test-native-automation-config.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SDEF="native-app/Resources/APW.sdef" +APP_INTENTS="native-app/Sources/APW/APWAutomationIntents.swift" +APPLE_SCRIPT_COMMANDS="native-app/Sources/APW/APWAppleScriptCommands.swift" +BROKER_CORE_TESTS="native-app/Tests/NativeAppTests/BrokerCoreTests.swift" +BUILD_SCRIPT="scripts/build-native-app.sh" + +require_line() { + local file="$1" + local pattern="$2" + local message="$3" + if ! grep -Fq "$pattern" "$file"; then + echo "$message" >&2 + exit 1 + fi +} + +if [ ! -f "$SDEF" ]; then + echo "Missing AppleScript dictionary: $SDEF" >&2 + exit 1 +fi + +python3 - "$SDEF" <<'PY' +import sys +import xml.etree.ElementTree as ET + +ET.parse(sys.argv[1]) +PY + +require_line "$SDEF" 'command name="request login"' "AppleScript dictionary must expose request login." +require_line "$SDEF" 'command name="request fill"' "AppleScript dictionary must expose request fill." +require_line "$SDEF" '' "AppleScript login command must be wired to a Cocoa command class." +require_line "$SDEF" '' "AppleScript fill command must be wired to a Cocoa command class." +require_line "$SDEF" "user still approves" "AppleScript dictionary must document user mediation." + +require_line "$APP_INTENTS" "struct APWLoginIntent" "Shortcuts login intent is missing." +require_line "$APP_INTENTS" "struct APWFillIntent" "Shortcuts fill intent is missing." +require_line "$APP_INTENTS" "AppShortcutsProvider" "Shortcuts provider is missing." +require_line "$APP_INTENTS" "BrokerAutomation.performResponseDataAsync" "Shortcuts intents must route through asynchronous BrokerAutomation." +if grep -Fq "@MainActor" "$APP_INTENTS"; then + echo "Shortcuts intents must not run broker requests synchronously on MainActor." >&2 + exit 1 +fi + +require_line "$APPLE_SCRIPT_COMMANDS" "final class APWRequestLoginCommand: NSScriptCommand" "AppleScript login command implementation is missing." +require_line "$APPLE_SCRIPT_COMMANDS" "final class APWRequestFillCommand: NSScriptCommand" "AppleScript fill command implementation is missing." +require_line "$APPLE_SCRIPT_COMMANDS" "BrokerAutomation.performResponseData" "AppleScript commands must route through BrokerAutomation." +require_line "$APPLE_SCRIPT_COMMANDS" "performOffMainThreadWhilePumpingRunLoop" "AppleScript commands must avoid blocking the main AppKit event loop." + +require_line "$BUILD_SCRIPT" 'cp "$PACKAGE_DIR/Resources/APW.sdef" "$RESOURCES_DIR/APW.sdef"' "APW.sdef must be copied into the app bundle." +require_line "$BUILD_SCRIPT" "NSAppleScriptEnabled" "App bundle must enable AppleScript." +require_line "$BUILD_SCRIPT" "OSAScriptingDefinition" "App bundle must publish the scripting definition." +require_line "native-app/Sources/NativeAppLib/BrokerCore.swift" "runScriptableBrokerApp" "Scriptable app launches must run an AppKit event loop." +require_line "native-app/Sources/NativeAppLib/BrokerCore.swift" "NSApplication.shared" "Scriptable app launches must initialize NSApplication." + +require_line "$BROKER_CORE_TESTS" "testAutomationEnvelopeMatchesBrokerRequestContract" "Automation envelope parity test is missing." +require_line "$BROKER_CORE_TESTS" "testAutomationResponseUsesInjectedBrokerServer" "Automation broker dispatch test is missing." +require_line "$BROKER_CORE_TESTS" "testAutomationRejectsNonHTTPSURLsBeforeBrokerDispatch" "Automation URL rejection test is missing." + +echo "Native automation configuration test passed."