diff --git a/README.md b/README.md index 0911f46..27e6ec3 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ headless credentials add --origin https://example.com --alias work --interactive headless credentials list --origin https://example.com headless credentials rename --origin https://example.com --alias work --to client headless credentials remove --origin https://example.com --alias client +headless auth login --challenge CHALLENGE_ID --account client +headless auth login --interactive ``` The interactive broker reads and confirms passwords only through the attached @@ -169,9 +171,18 @@ terminal with echo disabled. Agents can see approved usernames and aliases, but password values never enter arguments, MCP, browser commands, logs, flows, or output. macOS uses the encrypted default user Keychain with a decrypt-only ACL and reports the unsigned local security tier honestly. Linux requires an -available system Secret Service and never falls back to plaintext. This -increment manages vault records. Broker-owned user-presence checks, -origin-bound login challenges, and browser autofill follow in #157. +available system Secret Service and never falls back to plaintext. Confirmed +top-level same-origin POST login forms return an origin-bound, session-bound, +document-bound, single-use +`AUTH_REQUIRED` challenge containing matching aliases. `auth login` performs a +fresh broker-owned user-presence check, fills inside the trusted host, submits +once, and reports the continuation without replaying the blocked action. +Heuristic hints and cross-origin frames never trigger credential retrieval. +Interactive login uses trusted native or terminal input rather than password +arguments. It asks whether to save only after verified success, defaults to +No, and requires a user-entered alias. Linux vault management is available, +but saved alias use fails closed until a trusted per-use confirmation surface +exists. ## Agent skill diff --git a/apps/headless/CredentialBroker/main.swift b/apps/headless/CredentialBroker/main.swift index 386eba7..4a523d2 100644 --- a/apps/headless/CredentialBroker/main.swift +++ b/apps/headless/CredentialBroker/main.swift @@ -1,6 +1,11 @@ import CredentialBrokerCore import Foundation import HeadlessProtocol +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif private func printJSON(_ value: JSONValue) { guard let data = try? ProtocolCodec.encoder.encode(value) else { @@ -11,7 +16,108 @@ private func printJSON(_ value: JSONValue) { FileHandle.standardOutput.write(Data([0x0A])) } +private func trustedHostIsParent() -> Bool { + let broker = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() + let parentPath: String? + #if os(macOS) + var buffer = [CChar](repeating: 0, count: 4_096) + let count = proc_pidpath(getppid(), &buffer, UInt32(buffer.count)) + parentPath = count > 0 ? String(cString: buffer) : nil + let expected = [ + broker.deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("MacOS/Headless"), + broker.deletingLastPathComponent().appendingPathComponent("headless-host"), + ] + #else + parentPath = try? FileManager.default.destinationOfSymbolicLink( + atPath: "/proc/\(getppid())/exe" + ) + let expected = [broker.deletingLastPathComponent().appendingPathComponent("headless-host")] + #endif + guard let parentPath else { return false } + let parent = URL(fileURLWithPath: parentPath).resolvingSymlinksInPath().standardizedFileURL + return expected.contains { + parent == $0.resolvingSymlinksInPath().standardizedFileURL + } +} + +private func runInternalResolve(_ arguments: [String]) throws { + guard trustedHostIsParent() else { throw CredentialVaultError.userDenied } + #if os(Linux) + // An unlocked Secret Service can answer without prompting. Until the Linux + // host has a trusted confirmation UI, saved use must fail closed. + throw CredentialVaultError.userPresenceUnavailable + #else + var values = arguments + func option(_ name: String) throws -> String { + guard let index = values.firstIndex(of: name), index + 1 < values.count else { + throw CredentialCommandError.invalidArguments + } + let value = values.remove(at: index + 1) + values.remove(at: index) + return value + } + let origin = try CredentialOrigin(rawValue: option("--origin")) + let alias = try CredentialAlias(rawValue: option("--alias")) + guard values.isEmpty else { throw CredentialCommandError.invalidArguments } + let controller = CredentialVaultController( + metadata: CredentialMetadataStore(), secrets: try makePlatformCredentialSecretStore() + ) + let credential = try controller.resolve(origin: origin, alias: alias) + defer { credential.password.clear() } + var frame = try AuthenticationCredentialFrame.encode(credential) + FileHandle.standardOutput.write(frame) + frame.resetBytes(in: 0.. String { + guard let index = values.firstIndex(of: name), index + 1 < values.count else { + throw CredentialCommandError.invalidArguments + } + let value = values.remove(at: index + 1) + values.remove(at: index) + return value + } + let origin = try CredentialOrigin(rawValue: option("--origin")) + let alias = try CredentialAlias(rawValue: option("--alias")) + guard values.isEmpty else { throw CredentialCommandError.invalidArguments } + var frame = Data() + while frame.count <= AuthenticationCredentialFrame.maximumBytes { + let chunk = FileHandle.standardInput.readData( + ofLength: min(4_096, AuthenticationCredentialFrame.maximumBytes + 1 - frame.count) + ) + if chunk.isEmpty { break } + frame.append(chunk) + } + guard frame.count <= AuthenticationCredentialFrame.maximumBytes else { + throw CredentialVaultError.operationFailed("credential frame exceeded its size limit") + } + defer { frame.resetBytes(in: 0.. SensitiveBytes func remove(recordID: String) throws } @@ -242,6 +247,33 @@ public final class CredentialVaultController { } } + public func aliases(origin: CredentialOrigin) throws -> [AuthenticationAlias] { + let records = try withRecoveredState { transaction in + transaction.state.records.filter { $0.origin == origin }.sorted { + $0.alias.rawValue < $1.alias.rawValue + } + } + return try records.map { try AuthenticationAlias(alias: $0.alias, account: $0.account) } + } + + public func resolve( + origin: CredentialOrigin, alias: CredentialAlias + ) throws -> AuthenticationCredential { + let record = try withRecoveredState { transaction -> CredentialRecord in + guard let record = transaction.state.records.first(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { throw CredentialVaultError.notFound } + return record + } + let secret = try secrets.load(recordID: record.id) + defer { secret.clear() } + return try AuthenticationCredential( + account: record.account, + password: AuthenticationSecret(secret.withUnsafeBytes { Array($0) }) + ) + } + public func add(origin: CredentialOrigin, alias: CredentialAlias) throws -> JSONValue { try withRecoveredState { transaction in guard transaction.state.records.count < Self.maximumRecords else { @@ -262,6 +294,13 @@ public final class CredentialVaultController { throw CredentialVaultError.operationFailed("password confirmation did not match") } + return try store(origin: origin, alias: alias, account: account, secret: secret) + } + + public func store( + origin: CredentialOrigin, alias: CredentialAlias, account: String, secret: SensitiveBytes + ) throws -> JSONValue { + guard !secret.isEmpty else { throw CredentialVaultError.promptFailed } return try withRecoveredState { transaction in guard transaction.state.records.count < Self.maximumRecords else { throw CredentialVaultError.capacityExceeded diff --git a/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift b/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift index 0120846..5290eec 100644 --- a/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift +++ b/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift @@ -1,4 +1,5 @@ #if os(Linux) +import CHeadlessSecurePrompt import Dispatch import Foundation import Glibc @@ -28,6 +29,12 @@ public final class LinuxSecretServiceCredentialStore: CredentialSecretStore { ], secret: secret) } + public func load(recordID: String) throws -> SensitiveBytes { + try lookup([ + "lookup", "application", "com.headless.credentials.v1", "credential-id", recordID, + ]) + } + public func remove(recordID: String) throws { try run([ "clear", "application", "com.headless.credentials.v1", "credential-id", recordID, @@ -102,6 +109,63 @@ public final class LinuxSecretServiceCredentialStore: CredentialSecretStore { _ = errorCapture.text() } + private func lookup(_ arguments: [String]) throws -> SensitiveBytes { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.environment = Self.sanitizedEnvironment( + ProcessInfo.processInfo.environment, + runtimeDirectory: runtimeDirectory, + busAddress: busAddress + ) + process.standardInput = FileHandle.nullDevice + let output = Pipe() + let errors = Pipe() + process.standardOutput = output + process.standardError = errors + let outputCapture = BoundedSecretCapture(maximumBytes: 4_097) + let errorCapture = BoundedErrorCapture() + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in completion.signal() } + outputCapture.start(reading: output.fileHandleForReading) + errorCapture.start(reading: errors.fileHandleForReading) + do { try process.run() } + catch { + output.fileHandleForWriting.closeFile() + errors.fileHandleForWriting.closeFile() + _ = try? outputCapture.data() + _ = errorCapture.text() + throw CredentialVaultError.vaultUnavailable + } + output.fileHandleForWriting.closeFile() + errors.fileHandleForWriting.closeFile() + guard completion.wait(timeout: .now() + 15) == .success else { + if process.isRunning { process.terminate() } + if completion.wait(timeout: .now() + 2) == .timedOut, process.isRunning { + _ = kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + _ = try? outputCapture.data() + _ = errorCapture.text() + throw CredentialVaultError.operationFailed("Secret Service timeout") + } + var data = try outputCapture.data() + defer { data.resetBytes(in: 0.. URL? { for path in ["/usr/bin/secret-tool"] { var info = stat() @@ -157,6 +221,49 @@ public final class LinuxSecretServiceCredentialStore: CredentialSecretStore { } } +private final class BoundedSecretCapture: @unchecked Sendable { + private let maximumBytes: Int + private let group = DispatchGroup() + private let lock = NSLock() + private var bytes: [UInt8] = [] + private var overflowed = false + + init(maximumBytes: Int) { self.maximumBytes = maximumBytes } + + func start(reading handle: FileHandle) { + group.enter() + DispatchQueue.global(qos: .utility).async { [self] in + defer { group.leave() } + while true { + let data = handle.readData(ofLength: 4_096) + if data.isEmpty { return } + lock.lock() + let remaining = max(0, maximumBytes - bytes.count) + bytes.append(contentsOf: data.prefix(remaining)) + if data.count > remaining { overflowed = true } + lock.unlock() + } + } + } + + func data() throws -> Data { + group.wait() + lock.lock() + defer { + bytes.withUnsafeMutableBytes { buffer in + guard let base = buffer.baseAddress else { return } + headless_secure_clear(base.assumingMemoryBound(to: UInt8.self), buffer.count) + } + bytes.removeAll(keepingCapacity: false) + lock.unlock() + } + guard !overflowed else { + throw CredentialVaultError.operationFailed("invalid Secret Service value") + } + return Data(bytes) + } +} + private final class BoundedErrorCapture: @unchecked Sendable { private static let maximumBytes = 8_192 private let group = DispatchGroup() diff --git a/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift b/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift index 637b403..da379f3 100644 --- a/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift +++ b/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift @@ -1,5 +1,6 @@ #if os(macOS) import Foundation +import LocalAuthentication import Security public final class MacOSKeychainCredentialStore: CredentialSecretStore { @@ -35,6 +36,25 @@ public final class MacOSKeychainCredentialStore: CredentialSecretStore { guard status == errSecSuccess else { throw mappedKeychainError(status) } } + public func load(recordID: String) throws -> SensitiveBytes { + var query = baseQuery(recordID: recordID) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + let context = LAContext() + context.localizedReason = "Use the selected Headless credential" + context.interactionNotAllowed = false + query[kSecUseAuthenticationContext as String] = context + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, var data = result as? Data, + !data.isEmpty, data.count <= 4_096 else { + if status == errSecSuccess { throw CredentialVaultError.operationFailed("invalid Keychain value") } + throw mappedKeychainError(status) + } + defer { data.resetBytes(in: 0.. JSONValue { + try callAgent("return globalThis.__headlessAgent.authentication();") + } + + func agentFillCredential( + form: AuthenticationForm, credential: AuthenticationCredential + ) throws -> JSONValue { + guard try AuthenticationForm(agentAuthenticationState()) == form else { + throw AuthenticationError.formChanged + } + qaBridge.protectCredentialInput() + defer { credential.password.clear() } + let password = try credential.password.withUnsafeBytes { bytes -> String in + guard let base = bytes.baseAddress, + let value = String( + bytes: UnsafeBufferPointer( + start: base.assumingMemoryBound(to: UInt8.self), count: bytes.count + ), encoding: .utf8 + ) else { throw AuthenticationError.invalidBrokerResponse } + return value + } + guard let passwordTarget = form.passwordTarget else { + throw AuthenticationError.formChanged + } + var arguments: [String: Any] = [ + "origin": form.origin, + "passwordTarget": passwordTarget, + "account": credential.account, + "password": password, + ] + if let accountTarget = form.accountTarget { arguments["accountTarget"] = accountTarget } + if let submitTarget = form.submitTarget { arguments["submitTarget"] = submitTarget } + return try callAgent( + "return globalThis.__headlessAgent.credentialFill(args);", + arguments: ["args": arguments] + ) + } + + func agentFinishCredentialProtection(form: AuthenticationForm) { + if let passwordTarget = form.passwordTarget { + _ = try? callAgent( + "return globalThis.__headlessAgent.finishCredentialFill(args);", + arguments: ["args": ["passwordTarget": passwordTarget]] + ) + } + qaBridge.finishCredentialInput() + } + func agentPress(parameters: [String: JSONValue]) throws -> JSONValue { guard let key = parameters["key"]?.stringValue, !key.isEmpty, key.count <= 32 else { throw HostError(code: .operationFailed, message: "Missing command parameter: key") @@ -538,6 +586,21 @@ extension BrowserWindowController: BrowserEngineSession { } func hostPerformance() throws -> JSONValue { try agentPerformance() } func hostAnimations() throws -> JSONValue { try agentAnimations() } + func hostAuthenticationState() throws -> JSONValue { try agentAuthenticationState() } + func hostPromptCredential(origin: CredentialOrigin) throws -> AuthenticationCredential { + try promptCredential(origin: origin) + } + func hostPromptCredentialSave(origin: CredentialOrigin, account: String) throws -> CredentialAlias? { + try promptCredentialSave(origin: origin, account: account) + } + func hostFillCredential( + form: AuthenticationForm, credential: AuthenticationCredential + ) throws -> JSONValue { + try agentFillCredential(form: form, credential: credential) + } + func hostFinishCredentialProtection(form: AuthenticationForm) { + agentFinishCredentialProtection(form: form) + } } private func onMain(_ body: @escaping () -> T) -> T { diff --git a/apps/headless/Host/QADiagnosticsBridge.swift b/apps/headless/Host/QADiagnosticsBridge.swift index 80e22fa..51fdd60 100644 --- a/apps/headless/Host/QADiagnosticsBridge.swift +++ b/apps/headless/Host/QADiagnosticsBridge.swift @@ -97,6 +97,7 @@ final class WebKitQABridge: NSObject, WKScriptMessageHandler { private let lock = NSLock() private var acceptedEvents = 0 private var didRejectEvents = false + private var protectsCredential = false private let maximumEventsPerDocument = 500 func beginDocument() { @@ -106,6 +107,26 @@ final class WebKitQABridge: NSObject, WKScriptMessageHandler { lock.unlock() } + func protectCredentialInput() { + lock.lock() + protectsCredential = true + lock.unlock() + _ = store.clear() + } + + func didCommitDocument() { + lock.lock() + protectsCredential = false + lock.unlock() + } + + func finishCredentialInput() { + _ = store.clear() + lock.lock() + protectsCredential = false + lock.unlock() + } + func clear() -> JSONValue { beginDocument() return store.clear() @@ -113,6 +134,10 @@ final class WebKitQABridge: NSObject, WKScriptMessageHandler { private func acceptEvent() -> Bool { lock.lock() + guard !protectsCredential else { + lock.unlock() + return false + } if acceptedEvents < maximumEventsPerDocument { acceptedEvents += 1 lock.unlock() diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index 3c6aa15..a4fd6b3 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -500,6 +500,10 @@ final class LinuxBrowserSession: @unchecked Sendable { ]) } + func authenticationState() throws -> JSONValue { + try evaluate("return globalThis.__headlessAgent.authentication();") + } + func press(parameters: [String: JSONValue]) throws -> JSONValue { guard let key = parameters["key"]?.stringValue, !key.isEmpty, key.count <= 32 else { throw HostError(code: .operationFailed, message: "Missing command parameter: key") diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index f6a4c9d..c434faa 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -154,6 +154,7 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession { try browserSession.setNetworkMock(parameters: parameters) } func hostClearNetworkMocks() throws -> JSONValue { try browserSession.clearNetworkMocks() } + func hostAuthenticationState() throws -> JSONValue { try browserSession.authenticationState() } } do { @@ -163,10 +164,17 @@ do { let engine = try ChromiumBrowserEngine() let artifacts = try ArtifactStore() let stopped = DispatchSemaphore(value: 0) + let authenticationBroker: any AuthenticationBroker + if let broker = try? CredentialBrokerProcessClient() { + authenticationBroker = broker + } else { + authenticationBroker = UnavailableAuthenticationBroker() + } let core = HostCore( engine: engine, artifacts: artifacts, defaultSession: try engine.createSession(), + authenticationBroker: authenticationBroker, shutdownHandler: { stopped.signal() } ) let server = LocalSocketServer() diff --git a/apps/headless/Package.swift b/apps/headless/Package.swift index ddddd0e..ce8f0a1 100644 --- a/apps/headless/Package.swift +++ b/apps/headless/Package.swift @@ -38,7 +38,7 @@ let package = Package( ), .target( name: "HeadlessProtocol", - dependencies: ["CHeadlessVersion"], + dependencies: ["CHeadlessSecurePrompt", "CHeadlessVersion"], resources: [.process("Resources")] ), .target( @@ -51,6 +51,7 @@ let package = Package( dependencies: ["HeadlessProtocol", "CHeadlessSecurePrompt"], path: "CredentialBrokerCore", linkerSettings: [ + .linkedFramework("LocalAuthentication", .when(platforms: [.macOS])), .linkedFramework("Security", .when(platforms: [.macOS])), ] ), diff --git a/apps/headless/Sources/HeadlessProtocol/Authentication.swift b/apps/headless/Sources/HeadlessProtocol/Authentication.swift new file mode 100644 index 0000000..04113a0 --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/Authentication.swift @@ -0,0 +1,391 @@ +import CHeadlessSecurePrompt +import Foundation + +public enum AuthenticationDetection: String, Sendable { + case none + case hint + case confirmed + case additionalVerification = "additional-verification" + case passkey + case crossOrigin = "cross-origin" +} + +public struct AuthenticationForm: Equatable, Sendable { + public let origin: String + public let document: String? + public let credentialOrigin: CredentialOrigin? + public let detection: AuthenticationDetection + public let accountTarget: String? + public let passwordTarget: String? + public let submitTarget: String? + + public init(_ value: JSONValue) throws { + guard case .object(let object) = value, + let rawDetection = object["detection"]?.stringValue, + let detection = AuthenticationDetection(rawValue: rawDetection), + let rawOrigin = object["origin"]?.stringValue else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid authentication state") + } + guard rawOrigin.unicodeScalars.allSatisfy(\.isASCII), rawOrigin.utf8.count <= 2_048 else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid authentication origin") + } + if let url = URL(string: rawOrigin), ["http", "https"].contains(url.scheme?.lowercased()), + url.host != nil, (url.path.isEmpty || url.path == "/"), + url.query == nil, url.fragment == nil, url.user == nil, url.password == nil { + self.origin = rawOrigin + credentialOrigin = try? CredentialOrigin(rawValue: rawOrigin) + } else { + guard detection == .none else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid authentication origin") + } + self.origin = "" + credentialOrigin = nil + } + self.detection = detection + if let rawDocument = object["document"]?.stringValue { + guard rawDocument.utf8.count == 32, + rawDocument.unicodeScalars.allSatisfy({ scalar in + scalar.isASCII && ((scalar.value >= 48 && scalar.value <= 57) + || (scalar.value >= 97 && scalar.value <= 102)) + }) else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid document identity") + } + document = rawDocument + } else { + guard detection == .none else { + throw HostError(code: .operationFailed, message: "Browser omitted the document identity") + } + document = nil + } + accountTarget = try Self.target(object["accountTarget"]) + passwordTarget = try Self.target(object["passwordTarget"]) + submitTarget = try Self.target(object["submitTarget"]) + if detection == .confirmed, passwordTarget == nil { + throw HostError(code: .operationFailed, message: "Browser returned an incomplete authentication form") + } + } + + private static func target(_ value: JSONValue?) throws -> String? { + guard let value else { return nil } + if value == .null { return nil } + guard let target = value.stringValue, target.hasPrefix("@e"), target.utf8.count <= 16, + !target.dropFirst(2).isEmpty, target.dropFirst(2).allSatisfy(\.isNumber) else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid authentication target") + } + return target + } + + public var publicValue: JSONValue { + .object([ + "origin": .string(origin), + "detection": .string(detection.rawValue), + "untrustedContent": .bool(true), + ]) + } +} + +public struct AuthenticationAlias: Equatable, Sendable { + public let alias: CredentialAlias + public let account: String + + public init(alias: CredentialAlias, account: String) throws { + self.alias = alias + self.account = try validatedAuthenticationAccount(account) + } + + public var publicValue: JSONValue { + .object(["alias": .string(alias.rawValue), "username": .string(account)]) + } +} + +public final class AuthenticationSecret: @unchecked Sendable { + private var storage: [UInt8] + + public init(_ bytes: [UInt8]) { storage = bytes } + + deinit { clear() } + + public var isEmpty: Bool { storage.isEmpty } + + public func withUnsafeBytes(_ body: (UnsafeRawBufferPointer) throws -> T) rethrows -> T { + try storage.withUnsafeBytes(body) + } + + public func clear() { + storage.withUnsafeMutableBytes { buffer in + guard let base = buffer.baseAddress else { return } + headless_secure_clear(base.assumingMemoryBound(to: UInt8.self), buffer.count) + } + storage.removeAll(keepingCapacity: false) + } +} + +public struct AuthenticationCredential: @unchecked Sendable { + public let account: String + public let password: AuthenticationSecret + + public init(account: String, password: AuthenticationSecret) throws { + self.account = try validatedAuthenticationAccount(account) + guard !password.isEmpty else { throw AuthenticationError.invalidBrokerResponse } + self.password = password + } + + public func copy() throws -> AuthenticationCredential { + try AuthenticationCredential( + account: account, + password: AuthenticationSecret(password.withUnsafeBytes { Array($0) }) + ) + } +} + +private func validatedAuthenticationAccount(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.utf8.count <= 320, + !trimmed.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else { + throw AuthenticationError.invalidBrokerResponse + } + return trimmed +} + +public protocol AuthenticationBroker: Sendable { + func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] + func credential(for origin: CredentialOrigin, alias: CredentialAlias) throws -> AuthenticationCredential + func store( + _ credential: AuthenticationCredential, for origin: CredentialOrigin, alias: CredentialAlias + ) throws +} + +public extension AuthenticationBroker { + func store( + _ credential: AuthenticationCredential, for origin: CredentialOrigin, alias: CredentialAlias + ) throws { + credential.password.clear() + throw AuthenticationError.vaultUnavailable + } +} + +public struct UnavailableAuthenticationBroker: AuthenticationBroker { + public init() {} + public func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] { [] } + public func credential(for origin: CredentialOrigin, alias: CredentialAlias) throws -> AuthenticationCredential { + throw AuthenticationError.vaultUnavailable + } + public func store( + _ credential: AuthenticationCredential, for origin: CredentialOrigin, alias: CredentialAlias + ) throws { + credential.password.clear() + throw AuthenticationError.vaultUnavailable + } +} + +public struct SecureTerminalAuthenticationPrompt { + public init() {} + + public func readCredential() throws -> AuthenticationCredential { + let accountBytes = try read(prompt: "Account username/email: ", hidden: false, maximum: 320) + guard let account = String(bytes: accountBytes, encoding: .utf8) else { + throw AuthenticationError.userPresenceDenied + } + return try AuthenticationCredential( + account: account, + password: AuthenticationSecret( + try read(prompt: "Password: ", hidden: true, maximum: 4_096) + ) + ) + } + + public func confirmSave() throws -> CredentialAlias? { + let answerBytes = try read(prompt: "Save this credential? [y/N] ", hidden: false, maximum: 3) + guard let answer = String(bytes: answerBytes, encoding: .utf8)?.lowercased(), + answer == "y" || answer == "yes" else { return nil } + let aliasBytes = try read(prompt: "Credential alias: ", hidden: false, maximum: 64) + guard let alias = String(bytes: aliasBytes, encoding: .utf8) else { + throw AuthenticationError.userPresenceDenied + } + return try CredentialAlias(rawValue: alias) + } + + private func read(prompt: String, hidden: Bool, maximum: Int) throws -> [UInt8] { + var pointer: UnsafeMutablePointer? + var count = 0 + let result = prompt.withCString { + headless_read_tty_line($0, hidden ? 1 : 0, &pointer, &count) + } + guard result == Int32(HEADLESS_PROMPT_SUCCESS.rawValue), let pointer else { + throw AuthenticationError.userPresenceUnavailable + } + defer { headless_clear_and_free(pointer, count + 1) } + guard count <= maximum else { throw AuthenticationError.invalidBrokerResponse } + return Array(UnsafeBufferPointer(start: pointer, count: count)) + } +} + +public enum AuthenticationError: Error, Equatable, CustomStringConvertible { + case challengeNotFound + case challengeExpired + case challengeConsumed + case originChanged + case formChanged + case accountNotFound + case vaultUnavailable + case vaultLocked + case userPresenceUnavailable + case userPresenceDenied + case invalidBrokerResponse + case brokerFailed(String) + + public var code: String { + switch self { + case .challengeNotFound: return "AUTH_CHALLENGE_NOT_FOUND" + case .challengeExpired: return "AUTH_CHALLENGE_EXPIRED" + case .challengeConsumed: return "AUTH_CHALLENGE_CONSUMED" + case .originChanged: return "AUTH_ORIGIN_CHANGED" + case .formChanged: return "AUTH_FORM_CHANGED" + case .accountNotFound: return "AUTH_ACCOUNT_NOT_FOUND" + case .vaultUnavailable: return "VAULT_UNAVAILABLE" + case .vaultLocked: return "VAULT_LOCKED" + case .userPresenceUnavailable: return "USER_PRESENCE_UNAVAILABLE" + case .userPresenceDenied: return "USER_PRESENCE_DENIED" + case .invalidBrokerResponse: return "VAULT_RESPONSE_INVALID" + case .brokerFailed: return "VAULT_OPERATION_FAILED" + } + } + + public var description: String { + switch self { + case .challengeNotFound: return "Authentication challenge was not found for this session." + case .challengeExpired: return "Authentication challenge expired. Inspect the current page again." + case .challengeConsumed: return "Authentication challenge has already been used." + case .originChanged: return "The top-level authentication origin changed. Inspect the page again." + case .formChanged: return "The authentication form changed. Inspect the page again." + case .accountNotFound: return "No saved account matches that alias for this origin." + case .vaultUnavailable: return "An approved operating-system credential vault is unavailable." + case .vaultLocked: return "The operating-system credential vault is locked." + case .userPresenceUnavailable: + return "A trusted per-use user-presence mechanism is unavailable." + case .userPresenceDenied: return "Credential use was not authorized by the user." + case .invalidBrokerResponse: return "The credential broker returned an invalid response." + case .brokerFailed: return "The credential broker could not complete the request." + } + } +} + +public final class AuthenticationChallengeStore: @unchecked Sendable { + public static let lifetime: TimeInterval = 60 + public static let maximumChallenges = 64 + + public struct Challenge: Equatable, Sendable { + public let id: String + public let session: String + public let form: AuthenticationForm + fileprivate let expiresAt: TimeInterval + fileprivate var inUse: Bool + fileprivate var consumed: Bool + } + + private let lock = NSLock() + private var challenges: [String: Challenge] = [:] + private let now: @Sendable () -> TimeInterval + + public init(now: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.now = now + } + + public func issue(session: String, form: AuthenticationForm) -> Challenge { + lock.lock() + defer { lock.unlock() } + let issuedAt = now() + purgeExpired(at: issuedAt) + challenges = challenges.filter { $0.value.session != session } + while challenges.count >= Self.maximumChallenges, let oldest = challenges.min(by: { + $0.value.expiresAt < $1.value.expiresAt + })?.key { + challenges.removeValue(forKey: oldest) + } + let challenge = Challenge( + id: UUID().uuidString.lowercased(), session: session, form: form, + expiresAt: issuedAt + Self.lifetime, inUse: false, consumed: false + ) + challenges[challenge.id] = challenge + return challenge + } + + public func begin(id: String, session: String, currentForm: AuthenticationForm) throws -> Challenge { + lock.lock() + defer { lock.unlock() } + guard var challenge = challenges[id], challenge.session == session else { + throw AuthenticationError.challengeNotFound + } + guard now() <= challenge.expiresAt else { + challenges.removeValue(forKey: id) + throw AuthenticationError.challengeExpired + } + guard !challenge.consumed, !challenge.inUse else { throw AuthenticationError.challengeConsumed } + guard challenge.form.origin == currentForm.origin else { throw AuthenticationError.originChanged } + guard challenge.form.document == currentForm.document else { throw AuthenticationError.formChanged } + guard currentForm.detection == .confirmed, + challenge.form.passwordTarget == currentForm.passwordTarget, + challenge.form.accountTarget == currentForm.accountTarget, + challenge.form.submitTarget == currentForm.submitTarget else { + throw AuthenticationError.formChanged + } + challenge.inUse = true + challenges[id] = challenge + return challenge + } + + public func validateActive( + id: String, session: String, currentForm: AuthenticationForm + ) throws -> Challenge { + lock.lock() + defer { lock.unlock() } + guard let challenge = challenges[id], challenge.session == session else { + throw AuthenticationError.challengeNotFound + } + guard now() <= challenge.expiresAt else { + challenges.removeValue(forKey: id) + throw AuthenticationError.challengeExpired + } + guard !challenge.consumed, challenge.inUse else { + throw AuthenticationError.challengeConsumed + } + guard challenge.form.origin == currentForm.origin else { + throw AuthenticationError.originChanged + } + guard challenge.form.document == currentForm.document else { + throw AuthenticationError.formChanged + } + guard currentForm.detection == .confirmed, + challenge.form.passwordTarget == currentForm.passwordTarget, + challenge.form.accountTarget == currentForm.accountTarget, + challenge.form.submitTarget == currentForm.submitTarget else { + throw AuthenticationError.formChanged + } + return challenge + } + + public func finish(id: String, consumed: Bool) { + lock.lock() + defer { lock.unlock() } + guard var challenge = challenges[id] else { return } + challenge.inUse = false + challenge.consumed = consumed + challenges[id] = challenge + } + + public func invalidate(session: String) { + lock.lock() + challenges = challenges.filter { $0.value.session != session } + lock.unlock() + } + + public func removeAll() { + lock.lock() + challenges.removeAll() + lock.unlock() + } + + private func purgeExpired(at time: TimeInterval) { + challenges = challenges.filter { time <= $0.value.expiresAt } + } +} diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index b0b98c4..69b53d6 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -131,6 +131,8 @@ public struct CLIParser { case "credentials": guard session == nil else { throw CLIParseError.invalidOption("--session") } return try parseCredentials(arguments) + case "auth": + return try parseAuth(arguments, session: session, jsonOutput: jsonOutput) case "status": try requireEmpty(arguments) return remote(.ping, session: session, jsonOutput: jsonOutput) @@ -293,6 +295,32 @@ public struct CLIParser { guard arguments.isEmpty else { throw CredentialCommandError.invalidArguments } } + private func parseAuth( + _ arguments: [String], session: String?, jsonOutput: Bool + ) throws -> CLIInvocation { + guard arguments.first == "login" else { + throw CLIParseError.missingArgument("auth login") + } + var args = Array(arguments.dropFirst()) + let challenge = try removeOption("--challenge", from: &args) + let account = try removeOption("--account", from: &args) + let interactive = removeFlag("--interactive", from: &args) + try requireEmpty(args) + guard interactive != (account != nil), interactive || challenge != nil else { + throw CLIParseError.missingArgument("--challenge ID --account ALIAS, or --interactive") + } + if let account { _ = try CredentialAlias(rawValue: account) } + var parameters: [String: JSONValue] = [:] + if let challenge { parameters["challenge"] = .string(challenge) } + if interactive { parameters["interactive"] = .bool(true) } + if let account { parameters["account"] = .string(account) } + return remote( + .authLogin, session: session, + parameters: parameters, + jsonOutput: jsonOutput + ) + } + private func parseInspect(_ arguments: [String], session: String?, jsonOutput: Bool) throws -> CLIInvocation { var args = arguments let interactive = removeFlag("--interactive", from: &args) @@ -750,6 +778,7 @@ Commands: credentials add --origin URL --alias NAME --interactive credentials rename --origin URL --alias OLD --to NEW credentials remove --origin URL --alias NAME + auth login --challenge ID --account ALIAS | auth login --interactive session create [NAME] | session list | session close NAME visit URL inspect [--context summary|outline|text|actions|full] [--task TEXT] diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index 2322ba7..39e2b4e 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -73,6 +73,15 @@ public struct BrowserEngineCapabilities: Sendable { "storage": .string(normalProfileStorage), "clearCommand": .string(CommandName.profileClear.rawValue), ]), + "authentication": .object([ + "challengeCommand": .string(CommandName.authLogin.rawValue), + "exactOriginAliases": .bool(true), + "challengeLifetimeSeconds": .number(AuthenticationChallengeStore.lifetime), + "singleUse": .bool(true), + "savedCredentialUse": .bool(engine == .webkit), + "userPresencePerSavedUse": .bool(engine == .webkit), + "automaticActionReplay": .bool(false), + ]), ]), ]) } @@ -198,7 +207,10 @@ public let capabilitiesDocument: JSONValue = { "securityTier": .string(credentialSecurityTier), "availability": .string("checked-at-command-time"), "passwordTransport": .string("dedicated-local-broker"), - "userPresence": .string("required-on-every-use-by-broker"), + "userPresence": .string( + currentBrowserEngineCapabilities.engine == .webkit + ? "required-on-every-use-by-broker" : "unavailable-for-saved-use" + ), "silentUse": .bool(false), "agentReceivesPasswords": .bool(false), "privateContextAccess": .bool(false), diff --git a/apps/headless/Sources/HeadlessProtocol/CredentialBrokerProcess.swift b/apps/headless/Sources/HeadlessProtocol/CredentialBrokerProcess.swift new file mode 100644 index 0000000..9ef07be --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/CredentialBrokerProcess.swift @@ -0,0 +1,259 @@ +import Dispatch +import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum AuthenticationCredentialFrame { + private static let magic = Data("HEADLESS-AUTH-1\n".utf8) + public static let maximumBytes = 4_512 + + public static func encode(_ credential: AuthenticationCredential) throws -> Data { + let account = Data(credential.account.utf8) + var secret = credential.password.withUnsafeBytes { Data($0) } + defer { secret.resetBytes(in: 0.. AuthenticationCredential { + guard data.count <= maximumBytes, data.starts(with: magic) else { + throw AuthenticationError.invalidBrokerResponse + } + var offset = magic.count + let accountLength = Int(try readUInt32(data, offset: &offset)) + let secretLength = Int(try readUInt32(data, offset: &offset)) + guard accountLength <= 320, secretLength > 0, secretLength <= 4_096, + offset + accountLength + secretLength == data.count else { + throw AuthenticationError.invalidBrokerResponse + } + let accountData = data[offset..<(offset + accountLength)] + offset += accountLength + guard let account = String(data: accountData, encoding: .utf8) else { + throw AuthenticationError.invalidBrokerResponse + } + return try AuthenticationCredential( + account: account, + password: AuthenticationSecret(Array(data[offset..<(offset + secretLength)])) + ) + } + + private static func append(_ value: UInt32, to data: inout Data) { + var bigEndian = value.bigEndian + withUnsafeBytes(of: &bigEndian) { data.append(contentsOf: $0) } + } + + private static func readUInt32(_ data: Data, offset: inout Int) throws -> UInt32 { + guard offset + 4 <= data.count else { throw AuthenticationError.invalidBrokerResponse } + let value = data[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + offset += 4 + return value + } +} + +public final class CredentialBrokerProcessClient: @unchecked Sendable, AuthenticationBroker { + private let executableURL: URL + + public convenience init() throws { + try self.init(executableURL: Self.resolveExecutable()) + } + + public init(executableURL: URL) throws { + let resolved = executableURL.resolvingSymlinksInPath().standardizedFileURL + var info = stat() + guard lstat(resolved.path, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + info.st_nlink == 1, (info.st_uid == getuid() || info.st_uid == 0), + (info.st_mode & 0o022) == 0, + FileManager.default.isExecutableFile(atPath: resolved.path) else { + throw AuthenticationError.vaultUnavailable + } + self.executableURL = resolved + } + + public func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] { + let output = try run( + ["credentials", "list", "--origin", origin.rawValue, "--json"], + maximumOutputBytes: 256_000 + ) + guard let object = try? JSONSerialization.jsonObject(with: output) as? [String: Any], + object["ok"] as? Bool == true, + let result = object["result"] as? [String: Any], + let values = result["credentials"] as? [[String: Any]], values.count <= 500 else { + throw AuthenticationError.invalidBrokerResponse + } + return try values.map { value in + guard let rawAlias = value["alias"] as? String, + let account = value["username"] as? String else { + throw AuthenticationError.invalidBrokerResponse + } + return try AuthenticationAlias( + alias: CredentialAlias(rawValue: rawAlias), account: account + ) + } + } + + public func credential( + for origin: CredentialOrigin, alias: CredentialAlias + ) throws -> AuthenticationCredential { + var output = try run( + ["__resolve", "--origin", origin.rawValue, "--alias", alias.rawValue], + maximumOutputBytes: AuthenticationCredentialFrame.maximumBytes + ) + defer { output.resetBytes(in: 0.. Data { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.environment = Self.sanitizedEnvironment(ProcessInfo.processInfo.environment) + let inputPipe = input.map { _ in Pipe() } + if let inputPipe { + process.standardInput = inputPipe + } else { + process.standardInput = FileHandle.nullDevice + } + process.standardError = FileHandle.nullDevice + let output = Pipe() + process.standardOutput = output + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in completion.signal() } + do { try process.run() } + catch { throw AuthenticationError.vaultUnavailable } + if let input, let inputPipe { + inputPipe.fileHandleForWriting.write(input) + try? inputPipe.fileHandleForWriting.close() + } + let capture = BoundedBrokerOutput(maximumBytes: maximumOutputBytes) + capture.start(output.fileHandleForReading) + guard completion.wait(timeout: .now() + 30) == .success else { + if process.isRunning { process.terminate() } + if completion.wait(timeout: .now() + 2) == .timedOut, process.isRunning { + _ = kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + _ = try? capture.finish() + throw AuthenticationError.brokerFailed("timeout") + } + let data = try capture.finish() + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + switch process.terminationStatus { + case 77: throw AuthenticationError.userPresenceDenied + case 78: throw AuthenticationError.vaultUnavailable + case 79: throw AuthenticationError.accountNotFound + case 80: throw AuthenticationError.vaultLocked + case 81: throw AuthenticationError.userPresenceUnavailable + default: throw AuthenticationError.brokerFailed("request") + } + } + return data + } + + private static func resolveExecutable() throws -> URL { + let executable = try runningExecutableURL() + let directory = executable.deletingLastPathComponent() + let candidates: [URL] + #if os(macOS) + candidates = [ + directory.deletingLastPathComponent().appendingPathComponent( + "Resources/bin/headless-credential-broker" + ), + directory.appendingPathComponent("headless-credential-broker"), + ] + #else + candidates = [directory.appendingPathComponent("headless-credential-broker")] + #endif + guard let candidate = candidates.first(where: { + FileManager.default.isExecutableFile(atPath: $0.path) + }) else { throw AuthenticationError.vaultUnavailable } + return candidate + } + + private static func runningExecutableURL() throws -> URL { + #if os(macOS) + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + var buffer = [CChar](repeating: 0, count: Int(size)) + guard buffer.withUnsafeMutableBufferPointer({ + _NSGetExecutablePath($0.baseAddress, &size) + }) == 0 else { throw AuthenticationError.vaultUnavailable } + return URL(fileURLWithPath: String(cString: buffer)).resolvingSymlinksInPath() + #else + guard let path = try? FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") else { + throw AuthenticationError.vaultUnavailable + } + return URL(fileURLWithPath: path).standardizedFileURL + #endif + } + + private static func sanitizedEnvironment(_ source: [String: String]) -> [String: String] { + let allowed = [ + "HOME", "USER", "LOGNAME", "DISPLAY", "WAYLAND_DISPLAY", "LANG", + "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "__CF_USER_TEXT_ENCODING", + ] + var result = source.filter { allowed.contains($0.key) || $0.key.hasPrefix("LC_") } + result["PATH"] = "/usr/bin:/bin" + return result + } +} + +private final class BoundedBrokerOutput: @unchecked Sendable { + private let maximumBytes: Int + private let group = DispatchGroup() + private let lock = NSLock() + private var data = Data() + private var overflowed = false + + init(maximumBytes: Int) { self.maximumBytes = maximumBytes } + + func start(_ handle: FileHandle) { + group.enter() + DispatchQueue.global(qos: .utility).async { [self] in + defer { group.leave() } + while true { + let chunk = handle.readData(ofLength: 4_096) + if chunk.isEmpty { return } + lock.lock() + let remaining = max(0, maximumBytes - data.count) + data.append(chunk.prefix(remaining)) + if chunk.count > remaining { overflowed = true } + lock.unlock() + } + } + } + + func finish() throws -> Data { + group.wait() + lock.lock() + defer { lock.unlock() } + guard !overflowed else { throw AuthenticationError.invalidBrokerResponse } + let result = data + data.resetBytes(in: 0.. JSONValue func hostSetNetworkMock(parameters: [String: JSONValue]) throws -> JSONValue func hostClearNetworkMocks() throws -> JSONValue + func hostAuthenticationState() throws -> JSONValue + func hostPromptCredential(origin: CredentialOrigin) throws -> AuthenticationCredential + func hostPromptCredentialSave(origin: CredentialOrigin, account: String) throws -> CredentialAlias? + func hostFillCredential(form: AuthenticationForm, credential: AuthenticationCredential) throws -> JSONValue + func hostFinishCredentialProtection(form: AuthenticationForm) } public extension BrowserEngineSession { func hostEnableAgentControl() {} + func hostAuthenticationState() throws -> JSONValue { + .object(["origin": .string("http://localhost"), "detection": .string("none")]) + } + + func hostPromptCredential(origin: CredentialOrigin) throws -> AuthenticationCredential { + try SecureTerminalAuthenticationPrompt().readCredential() + } + + func hostPromptCredentialSave(origin: CredentialOrigin, account: String) throws -> CredentialAlias? { + try SecureTerminalAuthenticationPrompt().confirmSave() + } + + func hostFillCredential( + form: AuthenticationForm, credential: AuthenticationCredential + ) throws -> JSONValue { + credential.password.clear() + throw HostError( + code: .unsupportedCapability, + message: "Credential login is not supported by this engine." + ) + } + + func hostFinishCredentialProtection(form: AuthenticationForm) {} + func hostEmulateNetwork(parameters: [String: JSONValue]) throws -> JSONValue { throw HostError( code: .unsupportedCapability, @@ -97,6 +126,8 @@ public extension BrowserEngine { public final class HostCore: @unchecked Sendable { private let engine: Engine private let artifacts: ArtifactStore + private let authenticationBroker: AuthenticationBroker + private let authenticationChallenges: AuthenticationChallengeStore private let shutdownHandler: @Sendable () -> Void private let lock = NSLock() private var sessions: [String: Engine.Session] @@ -110,10 +141,14 @@ public final class HostCore: @unchecked Sendable { engine: Engine, artifacts: ArtifactStore, defaultSession: Engine.Session, + authenticationBroker: AuthenticationBroker = UnavailableAuthenticationBroker(), + authenticationChallenges: AuthenticationChallengeStore = AuthenticationChallengeStore(), shutdownHandler: @escaping @Sendable () -> Void ) { self.engine = engine self.artifacts = artifacts + self.authenticationBroker = authenticationBroker + self.authenticationChallenges = authenticationChallenges self.sessions = ["default": defaultSession] self.shutdownHandler = shutdownHandler } @@ -133,6 +168,7 @@ public final class HostCore: @unchecked Sendable { trace.removeValue(forKey: name) activeFlows.removeValue(forKey: name) if let recording = recordings.removeValue(forKey: name) { stopped.append(recording) } + authenticationChallenges.invalidate(session: name) } return stopped } @@ -151,6 +187,7 @@ public final class HostCore: @unchecked Sendable { sessions.removeAll() trace.removeAll() activeFlows.removeAll() + authenticationChallenges.removeAll() return (activeRecordings, openSessions) } for recording in captured.0 { _ = try? recording.stop(timeout: 5) } @@ -193,6 +230,12 @@ public final class HostCore: @unchecked Sendable { } session.hostEnableAgentControl() let result = try execute(request, sessionName: name, session: session) + if let response = try authenticationResponse( + after: request.command, request: request, sessionName: name, + session: session, result: result + ) { + return response + } record(request.command, session: name, result: result) if let step = flowStepIfSafe(command: request.command, parameters: request.parameters) { withState { @@ -203,6 +246,8 @@ public final class HostCore: @unchecked Sendable { return .success(id: request.id, result: result) } catch let error as HostError { return hostFailure(request, error) + } catch let error as AuthenticationError { + return failure(request, error.code, error.description) } catch let error as ProtocolValidationError { if case .unsafeResourceType = error { return failure( @@ -243,6 +288,7 @@ public final class HostCore: @unchecked Sendable { sessions.removeAll() trace.removeAll() activeFlows.removeAll() + authenticationChallenges.removeAll() return (activeRecordings, openSessions) } for recording in captured.0 { _ = try? recording.stop(timeout: 5) } @@ -331,6 +377,7 @@ public final class HostCore: @unchecked Sendable { let recording = recordings.removeValue(forKey: name) trace.removeValue(forKey: name) activeFlows.removeValue(forKey: name) + authenticationChallenges.invalidate(session: name) return (session, recording) } guard let session = closing.0 else { return missingSession(request, name) } @@ -422,6 +469,8 @@ public final class HostCore: @unchecked Sendable { case .networkEmulate: return try session.hostEmulateNetwork(parameters: request.parameters) case .networkMockSet: return try session.hostSetNetworkMock(parameters: request.parameters) case .networkMockClear: return try session.hostClearNetworkMocks() + case .authLogin: + return try authenticate(request, sessionName: name, session: session) case .visualCompare: return try visualCompare(request, sessionName: name) case .reportCreate: @@ -446,6 +495,187 @@ public final class HostCore: @unchecked Sendable { } } + private func authenticationResponse( + after command: CommandName, request: CommandRequest, sessionName: String, + session: Engine.Session, result: JSONValue + ) throws -> CommandResponse? { + guard [.visit, .inspect, .click, .wait, .back, .reload].contains(command) else { return nil } + let form = try AuthenticationForm(session.hostAuthenticationState()) + guard form.detection != .none else { + authenticationChallenges.invalidate(session: sessionName) + return nil + } + guard form.detection == .confirmed else { + authenticationChallenges.invalidate(session: sessionName) + return .success( + id: request.id, + result: merge(result, with: .object(["authentication": form.publicValue])) + ) + } + guard let credentialOrigin = form.credentialOrigin else { + return .success( + id: request.id, + result: merge(result, with: .object([ + "authentication": .object([ + "origin": .string(form.origin), + "detection": .string("hint"), + "credentialUseSupported": .bool(false), + ]), + ])) + ) + } + let challenge = authenticationChallenges.issue(session: sessionName, form: form) + let aliases: [AuthenticationAlias] + let vaultAvailable: Bool + let vaultStatus: String + do { + aliases = try authenticationBroker.aliases(for: credentialOrigin) + vaultAvailable = true + vaultStatus = "available" + } catch let error as AuthenticationError { + aliases = [] + vaultAvailable = false + vaultStatus = error.code + } catch { + aliases = [] + vaultAvailable = false + vaultStatus = "VAULT_OPERATION_FAILED" + } + #if os(macOS) + let credentialUseAvailable = true + let suggestion = "Ask the user to choose an account alias, then run `headless auth login --challenge ID --account ALIAS`." + #else + let credentialUseAvailable = false + let suggestion = "Saved credential use needs a trusted per-use confirmation surface on this platform." + #endif + let details: JSONValue = .object([ + "challenge": .string(challenge.id), + "origin": .string(form.origin), + "detection": .string("confirmed"), + "accounts": .array(aliases.map(\.publicValue)), + "expiresInSeconds": .number(AuthenticationChallengeStore.lifetime), + "userPresenceRequired": .bool(true), + "credentialUseAvailable": .bool(credentialUseAvailable), + "vaultAvailable": .bool(vaultAvailable), + "vaultStatus": .string(vaultStatus), + "untrustedContent": .bool(true), + "originalActionReplayed": .bool(false), + ]) + return failure( + request, "AUTH_REQUIRED", "Authentication is required for the current page.", + suggestion: suggestion, + details: details + ) + } + + private func authenticate( + _ request: CommandRequest, sessionName: String, session: Engine.Session + ) throws -> JSONValue { + let interactive = request.parameters["interactive"]?.boolValue ?? false + let alias = try request.parameters["account"]?.stringValue.map(CredentialAlias.init(rawValue:)) + guard interactive != (alias != nil) else { + throw HostError(code: .operationFailed, message: "Choose either interactive login or one account alias.") + } + let currentForm = try AuthenticationForm(session.hostAuthenticationState()) + let challengeID: String + if let requested = request.parameters["challenge"]?.stringValue { + challengeID = requested + } else if interactive, currentForm.detection == .confirmed, + currentForm.credentialOrigin != nil { + challengeID = authenticationChallenges.issue( + session: sessionName, form: currentForm + ).id + } else { + throw AuthenticationError.challengeNotFound + } + let challenge = try authenticationChallenges.begin( + id: challengeID, session: sessionName, currentForm: currentForm + ) + var consumed = false + defer { authenticationChallenges.finish(id: challengeID, consumed: consumed) } + let credential: AuthenticationCredential + do { + guard let credentialOrigin = challenge.form.credentialOrigin else { + throw AuthenticationError.originChanged + } + if interactive { + credential = try session.hostPromptCredential(origin: credentialOrigin) + } else if let alias { + credential = try authenticationBroker.credential(for: credentialOrigin, alias: alias) + } else { + throw AuthenticationError.accountNotFound + } + } catch let error as AuthenticationError { + throw error + } catch { + throw AuthenticationError.brokerFailed("credential retrieval") + } + defer { credential.password.clear() } + let saveCandidate = interactive ? try credential.copy() : nil + defer { saveCandidate?.password.clear() } + consumed = true + let approvedForm = try AuthenticationForm(session.hostAuthenticationState()) + _ = try authenticationChallenges.validateActive( + id: challengeID, session: sessionName, currentForm: approvedForm + ) + defer { session.hostFinishCredentialProtection(form: challenge.form) } + _ = try session.hostFillCredential(form: challenge.form, credential: credential) + let continuation: String + let deadline = ProcessInfo.processInfo.systemUptime + 5 + while true { + Thread.sleep(forTimeInterval: 0.1) + let state: AuthenticationForm + do { + state = try AuthenticationForm(session.hostAuthenticationState()) + } catch { + if ProcessInfo.processInfo.systemUptime >= deadline { + continuation = "verification-unknown" + break + } + continue + } + if state.origin != challenge.form.origin { + continuation = "redirected" + break + } + if state.detection == .none { + continuation = "authenticated" + break + } + if state.detection == .additionalVerification { + continuation = "additional-verification" + break + } + if state.detection == .passkey { + continuation = "passkey-required" + break + } + if ProcessInfo.processInfo.systemUptime >= deadline { + continuation = state.detection == .confirmed + ? "credentials-rejected" : "verification-unknown" + break + } + } + var response: [String: JSONValue] = [ + "origin": .string(challenge.form.origin), + "account": alias.map { .string($0.rawValue) } ?? .null, + "continuation": .string(continuation), + "passwordExposed": .bool(false), + "originalActionReplayed": .bool(false), + "saved": .bool(false), + ] + if interactive, continuation == "authenticated" || continuation == "redirected", + let saveCandidate, let credentialOrigin = challenge.form.credentialOrigin, + let saveAlias = try session.hostPromptCredentialSave( + origin: credentialOrigin, account: saveCandidate.account + ) { + try authenticationBroker.store(saveCandidate, for: credentialOrigin, alias: saveAlias) + response["account"] = .string(saveAlias.rawValue) + response["saved"] = .bool(true) + } + return .object(response) + } + private func captureInfo(_ session: Engine.Session, name: String) throws -> JSONValue { let base = try session.hostCaptureInfo() guard case .object(var object) = base else { return base } @@ -659,9 +889,10 @@ public final class HostCore: @unchecked Sendable { } private func failure( - _ request: CommandRequest, _ code: String, _ message: String, suggestion: String? = nil + _ request: CommandRequest, _ code: String, _ message: String, + suggestion: String? = nil, details: JSONValue? = nil ) -> CommandResponse { - .failure(id: request.id, code: code, message: message, suggestion: suggestion) + .failure(id: request.id, code: code, message: message, suggestion: suggestion, details: details) } private func merge(_ first: JSONValue, with second: JSONValue) -> JSONValue { diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 38d32ce..3b687a7 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -94,6 +94,7 @@ public enum CommandName: String, Codable, CaseIterable, Sendable { case networkEmulate = "network.emulate" case networkMockSet = "network.mock.set" case networkMockClear = "network.mock.clear" + case authLogin = "auth.login" } public struct CommandRequest: Codable, Equatable, Sendable { @@ -216,6 +217,25 @@ public struct CommandRequest: Codable, Equatable, Sendable { case .ping, .shutdown, .profileClear, .sessionList, .sessionClose, .back, .reload, .captureInfo, .artifactList, .recordStatus, .qaReport, .qaClear: try allow([]) + case .authLogin: + try allow(["challenge", "account", "interactive"]) + if let challenge = try string("challenge", maximumBytes: 64), + UUID(uuidString: challenge) == nil { + throw ProtocolValidationError.invalidParameter("Invalid authentication challenge") + } + if let account = try string("account", maximumBytes: 64) { + do { _ = try CredentialAlias(rawValue: account) } + catch { throw ProtocolValidationError.invalidParameter("Invalid account alias") } + } + try boolean("interactive") + let hasAccount = parameters["account"] != nil + let interactive = parameters["interactive"]?.boolValue ?? false + guard hasAccount != interactive, + interactive || parameters["challenge"] != nil else { + throw ProtocolValidationError.invalidParameter( + "Choose either interactive login or one account alias" + ) + } case .sessionCreate: try allow(["name"]) if let name = try string("name", required: true, maximumBytes: 64) { @@ -443,11 +463,13 @@ public struct CommandError: Codable, Equatable, Sendable { public let code: String public let message: String public let suggestion: String? + public let details: JSONValue? - public init(code: String, message: String, suggestion: String? = nil) { + public init(code: String, message: String, suggestion: String? = nil, details: JSONValue? = nil) { self.code = code self.message = message self.suggestion = suggestion + self.details = details } } @@ -467,13 +489,16 @@ public struct CommandResponse: Codable, Equatable, Sendable { CommandResponse(id: id, version: headlessProtocolVersion, ok: true, result: result, error: nil) } - public static func failure(id: String, code: String, message: String, suggestion: String? = nil) -> CommandResponse { + public static func failure( + id: String, code: String, message: String, suggestion: String? = nil, + details: JSONValue? = nil + ) -> CommandResponse { CommandResponse( id: id, version: headlessProtocolVersion, ok: false, result: nil, - error: CommandError(code: code, message: message, suggestion: suggestion) + error: CommandError(code: code, message: message, suggestion: suggestion, details: details) ) } } diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index 85eb8ca..f988887 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -2,6 +2,8 @@ if (!globalThis.__headlessAgent) { globalThis.__headlessAgent = (() => { let nextRef = 1; let nextRegionRef = 1; + const authenticationDocument = Array.from(crypto.getRandomValues(new Uint8Array(16)), value => + value.toString(16).padStart(2, '0')).join(''); const refs = new WeakMap(); const regionRefs = new WeakMap(); let current = new Map(); @@ -619,6 +621,60 @@ if (!globalThis.__headlessAgent) { element.dispatchEvent(new Event('change', {bubbles: true})); return {filled: refFor(element), valueLength: String(args.value).length}; }; + const credentialFill = args => { + const initialOrigin = String(location.origin || ''); + if (initialOrigin !== args.origin) throw new Error('AUTH_ORIGIN_CHANGED'); + const password = target({target: args.passwordTarget}); + if (!(password instanceof HTMLInputElement) || password.type.toLowerCase() !== 'password' || + password.disabled || password.readOnly) throw new Error('AUTH_FORM_CHANGED'); + const form = password.form || password.closest('form'); + const submit = args.submitTarget ? target({target: args.submitTarget}) : null; + const safeSubmission = () => { + try { + const method = String(submit?.getAttribute('formmethod') || form?.getAttribute('method') || 'get').toLowerCase(); + const action = new URL(submit?.getAttribute('formaction') || form?.getAttribute('action') || location.href, location.href); + return method === 'post' && action.origin === initialOrigin; + } catch (_) { return false; } + }; + if (!safeSubmission()) throw new Error('UNSAFE_CREDENTIAL_FORM'); + const setValue = (element, value) => { + element.focus({preventScroll: false}); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(element, value); + // Do not place credentials in InputEvent.data, where diagnostics may + // serialize them. The destination page necessarily receives its value. + element.dispatchEvent(new Event('input', {bubbles: true})); + element.dispatchEvent(new Event('change', {bubbles: true})); + }; + if (args.accountTarget) { + const account = target({target: args.accountTarget}); + if (!(account instanceof HTMLInputElement) || account.disabled || account.readOnly) { + throw new Error('AUTH_FORM_CHANGED'); + } + setValue(account, args.account); + } + if (String(location.origin || '') !== initialOrigin || !password.isConnected || !visible(password)) { + throw new Error('AUTH_FORM_CHANGED'); + } + setValue(password, args.password); + if (String(location.origin || '') !== initialOrigin) throw new Error('AUTH_ORIGIN_CHANGED'); + if (!safeSubmission()) throw new Error('UNSAFE_CREDENTIAL_FORM'); + if (submit) { + requireSafeClickTarget(submit); + submit.click(); + } else if (form?.requestSubmit) { + form.requestSubmit(); + } else { + password.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', code: 'Enter', bubbles: true})); + } + return {submitted: true, passwordExposed: false}; + }; + const finishCredentialFill = args => { + const element = current.get(args.passwordTarget); + if (element instanceof HTMLInputElement && element.type.toLowerCase() === 'password') { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(element, ''); + } + return {cleared: true}; + }; const press = key => { const element = document.activeElement || document.body; const options = {key, code: key, bubbles: true, cancelable: true}; @@ -633,6 +689,82 @@ if (!globalThis.__headlessAgent) { element.dispatchEvent(new KeyboardEvent('keyup', options)); return {pressed: key}; }; + const authentication = () => { + const origin = String(location.origin || '').slice(0, 2048); + const state = (detection, fields = {}) => ({ + origin, document: authenticationDocument, detection, ...fields + }); + if (!/^https?:\/\//i.test(origin)) return state('none'); + const inputs = Array.from(document.querySelectorAll('input')).filter(visible); + const passwords = inputs.filter(element => + (element.getAttribute('type') || '').toLowerCase() === 'password' && + !element.disabled && !element.readOnly + ); + const pageSignals = normalize([ + document.title, + document.querySelector('h1,h2,[role="heading"]')?.textContent, + passwords[0]?.form?.getAttribute('aria-label'), + passwords[0]?.form?.querySelector('button,[type="submit"]')?.textContent + ].join(' ')).toLowerCase(); + const loginSignal = /\b(log[ -]?in|sign[ -]?in|authenticate|account)\b/.test(pageSignals); + const currentPassword = passwords.find(element => + (element.getAttribute('autocomplete') || '').toLowerCase() === 'current-password' + ); + const otp = inputs.some(element => { + const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase(); + const signal = rankingText(element); + return autocomplete === 'one-time-code' || /\b(otp|verification code|security code|two.factor)\b/.test(signal); + }); + const captcha = Boolean(document.querySelector( + 'iframe[src*="captcha" i],iframe[title*="captcha" i],[class*="captcha" i],[id*="captcha" i]' + )); + const passkey = Boolean(document.querySelector('[autocomplete="webauthn"],button[data-webauthn]')) || + /\b(passkey|security key)\b/.test(pageSignals); + if (otp || captcha) return state('additional-verification'); + if (passkey) return state('passkey'); + const crossOriginAuthentication = Array.from(document.querySelectorAll('iframe')).some(frame => { + try { + const destination = new URL(frame.getAttribute('src') || '', location.href); + const signal = `${frame.getAttribute('title') || ''} ${destination.pathname}`.toLowerCase(); + return destination.origin !== origin && /\b(log[ -]?in|sign[ -]?in|auth|account)\b/.test(signal); + } catch (_) { return false; } + }); + if (crossOriginAuthentication) return state('cross-origin'); + if (passwords.length === 0) { + const signInControl = candidates().some(element => + ['button', 'link'].includes(role(element)) && + /\b(log[ -]?in|sign[ -]?in)\b/.test(rankingText(element)) + ); + return state(signInControl ? 'hint' : 'none'); + } + // Multiple password controls usually mean account creation or password + // rotation. Do not turn those pages into an autofill challenge. + const password = currentPassword || (passwords.length === 1 && loginSignal ? passwords[0] : null); + if (!password || passwords.length > 1) return state('hint'); + const form = password.form || password.closest('form'); + const scope = form || document; + const accounts = Array.from(scope.querySelectorAll('input')).filter(element => { + if (!visible(element) || element === password || element.disabled || element.readOnly) return false; + const type = (element.getAttribute('type') || 'text').toLowerCase(); + const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase(); + return ['email', 'text', 'tel'].includes(type) && + (['username', 'email'].includes(autocomplete) || /\b(user|email|account|login)\b/.test(rankingText(element))); + }); + const submits = Array.from(scope.querySelectorAll('button,input[type="submit"],[role="button"]')) + .filter(element => visible(element) && !element.disabled); + const submit = submits.find(element => /\b(log[ -]?in|sign[ -]?in|continue|submit)\b/.test(rankingText(element))) + || (submits.length === 1 ? submits[0] : null); + try { + const method = String(submit?.getAttribute('formmethod') || form?.getAttribute('method') || 'get').toLowerCase(); + const action = new URL(submit?.getAttribute('formaction') || form?.getAttribute('action') || location.href, location.href); + if (method !== 'post' || action.origin !== origin) return state('hint'); + } catch (_) { return state('hint'); } + return state('confirmed', { + accountTarget: accounts.length === 1 ? refFor(accounts[0]) : null, + passwordTarget: refFor(password), + submitTarget: submit ? refFor(submit) : null + }); + }; const scroll = args => { const amount = Number(args.amount || Math.max(240, innerHeight * 0.8)); if (args.direction === 'top') scrollTo({top: 0, behavior: 'smooth'}); @@ -782,7 +914,7 @@ if (!globalThis.__headlessAgent) { return {count: document.getAnimations().length, animations: all, truncated: document.getAnimations().length > all.length}; }; return { - snapshot, click, fill, press, inputTarget, scroll, state, tour, screenshotPlan, + snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, authentication, scroll, state, tour, screenshotPlan, scrollToCapturePoint, rectangle, styles, storage, performance: performanceSummary, animations }; diff --git a/apps/headless/Tests/Fixtures/auth-login.html b/apps/headless/Tests/Fixtures/auth-login.html new file mode 100644 index 0000000..d21ef32 --- /dev/null +++ b/apps/headless/Tests/Fixtures/auth-login.html @@ -0,0 +1,45 @@ + + + + + + Sign in to Fixture + + +
+

Sign in

+
+ + + +
+

Signed out

+
+ + + diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index d79e141..0db0181 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -42,6 +42,21 @@ private func expectSettingsError( } } +private func expectSettingsErrorForAuthentication( + _ expected: AuthenticationError, _ message: String, _ body: () throws -> Void +) throws { + do { + try body() + throw TestFailure(description: message) + } catch let error as AuthenticationError { + try expect(error == expected, "\(message): received \(error)") + } catch is TestFailure { + throw TestFailure(description: message) + } catch { + throw TestFailure(description: "\(message): received \(error)") + } +} + private func settingsObject(_ value: JSONValue, _ message: String) throws -> [String: JSONValue] { guard case .object(let object) = value else { throw TestFailure(description: message) } return object @@ -86,6 +101,15 @@ private final class ConcurrentSettingsErrors: @unchecked Sendable { } } +private final class TestMonotonicClock: @unchecked Sendable { + private let lock = NSLock() + private var value: TimeInterval + + init(_ value: TimeInterval) { self.value = value } + func now() -> TimeInterval { lock.withLock { value } } + func advance(by interval: TimeInterval) { lock.withLock { value += interval } } +} + private func connectRawUnixSocket(path: String) throws -> Int32 { #if canImport(Darwin) let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) @@ -206,6 +230,16 @@ private func readRawSocketLine(descriptor: Int32) throws -> Data { private final class TestBrowserSession: BrowserEngineSession { private(set) var agentControlEnableCount = 0 + var authenticationState: JSONValue = .object([ + "origin": .string("http://localhost"), "detection": .string("none"), + ]) + private(set) var filledCredentialAccount: String? + var authenticationStateAfterCredentialFill: JSONValue? + var promptedAccount = "interactive@example.test" + var promptedPassword = "interactive-secret" + var saveAlias: CredentialAlias? + private(set) var credentialPromptCount = 0 + private(set) var savePromptCount = 0 func hostEnableAgentControl() { agentControlEnableCount += 1 } func hostVisit(_ url: URL) throws -> JSONValue { .object(["url": .string(url.absoluteString)]) } @@ -246,6 +280,72 @@ private final class TestBrowserSession: BrowserEngineSession { func hostStorage(scope: String, includeValues: Bool) throws -> JSONValue { .object(["scope": .string(scope)]) } func hostPerformance() throws -> JSONValue { .object(["metrics": .array([])]) } func hostAnimations() throws -> JSONValue { .object(["animations": .array([])]) } + func hostAuthenticationState() throws -> JSONValue { authenticationState } + func hostPromptCredential(origin: CredentialOrigin) throws -> AuthenticationCredential { + credentialPromptCount += 1 + return try AuthenticationCredential( + account: promptedAccount, + password: AuthenticationSecret(Array(promptedPassword.utf8)) + ) + } + func hostPromptCredentialSave(origin: CredentialOrigin, account: String) throws -> CredentialAlias? { + savePromptCount += 1 + return saveAlias + } + func hostFillCredential( + form: AuthenticationForm, credential: AuthenticationCredential + ) throws -> JSONValue { + filledCredentialAccount = credential.account + credential.password.clear() + authenticationState = authenticationStateAfterCredentialFill ?? .object([ + "origin": .string(form.origin), "detection": .string("none"), + ]) + return .object(["submitted": .bool(true)]) + } +} + +private final class TestAuthenticationBroker: @unchecked Sendable, AuthenticationBroker { + let origin: CredentialOrigin + let alias: CredentialAlias + let account: String + let password: [UInt8] + var credentialError: AuthenticationError? + private(set) var storedAlias: CredentialAlias? + private(set) var storedAccount: String? + private(set) var storedPassword: [UInt8]? + + init(origin: CredentialOrigin, alias: CredentialAlias, account: String, password: String) { + self.origin = origin + self.alias = alias + self.account = account + self.password = Array(password.utf8) + } + + func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] { + guard origin == self.origin else { return [] } + return [try AuthenticationAlias(alias: alias, account: account)] + } + + func credential( + for origin: CredentialOrigin, alias: CredentialAlias + ) throws -> AuthenticationCredential { + if let credentialError { throw credentialError } + guard origin == self.origin, alias == self.alias else { + throw AuthenticationError.accountNotFound + } + return try AuthenticationCredential( + account: account, password: AuthenticationSecret(password) + ) + } + + func store( + _ credential: AuthenticationCredential, for origin: CredentialOrigin, alias: CredentialAlias + ) throws { + guard origin == self.origin else { throw AuthenticationError.originChanged } + storedAlias = alias + storedAccount = credential.account + storedPassword = credential.password.withUnsafeBytes { Array($0) } + } } private final class TestCredentialPrompt: CredentialPrompting { @@ -284,6 +384,13 @@ private final class TestCredentialSecretStore: CredentialSecretStore { afterStore?(record) } + func load(recordID: String) throws -> SensitiveBytes { + guard records[recordID] != nil, !storedSecretBytes.isEmpty else { + throw CredentialVaultError.notFound + } + return SensitiveBytes(storedSecretBytes) + } + func remove(recordID: String) throws { if let removeError { throw removeError } records.removeValue(forKey: recordID) @@ -2664,6 +2771,240 @@ struct ProtocolTests { try expect(checked >= 30, "expected to check every command line, checked \(checked)") } + static func authenticationProtocolAndChallengeLifecycle() throws { + let login = try CLIParser().parse([ + "--session", "work", "auth", "login", "--challenge", + "53a0f495-7d21-42ae-a243-c1bc97af4630", "--account", "personal", + ]) + try expect(login.request?.command == .authLogin, "auth login should parse as a remote command") + try expect(login.request?.session == "work", "auth login should retain the browser session") + try expect( + login.request?.parameters["account"] == .string("personal"), + "auth login should send only the alias" + ) + try login.request?.validate() + let interactive = try CLIParser().parse(["auth", "login", "--interactive"]) + try expect( + interactive.request?.parameters == ["interactive": .bool(true)], + "interactive auth must not put credentials or a synthetic challenge on the socket" + ) + try interactive.request?.validate() + try expectThrows("auth login should require a challenge") { + _ = try CLIParser().parse(["auth", "login", "--account", "personal"]) + } + try expectThrows("auth login should reject conflicting modes") { + _ = try CLIParser().parse([ + "auth", "login", "--interactive", "--challenge", + "53a0f495-7d21-42ae-a243-c1bc97af4630", "--account", "personal", + ]) + } + try expectThrows("auth login should reject invalid aliases") { + _ = try CLIParser().parse([ + "auth", "login", "--challenge", "challenge", "--account", "not valid", + ]) + } + try expectThrows("auth login should reject unknown protocol parameters") { + try CommandRequest( + command: .authLogin, + parameters: [ + "challenge": .string("challenge"), "account": .string("personal"), + "password": .string("must-not-enter-the-protocol"), + ] + ).validate() + } + + let origin = try CredentialOrigin(rawValue: "https://accounts.example.test") + let form = try AuthenticationForm(.object([ + "origin": .string(origin.rawValue), "detection": .string("confirmed"), + "document": .string("0123456789abcdef0123456789abcdef"), + "accountTarget": .string("@e1"), "passwordTarget": .string("@e2"), + "submitTarget": .string("@e3"), + ])) + let clock = TestMonotonicClock(10) + let store = AuthenticationChallengeStore(now: { clock.now() }) + let challenge = store.issue(session: "work", form: form) + _ = try store.begin(id: challenge.id, session: "work", currentForm: form) + try expectSettingsErrorForAuthentication( + .challengeConsumed, "concurrent challenge use must be rejected" + ) { + _ = try store.begin(id: challenge.id, session: "work", currentForm: form) + } + store.finish(id: challenge.id, consumed: false) + _ = try store.begin(id: challenge.id, session: "work", currentForm: form) + store.finish(id: challenge.id, consumed: true) + try expectSettingsErrorForAuthentication( + .challengeConsumed, "a completed challenge must remain single-use" + ) { + _ = try store.begin(id: challenge.id, session: "work", currentForm: form) + } + + let expiring = store.issue(session: "work", form: form) + _ = try store.begin(id: expiring.id, session: "work", currentForm: form) + clock.advance(by: AuthenticationChallengeStore.lifetime + 1) + try expectSettingsErrorForAuthentication(.challengeExpired, "expired challenge must fail") { + _ = try store.validateActive(id: expiring.id, session: "work", currentForm: form) + } + let wrongOrigin = try AuthenticationForm(.object([ + "origin": .string("https://other.example.test"), "detection": .string("confirmed"), + "document": .string("0123456789abcdef0123456789abcdef"), + "passwordTarget": .string("@e2"), + ])) + let originBound = store.issue(session: "work", form: form) + try expectSettingsErrorForAuthentication(.originChanged, "origin changes must invalidate use") { + _ = try store.begin(id: originBound.id, session: "work", currentForm: wrongOrigin) + } + let reloadedForm = try AuthenticationForm(.object([ + "origin": .string(origin.rawValue), "detection": .string("confirmed"), + "document": .string("fedcba9876543210fedcba9876543210"), + "accountTarget": .string("@e1"), "passwordTarget": .string("@e2"), + "submitTarget": .string("@e3"), + ])) + let documentBound = store.issue(session: "work", form: form) + try expectSettingsErrorForAuthentication(.formChanged, "same-origin reloads must invalidate use") { + _ = try store.begin(id: documentBound.id, session: "work", currentForm: reloadedForm) + } + + let credential = try AuthenticationCredential( + account: "person@example.test", password: AuthenticationSecret(Array("frame-secret".utf8)) + ) + var frame = try AuthenticationCredentialFrame.encode(credential) + defer { frame.resetBytes(in: 0.. + + + + +`; +window.document.body.append(authenticationFixture); +const confirmedAuthentication = agent.authentication(); +assert.equal(confirmedAuthentication.origin, 'http://127.0.0.1:41739'); +assert.equal(confirmedAuthentication.detection, 'confirmed'); +assert.match(confirmedAuthentication.accountTarget, /^@e\d+$/); +assert.match(confirmedAuthentication.passwordTarget, /^@e\d+$/); +assert.match(confirmedAuthentication.submitTarget, /^@e\d+$/); +assert.equal(JSON.stringify(confirmedAuthentication).includes('value'), false); + +let credentialInputData = 'not-fired'; +authenticationFixture.querySelector('input[type="password"]').addEventListener('input', event => { + credentialInputData = event.data; +}); +authenticationFixture.querySelector('form').addEventListener('submit', event => event.preventDefault()); +const credentialFillResult = agent.credentialFill({ + origin: confirmedAuthentication.origin, + accountTarget: confirmedAuthentication.accountTarget, + passwordTarget: confirmedAuthentication.passwordTarget, + submitTarget: confirmedAuthentication.submitTarget, + account: 'person@example.test', + password: 'runtime-only-secret', +}); +assert.equal(credentialFillResult.submitted, true); +assert.equal(credentialInputData, undefined, 'saved credentials must not enter InputEvent.data'); +agent.finishCredentialFill({passwordTarget: confirmedAuthentication.passwordTarget}); +assert.equal(authenticationFixture.querySelector('input[type="password"]').value, ''); + +authenticationFixture.querySelector('form').setAttribute('method', 'get'); +assert.equal(agent.authentication().detection, 'hint', 'GET password forms must not trigger autofill'); +authenticationFixture.querySelector('form').setAttribute('method', 'post'); +authenticationFixture.querySelector('button').setAttribute('formaction', 'https://other.example.test/login'); +assert.equal(agent.authentication().detection, 'hint', 'cross-origin submitters must not trigger autofill'); + +authenticationFixture.innerHTML = ` +
+ + + +
+`; +assert.equal(agent.authentication().detection, 'hint', 'password rotation must not trigger autofill'); + +authenticationFixture.innerHTML = ''; +assert.equal(agent.authentication().detection, 'additional-verification'); + +authenticationFixture.innerHTML = ` +
+ + + + +
+`; +assert.equal( + agent.authentication().detection, + 'additional-verification', + 'combined password and MFA forms must not trigger saved credential fill', +); + +authenticationFixture.innerHTML = ''; +assert.equal(agent.authentication().detection, 'passkey'); + +authenticationFixture.innerHTML = ''; +assert.equal(agent.authentication().detection, 'hint', 'username-first login should remain a hint'); + +authenticationFixture.innerHTML = ` + +

Welcome

+`; +assert.equal( + agent.authentication().detection, + 'cross-origin', + 'cross-origin authentication frames should return an explicit continuation state', +); +authenticationFixture.remove(); + const stationaryTour = await agent.tour({fullPage: false}); assert.equal(stationaryTour.start, stationaryTour.end); assert.equal(stationaryTour.durationMs, 0); diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs index 1ceebfa..67f9263 100644 --- a/apps/headless/Tests/fixture-server.mjs +++ b/apps/headless/Tests/fixture-server.mjs @@ -10,6 +10,7 @@ const routes = new Map([ ['/hostile', 'hostile.html'], ['/large-document', 'large-document.html'], ['/auth-state', 'auth-state.html'], + ['/auth-login', 'auth-login.html'], ]); const server = createServer(async (request, response) => { diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index e765e95..459b4b3 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -4,21 +4,33 @@ set -eu export HEADLESS_ARTIFACT_DIR="/tmp/headless-artifacts-e2e-$$" export XDG_DATA_HOME="/tmp/headless-data-e2e-$$" export XDG_CONFIG_HOME="/tmp/headless-config-e2e-$$" +export HEADLESS_HOST_LOG="/tmp/headless-host-e2e-$$.log" +STEP="setup" FIXTURE_ROOT="$(mktemp -d /tmp/headless-fixture.XXXXXX)" INSTALL_ROOT="$(mktemp -d /tmp/headless-install.XXXXXX)" -mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/api" +mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/auth-login" "$FIXTURE_ROOT/api" cp /opt/headless/fixtures/dashboard.html "$FIXTURE_ROOT/designers/dashboard/index.html" cp /opt/headless/fixtures/next.html "$FIXTURE_ROOT/next/index.html" cp /opt/headless/fixtures/hostile.html "$FIXTURE_ROOT/hostile/index.html" cp /opt/headless/fixtures/large-document.html "$FIXTURE_ROOT/large-document/index.html" cp /opt/headless/fixtures/trusted-input.html "$FIXTURE_ROOT/trusted-input/index.html" cp /opt/headless/fixtures/auth-state.html "$FIXTURE_ROOT/auth-state/index.html" +cp /opt/headless/fixtures/auth-login.html "$FIXTURE_ROOT/auth-login/index.html" cp /opt/headless/fixtures/api-diagnostic.json "$FIXTURE_ROOT/api/diagnostic" busybox httpd -f -p 127.0.0.1:41739 -h "$FIXTURE_ROOT" & FIXTURE_PID=$! cleanup() { + status=$? + trap - EXIT INT TERM + if [ "$status" -ne 0 ]; then + echo "Linux E2E failed during: $STEP" >&2 + if [ -s "$HEADLESS_HOST_LOG" ]; then + echo "--- host log ---" >&2 + cat "$HEADLESS_HOST_LOG" >&2 + fi + fi headless stop >/dev/null 2>&1 || true kill "$FIXTURE_PID" >/dev/null 2>&1 || true rm -rf "$FIXTURE_ROOT" @@ -26,6 +38,8 @@ cleanup() { rm -rf "$HEADLESS_ARTIFACT_DIR" rm -rf "$XDG_DATA_HOME" rm -rf "$XDG_CONFIG_HOME" + rm -f "$HEADLESS_HOST_LOG" + exit "$status" } trap cleanup EXIT INT TERM @@ -54,6 +68,7 @@ echo "$CREDENTIAL_LIST" | grep -q 'VAULT_UNAVAILABLE' test ! -e "$HOME/.local/share/headless/credential-vault/credentials-index.json" /opt/headless/linux-credential-vault.sh +STEP="runtime-discovery" headless runtime | grep -q '"executable":"/usr/lib/chromium/chromium"' headless runtime | grep -q '"transport":"inherited-devtools-pipe"' if SNAP_RUNTIME="$(HEADLESS_CHROMIUM_EXECUTABLE=/snap/bin/chromium headless runtime 2>&1)"; then @@ -68,6 +83,7 @@ if RELATIVE_RUNTIME="$(HEADLESS_CHROMIUM_EXECUTABLE=relative/chromium headless r fi echo "$RELATIVE_RUNTIME" | grep -q 'must be absolute' +STEP="settings" SETTINGS_LIST="$(headless config list)" echo "$SETTINGS_LIST" | grep -q '"key":"startup-presentation"' echo "$SETTINGS_LIST" | grep -q '"access":"agent-writable"' @@ -93,6 +109,7 @@ if PRESENTATION_START="$(headless start --foreground 2>&1)"; then fi echo "$PRESENTATION_START" | grep -q 'UNSUPPORTED_CAPABILITY' +STEP="host-start" headless start | grep -q '"ready":true' test "$(stat -c %a "$XDG_DATA_HOME/headless")" = "700" test "$(stat -c %a "$XDG_DATA_HOME/headless/chromium-profile")" = "700" @@ -102,9 +119,38 @@ if RUNNING_PRESENTATION_START="$(headless start --foreground 2>&1)"; then fi echo "$RUNNING_PRESENTATION_START" | grep -q 'UNSUPPORTED_CAPABILITY' +STEP="authentication-state-setup" headless visit 'http://127.0.0.1:41739/auth-state/?action=login' | grep -q 'Authentication State' -headless inspect --text | grep -q 'Cookie state: signed-in' -headless inspect --text | grep -q 'Storage state: signed-in' + +STEP="authentication-challenge" +if AUTH_REQUIRED="$(headless visit 'http://127.0.0.1:41739/auth-login/' 2>&1)"; then + echo "confirmed login form did not require authentication" >&2 + exit 1 +fi +STEP="authentication-challenge-code" +echo "$AUTH_REQUIRED" | grep -q '"code":"AUTH_REQUIRED"' +STEP="authentication-challenge-origin" +echo "$AUTH_REQUIRED" | grep -q '"origin":"http://127.0.0.1:41739"' +STEP="authentication-challenge-accounts" +echo "$AUTH_REQUIRED" | grep -q '"accounts":\[\]' +STEP="authentication-challenge-presence" +echo "$AUTH_REQUIRED" | grep -q '"userPresenceRequired":true' +STEP="authentication-challenge-availability" +echo "$AUTH_REQUIRED" | grep -q '"credentialUseAvailable":false' +STEP="authentication-direct-account" +headless fill @e1 -- 'fixture@example.test' | grep -q '"valueLength":20' +STEP="authentication-direct-password" +headless fill @e2 -- 'synthetic-direct-password' | grep -q '"valueLength":25' +STEP="authentication-direct-submit" +headless click @e3 | grep -q '"clicked"' +STEP="authentication-direct-continuation" +headless wait --text 'Signed in' | grep -q 'Signed in' +STEP="authentication-no-implicit-save" +test ! -e "$HOME/.local/share/headless/credential-vault/credentials-index.json" +STEP="authentication-cookie-state" +headless cookies list | grep -q '"name":"headless_auth_state"' +STEP="authentication-storage-state" +headless storage list --scope local | grep -q 'headless_auth_state' PROFILE_RESTART_PID="$(headless status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$PROFILE_RESTART_PID" headless stop | grep -q '"stopping":true' diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 01b676f..a7454d7 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -177,6 +177,20 @@ echo "$START_RESULT" | grep -q '"ready":true' || { fail } echo "▸ host ready" + +if AUTH_REQUIRED="$("$CLI" visit "http://127.0.0.1:$PORT/auth-login" 2>&1)"; then + print -r -u2 -- "confirmed login form did not require authentication" + exit 1 +fi +echo "$AUTH_REQUIRED" | grep -q '"code":"AUTH_REQUIRED"' +echo "$AUTH_REQUIRED" | grep -q "\"origin\":\"http://127.0.0.1:$PORT\"" +echo "$AUTH_REQUIRED" | grep -q '"accounts":\[\]' +echo "$AUTH_REQUIRED" | grep -q '"userPresenceRequired":true' +echo "$AUTH_REQUIRED" | grep -q '"credentialUseAvailable":true' +"$CLI" fill @e1 -- 'fixture@example.test' | grep -q '"valueLength":20' +"$CLI" fill @e2 -- 'synthetic-direct-password' | grep -q '"valueLength":25' +"$CLI" click @e3 | grep -q '"clicked"' +"$CLI" wait --text 'Signed in' | grep -q 'Signed in' STEP="tcp-check" HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$HOST_PID" diff --git a/apps/headless/build.sh b/apps/headless/build.sh index 0bb3059..6b2b08c 100755 --- a/apps/headless/build.sh +++ b/apps/headless/build.sh @@ -60,6 +60,7 @@ if [[ -z "${SDKROOT:-}" ]]; then if swiftc -module-cache-path build/module-cache -sdk "$sdk" \ -target "$NATIVE_ARCH-apple-macos13.0" -typecheck \ Sources/HeadlessProtocol/Protocol.swift \ + Sources/HeadlessProtocol/CredentialCommands.swift \ Sources/HeadlessProtocol/HostError.swift \ Sources/HeadlessProtocol/CaptureFormats.swift >/dev/null 2>&1; then export SDKROOT="$sdk" diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index c6d560d..27530f1 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -78,6 +78,7 @@ credentials list [--origin URL] credentials add --origin URL --alias NAME --interactive credentials rename --origin URL --alias OLD --to NEW credentials remove --origin URL --alias NAME +auth login --challenge ID --account ALIAS | auth login --interactive ``` Credential commands are local-only and never enter the browser protocol or MCP. @@ -94,8 +95,8 @@ underscores, or hyphens and are case-insensitively unique per origin. macOS stores passwords in the encrypted default user Keychain with an empty trusted-app list and passphrase protection on the decryption ACL. A fresh -broker-owned native user-presence gate is added to retrieval by #157. The -vault uses no shared access group. Local/ad-hoc builds are reported as +broker-owned native user-presence gate applies to every retrieval. The vault +uses no shared access group. Local/ad-hoc builds are reported as `local-unnotarized`; rebuilds may make macOS ask again. This deliberately uses the file-based default Keychain because Apple's biometric data-protection Keychain requires a provisioning-profile-authorized app identity. The file-based API is deprecated, @@ -114,9 +115,43 @@ backup and recovery behavior. Index writes are atomic. A durable transaction journal rolls back interrupted additions and completes interrupted deletions on the next vault command. Renames atomically update only the nonsecret index. Unknown future index schemas require an explicit migration. Normal-vault -aliases are unavailable to private contexts. Saved credential use and login -challenges are implemented separately by issue #157; until then these commands -manage records but do not autofill them. +aliases are unavailable to private contexts. When Headless confirms a +top-level, same-origin POST login form, the blocked command returns +`AUTH_REQUIRED` with a 60-second, single-use challenge and only the aliases for +that exact origin. The challenge is also bound to a per-document identity, so +a same-origin reload invalidates it. +`auth login` asks the operating-system vault to authorize the selected alias, +fills inside the trusted host, submits once, and reports whether the flow +redirected, needs additional verification or a passkey, rejected the +credentials, or requires fresh inspection because verification is ambiguous. +It never retries the original blocked action. + +`auth login --interactive` instead opens trusted input owned by Headless. On +macOS this is a native secure dialog; on Linux it reads the username and hidden +password from the foreground `/dev/tty`. It can create a challenge from the +current confirmed form, so a challenge ID is optional. After Headless verifies +that the form disappeared or redirected, it separately asks whether to save, +with No as the default. Saving requires a user-entered alias and sends the +candidate secret only through a bounded pipe to the trusted broker. Failed, +unverified, additional-verification, and passkey continuations never offer to +save. Raw `fill` remains available and never saves implicitly. + +Heuristic login hints do not create challenges. Cross-origin frames are not +inspected or filled and are reported as an explicit continuation. Public HTTP +origins cannot use saved credentials. Authentication metadata is marked as +untrusted page-derived content. A denied +or unavailable vault fails closed without filling; denial leaves the challenge +available for a deliberate retry. Existing raw `fill` commands remain +available for test credentials and never save values implicitly. Headless can +redact those values from its own output and artifacts, but cannot remove a +password that a user already supplied from a model provider's transcript. +Saved entry does not put the password in an input-event payload. Page +diagnostics are suppressed during credential submission, discarded afterward, +and restored only after the password field is cleared or a new document commits. + +Linux can enroll and manage Secret Service records, but saved use currently returns `USER_PRESENCE_UNAVAILABLE`: +an unlocked Secret Service does not guarantee a fresh prompt, and Headless does +not silently weaken the per-use authorization policy. ## Navigation and interaction diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index 46d378e..549791a 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -37,7 +37,7 @@ username metadata. It never stores password values. The unsigned macOS tier uses the encrypted default user Keychain with an empty trusted-app list and passphrase protection on the decryption ACL. A fresh -broker-owned native presence gate is required on every future retrieval. It +broker-owned native presence gate is required on every retrieval. It uses no shared access group. This file-based Keychain API is deprecated but, unlike biometric data-protection Keychain access, does not require a provisioning-profile-authorized identity. @@ -45,8 +45,24 @@ Linux requires `/usr/bin/secret-tool` backed by the current user's validated `/run/user/UID/bus` Secret Service socket. Caller-selected D-Bus addresses are ignored. Both platforms fail closed when secure storage is missing, locked, denied, timed out, or structurally unsafe. Private contexts cannot list or use -the normal vault. Credential use is intentionally deferred to #157, so this -increment cannot autofill or release a stored password. +the normal vault. + +Confirmed top-level login forms return `AUTH_REQUIRED` with aliases scoped to +the exact origin. Challenges expire after 60 seconds, are bound to one session +and document, and can be consumed only once. Only same-origin POST submission +is eligible. `auth login --challenge ID --account ALIAS` retrieves the selected password through the broker after native user +presence, fills it inside the host, submits once, and reports an explicit +continuation. It does not replay the blocked action. Heuristic hints, +cross-origin frames, public HTTP, changed forms, changed origins, replays, and +vault denial do not release or fill a secret. Authentication details remain +marked as untrusted page content. Diagnostics are suppressed during saved +credential entry and discarded before the password field is cleared. +`auth login --interactive` uses a native secure dialog on macOS or the +foreground terminal on Linux. Saving is offered only after verified success, +defaults to No, and requires a user-entered alias. The secret reaches the +broker only through a bounded private pipe. Linux saved use fails with +`USER_PRESENCE_UNAVAILABLE` until the host has a trusted confirmation surface; +an already-unlocked Secret Service is not treated as current user presence. ## Acceptance workflow diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 1f8d38a..69fc81d 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -347,6 +347,59 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, required init?(coder: NSCoder) { fatalError("not used") } + func promptCredential(origin: CredentialOrigin) throws -> AuthenticationCredential { + try onAgentMain { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = "Sign in to \(origin.rawValue)" + alert.informativeText = "The password is used only for this login unless you separately choose to save it." + let account = NSTextField(frame: NSRect(x: 0, y: 32, width: 320, height: 24)) + account.placeholderString = "Username or email" + let password = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + password.placeholderString = "Password" + let accessory = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 56)) + accessory.addSubview(account) + accessory.addSubview(password) + alert.accessoryView = accessory + alert.addButton(withTitle: "Sign In") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { + account.stringValue = "" + password.stringValue = "" + throw AuthenticationError.userPresenceDenied + } + let accountValue = account.stringValue + let passwordBytes = Array(password.stringValue.utf8) + account.stringValue = "" + password.stringValue = "" + return try AuthenticationCredential( + account: accountValue, password: AuthenticationSecret(passwordBytes) + ) + } + } + + func promptCredentialSave( + origin: CredentialOrigin, account: String + ) throws -> CredentialAlias? { + try onAgentMain { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = "Save this credential?" + alert.informativeText = "\(account) for \(origin.rawValue). Saving is optional and requires an alias." + let alias = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + alias.placeholderString = "Alias, for example work" + alert.accessoryView = alias + alert.addButton(withTitle: "Don't Save") + alert.addButton(withTitle: "Save") + guard alert.runModal() == .alertSecondButtonReturn else { + alias.stringValue = "" + return nil + } + defer { alias.stringValue = "" } + return try CredentialAlias(rawValue: alias.stringValue) + } + } + // MARK: Chrome (what little there is) private func setTrafficLights(visible: Bool) { @@ -700,6 +753,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + qaBridge.didCommitDocument() let u = webView.url?.absoluteString if u != nil && u != "about:blank" { pendingRestoredStartupURL = nil @@ -859,9 +913,9 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, // MARK: - App delegate -private func onAgentMain(_ body: @escaping () -> T) -> T { - if Thread.isMainThread { return body() } - return DispatchQueue.main.sync(execute: body) +private func onAgentMain(_ body: @escaping () throws -> T) rethrows -> T { + if Thread.isMainThread { return try body() } + return try DispatchQueue.main.sync(execute: body) } final class AppDelegate: NSObject, NSApplicationDelegate { @@ -905,10 +959,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { }, close: { controller in onAgentMain { controller.close() } } ) + let authenticationBroker: any AuthenticationBroker + if let broker = try? CredentialBrokerProcessClient() { + authenticationBroker = broker + } else { + authenticationBroker = UnavailableAuthenticationBroker() + } let core = HostCore( engine: engine, artifacts: artifacts, defaultSession: primaryController, + authenticationBroker: authenticationBroker, shutdownHandler: { DispatchQueue.main.async { NSApp.terminate(nil) } } ) hostCore = core diff --git a/apps/headless/test.sh b/apps/headless/test.sh index aac77bd..e5e4d1b 100755 --- a/apps/headless/test.sh +++ b/apps/headless/test.sh @@ -25,6 +25,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then if swiftc -module-cache-path build/module-cache -sdk "$sdk" \ -target "$(uname -m)-apple-macos13.0" -typecheck \ Sources/HeadlessProtocol/Protocol.swift \ + Sources/HeadlessProtocol/CredentialCommands.swift \ Sources/HeadlessProtocol/HostError.swift \ Sources/HeadlessProtocol/CaptureFormats.swift >/dev/null 2>&1; then export SDKROOT="$sdk" diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 52cb02e..2cc057e 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -652,6 +652,34 @@ wire-protocol version bump is unnecessary because config remains local-only. --- +## 27. Interactive authentication keeps consent in the trusted host + +**Decision:** `auth login --interactive` obtains the username and password only +through a host-owned native secure dialog on macOS or the foreground +`/dev/tty` on Linux. It never accepts password arguments or protocol fields. +The host fills once, verifies the resulting authentication state, and only +then presents a separate save decision whose default is No. An approved save +passes one bounded binary credential frame directly to the trusted broker over +stdin; the browser control socket, MCP, JSON, environment, and process +arguments continue to carry no secret value. + +Interactive login may create a challenge from the current confirmed, +same-origin POST form. Existing challenge IDs remain session-, document-, +origin-, expiry-, and replay-bound. Additional verification, passkeys, failed +credentials, and unknown verification outcomes do not offer persistence. Raw +`fill` remains compatible and never implies save consent. + +**Status:** implemented 2026-09-12 by +[#157](https://github.com/LockInTime/headless/issues/157). + +**Consequences:** the candidate secret exists only in host memory for the +login and immediate save decision and is cleared on every return path. Broker +storage still applies exact-origin and case-insensitive alias uniqueness. The +Linux terminal path enables interactive login but does not weaken the separate +rule that durable saved-credential retrieval needs trusted per-use presence. + +--- + ## Decision log | # | Decision | Status | Date | @@ -672,5 +700,6 @@ wire-protocol version bump is unnecessary because config remains local-only. | 21 | Rust port of shared core, protocol layer first | In progress | 2026-08-22 | | 24 | Credential broker on the unsigned local tier | Decided | 2026-09-10 | | 25 | Typed local settings registry; security policy stays fixed | Implemented | 2026-09-12 | +| 27 | Interactive authentication keeps consent in trusted host | Implemented | 2026-09-12 | New decisions append here with the same format. 22 and 23 are claimed by open PRs #170 and #169.