feat: add password reveal toggle, friendlier KDBX4 wrong-password message

Adds an eye-icon toggle to the master password field (UnlockView) so
users can verify what they typed before submitting.

Also maps KDBX4's ERROR_HEADER_HASH_VERIFICATION_FAILED -- surfaced
via the header HMAC check, which is password-derived -- to
KDBXError.invalidPassword alongside the existing
passwordAndOrKeyfileWrong check. KeePassKit uses this same error code
for both a wrong password/keyfile and genuine file corruption (it
doesn't distinguish the two), so the message hedges: "Incorrect
password, or this vault file is corrupted." Found while testing
against a real KDBX4 vault, which was surfacing this as a raw,
unreadable NSError string before the previous commit's LocalizedError
fix, and as *no* friendly message at all before this one (the error
code wasn't in the recognized set yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
This commit is contained in:
2026-09-19 16:17:17 +02:00
co-authored by Claude Sonnet 5
parent 82cc800415
commit ee350c4c02
4 changed files with 50 additions and 7 deletions
-2
View File
@@ -66,8 +66,6 @@ final class UnlockViewModel: ObservableObject {
try session.unlock(url: url, password: password)
try keychainStore.save(password: password, for: keychainAccount)
password = ""
} catch KDBXError.invalidPassword {
errorMessage = "Incorrect password."
} catch {
errorMessage = error.localizedDescription
}
+40 -3
View File
@@ -59,9 +59,7 @@ struct UnlockView: View {
.foregroundStyle(.secondary)
}
SecureField("Master Password", text: $vm.password)
.textFieldStyle(.roundedBorder)
.onSubmit(vm.unlockWithPassword)
PasswordField(text: $vm.password, onSubmit: vm.unlockWithPassword)
Button("Unlock", action: vm.unlockWithPassword)
.buttonStyle(.bordered)
@@ -87,3 +85,42 @@ struct UnlockView: View {
}
}
}
/// A password field with a trailing eye button to reveal/hide the typed value.
private struct PasswordField: View {
@Binding var text: String
var onSubmit: () -> Void
@State private var isRevealed = false
var body: some View {
HStack {
Group {
if isRevealed {
TextField("Master Password", text: $text)
} else {
SecureField("Master Password", text: $text)
}
}
.textFieldStyle(.plain)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.onSubmit(onSubmit)
Button {
isRevealed.toggle()
} label: {
Image(systemName: isRevealed ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(8)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
}
}
@@ -18,7 +18,15 @@ public struct KDBXDocument {
let tree = try KPKTree(contentsOf: url, key: key)
return KDBXMapper.database(from: tree)
} catch let err as NSError {
if err.domain == KPKErrorDomain && err.code == KPKErrorCode.passwordAndOrKeyfileWrong.rawValue {
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)
@@ -11,7 +11,7 @@ extension KDBXError: LocalizedError {
public var errorDescription: String? {
switch self {
case .invalidPassword:
return "Incorrect password."
return "Incorrect password, or this vault file is corrupted."
case .fileNotFound:
return "The vault file could not be found."
case .parseError(let message):