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/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs index 994cfb8..fc0dc28 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -25,6 +25,11 @@ static class FileFormatConstants public const byte LOGIN_FIELD_ACCOUNT_NUMBER_ID = 0x03; public const byte LOGIN_FIELD_NOTES_ID = 0x04; public const byte LOGIN_FIELD_CUSTOM_ID = 0xff; // пока не используется + public const byte TOTP_ABSENT = 0x00; + public const byte TOTP_PRESENT = 0x01; + public const byte TOTP_ALGO_SHA1 = 0x00; + public const byte TOTP_ALGO_SHA256 = 0x01; + public const byte TOTP_ALGO_SHA512 = 0x02; public const byte GROUP_TYPE_ROOT = 0x00; public const byte GROUP_TYPE_DEFAULT = 0x01; public const byte GROUP_TYPE_FAVOURITES = 0x02; diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs index a67c5f7..a9e6a79 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -46,31 +46,37 @@ public abstract class PassStoreEntry BinaryReader rd = new(str); try { - rd.Read7BitEncodedInt(); + int entryLength = rd.Read7BitEncodedInt(); + byte[] entryData = rd.ReadBytes(entryLength); + if (entryData.Length < entryLength) + throw PassStoreFileException.UnexpectedEndOfFile; + + MemoryStream entryStream = new(entryData); + BinaryReader entryRd = new(entryStream); byte[] uuidBuffer = new byte[16]; - if (rd.Read(uuidBuffer) < 16) + if (entryRd.Read(uuidBuffer) < 16) throw PassStoreFileException.UnexpectedEndOfFile; Guid id = new Guid(uuidBuffer); - ulong timestamp = FileFormatUtil.ReadVarUint16(str); + ulong timestamp = FileFormatUtil.ReadVarUint16(entryStream); DateTime createdAt = DateTimeOffset.FromUnixTimeSeconds((long)timestamp).UtcDateTime; - timestamp = FileFormatUtil.ReadVarUint16(str); + timestamp = FileFormatUtil.ReadVarUint16(entryStream); DateTime modifiedAt = DateTimeOffset.FromUnixTimeSeconds((long)timestamp).UtcDateTime; - if (rd.Read(uuidBuffer) < 16) + if (entryRd.Read(uuidBuffer) < 16) throw PassStoreFileException.UnexpectedEndOfFile; Guid iconType = new Guid(uuidBuffer); - string name = FileFormatUtil.ReadU16TaggedString(str); + string name = FileFormatUtil.ReadU16TaggedString(entryStream); - byte entryType = rd.ReadByte(); + byte entryType = entryRd.ReadByte(); if (entryType == ENTRY_GROUP_ID) { - return PassStoreEntryGroup.ReadFromStream(str, id, createdAt, modifiedAt, iconType, name); + return PassStoreEntryGroup.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name); } else if (entryType == ENTRY_PASS_ID) { - return PassStoreEntryPassword.ReadFromStream(str, id, createdAt, modifiedAt, iconType, name); + return PassStoreEntryPassword.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name); } else { throw PassStoreFileException.InvalidPassStoreEntry; diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs index 09cece9..529d919 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryPassword.cs @@ -10,8 +10,11 @@ public class PassStoreEntryPassword : PassStoreEntry public LoginField Username { get; set; } public LoginField Password { get; set; } public List ExtraFields { get; set; } + public TotpParameters? Totp { get; set; } - public PassStoreEntryPassword(Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name, LoginField username, LoginField password, List? extras = null) + public PassStoreEntryPassword(Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name, + LoginField username, LoginField password, + List? extras = null, TotpParameters? totp = null) { Id = id; CreationDate = createdAt; @@ -21,6 +24,7 @@ public class PassStoreEntryPassword : PassStoreEntry Username = username; Password = password; ExtraFields = extras ?? new(); + Totp = totp; } public static PassStoreEntry ReadFromStream(Stream str, Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name) @@ -34,6 +38,27 @@ public class PassStoreEntryPassword : PassStoreEntry int extraFieldCount = rd.Read7BitEncodedInt(); for (; extraFieldCount > 0; extraFieldCount--) entry.ExtraFields.Add(ReadField(str)); + + int totpPresence = str.ReadByte(); + if (totpPresence == TOTP_PRESENT) + { + TotpAlgorithm algo = rd.ReadByte() switch + { + TOTP_ALGO_SHA256 => TotpAlgorithm.SHA256, + TOTP_ALGO_SHA512 => TotpAlgorithm.SHA512, + _ => TotpAlgorithm.SHA1, + }; + byte digits = rd.ReadByte(); + int period = rd.Read7BitEncodedInt(); + string secret = FileFormatUtil.ReadU16TaggedString(str); + string issuer = FileFormatUtil.ReadU16TaggedString(str); + string accountName = FileFormatUtil.ReadU16TaggedString(str); + entry.Totp = new TotpParameters( + secret, algo, digits, period, + issuer.Length > 0 ? issuer : null, + accountName.Length > 0 ? accountName : null); + } + return entry; } catch (EndOfStreamException) { @@ -58,6 +83,28 @@ public class PassStoreEntryPassword : PassStoreEntry wr.Write7BitEncodedInt(ExtraFields.Count); foreach (LoginField field in ExtraFields) WriteField(str, field); + + if (Totp is TotpParameters totp) + { + str.WriteByte(TOTP_PRESENT); + byte algoByte = totp.Algorithm switch + { + TotpAlgorithm.SHA256 => TOTP_ALGO_SHA256, + TotpAlgorithm.SHA512 => TOTP_ALGO_SHA512, + _ => TOTP_ALGO_SHA1, + }; + str.WriteByte(algoByte); + str.WriteByte((byte)totp.Digits); + wr.Write7BitEncodedInt(totp.Period); + FileFormatUtil.WriteU16TaggedString(str, totp.Secret); + FileFormatUtil.WriteU16TaggedString(str, totp.Issuer ?? ""); + FileFormatUtil.WriteU16TaggedString(str, totp.AccountName ?? ""); + } + else + { + str.WriteByte(TOTP_ABSENT); + } + return str.ToArray(); } @@ -86,4 +133,4 @@ public class PassStoreEntryPassword : PassStoreEntry field.Value = FileFormatUtil.ReadU16TaggedString(str); return field; } -} \ No newline at end of file +} diff --git a/src/KeyKeeper/PasswordStore/TotpCodeGenerator.cs b/src/KeyKeeper/PasswordStore/TotpCodeGenerator.cs new file mode 100644 index 0000000..c512476 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/TotpCodeGenerator.cs @@ -0,0 +1,100 @@ +using System; +using System.Security.Cryptography; + +namespace KeyKeeper.PasswordStore; + +/// +/// RFC 6238 Time-based One-Time Password (TOTP) code generator. +/// +public static class TotpCodeGenerator +{ + private const long UnixEpochTicks = 621355968000000000L; + + /// + /// Generates a TOTP code for the given parameters at the current time. + /// + public static string GenerateCode(TotpParameters totp) + { + return GenerateCodeAtTime(totp, DateTime.UtcNow); + } + + /// + /// Generates a TOTP code for the given parameters at a specific time. + /// + public static string GenerateCodeAtTime(TotpParameters totp, DateTime utcTime) + { + try + { + byte[] secretBytes = Convert.FromHexString(totp.Secret); + long timeCounter = GetTimeCounter(utcTime, totp.Period); + byte[] counterBytes = BitConverter.GetBytes(timeCounter); + + if (BitConverter.IsLittleEndian) + Array.Reverse(counterBytes); + + byte[] hash = ComputeHmac(secretBytes, counterBytes, totp.Algorithm); + int code = DynamicTruncate(hash); + int digits = Math.Min(totp.Digits, 10); + int modulo = (int)Math.Pow(10, digits); + code %= modulo; + + return code.ToString().PadLeft(digits, '0'); + } + catch + { + return ""; + } + } + + /// + /// Gets the remaining seconds until the next TOTP code change. + /// + public static int GetSecondsUntilNextCode(TotpParameters totp) + { + return GetSecondsUntilNextCodeAtTime(totp, DateTime.UtcNow); + } + + /// + /// Gets the remaining seconds until the next code change at a specific time. + /// + public static int GetSecondsUntilNextCodeAtTime(TotpParameters totp, DateTime utcTime) + { + long unixTimestamp = GetUnixTimestamp(utcTime); + long secondsInCurrentPeriod = unixTimestamp % totp.Period; + return totp.Period - (int)secondsInCurrentPeriod; + } + + private static long GetTimeCounter(DateTime utcTime, int period) + { + long unixTimestamp = GetUnixTimestamp(utcTime); + return unixTimestamp / period; + } + + private static long GetUnixTimestamp(DateTime utcTime) + { + return (utcTime.Ticks - UnixEpochTicks) / TimeSpan.TicksPerSecond; + } + + private static byte[] ComputeHmac(byte[] secret, byte[] counter, TotpAlgorithm algorithm) + { + return algorithm switch + { + TotpAlgorithm.SHA256 => HMACSHA256.HashData(secret, counter), + TotpAlgorithm.SHA512 => HMACSHA512.HashData(secret, counter), + _ => HMACSHA1.HashData(secret, counter), + }; + } + + /// + /// Dynamic truncate per RFC 6238 section 5.4. + /// + private static int DynamicTruncate(byte[] hash) + { + int offset = hash[^1] & 0xf; + int p = (hash[offset] & 0x7f) << 24 + | (hash[offset + 1] & 0xff) << 16 + | (hash[offset + 2] & 0xff) << 8 + | (hash[offset + 3] & 0xff); + return p; + } +} diff --git a/src/KeyKeeper/PasswordStore/TotpParameters.cs b/src/KeyKeeper/PasswordStore/TotpParameters.cs new file mode 100644 index 0000000..d30560c --- /dev/null +++ b/src/KeyKeeper/PasswordStore/TotpParameters.cs @@ -0,0 +1,88 @@ +using System; +using System.Web; + +namespace KeyKeeper.PasswordStore; + +public enum TotpAlgorithm { SHA1, SHA256, SHA512 } + +public class TotpParameters +{ + public string Secret { get; set; } + public TotpAlgorithm Algorithm { get; set; } + public int Digits { get; set; } + public int Period { get; set; } + public string? Issuer { get; set; } + public string? AccountName { get; set; } + + public TotpParameters(string secret, TotpAlgorithm algorithm = TotpAlgorithm.SHA1, + int digits = 6, int period = 30, + string? issuer = null, string? accountName = null) + { + Secret = secret; + Algorithm = algorithm; + Digits = digits; + Period = period; + Issuer = issuer; + 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)) + throw new ArgumentException("URI must start with otpauth://totp/", nameof(uri)); + + Uri parsed = new(uri); + + string label = Uri.UnescapeDataString(parsed.AbsolutePath.TrimStart('/')); + string? issuer = null; + int colon = label.IndexOf(':'); + string? accountName; + if (colon >= 0) + { + issuer = label[..colon]; + accountName = label[(colon + 1)..]; + } + else + { + accountName = label; + } + + var query = HttpUtility.ParseQueryString(parsed.Query); + + string base32Secret = query["secret"] ?? throw new ArgumentException("URI is missing required 'secret' parameter", nameof(uri)); + + issuer = query["issuer"] ?? issuer; + + TotpAlgorithm algorithm = (query["algorithm"] ?? "SHA1").ToUpperInvariant() switch + { + "SHA256" => TotpAlgorithm.SHA256, + "SHA512" => TotpAlgorithm.SHA512, + _ => TotpAlgorithm.SHA1, + }; + + int digits = int.TryParse(query["digits"], out int d) ? d : 6; + int period = int.TryParse(query["period"], out int p) ? p : 30; + + 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/ViewModels/UnlockedRepositoryViewModel.cs b/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs index 2ffbe7c..101994c 100644 --- a/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs +++ b/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Timers; +using Avalonia.Threading; using KeyKeeper.PasswordStore; namespace KeyKeeper.ViewModels; @@ -10,6 +12,8 @@ public class UnlockedRepositoryViewModel : ViewModelBase private IPassStore passStore; private IPassStoreDirectory currentDirectory; private bool hasUnsavedChanges; + private DispatcherTimer? _totpRefreshTimer; + private Dictionary _totpCodes = new(); public IEnumerable Passwords { @@ -36,6 +40,22 @@ public class UnlockedRepositoryViewModel : ViewModelBase passStore = store; currentDirectory = directory; HasUnsavedChanges = false; + InitializeTotpCodes(); + StartTotpRefreshTimer(); + } + + /// + /// Gets the current TOTP code for an entry, or empty string if TOTP not configured. + /// + public string GetTotpCode(PassStoreEntryPassword entry) + { + if (entry.Totp == null) + return string.Empty; + + if (_totpCodes.TryGetValue(entry.Id, out var code)) + return code; + + return TotpCodeGenerator.GenerateCode(entry.Totp); } public void AddEntry(PassStoreEntry entry) @@ -68,4 +88,66 @@ public class UnlockedRepositoryViewModel : ViewModelBase passStore.Save(); HasUnsavedChanges = false; } + + private void InitializeTotpCodes() + { + _totpCodes.Clear(); + foreach (var entry in Passwords.Where(e => e.Totp != null)) + { + _totpCodes[entry.Id] = TotpCodeGenerator.GenerateCode(entry.Totp!); + } + } + + private void StartTotpRefreshTimer() + { + // Calculate time until next TOTP period boundary + int secondsUntilNextCode = CalculateSecondsUntilNextTotpRefresh(); + + _totpRefreshTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(secondsUntilNextCode) + }; + _totpRefreshTimer.Tick += OnTotpRefreshTimerTick; + _totpRefreshTimer.Start(); + } + + private void OnTotpRefreshTimerTick(object? sender, EventArgs e) + { + // Refresh all TOTP codes + InitializeTotpCodes(); + OnPropertyChanged(nameof(Passwords)); // Trigger UI update + + // Calculate next refresh time and reschedule + if (_totpRefreshTimer != null) + { + _totpRefreshTimer.Stop(); + int secondsUntilNextCode = CalculateSecondsUntilNextTotpRefresh(); + _totpRefreshTimer.Interval = TimeSpan.FromSeconds(secondsUntilNextCode); + _totpRefreshTimer.Start(); + } + } + + private int CalculateSecondsUntilNextTotpRefresh() + { + // Find the minimum seconds until next code change across all TOTP entries + var totpEntries = Passwords.Where(e => e.Totp != null).ToList(); + if (totpEntries.Count == 0) + return 60; // Default to 60 seconds if no TOTP entries + + // All periods should be the same, but use the minimum to be safe + int minSeconds = totpEntries + .Select(e => TotpCodeGenerator.GetSecondsUntilNextCode(e.Totp!)) + .Min(); + + return Math.Max(1, minSeconds); // At least 1 second + } + + public void StopTotpRefreshTimer() + { + if (_totpRefreshTimer != null) + { + _totpRefreshTimer.Stop(); + _totpRefreshTimer = null; + } + } } diff --git a/src/KeyKeeper/Views/EntryEditWindow.axaml b/src/KeyKeeper/Views/EntryEditWindow.axaml index 3bdf1c3..a513870 100644 --- a/src/KeyKeeper/Views/EntryEditWindow.axaml +++ b/src/KeyKeeper/Views/EntryEditWindow.axaml @@ -1,47 +1,105 @@ - - + + + - - + + - - + + - - - - - - - - - - + + + + + + + + + + + + +