SSH key expiry and last use !168

merged merged by cmc on 2026-10-14 08:08 UTC · krz/gitbay-ios:key-expiry into main

3 files changed, +117 −7

Layout: unified · split

gitbay/Account/AccountViewModel.swift +19 −1
@@ -13,7 +13,24 @@ final class AccountViewModel {
13 let scope: String 13 let scope: String
14 /// A name for the key (v1.21.0); omitted when none is set. 14 /// A name for the key (v1.21.0); omitted when none is set.
15 let label: String? 15 let label: String?
16 /// The token or key that added it, when one did rather than you.
17 var createdBy: String? = nil
18 /// When the key last signed in; absent when it never has.
19 var lastUsedAt: Date? = nil
20 /// When the key stops working; absent when it does not expire.
21 var expiresAt: Date? = nil
16 var id: String { fingerprint } 22 var id: String { fingerprint }
23
24 enum CodingKeys: String, CodingKey {
25 case fingerprint, algo, scope, label
26 case createdBy = "created_by"
27 case lastUsedAt = "last_used_at"
28 case expiresAt = "expires_at"
29 }
30
31 func isExpired(at now: Date) -> Bool {
32 expiresAt.map { $0 <= now } ?? false
33 }
17 /// What removal asks to be typed, as the web does: the first 34 /// What removal asks to be typed, as the web does: the first
18 /// eight characters of the fingerprint after `SHA256:`. 35 /// eight characters of the fingerprint after `SHA256:`.
19 var confirmation: String { 36 var confirmation: String {
@@ -135,10 +152,11 @@ final class AccountViewModel {
135 /// `keys add [--scope full|git] [--label <text>]` — the 152 /// `keys add [--scope full|git] [--label <text>]` — the
136 /// authorized_keys line travels as raw stdin; a public key is not a 153 /// authorized_keys line travels as raw stdin; a public key is not a
137 /// secret. A blank label sends no flag. 154 /// secret. A blank label sends no flag.
138 func addSSHKey(_ publicKey: String, scope: String, label: String = "") async { 155 func addSSHKey(_ publicKey: String, scope: String, label: String = "", ttl: String? = nil) async {
139 var argv = ["keys", "add", "--scope", scope] 156 var argv = ["keys", "add", "--scope", scope]
140 let label = label.trimmingCharacters(in: .whitespaces) 157 let label = label.trimmingCharacters(in: .whitespaces)
141 if !label.isEmpty { argv.append(contentsOf: ["--label", label]) } 158 if !label.isEmpty { argv.append(contentsOf: ["--label", label]) }
159 if let ttl { argv.append(contentsOf: ["--ttl", ttl]) }
142 await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines)) 160 await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
143 } 161 }
144 162
gitbay/Views/Account/AccountView.swift +42 −6
@@ -90,9 +90,9 @@ struct AccountView: View {
90 scopes: ["full", "git"], 90 scopes: ["full", "git"],
91 working: model.working, 91 working: model.working,
92 errorMessage: model.actionError 92 errorMessage: model.actionError
93 ) { text, scope, label in 93 ) { text, scope, label, ttl in
94 Task { 94 Task {
95 await model.addSSHKey(text, scope: scope ?? "full", label: label) 95 await model.addSSHKey(text, scope: scope ?? "full", label: label, ttl: ttl)
96 if model.actionError == nil { addingSSH = false } 96 if model.actionError == nil { addingSSH = false }
97 } 97 }
98 } 98 }
@@ -104,7 +104,7 @@ struct AccountView: View {
104 scopes: nil, 104 scopes: nil,
105 working: model.working, 105 working: model.working,
106 errorMessage: model.actionError 106 errorMessage: model.actionError
107 ) { text, _, _ in 107 ) { text, _, _, _ in
108 Task { 108 Task {
109 await model.addPGPKey(text) 109 await model.addPGPKey(text)
110 if model.actionError == nil { addingPGP = false } 110 if model.actionError == nil { addingPGP = false }
@@ -209,9 +209,13 @@ struct AccountView: View {
209 HStack(spacing: 6) { 209 HStack(spacing: 6) {
210 Text(key.algo) 210 Text(key.algo)
211 GBChip(key.scope, .secondary) 211 GBChip(key.scope, .secondary)
212 if let by = key.createdBy {
213 Text("added by \(by)")
214 }
212 } 215 }
213 .font(.gbSans(.caption2)) 216 .font(.gbSans(.caption2))
214 .foregroundStyle(.secondary) 217 .foregroundStyle(.secondary)
218 keyDates(key)
215 } 219 }
216 .swipeActions { 220 .swipeActions {
217 Button("Remove", role: .destructive) { 221 Button("Remove", role: .destructive) {
@@ -239,6 +243,29 @@ struct AccountView: View {
239 } 243 }
240 } 244 }
241 245
246 /// When the key was last used and when it stops working, as the CLI's
247 /// key list says it.
248 private func keyDates(_ key: AccountViewModel.SSHKey) -> some View {
249 HStack(spacing: 4) {
250 if let used = key.lastUsedAt {
251 Text("used \(used.formatted(.relative(presentation: .named)))")
252 } else {
253 Text("never used")
254 }
255 if let expires = key.expiresAt {
256 Text("·")
257 if key.isExpired(at: .now) {
258 Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
259 .foregroundStyle(Color.gbBad)
260 } else {
261 Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
262 }
263 }
264 }
265 .font(.gbSans(.caption2))
266 .foregroundStyle(.secondary)
267 }
268
242 private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View { 269 private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
243 Section { 270 Section {
244 ForEach(keys) { key in 271 ForEach(keys) { key in
@@ -345,13 +372,15 @@ private struct KeyPasteSheet: View {
345 let scopes: [String]? 372 let scopes: [String]?
346 let working: Bool 373 let working: Bool
347 let errorMessage: String? 374 let errorMessage: String?
348 /// Text, scope (nil without scopes), label (empty without scopes). 375 /// Text, scope (nil without scopes), label (empty without scopes),
349 let onSubmit: (String, String?, String) -> Void 376 /// and the key's lifetime (nil when it does not expire, or without scopes).
377 let onSubmit: (String, String?, String, String?) -> Void
350 378
351 @Environment(\.dismiss) private var dismiss 379 @Environment(\.dismiss) private var dismiss
352 @State private var text = "" 380 @State private var text = ""
353 @State private var scope = "full" 381 @State private var scope = "full"
354 @State private var label = "" 382 @State private var label = ""
383 @State private var ttl: String?
355 384
356 var body: some View { 385 var body: some View {
357 NavigationStack { 386 NavigationStack {
@@ -376,6 +405,13 @@ private struct KeyPasteSheet: View {
376 .autocorrectionDisabled() 405 .autocorrectionDisabled()
377 .textInputAutocapitalization(.never) 406 .textInputAutocapitalization(.never)
378 .accessibilityIdentifier("key-paste-label") 407 .accessibilityIdentifier("key-paste-label")
408 Picker("Expires", selection: $ttl) {
409 Text("Never").tag(String?.none)
410 Text("In 30 days").tag(String?.some("30d"))
411 Text("In 90 days").tag(String?.some("90d"))
412 Text("In a year").tag(String?.some("365d"))
413 }
414 .accessibilityIdentifier("key-paste-ttl")
379 } 415 }
380 } 416 }
381 if let errorMessage { 417 if let errorMessage {
@@ -395,7 +431,7 @@ private struct KeyPasteSheet: View {
395 ProgressView() 431 ProgressView()
396 } else { 432 } else {
397 Button("Add") { 433 Button("Add") {
398 onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "") 434 onSubmit(text, scopes != nil ? scope : nil, scopes != nil ? label : "", scopes != nil ? ttl : nil)
399 } 435 }
400 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 436 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
401 .accessibilityIdentifier("key-paste-submit") 437 .accessibilityIdentifier("key-paste-submit")
gitbayTests/KeyExpiryTests.swift added +56
@@ -0,0 +1,56 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func posted(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22struct KeyExpiryTests {
23
24 @Test func keysCarryLastUseExpiryAndWhoCreatedThem() throws {
25 let json = """
26 [{"fingerprint":"SHA256:a","algo":"ssh-ed25519","scope":"full","label":"laptop",\
27 "last_used_at":"2026-10-14T07:32:16.789Z"},\
28 {"fingerprint":"SHA256:b","algo":"ssh-ed25519","scope":"git","label":"ci",\
29 "created_by":"phone","expires_at":"2026-01-01T00:00:00Z"},\
30 {"fingerprint":"SHA256:c","algo":"ssh-ed25519","scope":"full","label":""}]
31 """
32 let keys = try GitbayClient.decoder().decode([AccountViewModel.SSHKey].self, from: Data(json.utf8))
33 let now = try Date("2026-10-15T00:00:00Z", strategy: .iso8601)
34
35 #expect(keys[0].lastUsedAt != nil)
36 #expect(keys[0].expiresAt == nil)
37 #expect(!keys[0].isExpired(at: now))
38 #expect(keys[1].createdBy == "phone")
39 #expect(keys[1].isExpired(at: now))
40 #expect(keys[2].lastUsedAt == nil)
41 }
42}
43
44@MainActor
45struct KeyTTLTests {
46
47 @Test func anExpiringKeyIsAddedWithItsTTL() async throws {
48 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
50 let model = AccountViewModel(client: client)
51
52 await model.addSSHKey("ssh-ed25519 AAAA phone", scope: "full", label: "phone", ttl: "90d")
53
54 #expect(try posted(stub) == [["keys", "add", "--scope", "full", "--label", "phone", "--ttl", "90d"]])
55 }
56}