From e0b30209cd86216228cc4917c86f65848c7adc95 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Fri, 27 Mar 2026 19:59:20 +0300 Subject: [PATCH] add UI to configure TOTP --- src/KeyKeeper/PasswordStore/Base32.cs | 105 ++++++ src/KeyKeeper/PasswordStore/TotpParameters.cs | 24 +- .../ViewModels/EntryEditViewModel.cs | 343 ++++++++++++++++++ src/KeyKeeper/Views/EntryEditWindow.axaml | 126 +++++-- src/KeyKeeper/Views/EntryEditWindow.axaml.cs | 84 +++-- 5 files changed, 618 insertions(+), 64 deletions(-) create mode 100644 src/KeyKeeper/PasswordStore/Base32.cs create mode 100644 src/KeyKeeper/ViewModels/EntryEditViewModel.cs diff --git a/src/KeyKeeper/PasswordStore/Base32.cs b/src/KeyKeeper/PasswordStore/Base32.cs new file mode 100644 index 0000000..e197002 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/Base32.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace KeyKeeper.PasswordStore; + +public static class Base32 +{ + private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + public static bool Validate(string? input) + { + if (string.IsNullOrEmpty(input)) + return false; + + foreach (char c in input) + { + if (!Base32Alphabet.Contains(c) && c != '=') + return false; + } + + return IsValidPadding(input); + } + + private static bool IsValidPadding(string input) + { + if (input.Length % 8 != 0) + return false; + + int paddingCount = 0; + for (int i = input.Length - 1; i >= 0; i--) + { + if (input[i] == '=') + paddingCount++; + else if (paddingCount > 0) + return false; + } + + return paddingCount <= 6; + } + + public static byte[] Decode(string input) + { + if (!Validate(input)) + throw new ArgumentException("Invalid Base32 string", nameof(input)); + + input = input.TrimEnd('='); + + var output = new List(); + int bitCount = 0; + int bitBuffer = 0; + + foreach (char c in input) + { + int value = Base32Alphabet.IndexOf(c); + if (value < 0) + throw new ArgumentException($"Invalid Base32 character: {c}", nameof(input)); + + bitBuffer = (bitBuffer << 5) | value; + bitCount += 5; + + if (bitCount >= 8) + { + bitCount -= 8; + output.Add((byte)((bitBuffer >> bitCount) & 0xFF)); + } + } + + return output.ToArray(); + } + + public static string Encode(byte[] input) + { + if (input == null || input.Length == 0) + return string.Empty; + + var output = new System.Text.StringBuilder(); + int bitCount = 0; + int bitBuffer = 0; + + foreach (byte b in input) + { + bitBuffer = (bitBuffer << 8) | b; + bitCount += 8; + + while (bitCount >= 5) + { + bitCount -= 5; + int index = (bitBuffer >> bitCount) & 0x1F; + output.Append(Base32Alphabet[index]); + } + } + + if (bitCount > 0) + { + bitBuffer <<= (5 - bitCount); + int index = bitBuffer & 0x1F; + output.Append(Base32Alphabet[index]); + } + + int paddingCount = (8 - (output.Length % 8)) % 8; + output.Append(new string('=', paddingCount)); + + return output.ToString(); + } +} diff --git a/src/KeyKeeper/PasswordStore/TotpParameters.cs b/src/KeyKeeper/PasswordStore/TotpParameters.cs index b57b9ff..d30560c 100644 --- a/src/KeyKeeper/PasswordStore/TotpParameters.cs +++ b/src/KeyKeeper/PasswordStore/TotpParameters.cs @@ -26,6 +26,26 @@ public class TotpParameters AccountName = accountName; } + public static TotpParameters FromBase32Secret(string base32Secret, + TotpAlgorithm algorithm = TotpAlgorithm.SHA1, + int digits = 6, int period = 30, + string? issuer = null, string? accountName = null) + { + if (!Base32.Validate(base32Secret)) + throw new ArgumentException("Invalid Base32-encoded secret", nameof(base32Secret)); + + byte[] secretBytes = Base32.Decode(base32Secret); + string hexSecret = Convert.ToHexString(secretBytes); + + return new TotpParameters(hexSecret, algorithm, digits, period, issuer, accountName); + } + + public string GetBase32Secret() + { + byte[] secretBytes = Convert.FromHexString(Secret); + return Base32.Encode(secretBytes); + } + public static TotpParameters FromUri(string uri) { if (!uri.StartsWith("otpauth://totp/", StringComparison.OrdinalIgnoreCase)) @@ -49,7 +69,7 @@ public class TotpParameters var query = HttpUtility.ParseQueryString(parsed.Query); - string secret = query["secret"] ?? throw new ArgumentException("URI is missing required 'secret' parameter", nameof(uri)); + string base32Secret = query["secret"] ?? throw new ArgumentException("URI is missing required 'secret' parameter", nameof(uri)); issuer = query["issuer"] ?? issuer; @@ -63,6 +83,6 @@ public class TotpParameters int digits = int.TryParse(query["digits"], out int d) ? d : 6; int period = int.TryParse(query["period"], out int p) ? p : 30; - return new TotpParameters(secret, algorithm, digits, period, issuer, accountName); + return FromBase32Secret(base32Secret, algorithm, digits, period, issuer, accountName); } } diff --git a/src/KeyKeeper/ViewModels/EntryEditViewModel.cs b/src/KeyKeeper/ViewModels/EntryEditViewModel.cs new file mode 100644 index 0000000..a0a105a --- /dev/null +++ b/src/KeyKeeper/ViewModels/EntryEditViewModel.cs @@ -0,0 +1,343 @@ +using System; +using KeyKeeper.PasswordStore; +using static KeyKeeper.PasswordStore.FileFormatConstants; + +namespace KeyKeeper.ViewModels; + +public class EntryEditViewModel : ViewModelBase +{ + private string _entryName = string.Empty; + private string _username = string.Empty; + private string _password = string.Empty; + private bool _isTotpConfigured; + private string _totpSecret = string.Empty; + private TotpAlgorithm _totpAlgorithm = TotpAlgorithm.SHA1; + private string _totpDigits = "6"; + private string _totpPeriod = "30"; + private string _totpIssuer = string.Empty; + private string _totpAccountName = string.Empty; + private string _secretValidationError = string.Empty; + private string _digitsValidationError = string.Empty; + private string _periodValidationError = string.Empty; + private PassStoreEntryPassword? _editedEntry; + private Guid? _existingId; + private DateTime? _createdAt; + + public string EntryName + { + get => _entryName; + set + { + _entryName = value; + OnPropertyChanged(nameof(EntryName)); + OnPropertyChanged(nameof(SaveAllowed)); + } + } + + public string Username + { + get => _username; + set + { + _username = value; + OnPropertyChanged(nameof(Username)); + OnPropertyChanged(nameof(SaveAllowed)); + } + } + + public string Password + { + get => _password; + set + { + _password = value; + OnPropertyChanged(nameof(Password)); + OnPropertyChanged(nameof(SaveAllowed)); + } + } + + public bool IsTotpConfigured + { + get => _isTotpConfigured; + set + { + _isTotpConfigured = value; + OnPropertyChanged(nameof(IsTotpConfigured)); + ValidateTotpSecret(); + ValidateTotpDigits(); + ValidateTotpPeriod(); + } + } + + public string TotpSecret + { + get => _totpSecret; + set + { + _totpSecret = value; + OnPropertyChanged(nameof(TotpSecret)); + ValidateTotpSecret(); + } + } + + public TotpAlgorithm TotpAlgorithm + { + get => _totpAlgorithm; + set { _totpAlgorithm = value; OnPropertyChanged(nameof(TotpAlgorithm)); } + } + + public string TotpDigits + { + get => _totpDigits; + set + { + _totpDigits = value; + OnPropertyChanged(nameof(TotpDigits)); + ValidateTotpDigits(); + } + } + + public string TotpPeriod + { + get => _totpPeriod; + set + { + _totpPeriod = value; + OnPropertyChanged(nameof(TotpPeriod)); + ValidateTotpPeriod(); + } + } + + public string TotpIssuer + { + get => _totpIssuer; + set { _totpIssuer = value; OnPropertyChanged(nameof(TotpIssuer)); } + } + + public string TotpAccountName + { + get => _totpAccountName; + set { _totpAccountName = value; OnPropertyChanged(nameof(TotpAccountName)); } + } + + public string SecretValidationError + { + get => _secretValidationError; + private set { _secretValidationError = value; OnPropertyChanged(nameof(SecretValidationError)); } + } + + public string DigitsValidationError + { + get => _digitsValidationError; + private set { _digitsValidationError = value; OnPropertyChanged(nameof(DigitsValidationError)); } + } + + public string PeriodValidationError + { + get => _periodValidationError; + private set { _periodValidationError = value; OnPropertyChanged(nameof(PeriodValidationError)); } + } + + public PassStoreEntryPassword? EditedEntry + { + get => _editedEntry; + private set { _editedEntry = value; OnPropertyChanged(nameof(EditedEntry)); } + } + + public bool SaveAllowed + { + get + { + bool loginFieldsValid = !string.IsNullOrEmpty(EntryName?.Trim()) + && !string.IsNullOrEmpty(Username?.Trim()) + && !string.IsNullOrEmpty(Password); + + if (!loginFieldsValid) + return false; + + if (IsTotpConfigured) + { + bool totpValid = string.IsNullOrEmpty(SecretValidationError) + && string.IsNullOrEmpty(DigitsValidationError) + && string.IsNullOrEmpty(PeriodValidationError); + return totpValid; + } + return true; + } + } + + public void LoadEntry(PassStoreEntryPassword entry) + { + _existingId = entry.Id; + _createdAt = entry.CreationDate; + + EntryName = entry.Name; + Username = entry.Username.Value; + Password = entry.Password.Value; + + if (entry.Totp != null) + { + IsTotpConfigured = true; + TotpSecret = entry.Totp.GetBase32Secret(); + TotpAlgorithm = entry.Totp.Algorithm; + TotpDigits = entry.Totp.Digits.ToString(); + TotpPeriod = entry.Totp.Period.ToString(); + TotpIssuer = entry.Totp.Issuer ?? string.Empty; + TotpAccountName = entry.Totp.AccountName ?? string.Empty; + } + else + { + IsTotpConfigured = false; + } + } + + public void ConfigureTotp() + { + if (!IsTotpConfigured) + { + IsTotpConfigured = true; + TotpSecret = string.Empty; + TotpAlgorithm = TotpAlgorithm.SHA1; + TotpDigits = "6"; + TotpPeriod = "30"; + TotpIssuer = string.Empty; + TotpAccountName = string.Empty; + } + } + + public void RemoveTotp() + { + IsTotpConfigured = false; + TotpSecret = string.Empty; + TotpAlgorithm = TotpAlgorithm.SHA1; + TotpDigits = "6"; + TotpPeriod = "30"; + TotpIssuer = string.Empty; + TotpAccountName = string.Empty; + SecretValidationError = string.Empty; + DigitsValidationError = string.Empty; + PeriodValidationError = string.Empty; + } + + public bool ParseOtpauthUrl(string uri) + { + try + { + if (string.IsNullOrEmpty(uri)) + return false; + + TotpParameters totp = TotpParameters.FromUri(uri); + TotpSecret = totp.GetBase32Secret(); + TotpAlgorithm = totp.Algorithm; + TotpDigits = totp.Digits.ToString(); + TotpPeriod = totp.Period.ToString(); + TotpIssuer = totp.Issuer ?? string.Empty; + TotpAccountName = totp.AccountName ?? string.Empty; + return true; + } + catch (Exception) + { + return false; + } + } + + private void ValidateTotpSecret() + { + if (!IsTotpConfigured) + { + SecretValidationError = string.Empty; + } + else if (string.IsNullOrWhiteSpace(TotpSecret)) + { + SecretValidationError = "Secret is required"; + } + else if (!Base32.Validate(TotpSecret)) + { + SecretValidationError = "Secret must be valid Base32 (A-Z, 2-7, and = padding)"; + } + else + { + SecretValidationError = string.Empty; + } + + OnPropertyChanged(nameof(SaveAllowed)); + } + + private void ValidateTotpDigits() + { + if (!IsTotpConfigured) + { + DigitsValidationError = string.Empty; + } + else if (!int.TryParse(TotpDigits, out int digits) || digits < 6 || digits > 8) + { + DigitsValidationError = "Digits must be 6, 7, or 8"; + } + else + { + DigitsValidationError = string.Empty; + } + + OnPropertyChanged(nameof(SaveAllowed)); + } + + private void ValidateTotpPeriod() + { + if (!IsTotpConfigured) + { + PeriodValidationError = string.Empty; + } + else if (!int.TryParse(TotpPeriod, out int period) || period <= 0) + { + PeriodValidationError = "Period must be a positive number (seconds)"; + } + else + { + PeriodValidationError = string.Empty; + } + + OnPropertyChanged(nameof(SaveAllowed)); + } + + public void CreateEntry() + { + if (!SaveAllowed) + return; + + Guid id = _existingId ?? Guid.NewGuid(); + DateTime created = _createdAt ?? DateTime.UtcNow; + + TotpParameters? totp = null; + if (IsTotpConfigured && !string.IsNullOrEmpty(TotpSecret)) + { + try + { + totp = TotpParameters.FromBase32Secret( + TotpSecret, + TotpAlgorithm, + int.Parse(TotpDigits), + int.Parse(TotpPeriod), + string.IsNullOrEmpty(TotpIssuer) ? null : TotpIssuer, + string.IsNullOrEmpty(TotpAccountName) ? null : TotpAccountName + ); + } + catch (Exception) + { + // Validation should have caught this, but handle gracefully + totp = null; + } + } + + EditedEntry = new PassStoreEntryPassword( + id, + created, + DateTime.UtcNow, + EntryIconType.DEFAULT, + EntryName.Trim(), + new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = Username.Trim() }, + new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = Password }, + null, + totp + ); + } +} diff --git a/src/KeyKeeper/Views/EntryEditWindow.axaml b/src/KeyKeeper/Views/EntryEditWindow.axaml index 3bdf1c3..949df97 100644 --- a/src/KeyKeeper/Views/EntryEditWindow.axaml +++ b/src/KeyKeeper/Views/EntryEditWindow.axaml @@ -1,47 +1,107 @@ - - + + + - - + + - - + + - - - - - - - - - - + + + + + + + + + + + + + + +