feat: copper accent theme, always-expanded group filter, app icon; rename MyPass to KeeVault
- Custom copper AccentColor and warm VaultBackground color assets (light/dark), applied to both the main app and the AutoFill extension (which needed its own copy of the asset catalog plus ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME and an explicit .tint() modifier, since extensions don't pick up the app-wide accent color the same way a full SwiftUI App/Scene does). - GroupFilterView no longer uses DisclosureGroup for subgroups -- the whole tree renders always-expanded (indented per depth) so active filters are visible at a glance instead of requiring tap-to-expand. - Added a placeholder app icon (padlock glyph, light/dark/tinted variants plus macOS sizes) -- previously the icon slot existed but had no actual images, which blocks App Store/TestFlight archive validation. - Added ITSAppUsesNonExemptEncryption = NO (standard encryption only, via KeePassKit) to skip the export-compliance prompt on each upload. - Renamed the app from MyPass to KeeVault throughout: Xcode project/targets/ schemes, the MyPassCore package (now KeeVaultCore) and every import site, folder and file names, bundle identifiers (org.antiloop222.keevault) and their App Group/Keychain-group entitlements, and remaining UI/string references -- MyPass was already taken as an App Store app name.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
public enum CredentialMatcher {
|
||||
public static func matches(entry: Entry, serviceIdentifier: String) -> Bool {
|
||||
guard !entry.url.isEmpty else { return false }
|
||||
guard let entryHost = host(from: entry.url),
|
||||
let serviceHost = host(from: serviceIdentifier)
|
||||
else {
|
||||
return entry.url.lowercased().contains(serviceIdentifier.lowercased())
|
||||
}
|
||||
return hostsMatch(entryHost, serviceHost)
|
||||
}
|
||||
|
||||
public static func filter(
|
||||
entries: [Entry],
|
||||
for serviceIdentifiers: [String]
|
||||
) -> (suggested: [Entry], all: [Entry]) {
|
||||
guard !serviceIdentifiers.isEmpty else { return ([], entries) }
|
||||
let suggested = entries.filter { entry in
|
||||
serviceIdentifiers.contains { matches(entry: entry, serviceIdentifier: $0) }
|
||||
}
|
||||
let suggestedIDs = Set(suggested.map(\.id))
|
||||
let rest = entries.filter { !suggestedIDs.contains($0.id) }
|
||||
return (suggested: suggested, all: rest)
|
||||
}
|
||||
|
||||
private static func host(from urlString: String) -> String? {
|
||||
// KDBX entries and AutoFill service identifiers commonly omit the scheme
|
||||
// (e.g. "allocine.fr"), but URL(string:) only populates `.host` when an
|
||||
// authority component ("//") is present -- without it, the whole string
|
||||
// is parsed as a relative path and `.host` is nil.
|
||||
if let host = URL(string: urlString)?.host, !host.isEmpty {
|
||||
return host
|
||||
}
|
||||
return URL(string: "https://" + urlString)?.host
|
||||
}
|
||||
|
||||
private static func hostsMatch(_ a: String, _ b: String) -> Bool {
|
||||
let na = stripped(a)
|
||||
let nb = stripped(b)
|
||||
return na == nb
|
||||
|| na.hasSuffix("." + nb)
|
||||
|| nb.hasSuffix("." + na)
|
||||
}
|
||||
|
||||
private static func stripped(_ host: String) -> String {
|
||||
host.hasPrefix("www.") ? String(host.dropFirst(4)).lowercased() : host.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Maps KeePass's standard icon palette (indices 0-68) to an approximate SF Symbol.
|
||||
/// This is a best-effort visual match, not the actual KeePass icon artwork -- KeeVault's
|
||||
/// data model only stores the numeric iconIndex, not custom icon images.
|
||||
public enum KeePassIconMapper {
|
||||
private static let symbolsByIndex: [Int: String] = [
|
||||
0: "key.fill",
|
||||
1: "globe",
|
||||
2: "exclamationmark.triangle.fill",
|
||||
3: "server.rack",
|
||||
4: "doc.on.clipboard.fill",
|
||||
5: "pencil",
|
||||
6: "camera.fill",
|
||||
7: "wrench.fill",
|
||||
8: "person.text.rectangle.fill",
|
||||
9: "externaldrive.fill",
|
||||
10: "creditcard.fill",
|
||||
11: "chevron.left.forwardslash.chevron.right",
|
||||
12: "chart.bar.fill",
|
||||
13: "gamecontroller.fill",
|
||||
14: "envelope.fill",
|
||||
15: "folder.fill",
|
||||
16: "lock.fill",
|
||||
17: "checkmark.circle.fill",
|
||||
18: "star.fill",
|
||||
19: "house.fill",
|
||||
20: "network",
|
||||
]
|
||||
|
||||
public static func symbolName(forIconIndex index: Int) -> String {
|
||||
symbolsByIndex[index] ?? "key.fill"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import KeePassKit
|
||||
|
||||
public struct KDBXDocument {
|
||||
public let url: URL
|
||||
|
||||
public init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
/// Reads a KDBX database from disk and maps it to a KDBXDatabase value type.
|
||||
public func read(password: String) throws -> KDBXDatabase {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw KDBXError.fileNotFound
|
||||
}
|
||||
let key = KPKCompositeKey(keys: [KPKPasswordKey(password: password)])
|
||||
do {
|
||||
let tree = try KPKTree(contentsOf: url, key: key)
|
||||
return KDBXMapper.database(from: tree)
|
||||
} catch let err as NSError {
|
||||
let wrongKeyCodes: Set<Int> = [
|
||||
Int(KPKErrorCode.passwordAndOrKeyfileWrong.rawValue),
|
||||
// KDBX4's header HMAC check (which is password-derived) uses this same
|
||||
// error code for both a wrong password/keyfile and true file corruption --
|
||||
// KeePassKit doesn't distinguish the two. Wrong password is by far the
|
||||
// more common cause, so we surface it as such.
|
||||
Int(KPKErrorCode.kdbxHeaderHashVerificationFailed.rawValue),
|
||||
]
|
||||
if err.domain == KPKErrorDomain && wrongKeyCodes.contains(err.code) {
|
||||
throw KDBXError.invalidPassword
|
||||
}
|
||||
throw KDBXError.parseError(err.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a KDBXDatabase value type to disk as a KDBX file.
|
||||
public func write(_ database: KDBXDatabase, password: String) throws {
|
||||
let tree = KDBXMapper.tree(from: database)
|
||||
let key = KPKCompositeKey(keys: [KPKPasswordKey(password: password)])
|
||||
do {
|
||||
let data = try tree.encrypt(with: key, format: .kdbx)
|
||||
try data.write(to: url, options: Data.WritingOptions.atomic)
|
||||
} catch {
|
||||
throw KDBXError.writeError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
public enum KDBXError: Error, Equatable {
|
||||
case invalidPassword
|
||||
case fileNotFound
|
||||
case parseError(String)
|
||||
case writeError(String)
|
||||
}
|
||||
|
||||
extension KDBXError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPassword:
|
||||
return "Incorrect password, or this vault file is corrupted."
|
||||
case .fileNotFound:
|
||||
return "The vault file could not be found."
|
||||
case .parseError(let message):
|
||||
return "Couldn't open the vault: \(message)"
|
||||
case .writeError(let message):
|
||||
return "Couldn't save the vault: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
import KeePassKit
|
||||
|
||||
enum KDBXMapper {
|
||||
|
||||
// MARK: - KeePassKit → KeeVaultCore
|
||||
|
||||
static func database(from tree: KPKTree) -> KDBXDatabase {
|
||||
let meta = DatabaseMetadata(
|
||||
name: tree.metaData?.databaseName ?? "",
|
||||
description: tree.metaData?.databaseDescription ?? ""
|
||||
)
|
||||
let root = group(from: tree.root ?? KPKGroup())
|
||||
return KDBXDatabase(metadata: meta, root: root)
|
||||
}
|
||||
|
||||
static func group(from g: KPKGroup) -> Group {
|
||||
// g.groups: direct child groups (not recursive)
|
||||
// g.entries: direct child entries (not recursive)
|
||||
Group(
|
||||
id: uuid(from: g.uuid),
|
||||
name: g.title ?? "",
|
||||
iconIndex: Int(g.iconId),
|
||||
subgroups: g.groups.map { group(from: $0) },
|
||||
entries: g.entries.map { entry(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
static func entry(from e: KPKEntry) -> Entry {
|
||||
// e.customAttributes: only non-default attributes
|
||||
let customFields: [CustomField] = e.customAttributes
|
||||
.filter { !reservedKeys.contains($0.key ?? "") }
|
||||
.map { attr in
|
||||
CustomField(
|
||||
id: UUID(),
|
||||
key: attr.key,
|
||||
value: ProtectedString(attr.value, isProtected: attr.protect)
|
||||
)
|
||||
}
|
||||
|
||||
// Parse TOTP from the otp URI attribute (KeePassOTP format)
|
||||
let totpURI = e.customAttributes.first { $0.key == "otp" }?.value
|
||||
let totpConfig = totpURI.flatMap { TOTPConfig.parse(from: $0) }
|
||||
|
||||
let attachments: [Attachment] = e.binaries.map { bin in
|
||||
Attachment(id: UUID(), name: bin.name, data: bin.data)
|
||||
}
|
||||
|
||||
let historyEntries: [Entry] = e.history.map { entry(from: $0) }
|
||||
|
||||
return Entry(
|
||||
id: uuid(from: e.uuid),
|
||||
title: e.title ?? "",
|
||||
username: e.username ?? "",
|
||||
password: ProtectedString(e.password ?? "", isProtected: true),
|
||||
url: e.url ?? "",
|
||||
notes: e.notes ?? "",
|
||||
customFields: customFields,
|
||||
attachments: attachments,
|
||||
totp: totpConfig,
|
||||
tags: [],
|
||||
iconIndex: Int(e.iconId),
|
||||
expiryDate: e.timeInfo?.expires == true ? e.timeInfo?.expirationDate : nil,
|
||||
creationDate: e.timeInfo?.creationDate ?? Date(),
|
||||
modificationDate: e.timeInfo?.modificationDate ?? Date(),
|
||||
history: historyEntries
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - KeeVaultCore → KeePassKit
|
||||
|
||||
static func tree(from db: KDBXDatabase) -> KPKTree {
|
||||
let tree = KPKTree()
|
||||
let root = kpkGroup(from: db.root)
|
||||
tree.root = root
|
||||
tree.metaData?.databaseName = db.metadata.name
|
||||
tree.metaData?.databaseDescription = db.metadata.description
|
||||
return tree
|
||||
}
|
||||
|
||||
static func kpkGroup(from g: Group) -> KPKGroup {
|
||||
let kpk = KPKGroup(uuid: g.id)!
|
||||
kpk.title = g.name
|
||||
kpk.iconId = g.iconIndex
|
||||
for sub in g.subgroups {
|
||||
kpkGroup(from: sub).add(to: kpk)
|
||||
}
|
||||
for e in g.entries {
|
||||
kpkEntry(from: e).add(to: kpk)
|
||||
}
|
||||
return kpk
|
||||
}
|
||||
|
||||
static func kpkEntry(from e: Entry) -> KPKEntry {
|
||||
let kpk = KPKEntry(uuid: e.id)!
|
||||
kpk.title = e.title
|
||||
kpk.username = e.username
|
||||
kpk.password = e.password.reveal()
|
||||
kpk.url = e.url
|
||||
kpk.notes = e.notes
|
||||
kpk.iconId = e.iconIndex
|
||||
for field in e.customFields {
|
||||
let attr = KPKAttribute(
|
||||
key: field.key,
|
||||
value: field.value.reveal(),
|
||||
isProtected: field.value.isProtected
|
||||
)
|
||||
kpk.addCustomAttribute(attr)
|
||||
}
|
||||
if let totp = e.totp {
|
||||
let uri = totpURI(from: totp)
|
||||
let attr = KPKAttribute(key: "otp", value: uri, isProtected: false)
|
||||
kpk.addCustomAttribute(attr)
|
||||
}
|
||||
for att in e.attachments {
|
||||
let bin = KPKBinary(name: att.name, data: att.data)
|
||||
kpk.addBinary(bin)
|
||||
}
|
||||
return kpk
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func uuid(from uuid: UUID?) -> UUID {
|
||||
uuid ?? UUID()
|
||||
}
|
||||
|
||||
private static func totpURI(from config: TOTPConfig) -> String {
|
||||
var components = URLComponents()
|
||||
components.scheme = "otpauth"
|
||||
components.host = "totp"
|
||||
components.path = "/KeeVault"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "secret", value: config.secret),
|
||||
URLQueryItem(name: "period", value: String(config.period)),
|
||||
URLQueryItem(name: "digits", value: String(config.digits)),
|
||||
URLQueryItem(name: "algorithm", value: config.algorithm.rawValue),
|
||||
]
|
||||
return components.url?.absoluteString ?? ""
|
||||
}
|
||||
|
||||
private static let reservedKeys: Set<String> = ["Title", "UserName", "Password", "URL", "Notes"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum KeychainError: Error, Equatable {
|
||||
case saveFailed(OSStatus)
|
||||
case loadFailed(OSStatus)
|
||||
case notFound
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public struct KeychainStore {
|
||||
private let accessGroup: String
|
||||
private let service: String
|
||||
|
||||
public init(accessGroup: String, service: String = "org.antiloop222.keevault") {
|
||||
self.accessGroup = accessGroup
|
||||
self.service = service
|
||||
}
|
||||
|
||||
public func save(password: String, for account: String) throws {
|
||||
let data = Data(password.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
kSecValueData: data,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw KeychainError.saveFailed(status) }
|
||||
}
|
||||
|
||||
public func load(for account: String) throws -> String {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess else {
|
||||
throw status == errSecItemNotFound ? KeychainError.notFound : KeychainError.loadFailed(status)
|
||||
}
|
||||
guard let data = result as? Data, let password = String(data: data, encoding: .utf8) else {
|
||||
throw KeychainError.loadFailed(errSecInvalidData)
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
public func delete(for account: String) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
public func exists(for account: String) -> Bool {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
public struct Attachment: Identifiable, Equatable {
|
||||
public var id: UUID
|
||||
public var name: String
|
||||
public var data: Data
|
||||
|
||||
public init(id: UUID = UUID(), name: String, data: Data) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
public struct CustomField: Identifiable, Equatable {
|
||||
public var id: UUID
|
||||
public var key: String
|
||||
public var value: ProtectedString
|
||||
|
||||
public init(id: UUID = UUID(), key: String, value: ProtectedString) {
|
||||
self.id = id
|
||||
self.key = key
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
public struct DatabaseMetadata: Equatable {
|
||||
public var name: String
|
||||
public var description: String
|
||||
public init(name: String, description: String = "") {
|
||||
self.name = name
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
public struct Entry: Identifiable, Equatable, Hashable {
|
||||
public var id: UUID
|
||||
public var title: String
|
||||
public var username: String
|
||||
public var password: ProtectedString
|
||||
public var url: String
|
||||
public var notes: String
|
||||
public var customFields: [CustomField]
|
||||
public var attachments: [Attachment]
|
||||
public var totp: TOTPConfig?
|
||||
public var tags: [String]
|
||||
public var iconIndex: Int
|
||||
public var expiryDate: Date?
|
||||
public var creationDate: Date
|
||||
public var modificationDate: Date
|
||||
public var history: [Entry]
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
title: String = "",
|
||||
username: String = "",
|
||||
password: ProtectedString = ProtectedString("", isProtected: true),
|
||||
url: String = "",
|
||||
notes: String = "",
|
||||
customFields: [CustomField] = [],
|
||||
attachments: [Attachment] = [],
|
||||
totp: TOTPConfig? = nil,
|
||||
tags: [String] = [],
|
||||
iconIndex: Int = 0,
|
||||
expiryDate: Date? = nil,
|
||||
creationDate: Date = Date(),
|
||||
modificationDate: Date = Date(),
|
||||
history: [Entry] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.url = url
|
||||
self.notes = notes
|
||||
self.customFields = customFields
|
||||
self.attachments = attachments
|
||||
self.totp = totp
|
||||
self.tags = tags
|
||||
self.iconIndex = iconIndex
|
||||
self.expiryDate = expiryDate
|
||||
self.creationDate = creationDate
|
||||
self.modificationDate = modificationDate
|
||||
self.history = history
|
||||
}
|
||||
|
||||
// Hashable by ID so SwiftUI List(selection:) works without requiring all fields to be Hashable.
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||
public static func == (lhs: Entry, rhs: Entry) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
public struct Group: Identifiable, Equatable, Hashable {
|
||||
public var id: UUID
|
||||
public var name: String
|
||||
public var iconIndex: Int
|
||||
public var subgroups: [Group]
|
||||
public var entries: [Entry]
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
iconIndex: Int = 0,
|
||||
subgroups: [Group] = [],
|
||||
entries: [Entry] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.iconIndex = iconIndex
|
||||
self.subgroups = subgroups
|
||||
self.entries = entries
|
||||
}
|
||||
|
||||
// Hashable by ID so SwiftUI List(selection:) works without hashing the full tree.
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||
public static func == (lhs: Group, rhs: Group) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct KDBXDatabase: Equatable {
|
||||
public var metadata: DatabaseMetadata
|
||||
public var root: Group
|
||||
|
||||
public init(metadata: DatabaseMetadata, root: Group) {
|
||||
self.metadata = metadata
|
||||
self.root = root
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
public struct ProtectedString: Equatable {
|
||||
private let obfuscated: [UInt8]
|
||||
private let key: [UInt8]
|
||||
public let isProtected: Bool
|
||||
|
||||
public init(_ value: String, isProtected: Bool = true) {
|
||||
self.isProtected = isProtected
|
||||
let bytes = Array(value.utf8)
|
||||
let k = (0 ..< bytes.count).map { _ in UInt8.random(in: 0 ... 255) }
|
||||
self.key = k
|
||||
self.obfuscated = zip(bytes, k).map { $0 ^ $1 }
|
||||
}
|
||||
|
||||
public func reveal() -> String {
|
||||
let bytes = zip(obfuscated, key).map { $0 ^ $1 }
|
||||
return String(bytes: bytes, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
public static func == (lhs: ProtectedString, rhs: ProtectedString) -> Bool {
|
||||
lhs.reveal() == rhs.reveal() && lhs.isProtected == rhs.isProtected
|
||||
}
|
||||
}
|
||||
|
||||
extension ProtectedString: CustomStringConvertible {
|
||||
public var description: String { isProtected ? "***" : reveal() }
|
||||
}
|
||||
|
||||
extension ProtectedString: CustomDebugStringConvertible {
|
||||
public var debugDescription: String { "ProtectedString(isProtected: \(isProtected))" }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
public enum TOTPAlgorithm: String, Equatable, CaseIterable {
|
||||
case sha1 = "SHA1"
|
||||
case sha256 = "SHA256"
|
||||
case sha512 = "SHA512"
|
||||
}
|
||||
|
||||
public struct TOTPConfig: Equatable {
|
||||
public var secret: String
|
||||
public var period: Int
|
||||
public var digits: Int
|
||||
public var algorithm: TOTPAlgorithm
|
||||
|
||||
public init(secret: String, period: Int = 30, digits: Int = 6, algorithm: TOTPAlgorithm = .sha1) {
|
||||
self.secret = secret
|
||||
self.period = period
|
||||
self.digits = digits
|
||||
self.algorithm = algorithm
|
||||
}
|
||||
|
||||
/// Parses an otpauth://totp/ URI (standard KeePass TOTP storage format).
|
||||
public static func parse(from uri: String) -> TOTPConfig? {
|
||||
guard let url = URL(string: uri),
|
||||
url.scheme == "otpauth",
|
||||
url.host == "totp",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return nil }
|
||||
let params = Dictionary(
|
||||
uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
}
|
||||
)
|
||||
guard let secret = params["secret"] else { return nil }
|
||||
let period = Int(params["period"] ?? "30") ?? 30
|
||||
let digits = Int(params["digits"] ?? "6") ?? 6
|
||||
let algo: TOTPAlgorithm
|
||||
switch params["algorithm"]?.uppercased() {
|
||||
case "SHA256": algo = .sha256
|
||||
case "SHA512": algo = .sha512
|
||||
default: algo = .sha1
|
||||
}
|
||||
return TOTPConfig(secret: secret, period: period, digits: digits, algorithm: algo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
public enum GroupSelection {
|
||||
/// `groupId` and all of its descendant subgroup IDs, found by searching `root`'s tree.
|
||||
/// Returns an empty set if `groupId` isn't found anywhere in `root`.
|
||||
public static func subtreeIds(of groupId: UUID, in root: Group) -> Set<UUID> {
|
||||
guard let found = findGroup(groupId, in: root) else { return [] }
|
||||
return allIds(in: found)
|
||||
}
|
||||
|
||||
/// Toggles `groupId` and its full subtree in `current`: if the group is already selected,
|
||||
/// it and its subtree are removed; otherwise they're added.
|
||||
public static func toggling(_ groupId: UUID, in root: Group, current: Set<UUID>) -> Set<UUID> {
|
||||
let subtree = subtreeIds(of: groupId, in: root)
|
||||
guard !subtree.isEmpty else { return current }
|
||||
if current.contains(groupId) {
|
||||
return current.subtracting(subtree)
|
||||
} else {
|
||||
return current.union(subtree)
|
||||
}
|
||||
}
|
||||
|
||||
private static func findGroup(_ id: UUID, in group: Group) -> Group? {
|
||||
if group.id == id { return group }
|
||||
for sub in group.subgroups {
|
||||
if let found = findGroup(id, in: sub) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func allIds(in group: Group) -> Set<UUID> {
|
||||
var ids: Set<UUID> = [group.id]
|
||||
for sub in group.subgroups {
|
||||
ids.formUnion(allIds(in: sub))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// An entry paired with the full ancestor chain of groups leading to it, excluding the
|
||||
/// vault's root group. Empty `breadcrumb` means the entry lives directly in the root group.
|
||||
public struct FlatEntry: Identifiable, Equatable {
|
||||
public let entry: Entry
|
||||
public let parentGroupId: UUID
|
||||
public let breadcrumb: [Group]
|
||||
public var id: UUID { entry.id }
|
||||
}
|
||||
|
||||
/// A group paired with the ancestor chain leading to its *parent*, excluding the vault's
|
||||
/// root group. The root group itself appears with an empty breadcrumb.
|
||||
public struct FlatGroup: Identifiable, Equatable {
|
||||
public let group: Group
|
||||
public let breadcrumb: [Group]
|
||||
public var id: UUID { group.id }
|
||||
}
|
||||
|
||||
/// Walks the vault tree once, pairing every entry with its full ancestor path.
|
||||
public func flattenEntries(in root: Group) -> [FlatEntry] {
|
||||
var result: [FlatEntry] = []
|
||||
func walk(_ group: Group, ancestorBreadcrumb: [Group]) {
|
||||
for entry in group.entries {
|
||||
result.append(FlatEntry(entry: entry, parentGroupId: group.id, breadcrumb: ancestorBreadcrumb))
|
||||
}
|
||||
for sub in group.subgroups {
|
||||
walk(sub, ancestorBreadcrumb: ancestorBreadcrumb + [sub])
|
||||
}
|
||||
}
|
||||
walk(root, ancestorBreadcrumb: [])
|
||||
return result
|
||||
}
|
||||
|
||||
/// Walks the vault tree once, producing every group (including the root) with its ancestor path.
|
||||
public func flattenGroups(in root: Group) -> [FlatGroup] {
|
||||
var result: [FlatGroup] = [FlatGroup(group: root, breadcrumb: [])]
|
||||
func walk(_ group: Group, ancestorBreadcrumb: [Group]) {
|
||||
for sub in group.subgroups {
|
||||
result.append(FlatGroup(group: sub, breadcrumb: ancestorBreadcrumb))
|
||||
walk(sub, ancestorBreadcrumb: ancestorBreadcrumb + [sub])
|
||||
}
|
||||
}
|
||||
walk(root, ancestorBreadcrumb: [])
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
public final class VaultSession: ObservableObject {
|
||||
@Published public private(set) var database: KDBXDatabase?
|
||||
@Published public private(set) var isLocked: Bool = true
|
||||
|
||||
private var document: KDBXDocument?
|
||||
private var masterPassword: String = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public func unlock(url: URL, password: String) throws {
|
||||
let doc = KDBXDocument(url: url)
|
||||
let db = try doc.read(password: password)
|
||||
document = doc
|
||||
database = db
|
||||
masterPassword = password
|
||||
isLocked = false
|
||||
}
|
||||
|
||||
public func lock() {
|
||||
masterPassword = ""
|
||||
database = nil
|
||||
document = nil
|
||||
isLocked = true
|
||||
}
|
||||
|
||||
public func save() throws {
|
||||
guard let db = database, let doc = document, !masterPassword.isEmpty else { return }
|
||||
try doc.write(db, password: masterPassword)
|
||||
}
|
||||
|
||||
// MARK: Entry CRUD
|
||||
|
||||
public func addEntry(_ entry: Entry, toGroupId groupId: UUID) throws {
|
||||
guard var db = database else { return }
|
||||
mutateGroup(id: groupId, in: &db.root) { $0.entries.append(entry) }
|
||||
database = db
|
||||
try save()
|
||||
}
|
||||
|
||||
public func updateEntry(_ entry: Entry) throws {
|
||||
guard var db = database else { return }
|
||||
updateEntryInTree(entry, in: &db.root)
|
||||
database = db
|
||||
try save()
|
||||
}
|
||||
|
||||
public func deleteEntry(id entryId: UUID, fromGroupId groupId: UUID) throws {
|
||||
guard var db = database else { return }
|
||||
mutateGroup(id: groupId, in: &db.root) { $0.entries.removeAll { $0.id == entryId } }
|
||||
database = db
|
||||
try save()
|
||||
}
|
||||
|
||||
// MARK: Search
|
||||
|
||||
public func allEntries() -> [Entry] {
|
||||
guard let db = database else { return [] }
|
||||
return flatEntries(in: db.root)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func mutateGroup(id: UUID, in group: inout Group, _ mutation: (inout Group) -> Void) {
|
||||
if group.id == id {
|
||||
mutation(&group)
|
||||
return
|
||||
}
|
||||
for i in group.subgroups.indices {
|
||||
mutateGroup(id: id, in: &group.subgroups[i], mutation)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEntryInTree(_ entry: Entry, in group: inout Group) {
|
||||
if let idx = group.entries.firstIndex(where: { $0.id == entry.id }) {
|
||||
var updated = entry
|
||||
updated.modificationDate = Date()
|
||||
group.entries[idx] = updated
|
||||
return
|
||||
}
|
||||
for i in group.subgroups.indices {
|
||||
updateEntryInTree(entry, in: &group.subgroups[i])
|
||||
}
|
||||
}
|
||||
|
||||
private func flatEntries(in group: Group) -> [Entry] {
|
||||
group.entries + group.subgroups.flatMap { flatEntries(in: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public enum TOTPGenerator {
|
||||
public static func generate(config: TOTPConfig, at date: Date = Date()) -> String {
|
||||
let counter = UInt64(date.timeIntervalSince1970) / UInt64(config.period)
|
||||
let keyBytes = base32Decode(config.secret)
|
||||
let counterBytes = withUnsafeBytes(of: counter.bigEndian, Array.init)
|
||||
let symKey = SymmetricKey(data: keyBytes)
|
||||
let hmacBytes: [UInt8]
|
||||
switch config.algorithm {
|
||||
case .sha1:
|
||||
hmacBytes = Array(HMAC<Insecure.SHA1>.authenticationCode(for: counterBytes, using: symKey))
|
||||
case .sha256:
|
||||
hmacBytes = Array(HMAC<SHA256>.authenticationCode(for: counterBytes, using: symKey))
|
||||
case .sha512:
|
||||
hmacBytes = Array(HMAC<SHA512>.authenticationCode(for: counterBytes, using: symKey))
|
||||
}
|
||||
let offset = Int(hmacBytes[hmacBytes.count - 1] & 0x0f)
|
||||
let truncated = ((Int(hmacBytes[offset]) & 0x7f) << 24)
|
||||
| (Int(hmacBytes[offset + 1]) << 16)
|
||||
| (Int(hmacBytes[offset + 2]) << 8)
|
||||
| Int(hmacBytes[offset + 3])
|
||||
let otp = truncated % Int(pow(10.0, Double(config.digits)))
|
||||
return String(format: "%0\(config.digits)d", otp)
|
||||
}
|
||||
|
||||
public static func secondsRemaining(config: TOTPConfig, at date: Date = Date()) -> Int {
|
||||
let elapsed = Int(date.timeIntervalSince1970) % config.period
|
||||
return config.period - elapsed
|
||||
}
|
||||
|
||||
private static func base32Decode(_ input: String) -> [UInt8] {
|
||||
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
let s = input.uppercased().filter { alphabet.contains($0) }
|
||||
var result: [UInt8] = []
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
for char in s {
|
||||
guard let idx = alphabet.firstIndex(of: char) else { continue }
|
||||
buffer = (buffer << 5) | alphabet.distance(from: alphabet.startIndex, to: idx)
|
||||
bitsLeft += 5
|
||||
if bitsLeft >= 8 {
|
||||
bitsLeft -= 8
|
||||
result.append(UInt8((buffer >> bitsLeft) & 0xff))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user