Commit fa307c5cb1

fa307c5cb107019bcefc93f6d3c63fc7f684c03c

parent: 2e7fd48cb0

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 07:34 UTC

SSH key expiry and last use

Each key says when it was last used, when it expires or that it has,
and the token or key that added it. Adding a key can set it to expire
in 30 days, 90 days or a year, through keys add --ttl.

Closes #32

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}