Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,27 @@ 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
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

Expand Down
116 changes: 116 additions & 0 deletions apps/headless/CredentialBroker/main.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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..<frame.count)
#endif
}

private func runInternalStore(_ arguments: [String]) throws {
guard trustedHostIsParent() else { throw CredentialVaultError.userDenied }
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 }
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..<frame.count) }
let credential = try AuthenticationCredentialFrame.decode(frame)
defer { credential.password.clear() }
let secret = SensitiveBytes(credential.password.withUnsafeBytes { Array($0) })
defer { secret.clear() }
let controller = CredentialVaultController(
metadata: CredentialMetadataStore(), secrets: try makePlatformCredentialSecretStore()
)
_ = try controller.store(
origin: origin, alias: alias, account: credential.account, secret: secret
)
}

do {
if CommandLine.arguments.dropFirst().first == "__resolve" {
try runInternalResolve(Array(CommandLine.arguments.dropFirst(2)))
exit(0)
}
if CommandLine.arguments.dropFirst().first == "__store" {
try runInternalStore(Array(CommandLine.arguments.dropFirst(2)))
exit(0)
}
let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst()))
guard case .credentials(let command)? = invocation.local, invocation.request == nil else {
throw CredentialCommandError.invalidArguments
Expand All @@ -33,6 +139,16 @@ do {
}
printJSON(.object(["ok": .bool(true), "result": result]))
} catch let error as CredentialVaultError {
if ["__resolve", "__store"].contains(CommandLine.arguments.dropFirst().first) {
switch error {
case .userDenied: exit(77)
case .vaultUnavailable: exit(78)
case .notFound: exit(79)
case .vaultLocked: exit(80)
case .userPresenceUnavailable: exit(81)
default: exit(70)
}
}
printJSON(.object([
"ok": .bool(false),
"error": .object(["code": .string(error.code), "message": .string(error.description)]),
Expand Down
39 changes: 39 additions & 0 deletions apps/headless/CredentialBrokerCore/CredentialVault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public enum CredentialVaultError: Error, Equatable, CustomStringConvertible {
case capacityExceeded
case vaultUnavailable
case vaultLocked
case userPresenceUnavailable
case userDenied
case corruptMetadata
case insecureMetadata
Expand All @@ -82,6 +83,7 @@ public enum CredentialVaultError: Error, Equatable, CustomStringConvertible {
case .capacityExceeded: return "CREDENTIAL_LIMIT_REACHED"
case .vaultUnavailable: return "VAULT_UNAVAILABLE"
case .vaultLocked: return "VAULT_LOCKED"
case .userPresenceUnavailable: return "USER_PRESENCE_UNAVAILABLE"
case .userDenied: return "USER_PRESENCE_DENIED"
case .corruptMetadata: return "VAULT_METADATA_CORRUPT"
case .insecureMetadata: return "VAULT_METADATA_INSECURE"
Expand All @@ -105,6 +107,8 @@ public enum CredentialVaultError: Error, Equatable, CustomStringConvertible {
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 .userDenied:
return "The user denied credential-vault authorization."
case .corruptMetadata:
Expand Down Expand Up @@ -155,6 +159,7 @@ public final class SensitiveBytes: @unchecked Sendable {
public protocol CredentialSecretStore {
var backendName: String { get }
func store(_ secret: SensitiveBytes, for record: CredentialRecord) throws
func load(recordID: String) throws -> SensitiveBytes
func remove(recordID: String) throws
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if os(Linux)
import CHeadlessSecurePrompt
import Dispatch
import Foundation
import Glibc
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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..<data.count) }
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
throw Self.classifiedBackendError(errorCapture.text())
}
_ = errorCapture.text()
guard !data.isEmpty, data.count <= 4_097 else {
throw CredentialVaultError.operationFailed("invalid Secret Service value")
}
var bytes = Array(data)
if bytes.last == 0x0A { bytes.removeLast() }
guard !bytes.isEmpty, bytes.count <= 4_096 else {
throw CredentialVaultError.operationFailed("invalid Secret Service value")
}
return SensitiveBytes(bytes)
}

private static func approvedExecutable() -> URL? {
for path in ["/usr/bin/secret-tool"] {
var info = stat()
Expand Down Expand Up @@ -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()
Expand Down
Loading