@@ -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.
2626actor 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