Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions src/PassKey.Core/Services/CardTypeDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ namespace PassKey.Core.Services;

public static class CardTypeDetector
{
/// <summary>
/// BIN (Bank Identification Number) / IIN prefix ranges used for card-type detection.
/// Sources: EMVCo IIN Registry; updated ranges as of 2024.
/// </summary>
private static class BinRanges
{
// MasterCard: standard range 51-55 is checked inline with a 2-digit prefix;
// the expanded range covers re-issued BINs introduced in 2017.
internal const int MasterCardExpandedStart = 2221;
internal const int MasterCardExpandedEnd = 2720;

// Discover: 3-digit prefix range (644-649)
internal const int DiscoverRange3Start = 644;
internal const int DiscoverRange3End = 649;

// Discover: 6-digit IIN range (co-branded UnionPay; 622126-622925)
internal const int DiscoverRange6Start = 622126;
internal const int DiscoverRange6End = 622925;

// JCB: 4-digit prefix range (3528-3589)
internal const int JcbRangeStart = 3528;
internal const int JcbRangeEnd = 3589;

// Diners Club Classic: 3-digit prefix range (300-305)
internal const int DinersRange3Start = 300;
internal const int DinersRange3End = 305;
}

/// <summary>
/// Detects the card type from the card number using BIN prefix tables.
/// </summary>
Expand All @@ -24,7 +52,7 @@ public static CardType Detect(string cardNumber)
if (digits.StartsWith('4'))
return CardType.Visa;

// MasterCard: 51-55 or 2221-2720
// MasterCard: 51-55 or expanded range 2221-2720
if (digits.Length >= 2)
{
var prefix2 = int.Parse(digits[..2]);
Expand All @@ -34,7 +62,7 @@ public static CardType Detect(string cardNumber)
if (digits.Length >= 4)
{
var prefix4 = int.Parse(digits[..4]);
if (prefix4 >= 2221 && prefix4 <= 2720)
if (prefix4 >= BinRanges.MasterCardExpandedStart && prefix4 <= BinRanges.MasterCardExpandedEnd)
return CardType.MasterCard;
}

Expand All @@ -44,21 +72,21 @@ public static CardType Detect(string cardNumber)
if (digits.Length >= 3)
{
var prefix3 = int.Parse(digits[..3]);
if (prefix3 >= 644 && prefix3 <= 649)
if (prefix3 >= BinRanges.DiscoverRange3Start && prefix3 <= BinRanges.DiscoverRange3End)
return CardType.Discover;
}
if (digits.Length >= 6)
{
var prefix6 = int.Parse(digits[..6]);
if (prefix6 >= 622126 && prefix6 <= 622925)
if (prefix6 >= BinRanges.DiscoverRange6Start && prefix6 <= BinRanges.DiscoverRange6End)
return CardType.Discover;
}

// JCB: 3528-3589
if (digits.Length >= 4)
{
var prefix4 = int.Parse(digits[..4]);
if (prefix4 >= 3528 && prefix4 <= 3589)
if (prefix4 >= BinRanges.JcbRangeStart && prefix4 <= BinRanges.JcbRangeEnd)
return CardType.JCB;
}

Expand All @@ -68,7 +96,7 @@ public static CardType Detect(string cardNumber)
if (digits.Length >= 3)
{
var prefix3 = int.Parse(digits[..3]);
if (prefix3 >= 300 && prefix3 <= 305)
if (prefix3 >= BinRanges.DinersRange3Start && prefix3 <= BinRanges.DinersRange3End)
return CardType.DinersClub;
}

Expand Down
102 changes: 38 additions & 64 deletions src/PassKey.Core/Services/MergeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,82 +11,56 @@ public ImportResult MergeInto(Vault target, Vault source, ImportMergeStrategy st
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(source);

int pwImported = 0, cardImported = 0, idImported = 0, noteImported = 0;
int skipped = 0, overwritten = 0;
var (pwImported, pwSkipped, pwOverwritten) = MergeCollection(target.Passwords, source.Passwords, strategy, ComputePasswordHash);
var (cardImported, cardSkipped, cardOverwritten) = MergeCollection(target.CreditCards, source.CreditCards, strategy, ComputeCardHash);
var (idImported, idSkipped, idOverwritten) = MergeCollection(target.Identities, source.Identities, strategy, ComputeIdentityHash);
var (noteImported, noteSkipped, noteOverwritten) = MergeCollection(target.SecureNotes, source.SecureNotes, strategy, ComputeNoteHash);

// Build hash sets of existing entries
var existingPwHashes = BuildHashSet(target.Passwords, ComputePasswordHash);
var existingCardHashes = BuildHashSet(target.CreditCards, ComputeCardHash);
var existingIdHashes = BuildHashSet(target.Identities, ComputeIdentityHash);
var existingNoteHashes = BuildHashSet(target.SecureNotes, ComputeNoteHash);
target.LastModified = DateTime.UtcNow;

// Merge passwords
foreach (var entry in source.Passwords)
return new ImportResult
{
var hash = ComputePasswordHash(entry);
var result = MergeEntry(target.Passwords, entry, hash, existingPwHashes, strategy, ComputePasswordHash);
switch (result)
{
case MergeAction.Imported: pwImported++; break;
case MergeAction.Skipped: skipped++; break;
case MergeAction.Overwritten: overwritten++; break;
}
}
PasswordsImported = pwImported,
CardsImported = cardImported,
IdentitiesImported = idImported,
NotesImported = noteImported,
Skipped = pwSkipped + cardSkipped + idSkipped + noteSkipped,
Overwritten = pwOverwritten + cardOverwritten + idOverwritten + noteOverwritten
};
}

// Merge credit cards
foreach (var entry in source.CreditCards)
{
var hash = ComputeCardHash(entry);
var result = MergeEntry(target.CreditCards, entry, hash, existingCardHashes, strategy, ComputeCardHash);
switch (result)
{
case MergeAction.Imported: cardImported++; break;
case MergeAction.Skipped: skipped++; break;
case MergeAction.Overwritten: overwritten++; break;
}
}
private enum MergeAction { Imported, Skipped, Overwritten }

// Merge identities
foreach (var entry in source.Identities)
{
var hash = ComputeIdentityHash(entry);
var result = MergeEntry(target.Identities, entry, hash, existingIdHashes, strategy, ComputeIdentityHash);
switch (result)
{
case MergeAction.Imported: idImported++; break;
case MergeAction.Skipped: skipped++; break;
case MergeAction.Overwritten: overwritten++; break;
}
}
private const int NoteHashSnippetLength = 256;

/// <summary>
/// Merges all entries from <paramref name="sourceList"/> into <paramref name="targetList"/>
/// applying the given <paramref name="strategy"/> for duplicates.
/// Returns a tuple of (imported, skipped, overwritten) counts.
/// </summary>
private static (int imported, int skipped, int overwritten) MergeCollection<T>(
List<T> targetList,
List<T> sourceList,
ImportMergeStrategy strategy,
Func<T, string> hashFunc) where T : class
{
int imported = 0, skipped = 0, overwritten = 0;
var existingHashes = BuildHashSet(targetList, hashFunc);

// Merge secure notes
foreach (var entry in source.SecureNotes)
foreach (var entry in sourceList)
{
var hash = ComputeNoteHash(entry);
var result = MergeEntry(target.SecureNotes, entry, hash, existingNoteHashes, strategy, ComputeNoteHash);
var hash = hashFunc(entry);
var result = MergeEntry(targetList, entry, hash, existingHashes, strategy, hashFunc);
switch (result)
{
case MergeAction.Imported: noteImported++; break;
case MergeAction.Skipped: skipped++; break;
case MergeAction.Imported: imported++; break;
case MergeAction.Skipped: skipped++; break;
case MergeAction.Overwritten: overwritten++; break;
}
}

target.LastModified = DateTime.UtcNow;

return new ImportResult
{
PasswordsImported = pwImported,
CardsImported = cardImported,
IdentitiesImported = idImported,
NotesImported = noteImported,
Skipped = skipped,
Overwritten = overwritten
};
return (imported, skipped, overwritten);
}

private enum MergeAction { Imported, Skipped, Overwritten }

private static MergeAction MergeEntry<T>(
List<T> targetList, T entry, string hash,
Dictionary<string, int> existingHashes,
Expand Down Expand Up @@ -165,8 +139,8 @@ internal static string ComputeIdentityHash(IdentityEntry entry)

internal static string ComputeNoteHash(SecureNoteEntry entry)
{
var contentSnippet = entry.Content.Length > 256
? entry.Content[..256]
var contentSnippet = entry.Content.Length > NoteHashSnippetLength
? entry.Content[..NoteHashSnippetLength]
: entry.Content;
var input = $"{entry.Title.ToLowerInvariant()}|{contentSnippet}";
return ComputeSha256(input);
Expand Down
8 changes: 6 additions & 2 deletions src/PassKey.Core/Services/PasswordGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ public sealed class PasswordGenerator : IPasswordGenerator
private const string Symbols = "!@#$%^&*()-_=+[]{}|;:',.<>?/~`";
private const string AmbiguousChars = "0O1lI|";

private const int MinPasswordLength = 8;
private const int MaxPasswordLength = 128;

public string Generate(PasswordGeneratorOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Length < 8 || options.Length > 128)
throw new ArgumentOutOfRangeException(nameof(options), "Length must be between 8 and 128.");
if (options.Length < MinPasswordLength || options.Length > MaxPasswordLength)
throw new ArgumentOutOfRangeException(nameof(options),
$"Length must be between {MinPasswordLength} and {MaxPasswordLength}.");

var charPool = BuildCharPool(options);
if (charPool.Length == 0)
Expand Down
25 changes: 19 additions & 6 deletions src/PassKey.Core/Services/PasswordStrengthAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ private static bool ContainsCommonPattern(string password)
return false;
}

/// <summary>
/// Time conversion constants used when formatting the estimated crack-time string.
/// </summary>
private static class TimeConstants
{
internal const double SecondsPerMinute = 60;
internal const double SecondsPerHour = 3_600; // 60 * 60
internal const double SecondsPerDay = 86_400; // 60 * 60 * 24
internal const double SecondsPerYear = 31_536_000; // 365 * 24 * 60 * 60
internal const double SecondsPerCentury = SecondsPerYear * 100;
internal const double SecondsPerMillennium = SecondsPerYear * 1_000;
}

private static string EstimateCrackTime(int length, bool hasUpper, bool hasLower, bool hasDigit, bool hasSymbol)
{
var charsetSize = 0;
Expand All @@ -121,12 +134,12 @@ private static string EstimateCrackTime(int length, bool hasUpper, bool hasLower
return seconds switch
{
< 1 => "instant",
< 60 => "seconds",
< 3600 => $"{(int)(seconds / 60)} minutes",
< 86400 => $"{(int)(seconds / 3600)} hours",
< 31536000 => $"{(int)(seconds / 86400)} days",
< 31536000.0 * 100 => $"{(int)(seconds / 31536000)} years",
< 31536000.0 * 1000 => "centuries",
< TimeConstants.SecondsPerMinute => "seconds",
< TimeConstants.SecondsPerHour => $"{(int)(seconds / TimeConstants.SecondsPerMinute)} minutes",
< TimeConstants.SecondsPerDay => $"{(int)(seconds / TimeConstants.SecondsPerHour)} hours",
< TimeConstants.SecondsPerYear => $"{(int)(seconds / TimeConstants.SecondsPerDay)} days",
< TimeConstants.SecondsPerCentury => $"{(int)(seconds / TimeConstants.SecondsPerYear)} years",
< TimeConstants.SecondsPerMillennium => "centuries",
_ => "millennia"
};
}
Expand Down
36 changes: 36 additions & 0 deletions src/PassKey.Desktop/Helpers/ListViewHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace PassKey.Desktop.Helpers;

/// <summary>
/// Shared UI helpers for list-view code-behind classes.
/// Centralises boilerplate that would otherwise be duplicated across
/// <c>PasswordsListView</c>, <c>CreditCardsListView</c>, <c>IdentitiesListView</c>
/// and <c>SecureNotesListView</c>.
/// </summary>
internal static class ListViewHelpers
{
/// <summary>
/// Opens <paramref name="tip"/> for 2 seconds, then closes it automatically
/// via a <see cref="DispatcherTimer"/>.
/// </summary>
/// <param name="tip">The <see cref="TeachingTip"/> to show (e.g. SavedTip).</param>
/// <param name="extraAction">
/// Optional action invoked immediately after opening the tip.
/// Used by <c>SecureNotesListView</c> to trigger the accessibility announcer.
/// </param>
internal static void ShowSavedToast(TeachingTip tip, Action? extraAction = null)
{
tip.IsOpen = true;
extraAction?.Invoke();

var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
timer.Tick += (s, _) =>
{
tip.IsOpen = false;
((DispatcherTimer)s!).Stop();
};
timer.Start();
}
}
Loading
Loading