Skip to content

Commit 1550d3f

Browse files
authored
Merge pull request #176 from LockInTime/feat/credential-vault-156
feat: add secure origin-bound credential vault
2 parents df92c78 + d2fa337 commit 1550d3f

34 files changed

Lines changed: 2332 additions & 21 deletions

‎.github/scripts/macos-package.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ package_app() {
3636
unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/MacOS/Headless'
3737
unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless'
3838
unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless-mcp'
39+
unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless-credential-broker'
3940
}
4041

4142
if [ "$MODE" = "--notarize" ]; then

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ jobs:
239239
archive="apps/headless/build/headless-${VERSION}-linux-amd64.tar.gz"
240240
test -s "$archive"
241241
tar -tzf "$archive" > archive-contents.txt
242-
for expected in headless headless-host headless-mcp install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do
242+
for expected in headless headless-host headless-mcp headless-credential-broker install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do
243243
grep -qx "$expected" archive-contents.txt
244244
done
245245
- uses: actions/upload-artifact@v7
@@ -269,7 +269,7 @@ jobs:
269269
archive="apps/headless/build/headless-${VERSION}-linux-arm64.tar.gz"
270270
test -s "$archive"
271271
tar -tzf "$archive" > archive-contents.txt
272-
for expected in headless headless-host headless-mcp install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do
272+
for expected in headless headless-host headless-mcp headless-credential-broker install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do
273273
grep -qx "$expected" archive-contents.txt
274274
done
275275
- uses: actions/upload-artifact@v7

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,24 @@ normal profile. Linux stores it in a private XDG data directory; macOS uses the
142142
persistent WebKit data store. Headless does not accept imported cookies or a
143143
caller-selected profile path.
144144

145+
Saved credentials use a dedicated local broker instead of the browser socket:
146+
147+
```sh
148+
headless credentials add --origin https://example.com --alias work --interactive
149+
headless credentials list --origin https://example.com
150+
headless credentials rename --origin https://example.com --alias work --to client
151+
headless credentials remove --origin https://example.com --alias client
152+
```
153+
154+
The interactive broker reads and confirms passwords only through the attached
155+
terminal with echo disabled. Agents can see approved usernames and aliases,
156+
but password values never enter arguments, MCP, browser commands, logs, flows,
157+
or output. macOS uses the encrypted default user Keychain with a decrypt-only
158+
ACL and reports the unsigned local security tier honestly. Linux requires an
159+
available system Secret Service and never falls back to plaintext. This
160+
increment manages vault records. Broker-owned user-presence checks,
161+
origin-bound login challenges, and browser autofill follow in #157.
162+
145163
## Agent skill
146164

147165
This repository ships a portable browser-computer-use skill at
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import CredentialBrokerCore
2+
import Foundation
3+
import HeadlessProtocol
4+
5+
private func printJSON(_ value: JSONValue) {
6+
guard let data = try? ProtocolCodec.encoder.encode(value) else {
7+
fputs("headless credential broker: output encoding failed\n", stderr)
8+
exit(70)
9+
}
10+
FileHandle.standardOutput.write(data)
11+
FileHandle.standardOutput.write(Data([0x0A]))
12+
}
13+
14+
do {
15+
let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst()))
16+
guard case .credentials(let command)? = invocation.local, invocation.request == nil else {
17+
throw CredentialCommandError.invalidArguments
18+
}
19+
let controller = CredentialVaultController(
20+
metadata: CredentialMetadataStore(),
21+
secrets: try makePlatformCredentialSecretStore()
22+
)
23+
let result: JSONValue
24+
switch command {
25+
case .list(let origin):
26+
result = try controller.list(origin: origin)
27+
case .add(let origin, let alias):
28+
result = try controller.add(origin: origin, alias: alias)
29+
case .rename(let origin, let alias, let newAlias):
30+
result = try controller.rename(origin: origin, alias: alias, to: newAlias)
31+
case .remove(let origin, let alias):
32+
result = try controller.remove(origin: origin, alias: alias)
33+
}
34+
printJSON(.object(["ok": .bool(true), "result": result]))
35+
} catch let error as CredentialVaultError {
36+
printJSON(.object([
37+
"ok": .bool(false),
38+
"error": .object(["code": .string(error.code), "message": .string(error.description)]),
39+
]))
40+
exit(error == .terminalRequired ? 64 : 69)
41+
} catch let error as CredentialCommandError {
42+
printJSON(.object([
43+
"ok": .bool(false),
44+
"error": .object(["code": .string("INVALID_CREDENTIAL_COMMAND"), "message": .string(error.description)]),
45+
]))
46+
exit(64)
47+
} catch let error as CLIParseError {
48+
printJSON(.object([
49+
"ok": .bool(false),
50+
"error": .object(["code": .string("INVALID_CREDENTIAL_COMMAND"), "message": .string(error.description)]),
51+
]))
52+
exit(64)
53+
} catch {
54+
printJSON(.object([
55+
"ok": .bool(false),
56+
"error": .object([
57+
"code": .string("VAULT_OPERATION_FAILED"),
58+
"message": .string("Credential vault operation failed without exposing sensitive details."),
59+
]),
60+
]))
61+
exit(70)
62+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import Foundation
2+
#if canImport(Darwin)
3+
import Darwin
4+
#elseif canImport(Glibc)
5+
import Glibc
6+
#endif
7+
8+
public enum CredentialTransactionKind: String, Codable, Sendable {
9+
case add
10+
case remove
11+
}
12+
13+
public struct CredentialPendingTransaction: Codable, Equatable, Sendable {
14+
public let kind: CredentialTransactionKind
15+
public let record: CredentialRecord
16+
17+
public init(kind: CredentialTransactionKind, record: CredentialRecord) {
18+
self.kind = kind
19+
self.record = record
20+
}
21+
}
22+
23+
public struct CredentialMetadataState: Equatable, Sendable {
24+
public var records: [CredentialRecord]
25+
public var pending: [CredentialPendingTransaction]
26+
27+
public init(
28+
records: [CredentialRecord] = [], pending: [CredentialPendingTransaction] = []
29+
) {
30+
self.records = records
31+
self.pending = pending
32+
}
33+
}
34+
35+
public final class CredentialMetadataTransaction {
36+
public var state: CredentialMetadataState
37+
private let persist: (CredentialMetadataState) throws -> Void
38+
39+
fileprivate init(
40+
state: CredentialMetadataState,
41+
persist: @escaping (CredentialMetadataState) throws -> Void
42+
) {
43+
self.state = state
44+
self.persist = persist
45+
}
46+
47+
public func save() throws {
48+
try persist(state)
49+
}
50+
}
51+
52+
private struct CredentialIndex: Codable {
53+
let schemaVersion: Int
54+
let records: [CredentialRecord]
55+
let pending: [CredentialPendingTransaction]?
56+
}
57+
58+
public final class CredentialMetadataStore: @unchecked Sendable {
59+
public static let maximumIndexBytes = 1_048_576
60+
61+
public let rootURL: URL
62+
private let indexURL: URL
63+
private let lockURL: URL
64+
65+
public init(
66+
rootURL: URL = CredentialMetadataStore.defaultRootURL()
67+
) {
68+
self.rootURL = rootURL.standardizedFileURL
69+
indexURL = self.rootURL.appendingPathComponent("credentials-index.json")
70+
lockURL = self.rootURL.appendingPathComponent("credentials-index.lock")
71+
}
72+
73+
public static func defaultRootURL() -> URL {
74+
let home = FileManager.default.homeDirectoryForCurrentUser
75+
#if os(macOS)
76+
return home.appendingPathComponent(
77+
"Library/Application Support/com.headless.app/credential-vault", isDirectory: true
78+
)
79+
#else
80+
let base = home.appendingPathComponent(".local/share", isDirectory: true)
81+
return base.appendingPathComponent("headless/credential-vault", isDirectory: true)
82+
#endif
83+
}
84+
85+
public func withLockedState<T>(
86+
_ body: (CredentialMetadataTransaction) throws -> T
87+
) throws -> T {
88+
try preparePrivateDirectory(rootURL)
89+
let lock = open(lockURL.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600)
90+
guard lock >= 0 else { throw CredentialVaultError.insecureMetadata }
91+
defer { close(lock) }
92+
try validatePrivateRegularFile(lock)
93+
guard flock(lock, LOCK_EX) == 0 else {
94+
throw CredentialVaultError.operationFailed("metadata lock")
95+
}
96+
defer { _ = flock(lock, LOCK_UN) }
97+
98+
let transaction = CredentialMetadataTransaction(
99+
state: try readState(), persist: { [self] in try writeState($0) }
100+
)
101+
return try body(transaction)
102+
}
103+
104+
private func readState() throws -> CredentialMetadataState {
105+
let descriptor = open(indexURL.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
106+
if descriptor < 0 {
107+
if errno == ENOENT { return CredentialMetadataState() }
108+
throw CredentialVaultError.insecureMetadata
109+
}
110+
defer { close(descriptor) }
111+
try validatePrivateRegularFile(descriptor)
112+
113+
var info = stat()
114+
guard fstat(descriptor, &info) == 0, info.st_size >= 0,
115+
info.st_size <= Self.maximumIndexBytes else {
116+
throw CredentialVaultError.corruptMetadata
117+
}
118+
var data = Data()
119+
var buffer = [UInt8](repeating: 0, count: 16_384)
120+
while true {
121+
let count = read(descriptor, &buffer, buffer.count)
122+
if count < 0 && errno == EINTR { continue }
123+
guard count >= 0 else { throw CredentialVaultError.operationFailed("metadata read") }
124+
if count == 0 { break }
125+
data.append(buffer, count: count)
126+
guard data.count <= Self.maximumIndexBytes else { throw CredentialVaultError.corruptMetadata }
127+
}
128+
guard let index = try? JSONDecoder().decode(CredentialIndex.self, from: data),
129+
index.schemaVersion == 1 else {
130+
throw CredentialVaultError.corruptMetadata
131+
}
132+
let pending = index.pending ?? []
133+
let allRecords = index.records + pending.map(\.record)
134+
guard allRecords.count <= CredentialVaultController.maximumRecords,
135+
Set(allRecords.map(\.id)).count == allRecords.count,
136+
Set(allRecords.map {
137+
"\($0.origin.rawValue)\u{0}\($0.alias.rawValue.lowercased())"
138+
}).count
139+
== allRecords.count else {
140+
throw CredentialVaultError.corruptMetadata
141+
}
142+
return CredentialMetadataState(records: index.records, pending: pending)
143+
}
144+
145+
private func writeState(_ state: CredentialMetadataState) throws {
146+
let allRecords = state.records + state.pending.map(\.record)
147+
guard allRecords.count <= CredentialVaultController.maximumRecords,
148+
Set(allRecords.map(\.id)).count == allRecords.count,
149+
Set(allRecords.map {
150+
"\($0.origin.rawValue)\u{0}\($0.alias.rawValue.lowercased())"
151+
}).count == allRecords.count else {
152+
throw CredentialVaultError.capacityExceeded
153+
}
154+
let data = try JSONEncoder.headlessCredentialEncoder.encode(
155+
CredentialIndex(schemaVersion: 1, records: state.records, pending: state.pending)
156+
)
157+
guard data.count <= Self.maximumIndexBytes else { throw CredentialVaultError.capacityExceeded }
158+
let temporary = rootURL.appendingPathComponent(".credentials-index.tmp-\(UUID().uuidString)")
159+
let descriptor = open(
160+
temporary.path, O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC | O_NOFOLLOW, 0o600
161+
)
162+
guard descriptor >= 0 else { throw CredentialVaultError.operationFailed("metadata creation") }
163+
var shouldRemove = true
164+
defer {
165+
close(descriptor)
166+
if shouldRemove { unlink(temporary.path) }
167+
}
168+
try validatePrivateRegularFile(descriptor)
169+
try data.withUnsafeBytes { bytes in
170+
guard let base = bytes.baseAddress else { return }
171+
var offset = 0
172+
while offset < bytes.count {
173+
let count = write(descriptor, base.advanced(by: offset), bytes.count - offset)
174+
if count < 0 && errno == EINTR { continue }
175+
guard count > 0 else { throw CredentialVaultError.operationFailed("metadata write") }
176+
offset += count
177+
}
178+
}
179+
guard fsync(descriptor) == 0 else { throw CredentialVaultError.operationFailed("metadata sync") }
180+
guard rename(temporary.path, indexURL.path) == 0 else {
181+
throw CredentialVaultError.operationFailed("metadata activation")
182+
}
183+
shouldRemove = false
184+
let directory = open(rootURL.path, O_RDONLY | O_CLOEXEC)
185+
if directory >= 0 {
186+
_ = fsync(directory)
187+
close(directory)
188+
}
189+
}
190+
191+
private func preparePrivateDirectory(_ url: URL) throws {
192+
let parent = url.deletingLastPathComponent()
193+
if parent.path != url.path, !FileManager.default.fileExists(atPath: parent.path) {
194+
try preparePrivateDirectory(parent)
195+
}
196+
var info = stat()
197+
if lstat(url.path, &info) == 0 {
198+
guard (info.st_mode & S_IFMT) == S_IFDIR, info.st_uid == getuid(),
199+
(info.st_mode & 0o077) == 0 else {
200+
throw CredentialVaultError.insecureMetadata
201+
}
202+
return
203+
}
204+
guard errno == ENOENT, mkdir(url.path, 0o700) == 0 || errno == EEXIST else {
205+
throw CredentialVaultError.operationFailed("metadata directory creation")
206+
}
207+
guard chmod(url.path, 0o700) == 0 else {
208+
throw CredentialVaultError.operationFailed("metadata directory permissions")
209+
}
210+
}
211+
212+
private func validatePrivateRegularFile(_ descriptor: Int32) throws {
213+
var info = stat()
214+
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG,
215+
info.st_uid == getuid(), (info.st_mode & 0o077) == 0 else {
216+
throw CredentialVaultError.insecureMetadata
217+
}
218+
guard fchmod(descriptor, 0o600) == 0 else {
219+
throw CredentialVaultError.operationFailed("metadata permissions")
220+
}
221+
}
222+
}
223+
224+
private extension JSONEncoder {
225+
static let headlessCredentialEncoder: JSONEncoder = {
226+
let encoder = JSONEncoder()
227+
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
228+
return encoder
229+
}()
230+
}

0 commit comments

Comments
 (0)