Files

166 lines
6.9 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
public nonisolated enum PasswordStorage: String, Codable, Hashable, Sendable, CaseIterable, Identifiable {
case thisDevice
case iCloud
public var id: String { rawValue }
public var displayName: String {
switch self {
case .thisDevice: String(localized: "This Mac")
case .iCloud: String(localized: "iCloud")
}
}
public var symbol: String {
switch self {
case .thisDevice: "lock.laptopcomputer"
case .iCloud: "icloud.fill"
}
}
}
/// How an Identity edit changes the password stored behind `credentialRef`.
///
/// `nil` could only mean one of "keep" and "remove", which made it impossible
/// for the editor to offer both operations without guessing. Keeping the three
/// intents explicit also lets `HostLibraryModel` update the model and Keychain as one
/// operation.
public nonisolated enum IdentityPasswordUpdate: Hashable, Sendable {
case keep
case replace(String)
case remove
}
public nonisolated enum SecretAvailability: Equatable, Sendable {
case notConfigured
case available
case missing
case failed(String)
}
public nonisolated enum HostCustomIdentityPurpose: String, Codable, Hashable, Sendable {
case ssh
case telnet
case proxy
case telnetProxy
}
/// A named way of logging in: a username plus a *reference* to the secret.
///
/// Split out from `Host` because the same login is usually reused across a whole
/// fleet — twelve boxes, one `deploy` account, one key. Editing the key in one
/// place then fixes twelve hosts, and the Keychain holds one item instead of
/// twelve copies of the same one.
///
/// The two secrets are independent, and a login may well have both: a key for
/// day-to-day use and a password for the same account at the console, or a key
/// that gets you in and a password `sudo` then asks for. Modelling them as a
/// choice forced a lie in one direction or the other, so each is its own
/// optional and either, both, or neither may be set.
///
/// Nothing secret is stored here regardless. `credentialRef` is a lookup string —
/// "the password is filed under this name" — and `keyID` points at an `SSHKeyRef`,
/// which is itself only a reference. Neither the key nor the password has a path
/// into this struct, so neither has a path into SwiftData or CloudKit.
public nonisolated struct Identity: Identifiable, Codable, Hashable, Sendable {
public var id: UUID = UUID()
public var name: String
public var username: String
/// Where to look the password up. Empty means this identity has no password.
public var credentialRef: String = ""
/// The secret itself never enters SwiftData. This value only selects the
/// Data Protection Keychain class used to resolve `credentialRef`.
public var passwordStorage: PasswordStorage = .iCloud
/// One of the entries on the Keys list. Storing the id rather than the key
/// means renaming or rotating a key never touches the identities that use
/// it. `nil` means this identity has no key.
public var keyID: SSHKeyRef.ID?
public var notes: String = ""
public var colorTag: TagColor = .none
/// Non-nil only for a Host-private Custom identity. These identities are
/// resolved for connections and sync like ordinary identities, but are not
/// offered as reusable Vault identities.
public var ownerHostID: UUID?
public var customPurpose: HostCustomIdentityPurpose?
/// User-defined position on the Keychain page. It is independent from the
/// display name so renaming an identity never silently moves its card.
public var sortIndex: Double = 0
public var usesPassword: Bool { !credentialRef.isEmpty }
public var usesKey: Bool { keyID != nil }
/// Proxy authentication can only consume a username and a saved password;
/// SSH keys on the same Identity are deliberately irrelevant here.
public var isProxyEligible: Bool {
usesPassword
&& !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// An identity with neither is legal — it is a login name waiting for its
/// secret — and the Keychain page says so on the card rather than hiding it.
public var hasSecret: Bool { usesPassword || usesKey }
public var isHostCustom: Bool { ownerHostID != nil && customPurpose != nil }
public func matches(_ query: String) -> Bool {
guard !query.isEmpty else { return true }
let q = query.lowercased()
return name.lowercased().contains(q)
|| username.lowercased().contains(q)
|| notes.lowercased().contains(q)
}
public init(
id: UUID = UUID(), name: String, username: String, credentialRef: String = "",
passwordStorage: PasswordStorage = .iCloud, keyID: SSHKeyRef.ID? = nil, notes: String = "",
colorTag: TagColor = .none, sortIndex: Double = 0,
ownerHostID: UUID? = nil, customPurpose: HostCustomIdentityPurpose? = nil
) {
self.id = id
self.name = name
self.username = username
self.credentialRef = credentialRef
self.passwordStorage = passwordStorage
self.keyID = keyID
self.notes = notes
self.colorTag = colorTag
self.sortIndex = sortIndex
self.ownerHostID = ownerHostID
self.customPurpose = customPurpose
}
}
nonisolated extension Identity {
private enum CodingKeys: String, CodingKey {
case id, name, username, credentialRef, passwordStorage, keyID, notes
case colorTag, sortIndex
case ownerHostID, customPurpose
}
/// Older Vault payloads predate identity colors. Missing or unrecognized
/// values intentionally become `.none` instead of quarantining an otherwise
/// valid credential record.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(UUID.self, forKey: .id),
name: try container.decode(String.self, forKey: .name),
username: try container.decode(String.self, forKey: .username),
credentialRef: try container.decode(String.self, forKey: .credentialRef),
passwordStorage: try container.decode(PasswordStorage.self, forKey: .passwordStorage),
keyID: try container.decodeIfPresent(SSHKeyRef.ID.self, forKey: .keyID),
notes: try container.decode(String.self, forKey: .notes),
colorTag: (try? container.decode(TagColor.self, forKey: .colorTag)) ?? .none,
sortIndex: try container.decode(Double.self, forKey: .sortIndex),
ownerHostID: try container.decodeIfPresent(UUID.self, forKey: .ownerHostID),
customPurpose: try container.decodeIfPresent(HostCustomIdentityPurpose.self, forKey: .customPurpose)
)
}
}