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 {
1313 let scope: String
1414 /// A name for the key (v1.21.0); omitted when none is set.
1515 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
1622 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 }
1734 /// What removal asks to be typed, as the web does: the first
1835 /// eight characters of the fingerprint after `SHA256:`.
1936 var confirmation: String {
@@ -135,10 +152,11 @@ final class AccountViewModel {
135152 /// `keys add [--scope full|git] [--label <text>]` — the
136153 /// authorized_keys line travels as raw stdin; a public key is not a
137154 /// 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 {
139156 var argv = ["keys", "add", "--scope", scope]
140157 let label = label.trimmingCharacters(in: .whitespaces)
141158 if !label.isEmpty { argv.append(contentsOf: ["--label", label]) }
159 if let ttl { argv.append(contentsOf: ["--ttl", ttl]) }
142160 await perform(argv, stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
143161 }
144162
gitbay/Views/Account/AccountView.swift +42 −6
@@ -90,9 +90,9 @@ struct AccountView: View {
9090 scopes: ["full", "git"],
9191 working: model.working,
9292 errorMessage: model.actionError
93 ) { text, scope, label in
93 ) { text, scope, label, ttl in
9494 Task {
95 await model.addSSHKey(text, scope: scope ?? "full", label: label)
95 await model.addSSHKey(text, scope: scope ?? "full", label: label, ttl: ttl)
9696 if model.actionError == nil { addingSSH = false }
9797 }
9898 }
@@ -104,7 +104,7 @@ struct AccountView: View {
104104 scopes: nil,
105105 working: model.working,
106106 errorMessage: model.actionError
107 ) { text, _, _ in
107 ) { text, _, _, _ in
108108 Task {
109109 await model.addPGPKey(text)
110110 if model.actionError == nil { addingPGP = false }
@@ -209,9 +209,13 @@ struct AccountView: View {
209209 HStack(spacing: 6) {
210210 Text(key.algo)
211211 GBChip(key.scope, .secondary)
212 if let by = key.createdBy {
213 Text("added by \(by)")
214 }
212215 }
213216 .font(.gbSans(.caption2))
214217 .foregroundStyle(.secondary)
218 keyDates(key)
215219 }
216220 .swipeActions {
217221 Button("Remove", role: .destructive) {
@@ -239,6 +243,29 @@ struct AccountView: View {
239243 }
240244 }
241245
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
242269 private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View {
243270 Section {
244271 ForEach(keys) { key in
@@ -345,13 +372,15 @@ private struct KeyPasteSheet: View {
345372 let scopes: [String]?
346373 let working: Bool
347374 let errorMessage: String?
348 /// Text, scope (nil without scopes), label (empty without scopes).
349 let onSubmit: (String, String?, String) -> Void
375 /// Text, scope (nil without scopes), label (empty without scopes),
376 /// and the key's lifetime (nil when it does not expire, or without scopes).
377 let onSubmit: (String, String?, String, String?) -> Void
350378
351379 @Environment(\.dismiss) private var dismiss
352380 @State private var text = ""
353381 @State private var scope = "full"
354382 @State private var label = ""
383 @State private var ttl: String?
355384
356385 var body: some View {
357386 NavigationStack {
@@ -376,6 +405,13 @@ private struct KeyPasteSheet: View {
376405 .autocorrectionDisabled()
377406 .textInputAutocapitalization(.never)
378407 .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")
379415 }
380416 }
381417 if let errorMessage {
@@ -395,7 +431,7 @@ private struct KeyPasteSheet: View {
395431 ProgressView()
396432 } else {
397433 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)
399435 }
400436 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
401437 .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}