mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-04-27 18:36:31 +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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
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