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