mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-05-07 08:56:30 +03:00
implement showing TOTP codes
This commit is contained in:
100
src/KeyKeeper/PasswordStore/TotpCodeGenerator.cs
Normal file
100
src/KeyKeeper/PasswordStore/TotpCodeGenerator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Timers;
|
||||||
|
using Avalonia.Threading;
|
||||||
using KeyKeeper.PasswordStore;
|
using KeyKeeper.PasswordStore;
|
||||||
|
|
||||||
namespace KeyKeeper.ViewModels;
|
namespace KeyKeeper.ViewModels;
|
||||||
@@ -10,6 +12,8 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
|||||||
private IPassStore passStore;
|
private IPassStore passStore;
|
||||||
private IPassStoreDirectory currentDirectory;
|
private IPassStoreDirectory currentDirectory;
|
||||||
private bool hasUnsavedChanges;
|
private bool hasUnsavedChanges;
|
||||||
|
private DispatcherTimer? _totpRefreshTimer;
|
||||||
|
private Dictionary<Guid, string> _totpCodes = new();
|
||||||
|
|
||||||
public IEnumerable<PassStoreEntryPassword> Passwords
|
public IEnumerable<PassStoreEntryPassword> Passwords
|
||||||
{
|
{
|
||||||
@@ -36,6 +40,22 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
|||||||
passStore = store;
|
passStore = store;
|
||||||
currentDirectory = directory;
|
currentDirectory = directory;
|
||||||
HasUnsavedChanges = false;
|
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)
|
public void AddEntry(PassStoreEntry entry)
|
||||||
@@ -68,4 +88,66 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
|||||||
passStore.Save();
|
passStore.Save();
|
||||||
HasUnsavedChanges = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
Background="White"
|
Background="White"
|
||||||
x:DataType="vm:RepositoryWindowViewModel">
|
x:DataType="vm:RepositoryWindowViewModel">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<kkp:TotpCodeConverter x:Key="TotpCodeConverter" />
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
<Window.DataTemplates>
|
<Window.DataTemplates>
|
||||||
<DataTemplate DataType="{x:Type vm:UnlockedRepositoryViewModel}">
|
<DataTemplate DataType="{x:Type vm:UnlockedRepositoryViewModel}">
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -124,6 +128,20 @@
|
|||||||
<TextBlock Text="{Binding Username.Value}"
|
<TextBlock Text="{Binding Username.Value}"
|
||||||
Foreground="#666"
|
Foreground="#666"
|
||||||
HorizontalAlignment="Center" />
|
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>
|
</StackPanel>
|
||||||
<Border.ContextMenu>
|
<Border.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ public partial class RepositoryWindow : Window
|
|||||||
AddHandler(KeyDownEvent, OnUserActivity, RoutingStrategies.Tunnel);
|
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)
|
private void OnUserActivity(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is RepositoryWindowViewModel vm)
|
if (DataContext is RepositoryWindowViewModel vm)
|
||||||
|
|||||||
25
src/KeyKeeper/Views/TotpCodeConverter.cs
Normal file
25
src/KeyKeeper/Views/TotpCodeConverter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user