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
2 changes: 1 addition & 1 deletion src/PassKey.Core/Models/CreditCardEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace PassKey.Core.Models;
/// Fields follow the physical card flow: Number → Name → Expiry → CVV → metadata.
/// Stored as part of the encrypted vault blob in the VaultData SQLite table.
/// </summary>
public sealed class CreditCardEntry
public sealed class CreditCardEntry : IVaultEntry
{
/// <summary>Gets or sets the unique identifier for this entry.</summary>
public Guid Id { get; set; } = Guid.NewGuid();
Expand Down
15 changes: 15 additions & 0 deletions src/PassKey.Core/Models/IVaultEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace PassKey.Core.Models;

/// <summary>
/// Common contract for all vault entry types (passwords, credit cards, identities, secure notes).
/// Used by generic ViewModels and services that need to operate uniformly across entry types
/// without coupling to concrete model classes.
/// </summary>
public interface IVaultEntry
{
/// <summary>The unique identifier for this entry (assigned on creation).</summary>
Guid Id { get; }

/// <summary>The UTC timestamp of the last modification to this entry.</summary>
DateTime ModifiedAt { get; set; }
}
2 changes: 1 addition & 1 deletion src/PassKey.Core/Models/IdentityEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace PassKey.Core.Models;
/// Stores contact information, postal address, and government-issued document numbers.
/// Stored as part of the encrypted vault blob in the VaultData SQLite table.
/// </summary>
public sealed class IdentityEntry
public sealed class IdentityEntry : IVaultEntry
{
/// <summary>Gets or sets the unique identifier for this entry.</summary>
public Guid Id { get; set; } = Guid.NewGuid();
Expand Down
2 changes: 1 addition & 1 deletion src/PassKey.Core/Models/PasswordEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace PassKey.Core.Models;
/// Instances are serialized to JSON, encrypted with AES-GCM, and stored as a
/// single encrypted blob in the VaultData SQLite table.
/// </summary>
public sealed class PasswordEntry
public sealed class PasswordEntry : IVaultEntry
{
/// <summary>Gets or sets the unique identifier for this entry.</summary>
public Guid Id { get; set; } = Guid.NewGuid();
Expand Down
2 changes: 1 addition & 1 deletion src/PassKey.Core/Models/SecureNoteEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace PassKey.Core.Models;
/// Represents an encrypted free-text note in the PassKey vault.
/// Stored as part of the encrypted vault blob in the VaultData SQLite table.
/// </summary>
public sealed class SecureNoteEntry
public sealed class SecureNoteEntry : IVaultEntry
{
/// <summary>Gets or sets the unique identifier for this note.</summary>
public Guid Id { get; set; } = Guid.NewGuid();
Expand Down
58 changes: 44 additions & 14 deletions src/PassKey.Core/Services/CardTypeDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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 MasterCardStandardStart = 51;
internal const int MasterCardStandardEnd = 55;
internal const int MasterCardExpandedStart = 2221;
internal const int MasterCardExpandedEnd = 2720;

Expand All @@ -32,6 +34,25 @@ private static class BinRanges
internal const int DinersRange3End = 305;
}

/// <summary>Network-specific literal prefixes used for card-type detection (string-startswith match).</summary>
private static class BinPrefixes
{
/// <summary>American Express: 34 or 37.</summary>
internal static readonly string[] Amex = ["34", "37"];

/// <summary>Visa always starts with 4.</summary>
internal const string Visa = "4";

/// <summary>Discover: 6011 or 65 (in addition to the 3- and 6-digit ranges in <see cref="BinRanges"/>).</summary>
internal static readonly string[] Discover = ["6011", "65"];

/// <summary>Diners Club: 36 or 38 (in addition to the 300-305 range in <see cref="BinRanges"/>).</summary>
internal static readonly string[] Diners = ["36", "38"];

/// <summary>Maestro: 5018, 5020, 5038, 6304, 6759, 6761, 6762, 6763.</summary>
internal static readonly string[] Maestro = ["5018", "5020", "5038", "6304", "6759", "6761", "6762", "6763"];
}

/// <summary>
/// Detects the card type from the card number using BIN prefix tables.
/// </summary>
Expand All @@ -44,19 +65,19 @@ public static CardType Detect(string cardNumber)
if (digits.Length < 4)
return CardType.Unknown;

// Amex: starts with 34 or 37
if (digits.StartsWith("34") || digits.StartsWith("37"))
// Amex: starts with 34 or 37.
if (StartsWithAny(digits, BinPrefixes.Amex))
return CardType.Amex;

// Visa: starts with 4
if (digits.StartsWith('4'))
// Visa: starts with 4.
if (digits.StartsWith(BinPrefixes.Visa))
return CardType.Visa;

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

// Discover: 6011, 622126-622925, 644-649, 65
if (digits.StartsWith("6011") || digits.StartsWith("65"))
// Discover: 6011, 622126-622925, 644-649, 65.
if (StartsWithAny(digits, BinPrefixes.Discover))
return CardType.Discover;
if (digits.Length >= 3)
{
Expand All @@ -82,16 +103,16 @@ public static CardType Detect(string cardNumber)
return CardType.Discover;
}

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

// Diners Club: 300-305, 36, 38
if (digits.StartsWith("36") || digits.StartsWith("38"))
// Diners Club: 300-305, 36, 38.
if (StartsWithAny(digits, BinPrefixes.Diners))
return CardType.DinersClub;
if (digits.Length >= 3)
{
Expand All @@ -100,14 +121,23 @@ public static CardType Detect(string cardNumber)
return CardType.DinersClub;
}

// Maestro: 5018, 5020, 5038, 6304, 6759, 6761, 6762, 6763
string[] maestroPrefixes = ["5018", "5020", "5038", "6304", "6759", "6761", "6762", "6763"];
if (digits.Length >= 4 && maestroPrefixes.Any(p => digits.StartsWith(p)))
// Maestro: 5018, 5020, 5038, 6304, 6759, 6761, 6762, 6763.
if (digits.Length >= 4 && StartsWithAny(digits, BinPrefixes.Maestro))
return CardType.Maestro;

return CardType.Unknown;
}

/// <summary>Returns true if the supplied digit string starts with any of the supplied prefixes.</summary>
private static bool StartsWithAny(string digits, string[] prefixes)
{
foreach (var prefix in prefixes)
{
if (digits.StartsWith(prefix)) return true;
}
return false;
}

/// <summary>
/// Validates a card number using the Luhn algorithm.
/// </summary>
Expand Down
33 changes: 32 additions & 1 deletion src/PassKey.Core/Services/CsvImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,26 @@ public Vault ParseCsv(string csvContent)

if (columnMap.Count == 0) return vault;

// NordPass and a few other exporters include a "type" column distinguishing
// password / credit_card / identity / note rows in a single CSV. When present,
// only "password" (or unrecognised/empty types) are imported here as
// PasswordEntries; richer cross-type import is tracked for a future release.
var typeColumnIndex = TryFindTypeColumn(headers);

for (int i = 1; i < lines.Length; i++)
{
var fields = ParseCsvLine(lines[i]);
if (fields.Count == 0) continue;

// Skip non-password rows when a "type" column is present so card/identity/note
// labels don't pollute the imported password list.
if (typeColumnIndex >= 0 && typeColumnIndex < fields.Count)
{
var rowType = fields[typeColumnIndex].Trim().ToLowerInvariant();
if (rowType.Length > 0 && rowType != "password" && rowType != "login")
continue;
}

var entry = new PasswordEntry
{
Id = Guid.NewGuid(),
Expand Down Expand Up @@ -71,7 +86,8 @@ private static Dictionary<ColumnType, int> MapHeaders(List<string> headers)
case "url" or "uri" or "website" or "login_uri":
map.TryAdd(ColumnType.Url, i);
break;
case "notes" or "comment" or "comments" or "extra":
case "notes" or "note" or "comment" or "comments" or "extra":
// "note" (singular) is required for NordPass exports.
map.TryAdd(ColumnType.Notes, i);
break;
}
Expand All @@ -87,6 +103,21 @@ private static string GetField(List<string> fields, Dictionary<ColumnType, int>
return fields[idx].Trim();
}

/// <summary>
/// Returns the zero-based index of a "type"-style column in the header row, or -1 if absent.
/// Used to detect mixed-format exports (e.g. NordPass) so non-password rows can be skipped.
/// </summary>
private static int TryFindTypeColumn(List<string> headers)
{
for (int i = 0; i < headers.Count; i++)
{
var name = headers[i].Trim().ToLowerInvariant();
if (name == "type" || name == "item_type" || name == "entry_type")
return i;
}
return -1;
}

/// <summary>
/// Parses a CSV line handling RFC 4180 quoted fields.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions src/PassKey.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ private async void OnActivated(object sender, WindowActivatedEventArgs args)
// Apply saved theme on startup
ApplySavedTheme();

// Register a lazy XamlRoot accessor with the dialog service so ViewModels can show
// ContentDialogs without each having to plumb a XamlRoot reference. A delegate is
// used (rather than caching Content.XamlRoot directly) because that property can be
// null until the window has rendered; reading it just-in-time, when each dialog is
// about to be shown, is always safe.
App.Services.GetRequiredService<IDialogQueueService>().XamlRootAccessor = () => Content?.XamlRoot;

try
{
await _mainViewModel.InitializeAsync();
Expand Down
30 changes: 30 additions & 0 deletions src/PassKey.Desktop/Services/DialogQueueService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace PassKey.Desktop.Services;
Expand All @@ -22,6 +23,35 @@ public sealed class DialogQueueService : IDialogQueueService
private readonly Queue<Func<Task>> _queue = new();
private bool _isPumping;

/// <inheritdoc/>
public Func<XamlRoot?>? XamlRootAccessor { get; set; }

/// <inheritdoc/>
public Task<bool> ConfirmAsync(
string title,
string content,
string primaryButtonText,
string closeButtonText,
ContentDialogButton defaultButton = ContentDialogButton.Close)
{
return EnqueueAndWait(() =>
{
// Resolve the XamlRoot lazily — on the UI thread, at the exact moment the dialog
// is about to be shown — so the value is guaranteed to be non-null by the time the
// window has rendered and the user has triggered a delete (or similar) action.
var dialog = new ContentDialog
{
Title = title,
Content = content,
PrimaryButtonText = primaryButtonText,
CloseButtonText = closeButtonText,
DefaultButton = defaultButton,
XamlRoot = XamlRootAccessor?.Invoke(),
};
return dialog.ShowAsync().AsTask();
}).ContinueWith(t => t.Result == ContentDialogResult.Primary, TaskScheduler.Default);
}

/// <summary>
/// Enqueues a dialog factory for fire-and-forget display. The result is discarded.
/// Use <see cref="EnqueueAndWait"/> if you need the <see cref="ContentDialogResult"/>.
Expand Down
44 changes: 44 additions & 0 deletions src/PassKey.Desktop/Services/IDialogQueueService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace PassKey.Desktop.Services;
Expand All @@ -12,9 +13,33 @@ namespace PassKey.Desktop.Services;
/// A <see cref="System.Threading.SemaphoreSlim"/> is intentionally avoided here because
/// awaiting it on the UI thread causes a deadlock; use a <see cref="System.Collections.Generic.Queue{T}"/>
/// with a serial pump instead.
///
/// <para>
/// <b>XamlRoot:</b> WinUI 3 desktop apps require every <see cref="ContentDialog"/> to have
/// its <see cref="ContentDialog.XamlRoot"/> assigned before <c>ShowAsync</c> is called;
/// otherwise <see cref="System.ArgumentException"/> ("This element does not have a XamlRoot")
/// is raised. Code-behind handlers can use <c>this.XamlRoot</c>, but ViewModels lack a
/// natural reference. To remove that duplication, the service exposes
/// <see cref="RootForDialogs"/> — set once at startup from the main window — and
/// <see cref="ConfirmAsync"/> which applies it automatically.
/// </para>
/// </remarks>
public interface IDialogQueueService
{
/// <summary>
/// Gets or sets the just-in-time accessor used by <see cref="ConfirmAsync"/> to resolve
/// the host <see cref="XamlRoot"/> at the moment the dialog is shown.
/// </summary>
/// <remarks>
/// A direct <see cref="XamlRoot"/> value cannot be cached at startup because
/// <see cref="Window.Content"/>.<see cref="UIElement.XamlRoot"/> can be <see langword="null"/>
/// until the window is fully loaded. The accessor pattern instead reads the root lazily on
/// the UI thread when each dialog is actually displayed, by which point the value is valid.
/// Set this once during application startup, e.g.
/// <c>XamlRootAccessor = () =&gt; mainWindow.Content?.XamlRoot;</c>.
/// </remarks>
Func<XamlRoot?>? XamlRootAccessor { get; set; }

/// <summary>
/// Enqueues a dialog factory for fire-and-forget execution.
/// The result is discarded; use <see cref="EnqueueAndWait"/> when the result is needed.
Expand All @@ -29,4 +54,23 @@ public interface IDialogQueueService
/// <param name="dialogFactory">A factory that creates and shows the <see cref="ContentDialog"/>.</param>
/// <returns>The <see cref="ContentDialogResult"/> selected by the user.</returns>
Task<ContentDialogResult> EnqueueAndWait(Func<Task<ContentDialogResult>> dialogFactory);

/// <summary>
/// Shows a standard confirmation dialog with the supplied strings and waits for the user's choice.
/// The dialog's <see cref="ContentDialog.XamlRoot"/> is set automatically from
/// <see cref="RootForDialogs"/>, removing a common source of <see cref="System.ArgumentException"/>
/// crashes when called from ViewModels.
/// </summary>
/// <param name="title">Localised dialog title.</param>
/// <param name="content">Localised dialog body text.</param>
/// <param name="primaryButtonText">Localised primary action label (returned as <see langword="true"/>).</param>
/// <param name="closeButtonText">Localised cancel/close label (returned as <see langword="false"/>).</param>
/// <param name="defaultButton">Which button is highlighted by default (defaults to the close/cancel button).</param>
/// <returns><see langword="true"/> if the user selected the primary button; <see langword="false"/> for close.</returns>
Task<bool> ConfirmAsync(
string title,
string content,
string primaryButtonText,
string closeButtonText,
ContentDialogButton defaultButton = ContentDialogButton.Close);
}
Loading
Loading