mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-05-12 11:26:30 +03:00
add UI to configure TOTP
This commit is contained in:
105
src/KeyKeeper/PasswordStore/Base32.cs
Normal file
105
src/KeyKeeper/PasswordStore/Base32.cs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace KeyKeeper.PasswordStore;
|
||||||
|
|
||||||
|
public static class Base32
|
||||||
|
{
|
||||||
|
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
public static bool Validate(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (char c in input)
|
||||||
|
{
|
||||||
|
if (!Base32Alphabet.Contains(c) && c != '=')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsValidPadding(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidPadding(string input)
|
||||||
|
{
|
||||||
|
if (input.Length % 8 != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int paddingCount = 0;
|
||||||
|
for (int i = input.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (input[i] == '=')
|
||||||
|
paddingCount++;
|
||||||
|
else if (paddingCount > 0)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return paddingCount <= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Decode(string input)
|
||||||
|
{
|
||||||
|
if (!Validate(input))
|
||||||
|
throw new ArgumentException("Invalid Base32 string", nameof(input));
|
||||||
|
|
||||||
|
input = input.TrimEnd('=');
|
||||||
|
|
||||||
|
var output = new List<byte>();
|
||||||
|
int bitCount = 0;
|
||||||
|
int bitBuffer = 0;
|
||||||
|
|
||||||
|
foreach (char c in input)
|
||||||
|
{
|
||||||
|
int value = Base32Alphabet.IndexOf(c);
|
||||||
|
if (value < 0)
|
||||||
|
throw new ArgumentException($"Invalid Base32 character: {c}", nameof(input));
|
||||||
|
|
||||||
|
bitBuffer = (bitBuffer << 5) | value;
|
||||||
|
bitCount += 5;
|
||||||
|
|
||||||
|
if (bitCount >= 8)
|
||||||
|
{
|
||||||
|
bitCount -= 8;
|
||||||
|
output.Add((byte)((bitBuffer >> bitCount) & 0xFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Encode(byte[] input)
|
||||||
|
{
|
||||||
|
if (input == null || input.Length == 0)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var output = new System.Text.StringBuilder();
|
||||||
|
int bitCount = 0;
|
||||||
|
int bitBuffer = 0;
|
||||||
|
|
||||||
|
foreach (byte b in input)
|
||||||
|
{
|
||||||
|
bitBuffer = (bitBuffer << 8) | b;
|
||||||
|
bitCount += 8;
|
||||||
|
|
||||||
|
while (bitCount >= 5)
|
||||||
|
{
|
||||||
|
bitCount -= 5;
|
||||||
|
int index = (bitBuffer >> bitCount) & 0x1F;
|
||||||
|
output.Append(Base32Alphabet[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitCount > 0)
|
||||||
|
{
|
||||||
|
bitBuffer <<= (5 - bitCount);
|
||||||
|
int index = bitBuffer & 0x1F;
|
||||||
|
output.Append(Base32Alphabet[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int paddingCount = (8 - (output.Length % 8)) % 8;
|
||||||
|
output.Append(new string('=', paddingCount));
|
||||||
|
|
||||||
|
return output.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,26 @@ public class TotpParameters
|
|||||||
AccountName = accountName;
|
AccountName = accountName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static TotpParameters FromBase32Secret(string base32Secret,
|
||||||
|
TotpAlgorithm algorithm = TotpAlgorithm.SHA1,
|
||||||
|
int digits = 6, int period = 30,
|
||||||
|
string? issuer = null, string? accountName = null)
|
||||||
|
{
|
||||||
|
if (!Base32.Validate(base32Secret))
|
||||||
|
throw new ArgumentException("Invalid Base32-encoded secret", nameof(base32Secret));
|
||||||
|
|
||||||
|
byte[] secretBytes = Base32.Decode(base32Secret);
|
||||||
|
string hexSecret = Convert.ToHexString(secretBytes);
|
||||||
|
|
||||||
|
return new TotpParameters(hexSecret, algorithm, digits, period, issuer, accountName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetBase32Secret()
|
||||||
|
{
|
||||||
|
byte[] secretBytes = Convert.FromHexString(Secret);
|
||||||
|
return Base32.Encode(secretBytes);
|
||||||
|
}
|
||||||
|
|
||||||
public static TotpParameters FromUri(string uri)
|
public static TotpParameters FromUri(string uri)
|
||||||
{
|
{
|
||||||
if (!uri.StartsWith("otpauth://totp/", StringComparison.OrdinalIgnoreCase))
|
if (!uri.StartsWith("otpauth://totp/", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -49,7 +69,7 @@ public class TotpParameters
|
|||||||
|
|
||||||
var query = HttpUtility.ParseQueryString(parsed.Query);
|
var query = HttpUtility.ParseQueryString(parsed.Query);
|
||||||
|
|
||||||
string secret = query["secret"] ?? throw new ArgumentException("URI is missing required 'secret' parameter", nameof(uri));
|
string base32Secret = query["secret"] ?? throw new ArgumentException("URI is missing required 'secret' parameter", nameof(uri));
|
||||||
|
|
||||||
issuer = query["issuer"] ?? issuer;
|
issuer = query["issuer"] ?? issuer;
|
||||||
|
|
||||||
@@ -63,6 +83,6 @@ public class TotpParameters
|
|||||||
int digits = int.TryParse(query["digits"], out int d) ? d : 6;
|
int digits = int.TryParse(query["digits"], out int d) ? d : 6;
|
||||||
int period = int.TryParse(query["period"], out int p) ? p : 30;
|
int period = int.TryParse(query["period"], out int p) ? p : 30;
|
||||||
|
|
||||||
return new TotpParameters(secret, algorithm, digits, period, issuer, accountName);
|
return FromBase32Secret(base32Secret, algorithm, digits, period, issuer, accountName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
343
src/KeyKeeper/ViewModels/EntryEditViewModel.cs
Normal file
343
src/KeyKeeper/ViewModels/EntryEditViewModel.cs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
using System;
|
||||||
|
using KeyKeeper.PasswordStore;
|
||||||
|
using static KeyKeeper.PasswordStore.FileFormatConstants;
|
||||||
|
|
||||||
|
namespace KeyKeeper.ViewModels;
|
||||||
|
|
||||||
|
public class EntryEditViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private string _entryName = string.Empty;
|
||||||
|
private string _username = string.Empty;
|
||||||
|
private string _password = string.Empty;
|
||||||
|
private bool _isTotpConfigured;
|
||||||
|
private string _totpSecret = string.Empty;
|
||||||
|
private TotpAlgorithm _totpAlgorithm = TotpAlgorithm.SHA1;
|
||||||
|
private string _totpDigits = "6";
|
||||||
|
private string _totpPeriod = "30";
|
||||||
|
private string _totpIssuer = string.Empty;
|
||||||
|
private string _totpAccountName = string.Empty;
|
||||||
|
private string _secretValidationError = string.Empty;
|
||||||
|
private string _digitsValidationError = string.Empty;
|
||||||
|
private string _periodValidationError = string.Empty;
|
||||||
|
private PassStoreEntryPassword? _editedEntry;
|
||||||
|
private Guid? _existingId;
|
||||||
|
private DateTime? _createdAt;
|
||||||
|
|
||||||
|
public string EntryName
|
||||||
|
{
|
||||||
|
get => _entryName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_entryName = value;
|
||||||
|
OnPropertyChanged(nameof(EntryName));
|
||||||
|
OnPropertyChanged(nameof(SaveAllowed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Username
|
||||||
|
{
|
||||||
|
get => _username;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_username = value;
|
||||||
|
OnPropertyChanged(nameof(Username));
|
||||||
|
OnPropertyChanged(nameof(SaveAllowed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Password
|
||||||
|
{
|
||||||
|
get => _password;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_password = value;
|
||||||
|
OnPropertyChanged(nameof(Password));
|
||||||
|
OnPropertyChanged(nameof(SaveAllowed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsTotpConfigured
|
||||||
|
{
|
||||||
|
get => _isTotpConfigured;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_isTotpConfigured = value;
|
||||||
|
OnPropertyChanged(nameof(IsTotpConfigured));
|
||||||
|
ValidateTotpSecret();
|
||||||
|
ValidateTotpDigits();
|
||||||
|
ValidateTotpPeriod();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TotpSecret
|
||||||
|
{
|
||||||
|
get => _totpSecret;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_totpSecret = value;
|
||||||
|
OnPropertyChanged(nameof(TotpSecret));
|
||||||
|
ValidateTotpSecret();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TotpAlgorithm TotpAlgorithm
|
||||||
|
{
|
||||||
|
get => _totpAlgorithm;
|
||||||
|
set { _totpAlgorithm = value; OnPropertyChanged(nameof(TotpAlgorithm)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TotpDigits
|
||||||
|
{
|
||||||
|
get => _totpDigits;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_totpDigits = value;
|
||||||
|
OnPropertyChanged(nameof(TotpDigits));
|
||||||
|
ValidateTotpDigits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TotpPeriod
|
||||||
|
{
|
||||||
|
get => _totpPeriod;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_totpPeriod = value;
|
||||||
|
OnPropertyChanged(nameof(TotpPeriod));
|
||||||
|
ValidateTotpPeriod();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TotpIssuer
|
||||||
|
{
|
||||||
|
get => _totpIssuer;
|
||||||
|
set { _totpIssuer = value; OnPropertyChanged(nameof(TotpIssuer)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TotpAccountName
|
||||||
|
{
|
||||||
|
get => _totpAccountName;
|
||||||
|
set { _totpAccountName = value; OnPropertyChanged(nameof(TotpAccountName)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SecretValidationError
|
||||||
|
{
|
||||||
|
get => _secretValidationError;
|
||||||
|
private set { _secretValidationError = value; OnPropertyChanged(nameof(SecretValidationError)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DigitsValidationError
|
||||||
|
{
|
||||||
|
get => _digitsValidationError;
|
||||||
|
private set { _digitsValidationError = value; OnPropertyChanged(nameof(DigitsValidationError)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PeriodValidationError
|
||||||
|
{
|
||||||
|
get => _periodValidationError;
|
||||||
|
private set { _periodValidationError = value; OnPropertyChanged(nameof(PeriodValidationError)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public PassStoreEntryPassword? EditedEntry
|
||||||
|
{
|
||||||
|
get => _editedEntry;
|
||||||
|
private set { _editedEntry = value; OnPropertyChanged(nameof(EditedEntry)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SaveAllowed
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
bool loginFieldsValid = !string.IsNullOrEmpty(EntryName?.Trim())
|
||||||
|
&& !string.IsNullOrEmpty(Username?.Trim())
|
||||||
|
&& !string.IsNullOrEmpty(Password);
|
||||||
|
|
||||||
|
if (!loginFieldsValid)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (IsTotpConfigured)
|
||||||
|
{
|
||||||
|
bool totpValid = string.IsNullOrEmpty(SecretValidationError)
|
||||||
|
&& string.IsNullOrEmpty(DigitsValidationError)
|
||||||
|
&& string.IsNullOrEmpty(PeriodValidationError);
|
||||||
|
return totpValid;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadEntry(PassStoreEntryPassword entry)
|
||||||
|
{
|
||||||
|
_existingId = entry.Id;
|
||||||
|
_createdAt = entry.CreationDate;
|
||||||
|
|
||||||
|
EntryName = entry.Name;
|
||||||
|
Username = entry.Username.Value;
|
||||||
|
Password = entry.Password.Value;
|
||||||
|
|
||||||
|
if (entry.Totp != null)
|
||||||
|
{
|
||||||
|
IsTotpConfigured = true;
|
||||||
|
TotpSecret = entry.Totp.GetBase32Secret();
|
||||||
|
TotpAlgorithm = entry.Totp.Algorithm;
|
||||||
|
TotpDigits = entry.Totp.Digits.ToString();
|
||||||
|
TotpPeriod = entry.Totp.Period.ToString();
|
||||||
|
TotpIssuer = entry.Totp.Issuer ?? string.Empty;
|
||||||
|
TotpAccountName = entry.Totp.AccountName ?? string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsTotpConfigured = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ConfigureTotp()
|
||||||
|
{
|
||||||
|
if (!IsTotpConfigured)
|
||||||
|
{
|
||||||
|
IsTotpConfigured = true;
|
||||||
|
TotpSecret = string.Empty;
|
||||||
|
TotpAlgorithm = TotpAlgorithm.SHA1;
|
||||||
|
TotpDigits = "6";
|
||||||
|
TotpPeriod = "30";
|
||||||
|
TotpIssuer = string.Empty;
|
||||||
|
TotpAccountName = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveTotp()
|
||||||
|
{
|
||||||
|
IsTotpConfigured = false;
|
||||||
|
TotpSecret = string.Empty;
|
||||||
|
TotpAlgorithm = TotpAlgorithm.SHA1;
|
||||||
|
TotpDigits = "6";
|
||||||
|
TotpPeriod = "30";
|
||||||
|
TotpIssuer = string.Empty;
|
||||||
|
TotpAccountName = string.Empty;
|
||||||
|
SecretValidationError = string.Empty;
|
||||||
|
DigitsValidationError = string.Empty;
|
||||||
|
PeriodValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ParseOtpauthUrl(string uri)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(uri))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
TotpParameters totp = TotpParameters.FromUri(uri);
|
||||||
|
TotpSecret = totp.GetBase32Secret();
|
||||||
|
TotpAlgorithm = totp.Algorithm;
|
||||||
|
TotpDigits = totp.Digits.ToString();
|
||||||
|
TotpPeriod = totp.Period.ToString();
|
||||||
|
TotpIssuer = totp.Issuer ?? string.Empty;
|
||||||
|
TotpAccountName = totp.AccountName ?? string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateTotpSecret()
|
||||||
|
{
|
||||||
|
if (!IsTotpConfigured)
|
||||||
|
{
|
||||||
|
SecretValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(TotpSecret))
|
||||||
|
{
|
||||||
|
SecretValidationError = "Secret is required";
|
||||||
|
}
|
||||||
|
else if (!Base32.Validate(TotpSecret))
|
||||||
|
{
|
||||||
|
SecretValidationError = "Secret must be valid Base32 (A-Z, 2-7, and = padding)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SecretValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(SaveAllowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateTotpDigits()
|
||||||
|
{
|
||||||
|
if (!IsTotpConfigured)
|
||||||
|
{
|
||||||
|
DigitsValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
else if (!int.TryParse(TotpDigits, out int digits) || digits < 6 || digits > 8)
|
||||||
|
{
|
||||||
|
DigitsValidationError = "Digits must be 6, 7, or 8";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DigitsValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(SaveAllowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateTotpPeriod()
|
||||||
|
{
|
||||||
|
if (!IsTotpConfigured)
|
||||||
|
{
|
||||||
|
PeriodValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
else if (!int.TryParse(TotpPeriod, out int period) || period <= 0)
|
||||||
|
{
|
||||||
|
PeriodValidationError = "Period must be a positive number (seconds)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PeriodValidationError = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(SaveAllowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateEntry()
|
||||||
|
{
|
||||||
|
if (!SaveAllowed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Guid id = _existingId ?? Guid.NewGuid();
|
||||||
|
DateTime created = _createdAt ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
TotpParameters? totp = null;
|
||||||
|
if (IsTotpConfigured && !string.IsNullOrEmpty(TotpSecret))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
totp = TotpParameters.FromBase32Secret(
|
||||||
|
TotpSecret,
|
||||||
|
TotpAlgorithm,
|
||||||
|
int.Parse(TotpDigits),
|
||||||
|
int.Parse(TotpPeriod),
|
||||||
|
string.IsNullOrEmpty(TotpIssuer) ? null : TotpIssuer,
|
||||||
|
string.IsNullOrEmpty(TotpAccountName) ? null : TotpAccountName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Validation should have caught this, but handle gracefully
|
||||||
|
totp = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditedEntry = new PassStoreEntryPassword(
|
||||||
|
id,
|
||||||
|
created,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
EntryIconType.DEFAULT,
|
||||||
|
EntryName.Trim(),
|
||||||
|
new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = Username.Trim() },
|
||||||
|
new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = Password },
|
||||||
|
null,
|
||||||
|
totp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,107 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:KeyKeeper.ViewModels"
|
xmlns:vm="using:KeyKeeper.ViewModels"
|
||||||
|
xmlns:ps="using:KeyKeeper.PasswordStore"
|
||||||
x:Class="KeyKeeper.Views.EntryEditWindow"
|
x:Class="KeyKeeper.Views.EntryEditWindow"
|
||||||
|
x:DataType="vm:EntryEditViewModel"
|
||||||
Title="Add Entry"
|
Title="Add Entry"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
Width="400"
|
Width="450"
|
||||||
Height="250"
|
Height="650"
|
||||||
Background="White">
|
Background="White">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*" ColumnDefinitions="1*,3*" Margin="5">
|
<ScrollViewer>
|
||||||
<TextBlock Text="Add New Password Entry" FontSize="20" HorizontalAlignment="Center"
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*" ColumnDefinitions="1*,3*" Margin="5">
|
||||||
Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,0,0,20"/>
|
<TextBlock Text="Add New Password Entry" FontSize="20" HorizontalAlignment="Center"
|
||||||
|
Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,0,0,20"/>
|
||||||
|
|
||||||
<TextBlock Text="Entry name:" HorizontalAlignment="Right"
|
<TextBlock Text="Entry name:" HorizontalAlignment="Right"
|
||||||
Grid.Row="1" Grid.Column="0" Margin="5" />
|
Grid.Row="1" Grid.Column="0" Margin="5" />
|
||||||
<TextBox Name="EntryNameEdit" Grid.Row="1" Grid.Column="1" Margin="5" />
|
<TextBox Name="EntryNameEdit" Text="{Binding EntryName}" Grid.Row="1" Grid.Column="1" Margin="5" />
|
||||||
|
|
||||||
<TextBlock Text="Username:" HorizontalAlignment="Right"
|
<TextBlock Text="Username:" HorizontalAlignment="Right"
|
||||||
Grid.Row="2" Grid.Column="0" Margin="5" />
|
Grid.Row="2" Grid.Column="0" Margin="5" />
|
||||||
<TextBox Name="UsernameEdit" Grid.Row="2" Grid.Column="1" Margin="5" />
|
<TextBox Name="UsernameEdit" Text="{Binding Username}" Grid.Row="2" Grid.Column="1" Margin="5" />
|
||||||
|
|
||||||
<TextBlock Text="Password:" HorizontalAlignment="Right"
|
<TextBlock Text="Password:" HorizontalAlignment="Right"
|
||||||
Grid.Row="3" Grid.Column="0" Margin="5" />
|
Grid.Row="3" Grid.Column="0" Margin="5" />
|
||||||
|
|
||||||
<!-- <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> -->
|
<!-- Grid for password field with strength indicator -->
|
||||||
<Grid Grid.Row="3" Grid.Column="1" Margin="5" RowDefinitions="Auto,Auto">
|
<Grid Grid.Row="3" Grid.Column="1" Margin="5" RowDefinitions="Auto,Auto">
|
||||||
<TextBox Name="PasswordEdit" Grid.Row="0" PasswordChar="*" />
|
<TextBox Name="PasswordEdit" Text="{Binding Password}" Grid.Row="0" PasswordChar="*" />
|
||||||
|
|
||||||
<!-- <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> -->
|
<!-- Password strength indicator -->
|
||||||
<Border Name="PasswordStrengthIndicator" Grid.Row="1"
|
<Border Name="PasswordStrengthIndicator" Grid.Row="1"
|
||||||
Height="4" CornerRadius="2" Margin="0,3,0,0"
|
Height="4" CornerRadius="2" Margin="0,3,0,0"
|
||||||
Background="#ddd">
|
Background="#ddd">
|
||||||
<Border Name="PasswordStrengthFill"
|
<Border Name="PasswordStrengthFill"
|
||||||
Width="0" Height="4"
|
Width="0" Height="4"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
CornerRadius="2" />
|
CornerRadius="2" />
|
||||||
</Border>
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="One-time passwords:" HorizontalAlignment="Right"
|
||||||
|
Grid.Row="4" Grid.Column="0" Margin="5" />
|
||||||
|
<StackPanel Grid.Row="4" Grid.Column="1" Margin="5">
|
||||||
|
<Button Name="ConfigureTotpButton" Content="Configure..." HorizontalAlignment="Left"
|
||||||
|
IsVisible="{Binding !IsTotpConfigured}" Padding="8,4" />
|
||||||
|
|
||||||
|
<StackPanel IsVisible="{Binding IsTotpConfigured}" Spacing="8">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Secret (Base32):" FontSize="12" Foreground="#666" />
|
||||||
|
<TextBox Name="TotpSecretEdit" Text="{Binding TotpSecret}" />
|
||||||
|
<TextBlock Text="{Binding SecretValidationError}"
|
||||||
|
IsVisible="{Binding SecretValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
FontSize="11" Foreground="Red" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Algorithm:" FontSize="12" Foreground="#666" />
|
||||||
|
<ComboBox SelectedItem="{Binding TotpAlgorithm}">
|
||||||
|
<ps:TotpAlgorithm>SHA1</ps:TotpAlgorithm>
|
||||||
|
<ps:TotpAlgorithm>SHA256</ps:TotpAlgorithm>
|
||||||
|
<ps:TotpAlgorithm>SHA512</ps:TotpAlgorithm>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Digits (6-8):" FontSize="12" Foreground="#666" />
|
||||||
|
<TextBox Name="TotpDigitsEdit" Text="{Binding TotpDigits}" />
|
||||||
|
<TextBlock Text="{Binding DigitsValidationError}"
|
||||||
|
IsVisible="{Binding DigitsValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
FontSize="11" Foreground="Red" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Period (seconds):" FontSize="12" Foreground="#666" />
|
||||||
|
<TextBox Name="TotpPeriodEdit" Text="{Binding TotpPeriod}" />
|
||||||
|
<TextBlock Text="{Binding PeriodValidationError}"
|
||||||
|
IsVisible="{Binding PeriodValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
FontSize="11" Foreground="Red" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Issuer (optional):" FontSize="12" Foreground="#666" />
|
||||||
|
<TextBox Name="TotpIssuerEdit" Text="{Binding TotpIssuer}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Account Name (optional):" FontSize="12" Foreground="#666" />
|
||||||
|
<TextBox Name="TotpAccountNameEdit" Text="{Binding TotpAccountName}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
|
<Button Name="PasteUrlButton" Content="Paste URL" Padding="8,4" />
|
||||||
|
<Button Name="RemoveTotpButton" Content="Remove TOTP" Padding="8,4" Background="#ddd" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Name="DoneButton" Content="Done" HorizontalAlignment="Center" Grid.Row="6" Grid.Column="0" Grid.ColumnSpan="2"
|
||||||
|
VerticalAlignment="Bottom" Background="#aaa" Padding="20,8" IsEnabled="{Binding SaveAllowed}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
<Button Content="Done" HorizontalAlignment="Center" Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
Background="#aaa" Click="AddButton_Click" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
<Style Selector="TextBlock">
|
<Style Selector="TextBlock">
|
||||||
@@ -51,4 +111,4 @@
|
|||||||
<Setter Property="Foreground" Value="Black" />
|
<Setter Property="Foreground" Value="Black" />
|
||||||
</Style>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -4,30 +4,51 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using KeyKeeper.PasswordStore;
|
using KeyKeeper.PasswordStore;
|
||||||
using static KeyKeeper.PasswordStore.FileFormatConstants;
|
using KeyKeeper.ViewModels;
|
||||||
|
|
||||||
namespace KeyKeeper.Views;
|
namespace KeyKeeper.Views;
|
||||||
|
|
||||||
public partial class EntryEditWindow : Window
|
public partial class EntryEditWindow : Window
|
||||||
{
|
{
|
||||||
public PassStoreEntryPassword? EditedEntry;
|
private EntryEditViewModel _viewModel;
|
||||||
private PassStoreEntryPassword? _originalEntry;
|
|
||||||
|
public PassStoreEntryPassword? EditedEntry => _viewModel.EditedEntry;
|
||||||
|
|
||||||
public EntryEditWindow()
|
public EntryEditWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
_viewModel = new EntryEditViewModel();
|
||||||
|
DataContext = _viewModel;
|
||||||
|
|
||||||
if (PasswordEdit != null)
|
if (PasswordEdit != null)
|
||||||
{
|
{
|
||||||
PasswordEdit.TextChanged += PasswordTextChanged;
|
PasswordEdit.TextChanged += PasswordTextChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ConfigureTotpButton != null)
|
||||||
|
{
|
||||||
|
ConfigureTotpButton.Click += ConfigureTotpButton_Click;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PasteUrlButton != null)
|
||||||
|
{
|
||||||
|
PasteUrlButton.Click += PasteUrlButton_Click;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveTotpButton != null)
|
||||||
|
{
|
||||||
|
RemoveTotpButton.Click += RemoveTotpButton_Click;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DoneButton != null)
|
||||||
|
{
|
||||||
|
DoneButton.Click += DoneButton_Click;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetEntry(PassStoreEntryPassword entry)
|
public void SetEntry(PassStoreEntryPassword entry)
|
||||||
{
|
{
|
||||||
_originalEntry = entry;
|
_viewModel.LoadEntry(entry);
|
||||||
EntryNameEdit.Text = entry.Name;
|
|
||||||
UsernameEdit.Text = entry.Username.Value;
|
|
||||||
PasswordEdit.Text = entry.Password.Value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PasswordTextChanged(object? sender, TextChangedEventArgs e)
|
private void PasswordTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
@@ -49,7 +70,7 @@ public partial class EntryEditWindow : Window
|
|||||||
|
|
||||||
int strength = CalculatePasswordStrength(password);
|
int strength = CalculatePasswordStrength(password);
|
||||||
double maxWidth = PasswordStrengthIndicator.Bounds.Width;
|
double maxWidth = PasswordStrengthIndicator.Bounds.Width;
|
||||||
if (maxWidth <= 0) maxWidth = 200;
|
if (maxWidth <= 0) maxWidth = 300;
|
||||||
|
|
||||||
PasswordStrengthFill.Width = (strength / 100.0) * maxWidth;
|
PasswordStrengthFill.Width = (strength / 100.0) * maxWidth;
|
||||||
|
|
||||||
@@ -79,30 +100,35 @@ public partial class EntryEditWindow : Window
|
|||||||
return Math.Min(100, score);
|
return Math.Min(100, score);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddButton_Click(object sender, RoutedEventArgs args)
|
private void ConfigureTotpButton_Click(object? sender, RoutedEventArgs args)
|
||||||
{
|
{
|
||||||
string name = EntryNameEdit?.Text?.Trim() ?? "";
|
_viewModel.ConfigureTotp();
|
||||||
if (string.IsNullOrEmpty(name)) return;
|
}
|
||||||
|
|
||||||
string username = UsernameEdit?.Text?.Trim() ?? "";
|
private async void PasteUrlButton_Click(object? sender, RoutedEventArgs args)
|
||||||
if (string.IsNullOrEmpty(username)) return;
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? clipboardText = await Clipboard!.GetTextAsync();
|
||||||
|
if (!string.IsNullOrEmpty(clipboardText))
|
||||||
|
{
|
||||||
|
_viewModel.ParseOtpauthUrl(clipboardText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail if clipboard access fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string password = PasswordEdit?.Text ?? "";
|
private void RemoveTotpButton_Click(object? sender, RoutedEventArgs args)
|
||||||
if (string.IsNullOrEmpty(password)) return;
|
{
|
||||||
|
_viewModel.RemoveTotp();
|
||||||
|
}
|
||||||
|
|
||||||
Guid id = _originalEntry?.Id ?? Guid.NewGuid();
|
private void DoneButton_Click(object? sender, RoutedEventArgs args)
|
||||||
DateTime created = DateTime.UtcNow;
|
{
|
||||||
|
_viewModel.CreateEntry();
|
||||||
EditedEntry = new PassStoreEntryPassword(
|
|
||||||
id,
|
|
||||||
created,
|
|
||||||
DateTime.UtcNow,
|
|
||||||
EntryIconType.DEFAULT,
|
|
||||||
name,
|
|
||||||
new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = username },
|
|
||||||
new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = password },
|
|
||||||
null
|
|
||||||
);
|
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user