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:
2026-09-20 15:05:19 +02:00
parent a8d612f745
commit 8bba64076f
74 changed files with 363 additions and 176 deletions
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>KeeVaultCore.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
</dict>
</plist>
+26
View File
@@ -0,0 +1,26 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "KeeVaultCore",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "KeeVaultCore", targets: ["KeeVaultCore"]),
],
dependencies: [
.package(path: "../Vendor/KeePassKit"),
],
targets: [
.target(
name: "KeeVaultCore",
dependencies: [
.product(name: "KeePassKit", package: "KeePassKit"),
]
),
.testTarget(
name: "KeeVaultCoreTests",
dependencies: ["KeeVaultCore"],
resources: [.copy("Fixtures")]
),
]
)
@@ -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
}
}
@@ -0,0 +1,60 @@
import XCTest
@testable import KeeVaultCore
final class CredentialMatcherTests: XCTestCase {
func makeEntry(url: String) -> Entry {
Entry(title: "Test", url: url)
}
func test_exactURLMatch() {
let e = makeEntry(url: "https://github.com/login")
XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
}
func test_wwwStripped() {
let e = makeEntry(url: "https://www.github.com")
XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
}
func test_subdomainMatch() {
let e = makeEntry(url: "https://api.github.com")
XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
}
func test_differentDomain_noMatch() {
let e = makeEntry(url: "https://gitlab.com")
XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
}
func test_emptyURL_noMatch() {
let e = makeEntry(url: "")
XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
}
func test_bareDomainSubdomainMatch() {
let e = makeEntry(url: "allocine.fr")
XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "mon.allocine.fr"))
}
func test_bareDomainExactMatch() {
let e = makeEntry(url: "allocine.fr")
XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "allocine.fr"))
}
func test_bareDomainDifferentDomain_noMatch() {
let e = makeEntry(url: "allocine.fr")
XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "notallocine.fr"))
}
func test_filter_suggestedAndRest() {
let entries = [
makeEntry(url: "https://github.com"),
makeEntry(url: "https://gitlab.com"),
makeEntry(url: ""),
]
let result = CredentialMatcher.filter(entries: entries, for: ["https://github.com"])
XCTAssertEqual(result.suggested.count, 1)
XCTAssertEqual(result.suggested[0].url, "https://github.com")
XCTAssertEqual(result.all.count, 2)
}
}
@@ -0,0 +1,57 @@
import XCTest
@testable import KeeVaultCore
final class GroupSelectionTests: XCTestCase {
private func makeSampleTree() -> (root: Group, work: Group, email: Group, personal: Group) {
let email = Group(name: "Email")
let work = Group(name: "Work", subgroups: [email])
let personal = Group(name: "Personal")
let root = Group(name: "Root", subgroups: [work, personal])
return (root, work, email, personal)
}
func test_subtreeIds_includesGroupAndAllDescendants() {
let (root, work, email, _) = makeSampleTree()
let ids = GroupSelection.subtreeIds(of: work.id, in: root)
XCTAssertEqual(ids, [work.id, email.id])
}
func test_subtreeIds_leafGroup_returnsOnlyItself() {
let (root, _, email, _) = makeSampleTree()
let ids = GroupSelection.subtreeIds(of: email.id, in: root)
XCTAssertEqual(ids, [email.id])
}
func test_toggling_selectsGroupAndSubtree() {
let (root, work, email, _) = makeSampleTree()
let result = GroupSelection.toggling(work.id, in: root, current: [])
XCTAssertEqual(result, [work.id, email.id])
}
func test_toggling_deselectsGroupAndSubtree() {
let (root, work, email, _) = makeSampleTree()
let result = GroupSelection.toggling(work.id, in: root, current: [work.id, email.id])
XCTAssertEqual(result, [])
}
func test_toggling_doesNotAffectUnrelatedSelection() {
let (root, work, email, personal) = makeSampleTree()
let result = GroupSelection.toggling(work.id, in: root, current: [personal.id])
XCTAssertEqual(result, [personal.id, work.id, email.id])
}
func test_subtreeIds_groupNotFound_returnsEmptySet() {
let (root, _, _, _) = makeSampleTree()
let nonExistentId = UUID()
let ids = GroupSelection.subtreeIds(of: nonExistentId, in: root)
XCTAssertEqual(ids, [])
}
func test_toggling_groupNotFound_returnsCurrentUnchanged() {
let (root, _, _, personal) = makeSampleTree()
let nonExistentId = UUID()
let current: Set<UUID> = [personal.id]
let result = GroupSelection.toggling(nonExistentId, in: root, current: current)
XCTAssertEqual(result, current)
}
}
@@ -0,0 +1,52 @@
import XCTest
@testable import KeeVaultCore
final class GroupTreeTests: XCTestCase {
private func makeSampleTree() -> (root: Group, work: Group, email: Group) {
let supportEntry = Entry(title: "Support Login", username: "alice")
let email = Group(name: "Email", entries: [supportEntry])
let bankEntry = Entry(title: "Bank", username: "bob")
let work = Group(name: "Work", subgroups: [email], entries: [bankEntry])
let rootEntry = Entry(title: "Root Entry", username: "root-user")
let root = Group(name: "Root", subgroups: [work], entries: [rootEntry])
return (root, work, email)
}
func test_flattenEntries_rootEntry_hasEmptyBreadcrumb() {
let (root, _, _) = makeSampleTree()
let flat = flattenEntries(in: root)
let rootEntry = flat.first { $0.entry.title == "Root Entry" }
XCTAssertEqual(rootEntry?.breadcrumb, [])
XCTAssertEqual(rootEntry?.parentGroupId, root.id)
}
func test_flattenEntries_directChildEntry_hasSingleGroupBreadcrumb() {
let (root, work, _) = makeSampleTree()
let flat = flattenEntries(in: root)
let bankEntry = flat.first { $0.entry.title == "Bank" }
XCTAssertEqual(bankEntry?.breadcrumb.map(\.name), ["Work"])
XCTAssertEqual(bankEntry?.parentGroupId, work.id)
}
func test_flattenEntries_nestedEntry_hasFullBreadcrumb() {
let (root, _, email) = makeSampleTree()
let flat = flattenEntries(in: root)
let supportEntry = flat.first { $0.entry.title == "Support Login" }
XCTAssertEqual(supportEntry?.breadcrumb.map(\.name), ["Work", "Email"])
XCTAssertEqual(supportEntry?.parentGroupId, email.id)
}
func test_flattenGroups_includesRootFirstWithEmptyBreadcrumb() {
let (root, _, _) = makeSampleTree()
let flat = flattenGroups(in: root)
XCTAssertEqual(flat.first?.group.id, root.id)
XCTAssertEqual(flat.first?.breadcrumb, [])
}
func test_flattenGroups_nestedGroup_hasBreadcrumb() {
let (root, _, _) = makeSampleTree()
let flat = flattenGroups(in: root)
let email = flat.first { $0.group.name == "Email" }
XCTAssertEqual(email?.breadcrumb.map(\.name), ["Work"])
}
}
@@ -0,0 +1,36 @@
import XCTest
@testable import KeeVaultCore
final class KDBXDocumentTests: XCTestCase {
var fixtureURL: URL!
override func setUp() {
super.setUp()
fixtureURL = Bundle.module.url(forResource: "test", withExtension: "kdbx", subdirectory: "Fixtures")!
}
func test_read_parsesRootGroup() throws {
let doc = KDBXDocument(url: fixtureURL)
let db = try doc.read(password: "1234")
XCTAssertFalse(db.root.name.isEmpty)
}
func test_read_wrongPassword_throwsInvalidPassword() throws {
let doc = KDBXDocument(url: fixtureURL)
XCTAssertThrowsError(try doc.read(password: "wrong")) { error in
XCTAssertEqual(error as? KDBXError, .invalidPassword)
}
}
func test_roundTrip_preservesEntryTitle() throws {
let doc = KDBXDocument(url: fixtureURL)
var db = try doc.read(password: "1234")
let newEntry = Entry(title: "RoundTripTest", username: "user", password: ProtectedString("pass"))
db.root.entries.append(newEntry)
let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("roundtrip.kdbx")
let tmpDoc = KDBXDocument(url: tmpURL)
try tmpDoc.write(db, password: "1234")
let reloaded = try KDBXDocument(url: tmpURL).read(password: "1234")
XCTAssertTrue(reloaded.root.entries.contains { $0.title == "RoundTripTest" })
}
}
@@ -0,0 +1,16 @@
import XCTest
@testable import KeeVaultCore
final class KeePassIconMapperTests: XCTestCase {
func test_knownIndex_returnsMappedSymbol() {
XCTAssertEqual(KeePassIconMapper.symbolName(forIconIndex: 14), "envelope.fill")
}
func test_indexZero_isKeyFill() {
XCTAssertEqual(KeePassIconMapper.symbolName(forIconIndex: 0), "key.fill")
}
func test_unmappedIndex_fallsBackToKeyFill() {
XCTAssertEqual(KeePassIconMapper.symbolName(forIconIndex: 999), "key.fill")
}
}
@@ -0,0 +1,30 @@
import XCTest
@testable import KeeVaultCore
final class ProtectedStringTests: XCTestCase {
func test_reveal_returnsOriginalValue() {
let ps = ProtectedString("hunter2", isProtected: true)
XCTAssertEqual(ps.reveal(), "hunter2")
}
func test_description_isRedactedWhenProtected() {
let ps = ProtectedString("secret", isProtected: true)
XCTAssertEqual("\(ps)", "***")
}
func test_description_isPlainWhenNotProtected() {
let ps = ProtectedString("visible", isProtected: false)
XCTAssertEqual("\(ps)", "visible")
}
func test_equality_matchesByRevealedValue() {
let a = ProtectedString("abc", isProtected: true)
let b = ProtectedString("abc", isProtected: true)
XCTAssertEqual(a, b)
}
func test_debugDescription_neverRevealSecret() {
let ps = ProtectedString("topsecret", isProtected: true)
XCTAssertFalse(ps.debugDescription.contains("topsecret"))
}
}
@@ -0,0 +1,34 @@
import XCTest
@testable import KeeVaultCore
final class TOTPGeneratorTests: XCTestCase {
// RFC 6238 Section 8 test vectors for SHA-1
// secret = "12345678901234567890" (ASCII), base32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
let sha1Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
func test_sha1_at59s() {
let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1)
let date = Date(timeIntervalSince1970: 59)
XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "94287082")
}
func test_sha1_at1111111109s() {
let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1)
let date = Date(timeIntervalSince1970: 1111111109)
XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "07081804")
}
func test_secondsRemaining_isWithinPeriod() {
let config = TOTPConfig(secret: sha1Secret, period: 30)
let remaining = TOTPGenerator.secondsRemaining(config: config, at: Date())
XCTAssertGreaterThan(remaining, 0)
XCTAssertLessThanOrEqual(remaining, 30)
}
func test_generate_defaultSixDigits() {
let config = TOTPConfig(secret: sha1Secret)
let code = TOTPGenerator.generate(config: config, at: Date())
XCTAssertEqual(code.count, 6)
XCTAssertNotNil(Int(code))
}
}