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/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/RepositoryWindow.axaml b/src/KeyKeeper/Views/RepositoryWindow.axaml index e412e8b..6378b15 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml @@ -12,6 +12,10 @@ Background="White" x:DataType="vm:RepositoryWindowViewModel"> + + + + @@ -124,6 +128,20 @@ + + + + + + + + + + diff --git a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs index 470f1f7..6efbfe9 100644 --- a/src/KeyKeeper/Views/RepositoryWindow.axaml.cs +++ b/src/KeyKeeper/Views/RepositoryWindow.axaml.cs @@ -33,6 +33,17 @@ public partial class RepositoryWindow : Window AddHandler(KeyDownEvent, OnUserActivity, RoutingStrategies.Tunnel); } + protected override void OnClosed(EventArgs e) + { + // Stop TOTP refresh timer when window closes + if (DataContext is RepositoryWindowViewModel vm && + vm.CurrentPage is UnlockedRepositoryViewModel unlockedVm) + { + unlockedVm.StopTotpRefreshTimer(); + } + base.OnClosed(e); + } + private void OnUserActivity(object? sender, RoutedEventArgs e) { if (DataContext is RepositoryWindowViewModel vm) diff --git a/src/KeyKeeper/Views/TotpCodeConverter.cs b/src/KeyKeeper/Views/TotpCodeConverter.cs new file mode 100644 index 0000000..9e78d0e --- /dev/null +++ b/src/KeyKeeper/Views/TotpCodeConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using KeyKeeper.PasswordStore; +using KeyKeeper.ViewModels; + +namespace KeyKeeper.Views; + +public class TotpCodeConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 2) + return ""; + + var entry = values[0] as PassStoreEntryPassword; + var viewModel = values[1] as UnlockedRepositoryViewModel; + + if (entry == null || viewModel == null) + return ""; + + return viewModel.GetTotpCode(entry); + } +}