implement showing TOTP codes

This commit is contained in:
2026-03-27 20:32:36 +03:00
parent 663831989f
commit e66ac11718
5 changed files with 236 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
using System;
using System.Security.Cryptography;
namespace KeyKeeper.PasswordStore;
/// <summary>
/// RFC 6238 Time-based One-Time Password (TOTP) code generator.
/// </summary>
public static class TotpCodeGenerator
{
private const long UnixEpochTicks = 621355968000000000L;
/// <summary>
/// Generates a TOTP code for the given parameters at the current time.
/// </summary>
public static string GenerateCode(TotpParameters totp)
{
return GenerateCodeAtTime(totp, DateTime.UtcNow);
}
/// <summary>
/// Generates a TOTP code for the given parameters at a specific time.
/// </summary>
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 "";
}
}
/// <summary>
/// Gets the remaining seconds until the next TOTP code change.
/// </summary>
public static int GetSecondsUntilNextCode(TotpParameters totp)
{
return GetSecondsUntilNextCodeAtTime(totp, DateTime.UtcNow);
}
/// <summary>
/// Gets the remaining seconds until the next code change at a specific time.
/// </summary>
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),
};
}
/// <summary>
/// Dynamic truncate per RFC 6238 section 5.4.
/// </summary>
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;
}
}

View File

@@ -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<Guid, string> _totpCodes = new();
public IEnumerable<PassStoreEntryPassword> Passwords
{
@@ -36,6 +40,22 @@ public class UnlockedRepositoryViewModel : ViewModelBase
passStore = store;
currentDirectory = directory;
HasUnsavedChanges = false;
InitializeTotpCodes();
StartTotpRefreshTimer();
}
/// <summary>
/// Gets the current TOTP code for an entry, or empty string if TOTP not configured.
/// </summary>
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;
}
}
}

View File

@@ -12,6 +12,10 @@
Background="White"
x:DataType="vm:RepositoryWindowViewModel">
<Window.Resources>
<kkp:TotpCodeConverter x:Key="TotpCodeConverter" />
</Window.Resources>
<Window.DataTemplates>
<DataTemplate DataType="{x:Type vm:UnlockedRepositoryViewModel}">
<Grid>
@@ -124,6 +128,20 @@
<TextBlock Text="{Binding Username.Value}"
Foreground="#666"
HorizontalAlignment="Center" />
<!-- TOTP Code Display -->
<TextBlock HorizontalAlignment="Center"
Foreground="#2A2ABB"
FontWeight="Bold"
FontSize="16"
Margin="0,4,0,0">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource TotpCodeConverter}">
<Binding />
<Binding Path="DataContext" RelativeSource="{RelativeSource AncestorType=ListBox}" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<Border.ContextMenu>
<ContextMenu>

View File

@@ -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)

View File

@@ -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<object?> 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);
}
}