SSH key expiry and last use !168
3 files changed, +117 −7
Layout: unified · split
gitbay/Account/AccountViewModel.swift +19 −1
| @@ -13,7 +13,24 @@ final class AccountViewModel { | ||
| 13 | 13 | let scope: String |
| 14 | 14 | /// A name for the key (v1.21.0); omitted when none is set. |
| 15 | 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 | 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 | 34 | /// What removal asks to be typed, as the web does: the first |
| 18 | 35 | /// eight characters of the fingerprint after `SHA256:`. |
| 19 | 36 | var confirmation: String { |
| @@ -135,10 +152,11 @@ final class AccountViewModel { | ||
| 135 | 152 | /// `keys add [--scope full|git] [--label <text>]` — the |
| 136 | 153 | /// authorized_keys line travels as raw stdin; a public key is not a |
| 137 | 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 | 156 | var argv = ["keys", "add", "--scope", scope] |
| 140 | 157 | let label = label.trimmingCharacters(in: .whitespaces) |
| 141 | 158 | if !label.isEmpty { argv.append(contentsOf: ["--label", label]) } |
| 159 | if let ttl { argv.append(contentsOf: ["--ttl", ttl]) } | |
| 142 | 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 | 90 | scopes: ["full", "git"], |
| 91 | 91 | working: model.working, |
| 92 | 92 | errorMessage: model.actionError |
| 93 | ) { text, scope, label in | |
| 93 | ) { text, scope, label, ttl in | |
| 94 | 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 | 96 | if model.actionError == nil { addingSSH = false } |
| 97 | 97 | } |
| 98 | 98 | } |
| @@ -104,7 +104,7 @@ struct AccountView: View { | ||
| 104 | 104 | scopes: nil, |
| 105 | 105 | working: model.working, |
| 106 | 106 | errorMessage: model.actionError |
| 107 | ) { text, _, _ in | |
| 107 | ) { text, _, _, _ in | |
| 108 | 108 | Task { |
| 109 | 109 | await model.addPGPKey(text) |
| 110 | 110 | if model.actionError == nil { addingPGP = false } |
| @@ -209,9 +209,13 @@ struct AccountView: View { | ||
| 209 | 209 | HStack(spacing: 6) { |
| 210 | 210 | Text(key.algo) |
| 211 | 211 | GBChip(key.scope, .secondary) |
| 212 | if let by = key.createdBy { | |
| 213 | Text("added by \(by)") | |
| 214 | } | |
| 212 | 215 | } |
| 213 | 216 | .font(.gbSans(.caption2)) |
| 214 | 217 | .foregroundStyle(.secondary) |
| 218 | keyDates(key) | |
| 215 | 219 | } |
| 216 | 220 | .swipeActions { |
| 217 | 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 | 269 | private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View { |
| 243 | 270 | Section { |
| 244 | 271 | ForEach(keys) { key in |
| @@ -345,13 +372,15 @@ private struct KeyPasteSheet: View { | ||
| 345 | 372 | let scopes: [String]? |
| 346 | 373 | let working: Bool |
| 347 | 374 | 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 | |
| 350 | 378 | |
| 351 | 379 | @Environment(\.dismiss) private var dismiss |
| 352 | 380 | @State private var text = "" |
| 353 | 381 | @State private var scope = "full" |
| 354 | 382 | @State private var label = "" |
| 383 | @State private var ttl: String? | |
| 355 | 384 | |
| 356 | 385 | var body: some View { |
| 357 | 386 | NavigationStack { |
| @@ -376,6 +405,13 @@ private struct KeyPasteSheet: View { | ||
| 376 | 405 | .autocorrectionDisabled() |
| 377 | 406 | .textInputAutocapitalization(.never) |
| 378 | 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 | 417 | if let errorMessage { |
| @@ -395,7 +431,7 @@ private struct KeyPasteSheet: View { | ||
| 395 | 431 | ProgressView() |
| 396 | 432 | } else { |
| 397 | 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 | 436 | .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| 401 | 437 | .accessibilityIdentifier("key-paste-submit") |
gitbayTests/KeyExpiryTests.swift added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 22 | struct 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 | |
| 45 | struct 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 | } | |