Skip to content

Commit bcada5e

Browse files
committed
feat: add ephemeral private credentials
1 parent c8bb91a commit bcada5e

8 files changed

Lines changed: 221 additions & 38 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ No, and requires a user-entered alias. Linux vault management is available,
185185
but saved alias use fails closed until a trusted per-use confirmation surface
186186
exists.
187187

188+
With `--session NAME` for an isolated session, interactive saves go only to an
189+
in-memory vault owned by that session. Private challenges list only those
190+
ephemeral aliases. Closing the session or terminating the host erases them;
191+
the durable normal vault is never queried.
192+
188193
## Agent skill
189194

190195
This repository ships a portable browser-computer-use skill at

apps/headless/Sources/HeadlessProtocol/Authentication.swift

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,71 @@ public struct UnavailableAuthenticationBroker: AuthenticationBroker {
178178
}
179179
}
180180

181+
public final class EphemeralAuthenticationBroker: @unchecked Sendable, AuthenticationBroker {
182+
public static let maximumRecords = 100
183+
184+
private struct Record {
185+
let origin: CredentialOrigin
186+
let alias: CredentialAlias
187+
let account: String
188+
let password: AuthenticationSecret
189+
}
190+
191+
private let lock = NSLock()
192+
private var records: [Record] = []
193+
194+
public init() {}
195+
deinit { removeAll() }
196+
197+
public func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] {
198+
try lock.withLock {
199+
try records.filter { $0.origin == origin }
200+
.sorted { $0.alias.rawValue < $1.alias.rawValue }
201+
.map { try AuthenticationAlias(alias: $0.alias, account: $0.account) }
202+
}
203+
}
204+
205+
public func credential(
206+
for origin: CredentialOrigin, alias: CredentialAlias
207+
) throws -> AuthenticationCredential {
208+
try lock.withLock {
209+
guard let record = records.first(where: {
210+
$0.origin == origin
211+
&& $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame
212+
}) else { throw AuthenticationError.accountNotFound }
213+
return try AuthenticationCredential(
214+
account: record.account,
215+
password: AuthenticationSecret(record.password.withUnsafeBytes { Array($0) })
216+
)
217+
}
218+
}
219+
220+
public func store(
221+
_ credential: AuthenticationCredential, for origin: CredentialOrigin, alias: CredentialAlias
222+
) throws {
223+
try lock.withLock {
224+
guard records.count < Self.maximumRecords else {
225+
throw AuthenticationError.brokerFailed("private credential limit")
226+
}
227+
guard !records.contains(where: {
228+
$0.origin == origin
229+
&& $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame
230+
}) else { throw AuthenticationError.credentialAliasExists }
231+
records.append(Record(
232+
origin: origin, alias: alias, account: credential.account,
233+
password: AuthenticationSecret(credential.password.withUnsafeBytes { Array($0) })
234+
))
235+
}
236+
}
237+
238+
public func removeAll() {
239+
lock.withLock {
240+
records.forEach { $0.password.clear() }
241+
records.removeAll(keepingCapacity: false)
242+
}
243+
}
244+
}
245+
181246
public struct SecureTerminalAuthenticationPrompt {
182247
public init() {}
183248

@@ -227,7 +292,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible {
227292
case originChanged
228293
case formChanged
229294
case accountNotFound
230-
case privateContextCredentialUnavailable
295+
case credentialAliasExists
231296
case vaultUnavailable
232297
case vaultLocked
233298
case userPresenceUnavailable
@@ -243,7 +308,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible {
243308
case .originChanged: return "AUTH_ORIGIN_CHANGED"
244309
case .formChanged: return "AUTH_FORM_CHANGED"
245310
case .accountNotFound: return "AUTH_ACCOUNT_NOT_FOUND"
246-
case .privateContextCredentialUnavailable: return "PRIVATE_CREDENTIAL_UNAVAILABLE"
311+
case .credentialAliasExists: return "CREDENTIAL_ALIAS_EXISTS"
247312
case .vaultUnavailable: return "VAULT_UNAVAILABLE"
248313
case .vaultLocked: return "VAULT_LOCKED"
249314
case .userPresenceUnavailable: return "USER_PRESENCE_UNAVAILABLE"
@@ -261,8 +326,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible {
261326
case .originChanged: return "The top-level authentication origin changed. Inspect the page again."
262327
case .formChanged: return "The authentication form changed. Inspect the page again."
263328
case .accountNotFound: return "No saved account matches that alias for this origin."
264-
case .privateContextCredentialUnavailable:
265-
return "Normal-profile credentials are unavailable in an isolated session."
329+
case .credentialAliasExists: return "That credential alias already exists for this origin."
266330
case .vaultUnavailable: return "An approved operating-system credential vault is unavailable."
267331
case .vaultLocked: return "The operating-system credential vault is locked."
268332
case .userPresenceUnavailable:

apps/headless/Sources/HeadlessProtocol/Capabilities.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public struct BrowserEngineCapabilities: Sendable {
7878
"storage": .string("engine-native-ephemeral-context"),
7979
"sharedAcrossSessions": .bool(false),
8080
"normalVaultAvailable": .bool(false),
81+
"ephemeralCredentials": .bool(true),
8182
"destroyedOnClose": .bool(true),
8283
]),
8384
"authentication": .object([
@@ -88,6 +89,7 @@ public struct BrowserEngineCapabilities: Sendable {
8889
"savedCredentialUse": .bool(engine == .webkit),
8990
"userPresencePerSavedUse": .bool(engine == .webkit),
9091
"automaticActionReplay": .bool(false),
92+
"interactiveLogin": .bool(true),
9193
]),
9294
]),
9395
])
@@ -221,6 +223,7 @@ public let capabilitiesDocument: JSONValue = {
221223
"silentUse": .bool(false),
222224
"agentReceivesPasswords": .bool(false),
223225
"privateContextAccess": .bool(false),
226+
"privateEphemeralCredentials": .bool(true),
224227
]),
225228
"security": .object([
226229
"tcpListener": .bool(false),

apps/headless/Sources/HeadlessProtocol/HostCore.swift

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
145145
private var trace: [String: [JSONValue]] = ["default": []]
146146
private var activeFlows: [String: [RecordedFlowStep]] = [:]
147147
private var recordings: [String: BrowserRecording] = [:]
148+
private var privateAuthenticationBrokers: [String: EphemeralAuthenticationBroker]
148149
private var stopping = false
149150
private let traceStartedAt = ProcessInfo.processInfo.systemUptime
150151

@@ -161,6 +162,8 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
161162
self.authenticationBroker = authenticationBroker
162163
self.authenticationChallenges = authenticationChallenges
163164
self.sessions = ["default": defaultSession]
165+
self.privateAuthenticationBrokers = defaultSession.hostIsolated
166+
? ["default": EphemeralAuthenticationBroker()] : [:]
164167
self.shutdownHandler = shutdownHandler
165168
}
166169

@@ -179,6 +182,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
179182
trace.removeValue(forKey: name)
180183
activeFlows.removeValue(forKey: name)
181184
if let recording = recordings.removeValue(forKey: name) { stopped.append(recording) }
185+
privateAuthenticationBrokers.removeValue(forKey: name)?.removeAll()
182186
authenticationChallenges.invalidate(session: name)
183187
}
184188
return stopped
@@ -198,6 +202,8 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
198202
sessions.removeAll()
199203
trace.removeAll()
200204
activeFlows.removeAll()
205+
privateAuthenticationBrokers.values.forEach { $0.removeAll() }
206+
privateAuthenticationBrokers.removeAll()
201207
authenticationChallenges.removeAll()
202208
return (activeRecordings, openSessions)
203209
}
@@ -311,6 +317,8 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
311317
sessions.removeAll()
312318
trace.removeAll()
313319
activeFlows.removeAll()
320+
privateAuthenticationBrokers.values.forEach { $0.removeAll() }
321+
privateAuthenticationBrokers.removeAll()
314322
authenticationChallenges.removeAll()
315323
return (activeRecordings, openSessions)
316324
}
@@ -379,6 +387,9 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
379387
if sessions[name] != nil { return "SESSION_EXISTS" }
380388
sessions[name] = created
381389
trace[name] = []
390+
if created.hostIsolated {
391+
privateAuthenticationBrokers[name] = EphemeralAuthenticationBroker()
392+
}
382393
return nil
383394
}
384395
if let rejection {
@@ -403,6 +414,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
403414
let recording = recordings.removeValue(forKey: name)
404415
trace.removeValue(forKey: name)
405416
activeFlows.removeValue(forKey: name)
417+
privateAuthenticationBrokers.removeValue(forKey: name)?.removeAll()
406418
authenticationChallenges.invalidate(session: name)
407419
return (session, recording)
408420
}
@@ -554,30 +566,27 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
554566
let aliases: [AuthenticationAlias]
555567
let vaultAvailable: Bool
556568
let vaultStatus: String
557-
if session.hostIsolated {
569+
let broker = broker(for: sessionName, session: session)
570+
do {
571+
aliases = try broker.aliases(for: credentialOrigin)
572+
vaultAvailable = true
573+
vaultStatus = session.hostIsolated ? "private-ephemeral" : "available"
574+
} catch let error as AuthenticationError {
558575
aliases = []
559576
vaultAvailable = false
560-
vaultStatus = AuthenticationError.privateContextCredentialUnavailable.code
561-
} else {
562-
do {
563-
aliases = try authenticationBroker.aliases(for: credentialOrigin)
564-
vaultAvailable = true
565-
vaultStatus = "available"
566-
} catch let error as AuthenticationError {
567-
aliases = []
568-
vaultAvailable = false
569-
vaultStatus = error.code
570-
} catch {
571-
aliases = []
572-
vaultAvailable = false
573-
vaultStatus = "VAULT_OPERATION_FAILED"
574-
}
577+
vaultStatus = error.code
578+
} catch {
579+
aliases = []
580+
vaultAvailable = false
581+
vaultStatus = "VAULT_OPERATION_FAILED"
575582
}
576583
let credentialUseAvailable: Bool
577584
let suggestion: String
578585
if session.hostIsolated {
579-
credentialUseAvailable = false
580-
suggestion = "Log in interactively without the normal credential vault; private credential enrollment is not available yet."
586+
credentialUseAvailable = true
587+
suggestion = aliases.isEmpty
588+
? "Run `headless auth login --interactive --session \(sessionName)` to enroll an ephemeral account."
589+
: "Choose a private account alias or log in interactively."
581590
} else {
582591
#if os(macOS)
583592
credentialUseAvailable = true
@@ -593,7 +602,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
593602
"detection": .string("confirmed"),
594603
"accounts": .array(aliases.map(\.publicValue)),
595604
"expiresInSeconds": .number(AuthenticationChallengeStore.lifetime),
596-
"userPresenceRequired": .bool(true),
605+
"userPresenceRequired": .bool(!session.hostIsolated),
597606
"credentialUseAvailable": .bool(credentialUseAvailable),
598607
"vaultAvailable": .bool(vaultAvailable),
599608
"vaultStatus": .string(vaultStatus),
@@ -640,7 +649,9 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
640649
if interactive {
641650
credential = try session.hostPromptCredential(origin: credentialOrigin)
642651
} else if let alias {
643-
credential = try authenticationBroker.credential(for: credentialOrigin, alias: alias)
652+
credential = try broker(for: sessionName, session: session).credential(
653+
for: credentialOrigin, alias: alias
654+
)
644655
} else {
645656
throw AuthenticationError.accountNotFound
646657
}
@@ -708,13 +719,25 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
708719
let saveAlias = try session.hostPromptCredentialSave(
709720
origin: credentialOrigin, account: saveCandidate.account
710721
) {
711-
try authenticationBroker.store(saveCandidate, for: credentialOrigin, alias: saveAlias)
722+
try broker(for: sessionName, session: session).store(
723+
saveCandidate, for: credentialOrigin, alias: saveAlias
724+
)
712725
response["account"] = .string(saveAlias.rawValue)
713726
response["saved"] = .bool(true)
714727
}
715728
return .object(response)
716729
}
717730

731+
private func broker(
732+
for sessionName: String, session: Engine.Session
733+
) -> any AuthenticationBroker {
734+
if session.hostIsolated {
735+
return withState({ privateAuthenticationBrokers[sessionName] })
736+
?? UnavailableAuthenticationBroker()
737+
}
738+
return authenticationBroker
739+
}
740+
718741
private func captureInfo(_ session: Engine.Session, name: String) throws -> JSONValue {
719742
let base = try session.hostCaptureInfo()
720743
guard case .object(var object) = base else { return base }

0 commit comments

Comments
 (0)