mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-04-28 19:06:37 +03:00
merge branch 'feature/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();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ static class FileFormatConstants
|
||||
public const byte LOGIN_FIELD_ACCOUNT_NUMBER_ID = 0x03;
|
||||
public const byte LOGIN_FIELD_NOTES_ID = 0x04;
|
||||
public const byte LOGIN_FIELD_CUSTOM_ID = 0xff; // пока не используется
|
||||
public const byte TOTP_ABSENT = 0x00;
|
||||
public const byte TOTP_PRESENT = 0x01;
|
||||
public const byte TOTP_ALGO_SHA1 = 0x00;
|
||||
public const byte TOTP_ALGO_SHA256 = 0x01;
|
||||
public const byte TOTP_ALGO_SHA512 = 0x02;
|
||||
public const byte GROUP_TYPE_ROOT = 0x00;
|
||||
public const byte GROUP_TYPE_DEFAULT = 0x01;
|
||||
public const byte GROUP_TYPE_FAVOURITES = 0x02;
|
||||
|
||||
@@ -46,31 +46,37 @@ public abstract class PassStoreEntry
|
||||
BinaryReader rd = new(str);
|
||||
try
|
||||
{
|
||||
rd.Read7BitEncodedInt();
|
||||
int entryLength = rd.Read7BitEncodedInt();
|
||||
byte[] entryData = rd.ReadBytes(entryLength);
|
||||
if (entryData.Length < entryLength)
|
||||
throw PassStoreFileException.UnexpectedEndOfFile;
|
||||
|
||||
MemoryStream entryStream = new(entryData);
|
||||
BinaryReader entryRd = new(entryStream);
|
||||
|
||||
byte[] uuidBuffer = new byte[16];
|
||||
if (rd.Read(uuidBuffer) < 16)
|
||||
if (entryRd.Read(uuidBuffer) < 16)
|
||||
throw PassStoreFileException.UnexpectedEndOfFile;
|
||||
Guid id = new Guid(uuidBuffer);
|
||||
|
||||
ulong timestamp = FileFormatUtil.ReadVarUint16(str);
|
||||
ulong timestamp = FileFormatUtil.ReadVarUint16(entryStream);
|
||||
DateTime createdAt = DateTimeOffset.FromUnixTimeSeconds((long)timestamp).UtcDateTime;
|
||||
timestamp = FileFormatUtil.ReadVarUint16(str);
|
||||
timestamp = FileFormatUtil.ReadVarUint16(entryStream);
|
||||
DateTime modifiedAt = DateTimeOffset.FromUnixTimeSeconds((long)timestamp).UtcDateTime;
|
||||
|
||||
if (rd.Read(uuidBuffer) < 16)
|
||||
if (entryRd.Read(uuidBuffer) < 16)
|
||||
throw PassStoreFileException.UnexpectedEndOfFile;
|
||||
Guid iconType = new Guid(uuidBuffer);
|
||||
|
||||
string name = FileFormatUtil.ReadU16TaggedString(str);
|
||||
string name = FileFormatUtil.ReadU16TaggedString(entryStream);
|
||||
|
||||
byte entryType = rd.ReadByte();
|
||||
byte entryType = entryRd.ReadByte();
|
||||
if (entryType == ENTRY_GROUP_ID)
|
||||
{
|
||||
return PassStoreEntryGroup.ReadFromStream(str, id, createdAt, modifiedAt, iconType, name);
|
||||
return PassStoreEntryGroup.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name);
|
||||
} else if (entryType == ENTRY_PASS_ID)
|
||||
{
|
||||
return PassStoreEntryPassword.ReadFromStream(str, id, createdAt, modifiedAt, iconType, name);
|
||||
return PassStoreEntryPassword.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name);
|
||||
} else
|
||||
{
|
||||
throw PassStoreFileException.InvalidPassStoreEntry;
|
||||
|
||||
@@ -10,8 +10,11 @@ public class PassStoreEntryPassword : PassStoreEntry
|
||||
public LoginField Username { get; set; }
|
||||
public LoginField Password { get; set; }
|
||||
public List<LoginField> ExtraFields { get; set; }
|
||||
public TotpParameters? Totp { get; set; }
|
||||
|
||||
public PassStoreEntryPassword(Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name, LoginField username, LoginField password, List<LoginField>? extras = null)
|
||||
public PassStoreEntryPassword(Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name,
|
||||
LoginField username, LoginField password,
|
||||
List<LoginField>? extras = null, TotpParameters? totp = null)
|
||||
{
|
||||
Id = id;
|
||||
CreationDate = createdAt;
|
||||
@@ -21,6 +24,7 @@ public class PassStoreEntryPassword : PassStoreEntry
|
||||
Username = username;
|
||||
Password = password;
|
||||
ExtraFields = extras ?? new();
|
||||
Totp = totp;
|
||||
}
|
||||
|
||||
public static PassStoreEntry ReadFromStream(Stream str, Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name)
|
||||
@@ -34,6 +38,27 @@ public class PassStoreEntryPassword : PassStoreEntry
|
||||
int extraFieldCount = rd.Read7BitEncodedInt();
|
||||
for (; extraFieldCount > 0; extraFieldCount--)
|
||||
entry.ExtraFields.Add(ReadField(str));
|
||||
|
||||
int totpPresence = str.ReadByte();
|
||||
if (totpPresence == TOTP_PRESENT)
|
||||
{
|
||||
TotpAlgorithm algo = rd.ReadByte() switch
|
||||
{
|
||||
TOTP_ALGO_SHA256 => TotpAlgorithm.SHA256,
|
||||
TOTP_ALGO_SHA512 => TotpAlgorithm.SHA512,
|
||||
_ => TotpAlgorithm.SHA1,
|
||||
};
|
||||
byte digits = rd.ReadByte();
|
||||
int period = rd.Read7BitEncodedInt();
|
||||
string secret = FileFormatUtil.ReadU16TaggedString(str);
|
||||
string issuer = FileFormatUtil.ReadU16TaggedString(str);
|
||||
string accountName = FileFormatUtil.ReadU16TaggedString(str);
|
||||
entry.Totp = new TotpParameters(
|
||||
secret, algo, digits, period,
|
||||
issuer.Length > 0 ? issuer : null,
|
||||
accountName.Length > 0 ? accountName : null);
|
||||
}
|
||||
|
||||
return entry;
|
||||
} catch (EndOfStreamException)
|
||||
{
|
||||
@@ -58,6 +83,28 @@ public class PassStoreEntryPassword : PassStoreEntry
|
||||
wr.Write7BitEncodedInt(ExtraFields.Count);
|
||||
foreach (LoginField field in ExtraFields)
|
||||
WriteField(str, field);
|
||||
|
||||
if (Totp is TotpParameters totp)
|
||||
{
|
||||
str.WriteByte(TOTP_PRESENT);
|
||||
byte algoByte = totp.Algorithm switch
|
||||
{
|
||||
TotpAlgorithm.SHA256 => TOTP_ALGO_SHA256,
|
||||
TotpAlgorithm.SHA512 => TOTP_ALGO_SHA512,
|
||||
_ => TOTP_ALGO_SHA1,
|
||||
};
|
||||
str.WriteByte(algoByte);
|
||||
str.WriteByte((byte)totp.Digits);
|
||||
wr.Write7BitEncodedInt(totp.Period);
|
||||
FileFormatUtil.WriteU16TaggedString(str, totp.Secret);
|
||||
FileFormatUtil.WriteU16TaggedString(str, totp.Issuer ?? "");
|
||||
FileFormatUtil.WriteU16TaggedString(str, totp.AccountName ?? "");
|
||||
}
|
||||
else
|
||||
{
|
||||
str.WriteByte(TOTP_ABSENT);
|
||||
}
|
||||
|
||||
return str.ToArray();
|
||||
}
|
||||
|
||||
@@ -86,4 +133,4 @@ public class PassStoreEntryPassword : PassStoreEntry
|
||||
field.Value = FileFormatUtil.ReadU16TaggedString(str);
|
||||
return field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
src/KeyKeeper/PasswordStore/TotpParameters.cs
Normal file
88
src/KeyKeeper/PasswordStore/TotpParameters.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Web;
|
||||
|
||||
namespace KeyKeeper.PasswordStore;
|
||||
|
||||
public enum TotpAlgorithm { SHA1, SHA256, SHA512 }
|
||||
|
||||
public class TotpParameters
|
||||
{
|
||||
public string Secret { get; set; }
|
||||
public TotpAlgorithm Algorithm { get; set; }
|
||||
public int Digits { get; set; }
|
||||
public int Period { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public string? AccountName { get; set; }
|
||||
|
||||
public TotpParameters(string secret, TotpAlgorithm algorithm = TotpAlgorithm.SHA1,
|
||||
int digits = 6, int period = 30,
|
||||
string? issuer = null, string? accountName = null)
|
||||
{
|
||||
Secret = secret;
|
||||
Algorithm = algorithm;
|
||||
Digits = digits;
|
||||
Period = period;
|
||||
Issuer = issuer;
|
||||
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)
|
||||
{
|
||||
if (!uri.StartsWith("otpauth://totp/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("URI must start with otpauth://totp/", nameof(uri));
|
||||
|
||||
Uri parsed = new(uri);
|
||||
|
||||
string label = Uri.UnescapeDataString(parsed.AbsolutePath.TrimStart('/'));
|
||||
string? issuer = null;
|
||||
int colon = label.IndexOf(':');
|
||||
string? accountName;
|
||||
if (colon >= 0)
|
||||
{
|
||||
issuer = label[..colon];
|
||||
accountName = label[(colon + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
accountName = label;
|
||||
}
|
||||
|
||||
var query = HttpUtility.ParseQueryString(parsed.Query);
|
||||
|
||||
string base32Secret = query["secret"] ?? throw new ArgumentException("URI is missing required 'secret' parameter", nameof(uri));
|
||||
|
||||
issuer = query["issuer"] ?? issuer;
|
||||
|
||||
TotpAlgorithm algorithm = (query["algorithm"] ?? "SHA1").ToUpperInvariant() switch
|
||||
{
|
||||
"SHA256" => TotpAlgorithm.SHA256,
|
||||
"SHA512" => TotpAlgorithm.SHA512,
|
||||
_ => TotpAlgorithm.SHA1,
|
||||
};
|
||||
|
||||
int digits = int.TryParse(query["digits"], out int d) ? d : 6;
|
||||
int period = int.TryParse(query["period"], out int p) ? p : 30;
|
||||
|
||||
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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,105 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:KeyKeeper.ViewModels"
|
||||
xmlns:ps="using:KeyKeeper.PasswordStore"
|
||||
x:Class="KeyKeeper.Views.EntryEditWindow"
|
||||
x:DataType="vm:EntryEditViewModel"
|
||||
Title="Add Entry"
|
||||
CanResize="False"
|
||||
Width="400"
|
||||
Height="250"
|
||||
Width="450"
|
||||
Height="400"
|
||||
Background="White">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*" ColumnDefinitions="1*,3*" Margin="5">
|
||||
<TextBlock Text="Add New Password Entry" FontSize="20" HorizontalAlignment="Center"
|
||||
Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,0,0,20"/>
|
||||
<ScrollViewer>
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*" ColumnDefinitions="1*,3*" Margin="5">
|
||||
<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"
|
||||
Grid.Row="1" Grid.Column="0" Margin="5" />
|
||||
<TextBox Name="EntryNameEdit" Grid.Row="1" Grid.Column="1" Margin="5" />
|
||||
<TextBlock Text="Entry name:" HorizontalAlignment="Right"
|
||||
Grid.Row="1" Grid.Column="0" Margin="5" />
|
||||
<TextBox Name="EntryNameEdit" Text="{Binding EntryName}" Grid.Row="1" Grid.Column="1" Margin="5" />
|
||||
|
||||
<TextBlock Text="Username:" HorizontalAlignment="Right"
|
||||
Grid.Row="2" Grid.Column="0" Margin="5" />
|
||||
<TextBox Name="UsernameEdit" Grid.Row="2" Grid.Column="1" Margin="5" />
|
||||
<TextBlock Text="Username:" HorizontalAlignment="Right"
|
||||
Grid.Row="2" Grid.Column="0" Margin="5" />
|
||||
<TextBox Name="UsernameEdit" Text="{Binding Username}" Grid.Row="2" Grid.Column="1" Margin="5" />
|
||||
|
||||
<TextBlock Text="Password:" HorizontalAlignment="Right"
|
||||
Grid.Row="3" Grid.Column="0" Margin="5" />
|
||||
|
||||
<!-- <20><><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 Grid.Row="3" Grid.Column="1" Margin="5" RowDefinitions="Auto,Auto">
|
||||
<TextBox Name="PasswordEdit" Grid.Row="0" PasswordChar="*" />
|
||||
|
||||
<!-- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> -->
|
||||
<Border Name="PasswordStrengthIndicator" Grid.Row="1"
|
||||
Height="4" CornerRadius="2" Margin="0,3,0,0"
|
||||
Background="#ddd">
|
||||
<Border Name="PasswordStrengthFill"
|
||||
Width="0" Height="4"
|
||||
HorizontalAlignment="Left"
|
||||
CornerRadius="2" />
|
||||
</Border>
|
||||
<TextBlock Text="Password:" HorizontalAlignment="Right"
|
||||
Grid.Row="3" Grid.Column="0" Margin="5" />
|
||||
|
||||
<Grid Grid.Row="3" Grid.Column="1" Margin="5" RowDefinitions="Auto,Auto">
|
||||
<TextBox Name="PasswordEdit" Text="{Binding Password}" Grid.Row="0" PasswordChar="*" />
|
||||
|
||||
<Border Name="PasswordStrengthIndicator" Grid.Row="1"
|
||||
Height="4" CornerRadius="2" Margin="0,3,0,0"
|
||||
Background="#ddd">
|
||||
<Border Name="PasswordStrengthFill"
|
||||
Width="0" Height="4"
|
||||
HorizontalAlignment="Left"
|
||||
CornerRadius="2" />
|
||||
</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>
|
||||
|
||||
<Button Content="Done" HorizontalAlignment="Center" Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
VerticalAlignment="Bottom"
|
||||
Background="#aaa" Click="AddButton_Click" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
@@ -51,4 +109,4 @@
|
||||
<Setter Property="Foreground" Value="Black" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
</Window>
|
||||
</Window>
|
||||
|
||||
@@ -4,30 +4,51 @@ using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using KeyKeeper.PasswordStore;
|
||||
using static KeyKeeper.PasswordStore.FileFormatConstants;
|
||||
using KeyKeeper.ViewModels;
|
||||
|
||||
namespace KeyKeeper.Views;
|
||||
|
||||
public partial class EntryEditWindow : Window
|
||||
{
|
||||
public PassStoreEntryPassword? EditedEntry;
|
||||
private PassStoreEntryPassword? _originalEntry;
|
||||
private EntryEditViewModel _viewModel;
|
||||
|
||||
public PassStoreEntryPassword? EditedEntry => _viewModel.EditedEntry;
|
||||
|
||||
public EntryEditWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = new EntryEditViewModel();
|
||||
DataContext = _viewModel;
|
||||
|
||||
if (PasswordEdit != null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
_originalEntry = entry;
|
||||
EntryNameEdit.Text = entry.Name;
|
||||
UsernameEdit.Text = entry.Username.Value;
|
||||
PasswordEdit.Text = entry.Password.Value;
|
||||
_viewModel.LoadEntry(entry);
|
||||
}
|
||||
|
||||
private void PasswordTextChanged(object? sender, TextChangedEventArgs e)
|
||||
@@ -49,7 +70,7 @@ public partial class EntryEditWindow : Window
|
||||
|
||||
int strength = CalculatePasswordStrength(password);
|
||||
double maxWidth = PasswordStrengthIndicator.Bounds.Width;
|
||||
if (maxWidth <= 0) maxWidth = 200;
|
||||
if (maxWidth <= 0) maxWidth = 300;
|
||||
|
||||
PasswordStrengthFill.Width = (strength / 100.0) * maxWidth;
|
||||
|
||||
@@ -79,30 +100,35 @@ public partial class EntryEditWindow : Window
|
||||
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() ?? "";
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
_viewModel.ConfigureTotp();
|
||||
}
|
||||
|
||||
string username = UsernameEdit?.Text?.Trim() ?? "";
|
||||
if (string.IsNullOrEmpty(username)) return;
|
||||
private async void PasteUrlButton_Click(object? sender, RoutedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? clipboardText = await Clipboard!.GetTextAsync();
|
||||
if (!string.IsNullOrEmpty(clipboardText))
|
||||
{
|
||||
_viewModel.ParseOtpauthUrl(clipboardText);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail if clipboard access fails
|
||||
}
|
||||
}
|
||||
|
||||
string password = PasswordEdit?.Text ?? "";
|
||||
if (string.IsNullOrEmpty(password)) return;
|
||||
private void RemoveTotpButton_Click(object? sender, RoutedEventArgs args)
|
||||
{
|
||||
_viewModel.RemoveTotp();
|
||||
}
|
||||
|
||||
Guid id = _originalEntry?.Id ?? Guid.NewGuid();
|
||||
DateTime created = DateTime.UtcNow;
|
||||
|
||||
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
|
||||
);
|
||||
private void DoneButton_Click(object? sender, RoutedEventArgs args)
|
||||
{
|
||||
_viewModel.CreateEntry();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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