Skip to content

Commit cb3b26d

Browse files
committed
Stop the SSH agent asking on every background signature
An approval only counted for 15 seconds, so anything that signs on its own, an editor's git fetch above all, prompted every couple of minutes (#1). One approval now covers the same program and key for a settable window, five minutes by default, and requests that arrive together share one prompt. The card also shows without Touch ID hardware, where the bare system prompt named neither program nor key, failed daemon restarts back off instead of running on every connection, and marking an app trusted applies to the approved key rather than to every key.
1 parent f2252ed commit cb3b26d

11 files changed

Lines changed: 348 additions & 108 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# Changelog
22

3+
## v2026-08-12.1
4+
5+
### Fixed
6+
- The SSH agent no longer asks for Touch ID over and over while the Mac sits
7+
idle. Anything that speaks to `ssh` in the background, an editor's automatic
8+
fetch above all, signs every couple of minutes on its own, and each of those
9+
signatures used to be a fresh prompt. One approval now covers further
10+
signatures from the same program on the same key for five minutes, and the
11+
window is yours to set under Settings → SSH → Trusted Apps, from every
12+
signature to eight hours.
13+
- Two signatures that begin at the same moment share one prompt instead of
14+
queueing two.
15+
- A Mac without Touch ID now gets the same approval card as everyone else,
16+
naming the program that is asking and the key it wants, instead of a bare
17+
system password box that named neither. Likewise when Touch ID is locked out:
18+
the card stays on screen and offers your password.
19+
- An `ssh` that can't be traced back to a signed identity is no longer condemned
20+
to a prompt per signature. It rides the approval window too, pinned to that
21+
exact binary, while permanent trust still requires a signature anchored to
22+
Apple.
23+
- An upstream agent that keeps failing to come back is now retried with a
24+
widening gap rather than on every connection. Each restart is two `pass-cli`
25+
runs and two reads of your local key from the Keychain, which on some setups
26+
is a prompt of its own.
27+
28+
### Changed
29+
- Marking an app trusted now applies to the key you approved rather than to
30+
every key in your vaults. Entries made before this keep working as they were.
31+
332
## v2026-08-02.2
433

534
### Fixed

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,21 @@ Proton-maintained command-line client, and wraps it in a native macOS UI.
102102
## SSH agent
103103

104104
An optional SSH agent serves your Proton Pass SSH keys to `git` and `ssh`, the
105-
way 1Password's does, and asks for Touch ID before every signature, naming the
106-
app that requested it. It is off by default; turn it on under **Settings → SSH**.
105+
way 1Password's does, and asks for Touch ID before a signature, naming the app
106+
that requested it. It is off by default; turn it on under **Settings → SSH**.
107107

108108
![SSH key signature request with Touch ID](docs/screenshots/ssh-agent.png)
109109

110110
It does not hold keys or sign anything itself. `pass-cli` already ships an SSH
111111
agent that stores the keys and does the signing; this app runs a thin **proxy**
112112
in front of it that adds the native confirmation. Private keys never enter the
113-
app, consistent with the security model below. Repeated signatures within a few
114-
seconds aren't re-prompted, you can mark an app trusted so it stops asking, and
115-
non-interactive `BatchMode` probes are denied without a prompt.
113+
app, consistent with the security model below. One approval covers further
114+
signatures from the same program on the same key for five minutes by default
115+
(*Settings → SSH → Trusted Apps*, anything from every signature to eight hours),
116+
you can mark an app trusted for a key so it stops asking, and non-interactive
117+
`BatchMode` probes are denied without a prompt. The window matters more than it
118+
sounds: background tools such as an editor's automatic fetch sign every couple of
119+
minutes on their own.
116120

117121
### Setting it up
118122

@@ -135,7 +139,7 @@ non-interactive `BatchMode` probes are denied without a prompt.
135139
login session via `launchctl`, so they pick it up too. It applies to programs
136140
launched afterwards, so quit and reopen a terminal (or app) for it to take
137141
effect.
138-
4. **Use `git` and `ssh` normally.** Each signature pops a Touch ID prompt naming
142+
4. **Use `git` and `ssh` normally.** A signature pops a Touch ID prompt naming
139143
the app and key. Check the keys are served with:
140144
```sh
141145
SSH_AUTH_SOCK=~/.ssh/pass-quick-access-agent.sock ssh-add -l

Sources/PassQuickAccess/SSH/AgentProxyController.swift

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,17 @@ final class AgentProxyController: ObservableObject {
3333
private let infoCoordinator = SignInfoCoordinator()
3434
private var listener: AgentSocketListener?
3535

36-
/// Debounce for `autoRecover`, so a flurry of failed signatures restarts the
37-
/// daemon once rather than repeatedly.
36+
/// Debounce for the daemon restarts, so a flurry of failed signatures restarts
37+
/// it once rather than repeatedly. A restart is two `pass-cli` runs and two
38+
/// reads of the local key from the Keychain, so a daemon that keeps failing
39+
/// (a lapsed Proton session, typically) must not be retried on every ssh
40+
/// connection: the wait doubles up to `maxRestartCooldown` and only a restart
41+
/// that works puts it back to the floor.
3842
private var isAutoRecovering = false
39-
private var lastAutoRecover: Date?
40-
private let autoRecoverCooldown: TimeInterval = 5
43+
private var lastRestart: Date?
44+
private var lastRestartWorked = true
45+
private var restartCooldown: TimeInterval = 5
46+
private static let maxRestartCooldown: TimeInterval = 300
4147

4248
/// The in-flight upstream restart, so concurrent connections that all find the
4349
/// daemon down share one restart instead of each spawning their own.
@@ -128,24 +134,39 @@ final class AgentProxyController: ObservableObject {
128134
/// burst of failed signatures triggers a single restart rather than a storm.
129135
private func autoRecover() {
130136
guard UserDefaults.standard.bool(forKey: SettingKey.sshAgentEnabled), listener != nil else { return }
131-
guard !isAutoRecovering, healTask == nil else { return }
132-
if let last = lastAutoRecover, Date().timeIntervalSince(last) < autoRecoverCooldown { return }
137+
guard !isAutoRecovering, healTask == nil, !isCoolingDown else { return }
133138
isAutoRecovering = true
134-
lastAutoRecover = Date()
135139
Task {
136140
await restartDaemon()
137141
isAutoRecovering = false
138142
}
139143
}
140144

141-
private func restartDaemon() async {
145+
/// Whether the last restart failed recently enough that another one would
146+
/// just be noise. A restart that worked never holds anything back.
147+
private var isCoolingDown: Bool {
148+
guard let lastRestart, !lastRestartWorked else { return false }
149+
return Date().timeIntervalSince(lastRestart) < restartCooldown
150+
}
151+
152+
@discardableResult
153+
private func restartDaemon() async -> Bool {
142154
status = .starting
143155
let daemon = UpstreamDaemonManager(
144156
executable: executable,
145157
socketPath: configuredUpstreamPath(),
146158
vaultFilter: vaultFilter()
147159
)
148-
status = await daemon.restart() ? .running : .upstreamUnavailable
160+
let ready = await daemon.restart()
161+
noteRestart(worked: ready)
162+
status = ready ? .running : .upstreamUnavailable
163+
return ready
164+
}
165+
166+
private func noteRestart(worked: Bool) {
167+
lastRestart = Date()
168+
lastRestartWorked = worked
169+
restartCooldown = worked ? 5 : min(Self.maxRestartCooldown, restartCooldown * 2)
149170
}
150171

151172
/// Restarts the upstream daemon and waits for it to come back, coalescing
@@ -157,6 +178,9 @@ final class AgentProxyController: ObservableObject {
157178
func healUpstreamAndWait() async -> Bool {
158179
guard UserDefaults.standard.bool(forKey: SettingKey.sshAgentEnabled), listener != nil else { return false }
159180
if let healTask { return await healTask.value }
181+
// Fail this connection fast rather than spend another restart on a daemon
182+
// that just refused to come back.
183+
if isCoolingDown { return false }
160184

161185
status = .starting
162186
let daemon = UpstreamDaemonManager(
@@ -168,9 +192,7 @@ final class AgentProxyController: ObservableObject {
168192
healTask = task
169193
let ready = await task.value
170194
healTask = nil
171-
// A restart supersedes the background-restart cooldown: the daemon is fresh,
172-
// so a stray failed signature shouldn't immediately trigger another.
173-
lastAutoRecover = Date()
195+
noteRestart(worked: ready)
174196
status = ready ? .running : .upstreamUnavailable
175197
return ready
176198
}

Sources/PassQuickAccess/SSH/CodeSignatureCheck.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ struct VerifiedPeer: Sendable, Equatable {
1212
/// `team:2BUA8C4S2C:com.agilebits...`, or `nil` when the peer couldn't be
1313
/// anchored to a trustworthy signature.
1414
let identity: String?
15+
/// The binary's code directory hash. It says nothing about who wrote the
16+
/// code, so it never backs a persisted decision, but it does pin the exact
17+
/// binary, which is enough to scope the short approval window for a peer
18+
/// that couldn't be anchored.
19+
let codeHash: String?
20+
21+
init(identity: String?, codeHash: String? = nil) {
22+
self.identity = identity
23+
self.codeHash = codeHash
24+
}
1525

1626
var isVerified: Bool { identity != nil }
1727

@@ -47,6 +57,8 @@ enum CodeSignatureCheck {
4757
!identifier.isEmpty else { return .unverified }
4858

4959
let team = info[kSecCodeInfoTeamIdentifier as String] as? String
60+
let codeHash = (info[kSecCodeInfoUnique as String] as? Data)
61+
.map { $0.map { String(format: "%02x", $0) }.joined() }
5062

5163
// The signing identifier alone is attacker-controllable: anyone can
5264
// ad-hoc sign a binary claiming identifier "git" to inherit git's
@@ -55,15 +67,15 @@ enum CodeSignatureCheck {
5567
// Apple's own tools (/usr/bin/ssh, git from the CLT, …) are signed by
5668
// Apple with no Team ID; "anchor apple" matches exactly those.
5769
if satisfies(onDisk, requirement: "anchor apple") {
58-
return VerifiedPeer(identity: "platform:\(identifier)")
70+
return VerifiedPeer(identity: "platform:\(identifier)", codeHash: codeHash)
5971
}
6072
// Third-party code must chain to Apple (Developer ID / App Store) *and*
6173
// carry a Team ID. The Apple-chain check is essential: without it a
6274
// self-signed certificate could claim any Team ID it likes.
6375
if let team, !team.isEmpty, satisfies(onDisk, requirement: "anchor apple generic") {
64-
return VerifiedPeer(identity: "team:\(team):\(identifier)")
76+
return VerifiedPeer(identity: "team:\(team):\(identifier)", codeHash: codeHash)
6577
}
66-
return .unverified
78+
return VerifiedPeer(identity: nil, codeHash: codeHash)
6779
}
6880

6981
/// Whether the on-disk code validates against a code-signing requirement.

Sources/PassQuickAccess/SSH/SignAuthorizer.swift

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,78 +19,88 @@ protocol SignApprovalPresenting: Sendable {
1919
func present(_ request: SignRequest) async -> Bool
2020
}
2121

22-
/// Decides whether a sign request goes through. Order: a short session cache (to
23-
/// avoid re-prompting during one workflow), then any persisted decision for the
24-
/// app, then a fast deny for non-interactive `BatchMode` probes, and finally the
25-
/// Touch ID prompt.
22+
/// Decides whether a sign request goes through. Order: the approval window (so a
23+
/// program that signs repeatedly isn't asked every time), then any persisted
24+
/// decision for the app, then a fast deny for non-interactive `BatchMode`
25+
/// probes, and finally the Touch ID prompt.
2626
actor SignAuthorizer {
27-
/// How long an approval is reused without re-prompting, keyed by app + key.
28-
static let sessionTTL: TimeInterval = 15
29-
3027
private let store: RememberedDecisionsStore
3128
private let presenter: SignApprovalPresenting
3229
private let rememberApprovedApps: @Sendable () -> Bool
30+
private let window: @Sendable () -> TimeInterval
3331
private let now: @Sendable () -> Date
34-
private var sessionApprovals: [String: Date] = [:]
32+
private var approvals: [String: Date] = [:]
33+
/// Prompts in flight, keyed the same way as `approvals`. Without this, two
34+
/// `git fetch`es that start together each get their own prompt for the same
35+
/// app and key.
36+
private var pending: [String: Task<Bool, Never>] = [:]
3537

3638
init(
3739
store: RememberedDecisionsStore,
3840
presenter: SignApprovalPresenting,
3941
rememberApprovedApps: @escaping @Sendable () -> Bool
4042
= { UserDefaults.standard.bool(forKey: SettingKey.sshRememberApprovedApps) },
43+
window: @escaping @Sendable () -> TimeInterval = { SSHApprovalWindow.current().duration },
4144
now: @escaping @Sendable () -> Date = { Date() }
4245
) {
4346
self.store = store
4447
self.presenter = presenter
4548
self.rememberApprovedApps = rememberApprovedApps
49+
self.window = window
4650
self.now = now
4751
}
4852

4953
func authorize(_ request: SignRequest) async -> Bool {
50-
// A verified peer can ride the session cache and remembered decisions; an
51-
// unverified one must always face the prompt, so trust can't be spoofed.
52-
if let identity = request.peer.identity {
53-
if isInSession(identity: identity, fingerprint: request.fingerprint) {
54-
return true
55-
}
56-
if let decision = await store.decision(identity: identity, fingerprint: request.fingerprint) {
57-
return decision.allow
58-
}
54+
let key = windowKey(for: request)
55+
56+
if let key, isWithinWindow(key) { return true }
57+
if let identity = request.peer.identity,
58+
let decision = await store.decision(identity: identity, fingerprint: request.fingerprint) {
59+
return decision.allow
5960
}
6061

6162
// Scripted probes (`ssh -o BatchMode=yes`) abandon the connection before a
6263
// human can answer, so deny rather than hang.
6364
if request.client.batchMode { return false }
6465

66+
guard let key else { return await prompt(for: request) }
67+
if let existing = pending[key] { return await existing.value }
68+
let task = Task { await self.prompt(for: request) }
69+
pending[key] = task
70+
let approved = await task.value
71+
pending[key] = nil
72+
if approved { approvals[key] = now() }
73+
return approved
74+
}
75+
76+
private func prompt(for request: SignRequest) async -> Bool {
6577
let approved = await presenter.present(request)
66-
if approved, let identity = request.peer.identity {
67-
noteSession(identity: identity, fingerprint: request.fingerprint)
68-
if rememberApprovedApps() {
69-
await store.remember(RememberedSignDecision(
70-
identity: identity,
71-
appName: request.client.name,
72-
fingerprint: nil,
73-
keyName: nil,
74-
allow: true,
75-
createdAt: now()
76-
))
77-
}
78-
}
78+
guard approved, let identity = request.peer.identity, rememberApprovedApps() else { return approved }
79+
await store.remember(RememberedSignDecision(
80+
identity: identity,
81+
appName: request.client.name,
82+
fingerprint: request.fingerprint,
83+
keyName: request.keyName,
84+
allow: true,
85+
createdAt: now()
86+
))
7987
return approved
8088
}
8189

82-
private func sessionKey(identity: String, fingerprint: String) -> String {
83-
"\(identity)|\(fingerprint)"
90+
/// What an approval is remembered against for the length of the window: the
91+
/// app's signing identity when we have one, otherwise the exact binary. A
92+
/// peer we can pin neither way is asked about every time.
93+
private func windowKey(for request: SignRequest) -> String? {
94+
if let identity = request.peer.identity { return "\(identity)|\(request.fingerprint)" }
95+
return request.peer.codeHash.map { "code:\($0)|\(request.fingerprint)" }
8496
}
8597

86-
private func isInSession(identity: String, fingerprint: String) -> Bool {
87-
guard let approvedAt = sessionApprovals[sessionKey(identity: identity, fingerprint: fingerprint)] else {
98+
private func isWithinWindow(_ key: String) -> Bool {
99+
guard let approvedAt = approvals[key] else { return false }
100+
guard now().timeIntervalSince(approvedAt) < window() else {
101+
approvals[key] = nil
88102
return false
89103
}
90-
return now().timeIntervalSince(approvedAt) < Self.sessionTTL
91-
}
92-
93-
private func noteSession(identity: String, fingerprint: String) {
94-
sessionApprovals[sessionKey(identity: identity, fingerprint: fingerprint)] = now()
104+
return true
95105
}
96106
}

0 commit comments

Comments
 (0)