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 {
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}