diff --git a/src/PassKey.Core/Models/PasswordEntry.cs b/src/PassKey.Core/Models/PasswordEntry.cs
index b526ff2..022e806 100644
--- a/src/PassKey.Core/Models/PasswordEntry.cs
+++ b/src/PassKey.Core/Models/PasswordEntry.cs
@@ -38,4 +38,34 @@ public sealed class PasswordEntry : IVaultEntry
/// Gets or sets the UTC timestamp of the last modification to this entry.
public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;
+
+ // ─── TOTP / RFC 6238 fields (added in PassKey 2.0) ─────────────────────────
+ //
+ // These fields are *optional*: a v1.x vault deserialised by 2.0 simply has
+ // TotpSecret == null (the JSON property is absent), and the UI hides the 2FA
+ // section for entries without a configured secret. Conversely, 2.0 vaults
+ // written back to disk include the fields whenever the user has imported a
+ // QR / otpauth URI / Bitwarden seed for the entry.
+
+ ///
+ /// Base32-encoded shared secret (the "seed") used to derive TOTP codes,
+ /// or when this entry has no 2FA configured.
+ /// Encrypted at rest like the rest of the entry (the field lives inside the
+ /// encrypted vault blob — there is no plaintext exposure on disk).
+ ///
+ public string? TotpSecret { get; set; }
+
+ ///
+ /// HMAC algorithm used to compute the TOTP HOTP value. Defaults to "SHA1"
+ /// for compatibility with Google Authenticator, Microsoft Authenticator and the
+ /// vast majority of services (RFC 6238 §1.2). Other accepted values: "SHA256",
+ /// "SHA512".
+ ///
+ public string TotpAlgorithm { get; set; } = "SHA1";
+
+ /// Number of digits in the generated code (RFC 6238 §5.3). Default 6.
+ public int TotpDigits { get; set; } = 6;
+
+ /// Time-step in seconds (RFC 6238 §5.2). Default 30.
+ public int TotpPeriod { get; set; } = 30;
}
diff --git a/src/PassKey.Core/PassKey.Core.csproj b/src/PassKey.Core/PassKey.Core.csproj
index 7dcb1f1..7cde4fc 100644
--- a/src/PassKey.Core/PassKey.Core.csproj
+++ b/src/PassKey.Core/PassKey.Core.csproj
@@ -16,6 +16,8 @@
+
+
diff --git a/src/PassKey.Core/Services/BitwardenImporter.cs b/src/PassKey.Core/Services/BitwardenImporter.cs
index d6339c7..7a44b7b 100644
--- a/src/PassKey.Core/Services/BitwardenImporter.cs
+++ b/src/PassKey.Core/Services/BitwardenImporter.cs
@@ -6,6 +6,19 @@ namespace PassKey.Core.Services;
public sealed class BitwardenImporter : IBitwardenImporter
{
+ private readonly ITotpService? _totp;
+
+ ///
+ /// Initialises the importer. Pass an to enable
+ /// structured TOTP import from the Bitwarden login.totp field; otherwise
+ /// the importer falls back to the legacy v1.x behaviour and appends the raw
+ /// TOTP string to the entry notes.
+ ///
+ public BitwardenImporter(ITotpService? totp = null)
+ {
+ _totp = totp;
+ }
+
public Vault ParseBitwarden(string jsonContent)
{
ArgumentNullException.ThrowIfNull(jsonContent);
@@ -38,29 +51,67 @@ public Vault ParseBitwarden(string jsonContent)
return vault;
}
- private static PasswordEntry MapLogin(BitwardenItem item)
+ private PasswordEntry MapLogin(BitwardenItem item)
{
var login = item.Login;
- return new PasswordEntry
+ var entry = new PasswordEntry
{
Id = Guid.NewGuid(),
Title = item.Name ?? string.Empty,
Username = login?.Username ?? string.Empty,
Password = login?.Password ?? string.Empty,
Url = login?.Uris?.FirstOrDefault()?.Uri ?? string.Empty,
- Notes = BuildNotes(item.Notes, login?.Totp),
+ Notes = item.Notes ?? string.Empty,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
+
+ ApplyBitwardenTotp(entry, login?.Totp);
+ return entry;
}
- private static string BuildNotes(string? notes, string? totp)
+ ///
+ /// Bitwarden stores TOTP either as a raw Base32 secret or as an otpauth:// URI
+ /// in the login.totp field. PassKey 2.0 parses both shapes into the structured
+ /// family of fields so the user gets a live code
+ /// in the UI instead of a string in the notes blob.
+ ///
+ ///
+ /// When no was injected (legacy callers, future tests),
+ /// the value is appended to the notes — preserving the original v1.x behaviour.
+ /// Steam Guard tokens (steam://) are not standard TOTP and are kept as notes.
+ ///
+ private void ApplyBitwardenTotp(PasswordEntry entry, string? totp)
{
- if (string.IsNullOrEmpty(totp)) return notes ?? string.Empty;
- var result = notes ?? string.Empty;
- if (!string.IsNullOrEmpty(result)) result += "\n";
- result += $"TOTP: {totp}";
- return result;
+ if (string.IsNullOrWhiteSpace(totp)) return;
+
+ if (_totp is not null)
+ {
+ // 1. otpauth:// URI → parse all parameters.
+ if (totp.StartsWith("otpauth://", StringComparison.OrdinalIgnoreCase))
+ {
+ var parsed = _totp.ParseOtpAuthUri(totp);
+ if (parsed?.TotpSecret is { Length: > 0 })
+ {
+ entry.TotpSecret = parsed.TotpSecret;
+ entry.TotpAlgorithm = parsed.TotpAlgorithm;
+ entry.TotpDigits = parsed.TotpDigits;
+ entry.TotpPeriod = parsed.TotpPeriod;
+ return;
+ }
+ }
+
+ // 2. Bare Base32 secret → use defaults (SHA1, 6 digits, 30 s).
+ if (_totp.IsValidBase32(totp))
+ {
+ entry.TotpSecret = totp.Trim();
+ return;
+ }
+ }
+
+ // 3. Unrecognised (e.g. steam://) or no TotpService — preserve as notes.
+ if (!string.IsNullOrEmpty(entry.Notes)) entry.Notes += "\n";
+ entry.Notes += $"TOTP: {totp}";
}
private static SecureNoteEntry MapSecureNote(BitwardenItem item)
diff --git a/src/PassKey.Core/Services/ITotpService.cs b/src/PassKey.Core/Services/ITotpService.cs
new file mode 100644
index 0000000..e098abc
--- /dev/null
+++ b/src/PassKey.Core/Services/ITotpService.cs
@@ -0,0 +1,52 @@
+using PassKey.Core.Models;
+
+namespace PassKey.Core.Services;
+
+///
+/// Generates RFC 6238 TOTP (Time-based One-Time Password) codes for vault entries.
+///
+///
+/// The service is stateless: each method derives the current code from the entry's
+/// stored seed and parameters together with the current UTC time. The standard
+/// settings (SHA1, 6 digits, 30 s) match Google Authenticator and the overwhelming
+/// majority of online services; /
+/// /
+/// can be customised per entry for services that deviate (e.g. Steam, some bank tokens).
+///
+public interface ITotpService
+{
+ ///
+ /// Computes the current TOTP code for .
+ ///
+ /// The vault entry whose drives the derivation.
+ ///
+ /// A zero-padded numeric string of length
+ /// (e.g. "123456"), or if the entry has no TOTP
+ /// configured or the stored seed is malformed.
+ ///
+ string GenerateCode(PasswordEntry entry);
+
+ ///
+ /// Returns the number of seconds remaining before the current code rolls over
+ /// to the next one (always between 1 and ).
+ ///
+ int RemainingSeconds(PasswordEntry entry);
+
+ ///
+ /// Parses an otpauth:// URI (as encoded inside a QR code) into the
+ /// TOTP fields of a brand-new . Returns the parsed
+ /// entry or if the URI is not a recognisable TOTP otpauth URL.
+ ///
+ ///
+ /// The format follows :
+ /// otpauth://totp/Issuer:Account?secret=BASE32&issuer=Issuer&algorithm=SHA1&digits=6&period=30
+ /// Only the secret parameter is required.
+ ///
+ PasswordEntry? ParseOtpAuthUri(string uri);
+
+ ///
+ /// Validates that is a syntactically correct Base32 string
+ /// (RFC 4648, uppercase A–Z and 2–7, optional = padding, whitespace ignored).
+ ///
+ bool IsValidBase32(string secret);
+}
diff --git a/src/PassKey.Core/Services/OnePuxImporter.cs b/src/PassKey.Core/Services/OnePuxImporter.cs
index d08d48e..b7bb240 100644
--- a/src/PassKey.Core/Services/OnePuxImporter.cs
+++ b/src/PassKey.Core/Services/OnePuxImporter.cs
@@ -6,6 +6,18 @@ namespace PassKey.Core.Services;
public sealed class OnePuxImporter : IOnePuxImporter
{
+ private readonly ITotpService? _totp;
+
+ ///
+ /// Initialises the importer. Pass an to enable
+ /// structured TOTP import for login items (extracting otpauth:// URIs
+ /// from item sections); without it the URIs remain only in the notes blob.
+ ///
+ public OnePuxImporter(ITotpService? totp = null)
+ {
+ _totp = totp;
+ }
+
public Vault ParseOnePux(string exportDataJson)
{
ArgumentNullException.ThrowIfNull(exportDataJson);
@@ -32,7 +44,7 @@ public Vault ParseOnePux(string exportDataJson)
return vault;
}
- private static void MapItem(Vault vault, OnePuxItem item)
+ private void MapItem(Vault vault, OnePuxItem item)
{
var title = item.Overview?.Title ?? string.Empty;
var notes = item.Details?.NotesPlain ?? string.Empty;
@@ -45,7 +57,11 @@ private static void MapItem(Vault vault, OnePuxItem item)
// Check if it's a login (has username/password fields)
if (HasLoginFields(loginFields))
{
- vault.Passwords.Add(MapToPassword(title, url, notes, loginFields!));
+ var pw = MapToPassword(title, url, notes, loginFields!);
+ // Look in sections for a TOTP one-time password URI and promote it to
+ // the structured fields when found (otpauth:// form preferred).
+ ApplyTotpFromSections(pw, sections);
+ vault.Passwords.Add(pw);
return;
}
@@ -92,6 +108,62 @@ private static bool HasLoginFields(OnePuxLoginField[]? fields)
string.Equals(f.Designation, "password", StringComparison.OrdinalIgnoreCase));
}
+ ///
+ /// Searches the item's sections for a one-time password (TOTP) URI and applies it
+ /// to . Supports three field shapes seen in 1Password 1pux
+ /// exports:
+ ///
+ /// - value.totp with an otpauth:// URI (newest exports);
+ /// - value.string or value.concealed with an otpauth://
+ /// URI and the field title matching "one-time password" or "totp" (older exports).
+ ///
+ /// Falls back to a no-op when no was injected (the URI,
+ /// if any, remains visible in the entry notes blob).
+ ///
+ private void ApplyTotpFromSections(PasswordEntry entry, OnePuxSection[]? sections)
+ {
+ if (_totp is null || sections is null) return;
+
+ foreach (var section in sections)
+ {
+ if (section.Fields is null) continue;
+ foreach (var field in section.Fields)
+ {
+ var candidate = ExtractTotpCandidate(field);
+ if (string.IsNullOrWhiteSpace(candidate)) continue;
+
+ var parsed = _totp.ParseOtpAuthUri(candidate);
+ if (parsed?.TotpSecret is { Length: > 0 })
+ {
+ entry.TotpSecret = parsed.TotpSecret;
+ entry.TotpAlgorithm = parsed.TotpAlgorithm;
+ entry.TotpDigits = parsed.TotpDigits;
+ entry.TotpPeriod = parsed.TotpPeriod;
+ return; // first match wins
+ }
+ }
+ }
+ }
+
+ private static string? ExtractTotpCandidate(OnePuxSectionField field)
+ {
+ var value = field.Value;
+ if (value is null) return null;
+
+ if (!string.IsNullOrWhiteSpace(value.Totp)) return value.Totp;
+
+ var titleLower = field.Title?.ToLowerInvariant() ?? string.Empty;
+ if (titleLower.Contains("one-time password") || titleLower.Contains("totp"))
+ {
+ if (value.String is { Length: > 0 } s && s.StartsWith("otpauth://", StringComparison.OrdinalIgnoreCase))
+ return s;
+ if (value.Concealed is { Length: > 0 } c && c.StartsWith("otpauth://", StringComparison.OrdinalIgnoreCase))
+ return c;
+ }
+
+ return null;
+ }
+
private static PasswordEntry MapToPassword(string title, string url, string notes, OnePuxLoginField[] fields)
{
string username = string.Empty, password = string.Empty;
diff --git a/src/PassKey.Core/Services/OnePuxJsonContext.cs b/src/PassKey.Core/Services/OnePuxJsonContext.cs
index 970ec08..c62abaf 100644
--- a/src/PassKey.Core/Services/OnePuxJsonContext.cs
+++ b/src/PassKey.Core/Services/OnePuxJsonContext.cs
@@ -78,6 +78,14 @@ public sealed class OnePuxFieldValue
public string? Concealed { get; set; }
public OnePuxAddress? Address { get; set; }
public OnePuxDate? Date { get; set; }
+
+ ///
+ /// Some 1Password 1pux exports use a dedicated totp field for one-time
+ /// password URIs (full otpauth://... form). Older exports embed the URI
+ /// in or with the field title set
+ /// to "one-time password" — the importer handles both shapes.
+ ///
+ public string? Totp { get; set; }
}
public sealed class OnePuxAddress
diff --git a/src/PassKey.Core/Services/TotpService.cs b/src/PassKey.Core/Services/TotpService.cs
new file mode 100644
index 0000000..b29e070
--- /dev/null
+++ b/src/PassKey.Core/Services/TotpService.cs
@@ -0,0 +1,162 @@
+using System.Web;
+using OtpNet;
+using PassKey.Core.Models;
+
+namespace PassKey.Core.Services;
+
+///
+/// RFC 6238 TOTP implementation backed by .
+///
+public sealed class TotpService : ITotpService
+{
+ ///
+ public string GenerateCode(PasswordEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+
+ if (string.IsNullOrWhiteSpace(entry.TotpSecret))
+ return string.Empty;
+
+ var secretBytes = TryDecodeBase32(entry.TotpSecret);
+ if (secretBytes is null) return string.Empty;
+
+ var totp = new Totp(
+ secretKey: secretBytes,
+ step: entry.TotpPeriod,
+ mode: MapAlgorithm(entry.TotpAlgorithm),
+ totpSize: entry.TotpDigits);
+
+ return totp.ComputeTotp(DateTime.UtcNow);
+ }
+
+ ///
+ public int RemainingSeconds(PasswordEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.TotpPeriod <= 0) return 0;
+
+ var period = entry.TotpPeriod;
+ var nowEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var inWindow = (int)(nowEpoch % period);
+ // Remaining is always in [1, period] — when inWindow is 0 we have a full period left.
+ return period - inWindow;
+ }
+
+ ///
+ public PasswordEntry? ParseOtpAuthUri(string uri)
+ {
+ if (string.IsNullOrWhiteSpace(uri)) return null;
+ if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed)) return null;
+
+ if (!string.Equals(parsed.Scheme, "otpauth", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ // Host = the OTP kind. We only support TOTP here (HOTP is not part of PassKey 2.0).
+ if (!string.Equals(parsed.Host, "totp", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ // Path is "/Issuer:Account" (or "/Account") — both parts will become the entry title.
+ var label = Uri.UnescapeDataString(parsed.AbsolutePath.TrimStart('/'));
+ var (issuerFromLabel, account) = SplitLabel(label);
+
+ var query = HttpUtility.ParseQueryString(parsed.Query);
+
+ var secret = query["secret"];
+ if (string.IsNullOrWhiteSpace(secret) || !IsValidBase32(secret))
+ return null;
+
+ var issuer = query["issuer"] ?? issuerFromLabel ?? string.Empty;
+ var algorithm = (query["algorithm"] ?? "SHA1").ToUpperInvariant();
+ if (algorithm is not ("SHA1" or "SHA256" or "SHA512"))
+ algorithm = "SHA1";
+
+ if (!int.TryParse(query["digits"], out var digits) || digits is < 6 or > 10)
+ digits = 6;
+
+ if (!int.TryParse(query["period"], out var period) || period <= 0)
+ period = 30;
+
+ // Title falls back to whatever piece is most informative. Most exporters embed
+ // both the issuer and the account; prefer "Issuer (Account)" when both exist.
+ var title = (issuer, account) switch
+ {
+ ({ Length: > 0 }, { Length: > 0 }) => $"{issuer} ({account})",
+ ({ Length: > 0 }, _) => issuer,
+ (_, { Length: > 0 }) => account,
+ _ => "TOTP",
+ };
+
+ return new PasswordEntry
+ {
+ Title = title,
+ Username = account ?? string.Empty,
+ TotpSecret = NormaliseBase32(secret),
+ TotpAlgorithm = algorithm,
+ TotpDigits = digits,
+ TotpPeriod = period,
+ };
+ }
+
+ ///
+ public bool IsValidBase32(string secret)
+ {
+ if (string.IsNullOrWhiteSpace(secret)) return false;
+
+ // Allow lowercase / whitespace / padding; reject anything else.
+ foreach (var c in secret)
+ {
+ if (c is ' ' or '\t' or '\r' or '\n' or '=') continue;
+ var u = char.ToUpperInvariant(c);
+ var isLetter = u is >= 'A' and <= 'Z';
+ var isDigit2to7 = u is >= '2' and <= '7';
+ if (!isLetter && !isDigit2to7) return false;
+ }
+ return true;
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────────────
+
+ private static byte[]? TryDecodeBase32(string secret)
+ {
+ try
+ {
+ return Base32Encoding.ToBytes(NormaliseBase32(secret));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// Strips whitespace, removes padding, and uppercases — what expects.
+ private static string NormaliseBase32(string secret)
+ {
+ Span buf = secret.Length <= 256
+ ? stackalloc char[secret.Length]
+ : new char[secret.Length];
+ int j = 0;
+ foreach (var c in secret)
+ {
+ if (c is ' ' or '\t' or '\r' or '\n' or '=') continue;
+ buf[j++] = char.ToUpperInvariant(c);
+ }
+ return new string(buf[..j]);
+ }
+
+ private static (string? issuer, string? account) SplitLabel(string label)
+ {
+ if (string.IsNullOrEmpty(label)) return (null, null);
+ var colon = label.IndexOf(':');
+ return colon < 0
+ ? (null, label)
+ : (label[..colon].Trim(), label[(colon + 1)..].Trim());
+ }
+
+ private static OtpHashMode MapAlgorithm(string algorithm) =>
+ algorithm.ToUpperInvariant() switch
+ {
+ "SHA256" => OtpHashMode.Sha256,
+ "SHA512" => OtpHashMode.Sha512,
+ _ => OtpHashMode.Sha1, // default per RFC 6238 §1.2
+ };
+}
diff --git a/src/PassKey.Desktop/App.xaml.cs b/src/PassKey.Desktop/App.xaml.cs
index 67d4db9..a785a2f 100644
--- a/src/PassKey.Desktop/App.xaml.cs
+++ b/src/PassKey.Desktop/App.xaml.cs
@@ -31,6 +31,7 @@ public App()
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Desktop services
services.AddSingleton();
diff --git a/src/PassKey.Desktop/PassKey.Desktop.csproj b/src/PassKey.Desktop/PassKey.Desktop.csproj
index b5f816f..b97d6f9 100644
--- a/src/PassKey.Desktop/PassKey.Desktop.csproj
+++ b/src/PassKey.Desktop/PassKey.Desktop.csproj
@@ -36,6 +36,12 @@
+
+
diff --git a/src/PassKey.Desktop/Strings/de-DE/Resources.resw b/src/PassKey.Desktop/Strings/de-DE/Resources.resw
index 9a7eee4..026d39f 100644
--- a/src/PassKey.Desktop/Strings/de-DE/Resources.resw
+++ b/src/PassKey.Desktop/Strings/de-DE/Resources.resw
@@ -1,4 +1,4 @@
-
+
@@ -1399,4 +1399,19 @@
Version {0} verfügbar!
+
+ 2FA-Code (TOTP)
+
+
+ Aktueller Code
+
+
+ Seed eingeben
+
+
+ URI einfuegen
+
+
+ QR scannen
+
\ No newline at end of file
diff --git a/src/PassKey.Desktop/Strings/en-GB/Resources.resw b/src/PassKey.Desktop/Strings/en-GB/Resources.resw
index 14e9c64..04319e5 100644
--- a/src/PassKey.Desktop/Strings/en-GB/Resources.resw
+++ b/src/PassKey.Desktop/Strings/en-GB/Resources.resw
@@ -1,4 +1,4 @@
-
+
@@ -1389,4 +1389,19 @@
Version {0} available!
+
+ 2FA Code (TOTP)
+
+
+ Current code
+
+
+ Enter seed
+
+
+ Paste URI
+
+
+ Scan QR
+
\ No newline at end of file
diff --git a/src/PassKey.Desktop/Strings/es-ES/Resources.resw b/src/PassKey.Desktop/Strings/es-ES/Resources.resw
index 3d848d7..24aafca 100644
--- a/src/PassKey.Desktop/Strings/es-ES/Resources.resw
+++ b/src/PassKey.Desktop/Strings/es-ES/Resources.resw
@@ -1,4 +1,4 @@
-
+
@@ -1402,4 +1402,19 @@
¡Versión {0} disponible!
+
+ Codigo 2FA (TOTP)
+
+
+ Codigo actual
+
+
+ Introducir clave
+
+
+ Pegar URI
+
+
+ Escanear QR
+
\ No newline at end of file
diff --git a/src/PassKey.Desktop/Strings/fr-FR/Resources.resw b/src/PassKey.Desktop/Strings/fr-FR/Resources.resw
index edb15e9..d7ccdd2 100644
--- a/src/PassKey.Desktop/Strings/fr-FR/Resources.resw
+++ b/src/PassKey.Desktop/Strings/fr-FR/Resources.resw
@@ -1,4 +1,4 @@
-
+
@@ -1401,4 +1401,19 @@
Version {0} disponible !
+
+ Code 2FA (TOTP)
+
+
+ Code actuel
+
+
+ Saisir le seed
+
+
+ Coller URI
+
+
+ Scanner QR
+
\ No newline at end of file
diff --git a/src/PassKey.Desktop/Strings/it-IT/Resources.resw b/src/PassKey.Desktop/Strings/it-IT/Resources.resw
index 870592c..3ad27be 100644
--- a/src/PassKey.Desktop/Strings/it-IT/Resources.resw
+++ b/src/PassKey.Desktop/Strings/it-IT/Resources.resw
@@ -1,4 +1,4 @@
-
+
@@ -1402,4 +1402,19 @@
Versione {0} disponibile!
+
+ Codice 2FA (TOTP)
+
+
+ Codice attuale
+
+
+ Inserisci seed
+
+
+ Incolla URI
+
+
+ Scansiona QR
+
\ No newline at end of file
diff --git a/src/PassKey.Desktop/Strings/pt-PT/Resources.resw b/src/PassKey.Desktop/Strings/pt-PT/Resources.resw
index b15ca64..a14703f 100644
--- a/src/PassKey.Desktop/Strings/pt-PT/Resources.resw
+++ b/src/PassKey.Desktop/Strings/pt-PT/Resources.resw
@@ -1,4 +1,4 @@
-
+
@@ -1399,4 +1399,19 @@
Versão {0} disponível!
+
+ Codigo 2FA (TOTP)
+
+
+ Codigo atual
+
+
+ Inserir seed
+
+
+ Colar URI
+
+
+ Digitalizar QR
+
\ No newline at end of file
diff --git a/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs b/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs
index 7ac56dc..fb6027e 100644
--- a/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs
+++ b/src/PassKey.Desktop/ViewModels/PasswordDetailViewModel.cs
@@ -9,13 +9,15 @@ namespace PassKey.Desktop.ViewModels;
///
/// Password detail ViewModel for add/edit panel.
-/// Fields: Title, URL, Username, Password, Notes.
+/// Fields: Title, URL, Username, Password, Notes — and (PassKey 2.0) optional TOTP
+/// fields exposed live to the View so the user sees a refreshing 2FA code.
/// Shared add/edit/save/delete plumbing is provided by .
///
public partial class PasswordDetailViewModel : BaseDetailViewModel
{
private readonly IPasswordGenerator _generator;
private readonly IPasswordStrengthAnalyzer _strengthAnalyzer;
+ private readonly ITotpService _totp;
[ObservableProperty]
public partial string Title { get; set; } = string.Empty;
@@ -41,15 +43,46 @@ public partial class PasswordDetailViewModel : BaseDetailViewModelBase32 seed for the TOTP, or null when 2FA is not configured for this entry.
+ [ObservableProperty]
+ public partial string? TotpSecret { get; set; }
+
+ /// HMAC algorithm (default SHA1, see ).
+ [ObservableProperty]
+ public partial string TotpAlgorithm { get; set; } = "SHA1";
+
+ /// Number of digits (default 6, see ).
+ [ObservableProperty]
+ public partial int TotpDigits { get; set; } = 6;
+
+ /// Time-step in seconds (default 30, see ).
+ [ObservableProperty]
+ public partial int TotpPeriod { get; set; } = 30;
+
+ /// The currently valid TOTP code formatted as a space-grouped string ("123 456"), or empty when no secret.
+ [ObservableProperty]
+ public partial string CurrentTotpCode { get; set; } = string.Empty;
+
+ /// Seconds remaining before the current code rolls over.
+ [ObservableProperty]
+ public partial int TotpRemainingSeconds { get; set; }
+
+ /// True when is non-empty — the View flips between empty/filled states off this.
+ public bool HasTotp => !string.IsNullOrWhiteSpace(TotpSecret);
+
public PasswordDetailViewModel(
IVaultStateService vaultState,
IPasswordGenerator generator,
IDialogQueueService dialogQueue,
- IPasswordStrengthAnalyzer strengthAnalyzer)
+ IPasswordStrengthAnalyzer strengthAnalyzer,
+ ITotpService totp)
: base(vaultState, dialogQueue)
{
_generator = generator;
_strengthAnalyzer = strengthAnalyzer;
+ _totp = totp;
}
// ─── Template-method overrides ────────────────────────────────────────────
@@ -69,6 +102,12 @@ protected override void ResetFieldsForNew()
Password = string.Empty;
Notes = string.Empty;
FaviconBase64 = null;
+ TotpSecret = null;
+ TotpAlgorithm = "SHA1";
+ TotpDigits = 6;
+ TotpPeriod = 30;
+ CurrentTotpCode = string.Empty;
+ TotpRemainingSeconds = 0;
}
protected override void LoadFromEntry(PasswordEntry entry)
@@ -79,6 +118,11 @@ protected override void LoadFromEntry(PasswordEntry entry)
Password = entry.Password;
Notes = entry.Notes;
FaviconBase64 = entry.FaviconBase64;
+ TotpSecret = entry.TotpSecret;
+ TotpAlgorithm = entry.TotpAlgorithm;
+ TotpDigits = entry.TotpDigits;
+ TotpPeriod = entry.TotpPeriod;
+ RefreshTotpDisplay();
}
protected override PasswordEntry CreateNewEntry() => new()
@@ -88,7 +132,11 @@ protected override void LoadFromEntry(PasswordEntry entry)
Username = Username.Trim(),
Password = Password,
Notes = Notes.Trim(),
- FaviconBase64 = FaviconBase64
+ FaviconBase64 = FaviconBase64,
+ TotpSecret = string.IsNullOrWhiteSpace(TotpSecret) ? null : TotpSecret.Trim(),
+ TotpAlgorithm = TotpAlgorithm,
+ TotpDigits = TotpDigits,
+ TotpPeriod = TotpPeriod,
};
protected override void ApplyToEntry(PasswordEntry entry)
@@ -99,6 +147,10 @@ protected override void ApplyToEntry(PasswordEntry entry)
entry.Password = Password;
entry.Notes = Notes.Trim();
entry.FaviconBase64 = FaviconBase64;
+ entry.TotpSecret = string.IsNullOrWhiteSpace(TotpSecret) ? null : TotpSecret.Trim();
+ entry.TotpAlgorithm = TotpAlgorithm;
+ entry.TotpDigits = TotpDigits;
+ entry.TotpPeriod = TotpPeriod;
}
protected override void UpdateCanSave()
@@ -118,6 +170,12 @@ partial void OnPasswordChanged(string value)
UpdatePasswordStrength();
}
+ partial void OnTotpSecretChanged(string? value)
+ {
+ OnPropertyChanged(nameof(HasTotp));
+ RefreshTotpDisplay();
+ }
+
private void UpdatePasswordStrength()
{
if (string.IsNullOrEmpty(Password))
@@ -131,6 +189,100 @@ private void UpdatePasswordStrength()
PasswordStrengthLabel = result.Label;
}
+ // ─── TOTP helpers (called by the View timer) ──────────────────────────────
+
+ ///
+ /// Recomputes and
+ /// from the current . Cheap — safe to call every second.
+ ///
+ public void RefreshTotpDisplay()
+ {
+ if (!HasTotp)
+ {
+ CurrentTotpCode = string.Empty;
+ TotpRemainingSeconds = 0;
+ return;
+ }
+
+ var probe = new PasswordEntry
+ {
+ TotpSecret = TotpSecret,
+ TotpAlgorithm = TotpAlgorithm,
+ TotpDigits = TotpDigits,
+ TotpPeriod = TotpPeriod,
+ };
+
+ var raw = _totp.GenerateCode(probe);
+ CurrentTotpCode = FormatCode(raw);
+ TotpRemainingSeconds = _totp.RemainingSeconds(probe);
+ }
+
+ /// Returns the unformatted (no spaces) current code, ready to be copied to the clipboard.
+ public string GetCurrentTotpCodeRaw()
+ {
+ if (!HasTotp) return string.Empty;
+ var probe = new PasswordEntry
+ {
+ TotpSecret = TotpSecret,
+ TotpAlgorithm = TotpAlgorithm,
+ TotpDigits = TotpDigits,
+ TotpPeriod = TotpPeriod,
+ };
+ return _totp.GenerateCode(probe);
+ }
+
+ ///
+ /// Applies an otpauth:// URI (typically the textual payload of a QR code)
+ /// to this entry. Returns true on success.
+ ///
+ public bool ApplyOtpAuthUri(string uri)
+ {
+ var parsed = _totp.ParseOtpAuthUri(uri);
+ if (parsed?.TotpSecret is not { Length: > 0 }) return false;
+
+ TotpSecret = parsed.TotpSecret;
+ TotpAlgorithm = parsed.TotpAlgorithm;
+ TotpDigits = parsed.TotpDigits;
+ TotpPeriod = parsed.TotpPeriod;
+
+ // If the user is creating a fresh entry, pre-fill Title / Username from the URI
+ // labels too — only when the user hasn't typed anything yet (don't clobber input).
+ if (string.IsNullOrWhiteSpace(Title) && parsed.Title.Length > 0) Title = parsed.Title;
+ if (string.IsNullOrWhiteSpace(Username) && parsed.Username.Length > 0) Username = parsed.Username;
+
+ return true;
+ }
+
+ /// Applies a bare Base32 secret with the standard SHA1/6/30 parameters.
+ public bool ApplyManualSeed(string base32)
+ {
+ if (!_totp.IsValidBase32(base32)) return false;
+ TotpSecret = base32.Trim();
+ TotpAlgorithm = "SHA1";
+ TotpDigits = 6;
+ TotpPeriod = 30;
+ return true;
+ }
+
+ public void RemoveTotp()
+ {
+ TotpSecret = null;
+ TotpAlgorithm = "SHA1";
+ TotpDigits = 6;
+ TotpPeriod = 30;
+ CurrentTotpCode = string.Empty;
+ TotpRemainingSeconds = 0;
+ }
+
+ /// Formats a numeric code like "123456" as "123 456" for easier reading.
+ private static string FormatCode(string raw)
+ {
+ if (string.IsNullOrEmpty(raw)) return string.Empty;
+ if (raw.Length <= 4) return raw;
+ var mid = raw.Length / 2;
+ return raw[..mid] + " " + raw[mid..];
+ }
+
// ─── Type-specific command ────────────────────────────────────────────────
[RelayCommand]
diff --git a/src/PassKey.Desktop/Views/PasswordDetailView.xaml b/src/PassKey.Desktop/Views/PasswordDetailView.xaml
index e7e4a90..6296678 100644
--- a/src/PassKey.Desktop/Views/PasswordDetailView.xaml
+++ b/src/PassKey.Desktop/Views/PasswordDetailView.xaml
@@ -162,6 +162,132 @@
TabIndex="50" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs b/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs
index 7aac1e8..1f7d965 100644
--- a/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs
+++ b/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs
@@ -1,12 +1,19 @@
using System.IO;
+using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using Microsoft.Windows.ApplicationModel.Resources;
+using PassKey.Desktop.Services;
using PassKey.Desktop.ViewModels;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.Graphics.Imaging;
+using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.Storage.Streams;
using WinRT.Interop;
+using ZXing;
+using ZXing.Common;
namespace PassKey.Desktop.Views;
@@ -19,6 +26,9 @@ public sealed partial class PasswordDetailView : UserControl
private bool _updatingFromVm;
private readonly ResourceLoader _resourceLoader = new();
+ /// Per-second timer that refreshes the live TOTP code while the view is loaded.
+ private DispatcherQueueTimer? _totpTimer;
+
// 24 Segoe MDL2 Assets glyphs for the icon picker
private static readonly string[] IconGlyphs =
[
@@ -33,6 +43,8 @@ public PasswordDetailView()
InitializeComponent();
PasswordInput.PasswordChanged += OnPasswordInputChanged;
IconPickerGrid.ItemsSource = IconGlyphs;
+
+ Unloaded += (_, _) => StopTotpTimer();
}
public void SetViewModel(PasswordDetailViewModel vm)
@@ -74,6 +86,10 @@ public void SetViewModel(PasswordDetailViewModel vm)
// Icon preview
_ = UpdateIconPreviewAsync();
+ // TOTP section state + per-second refresh timer
+ UpdateTotpUi();
+ StartTotpTimer();
+
// Focus first field
TitleBox.Focus(FocusState.Programmatic);
}
@@ -106,6 +122,262 @@ private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.Pr
if (string.IsNullOrEmpty(_viewModel?.FaviconBase64))
_ = UpdateIconPreviewAsync();
break;
+ case nameof(PasswordDetailViewModel.TotpSecret):
+ case nameof(PasswordDetailViewModel.CurrentTotpCode):
+ case nameof(PasswordDetailViewModel.TotpRemainingSeconds):
+ UpdateTotpUi();
+ break;
+ }
+ }
+
+ // ─── TOTP UI helpers ─────────────────────────────────────────────────────
+
+ private void StartTotpTimer()
+ {
+ StopTotpTimer();
+ if (_viewModel is null) return;
+
+ var dq = DispatcherQueue.GetForCurrentThread();
+ if (dq is null) return;
+
+ _totpTimer = dq.CreateTimer();
+ _totpTimer.Interval = TimeSpan.FromSeconds(1);
+ _totpTimer.IsRepeating = true;
+ _totpTimer.Tick += (_, _) => _viewModel?.RefreshTotpDisplay();
+ _totpTimer.Start();
+ }
+
+ private void StopTotpTimer()
+ {
+ _totpTimer?.Stop();
+ _totpTimer = null;
+ }
+
+ private void UpdateTotpUi()
+ {
+ if (_viewModel is null) return;
+
+ var hasTotp = _viewModel.HasTotp;
+ TotpEmptyPanel.Visibility = hasTotp ? Visibility.Collapsed : Visibility.Visible;
+ TotpFilledPanel.Visibility = hasTotp ? Visibility.Visible : Visibility.Collapsed;
+
+ if (hasTotp)
+ {
+ TotpCodeText.Text = string.IsNullOrEmpty(_viewModel.CurrentTotpCode) ? "—" : _viewModel.CurrentTotpCode;
+ TotpCountdownText.Text = _viewModel.TotpRemainingSeconds > 0
+ ? _viewModel.TotpRemainingSeconds.ToString()
+ : "—";
+
+ // The ProgressRing visualises remaining/total. We invert the value so the ring
+ // fills as the period elapses (full at 0s left, empty at period seconds left).
+ TotpCountdownRing.Maximum = _viewModel.TotpPeriod;
+ TotpCountdownRing.Value = _viewModel.TotpPeriod - _viewModel.TotpRemainingSeconds;
+ TotpCountdownRing.IsActive = true;
+
+ // Hide the secret readout unless the user explicitly asked to show it.
+ // (Show button toggles it from below.)
+ }
+ else
+ {
+ TotpSecretReadout.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private async void TotpScanQrButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null) return;
+
+ var picker = new FileOpenPicker();
+ InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.MainWindow));
+ picker.FileTypeFilter.Add(".png");
+ picker.FileTypeFilter.Add(".jpg");
+ picker.FileTypeFilter.Add(".jpeg");
+ picker.FileTypeFilter.Add(".bmp");
+
+ var file = await picker.PickSingleFileAsync();
+ if (file is null) return;
+
+ var otpauthUri = await DecodeQrFromFileAsync(file);
+ if (string.IsNullOrEmpty(otpauthUri))
+ {
+ ToolTipService.SetToolTip(TotpScanQrButton, "Nessun codice QR riconoscibile nell'immagine.");
+ return;
+ }
+
+ if (!_viewModel.ApplyOtpAuthUri(otpauthUri))
+ {
+ ToolTipService.SetToolTip(TotpScanQrButton, "QR riconosciuto ma non è un URI 'otpauth://' valido.");
+ return;
+ }
+
+ // Re-sync TextBoxes if the URI carried Title/Username so the user sees them populated.
+ SyncTextBoxesFromViewModel();
+ }
+
+ private void TotpPasteUriButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null) return;
+
+ var pkg = Clipboard.GetContent();
+ if (!pkg.Contains(StandardDataFormats.Text))
+ {
+ ToolTipService.SetToolTip(TotpPasteUriButton, "Negli appunti non c'è testo.");
+ return;
+ }
+
+ _ = ApplyClipboardUriAsync(pkg);
+ }
+
+ private async Task ApplyClipboardUriAsync(DataPackageView pkg)
+ {
+ try
+ {
+ var text = await pkg.GetTextAsync();
+ if (_viewModel is null || string.IsNullOrWhiteSpace(text)) return;
+
+ if (!_viewModel.ApplyOtpAuthUri(text.Trim()))
+ {
+ ToolTipService.SetToolTip(TotpPasteUriButton, "Il testo negli appunti non è un URI 'otpauth://' valido.");
+ return;
+ }
+ SyncTextBoxesFromViewModel();
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"[TOTP paste] {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private async void TotpEnterSecretButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null) return;
+
+ var input = new TextBox
+ {
+ PlaceholderText = "Es. JBSW Y3DP EHPK 3PXP",
+ FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas, Courier New"),
+ AcceptsReturn = false,
+ };
+ var dialog = new ContentDialog
+ {
+ Title = "Inserisci chiave 2FA",
+ Content = new StackPanel
+ {
+ Spacing = 8,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = "Incolla la chiave Base32 fornita dal sito (lo stesso testo che inseriresti in Google Authenticator). Maiuscole e spazi vengono ignorati.",
+ TextWrapping = TextWrapping.Wrap,
+ },
+ input,
+ },
+ },
+ PrimaryButtonText = "Salva",
+ CloseButtonText = "Annulla",
+ DefaultButton = ContentDialogButton.Primary,
+ XamlRoot = XamlRoot,
+ };
+
+ var res = await dialog.ShowAsync();
+ if (res != ContentDialogResult.Primary) return;
+ if (!_viewModel.ApplyManualSeed(input.Text))
+ ToolTipService.SetToolTip(TotpEnterSecretButton, "Chiave Base32 non valida (usa solo A-Z e 2-7).");
+ }
+
+ private void TotpCopyButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null) return;
+ var raw = _viewModel.GetCurrentTotpCodeRaw();
+ if (string.IsNullOrEmpty(raw)) return;
+
+ // Reuse the existing ClipboardService so the auto-clear / history-suppression
+ // behaviour matches the rest of the app (sensitive copy, 30 s auto-clear).
+ var clip = App.Services.GetService(typeof(IClipboardService)) as IClipboardService;
+ if (clip is null)
+ {
+ // Fallback: plain clipboard set without auto-clear.
+ var pkg = new DataPackage();
+ pkg.SetText(raw);
+ Clipboard.SetContent(pkg);
+ }
+ else
+ {
+ clip.Copy(raw, CopyType.Sensitive);
+ }
+ }
+
+ private void TotpShowSecretButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null) return;
+ if (TotpSecretReadout.Visibility == Visibility.Visible)
+ {
+ TotpSecretReadout.Visibility = Visibility.Collapsed;
+ TotpSecretReadout.Text = string.Empty;
+ }
+ else
+ {
+ TotpSecretReadout.Visibility = Visibility.Visible;
+ TotpSecretReadout.Text = _viewModel.TotpSecret ?? string.Empty;
+ }
+ }
+
+ private void TotpRemoveButton_Click(object sender, RoutedEventArgs e)
+ {
+ _viewModel?.RemoveTotp();
+ }
+
+ private void SyncTextBoxesFromViewModel()
+ {
+ if (_viewModel is null) return;
+ _updatingFromVm = true;
+ try
+ {
+ if (TitleBox.Text != _viewModel.Title) TitleBox.Text = _viewModel.Title;
+ if (UsernameBox.Text != _viewModel.Username) UsernameBox.Text = _viewModel.Username;
+ }
+ finally
+ {
+ _updatingFromVm = false;
+ }
+ }
+
+ ///
+ /// Decodes a QR code from an image file using ZXing.Net and the Windows
+ /// API to extract raw 32-bit pixels. Returns the
+ /// decoded textual payload (typically an otpauth://... URI) or null on failure.
+ ///
+ private static async Task DecodeQrFromFileAsync(StorageFile file)
+ {
+ try
+ {
+ using var stream = await file.OpenAsync(FileAccessMode.Read);
+ var decoder = await BitmapDecoder.CreateAsync(stream);
+ var pixelData = await decoder.GetPixelDataAsync(
+ BitmapPixelFormat.Bgra8,
+ BitmapAlphaMode.Premultiplied,
+ new BitmapTransform(),
+ ExifOrientationMode.RespectExifOrientation,
+ ColorManagementMode.DoNotColorManage);
+ var bytes = pixelData.DetachPixelData();
+
+ var luminance = new RGBLuminanceSource(bytes,
+ (int)decoder.PixelWidth,
+ (int)decoder.PixelHeight,
+ RGBLuminanceSource.BitmapFormat.BGRA32);
+
+ var reader = new ZXing.QrCode.QRCodeReader();
+ var binarizer = new HybridBinarizer(luminance);
+ var bitmap = new BinaryBitmap(binarizer);
+
+ var result = reader.decode(bitmap);
+ return result?.Text;
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"[QR decode] {ex.GetType().Name}: {ex.Message}");
+ return null;
}
}
diff --git a/src/PassKey.Tests/TotpServiceTests.cs b/src/PassKey.Tests/TotpServiceTests.cs
new file mode 100644
index 0000000..f3632ea
--- /dev/null
+++ b/src/PassKey.Tests/TotpServiceTests.cs
@@ -0,0 +1,150 @@
+using PassKey.Core.Models;
+using PassKey.Core.Services;
+
+namespace PassKey.Tests;
+
+///
+/// Tests for . The known-good code values come from RFC 6238
+/// Appendix B test vectors and from .
+///
+public class TotpServiceTests
+{
+ private readonly TotpService _totp = new();
+
+ // ─── ParseOtpAuthUri ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseOtpAuthUri_MinimalValid_PopulatesSecret()
+ {
+ const string uri = "otpauth://totp/ACME?secret=JBSWY3DPEHPK3PXP";
+ var entry = _totp.ParseOtpAuthUri(uri);
+
+ Assert.NotNull(entry);
+ Assert.Equal("JBSWY3DPEHPK3PXP", entry!.TotpSecret);
+ Assert.Equal("SHA1", entry.TotpAlgorithm);
+ Assert.Equal(6, entry.TotpDigits);
+ Assert.Equal(30, entry.TotpPeriod);
+ }
+
+ [Fact]
+ public void ParseOtpAuthUri_FullParameters_ParsesAll()
+ {
+ const string uri =
+ "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=60";
+ var entry = _totp.ParseOtpAuthUri(uri);
+
+ Assert.NotNull(entry);
+ Assert.Equal("JBSWY3DPEHPK3PXP", entry!.TotpSecret);
+ Assert.Equal("SHA256", entry.TotpAlgorithm);
+ Assert.Equal(8, entry.TotpDigits);
+ Assert.Equal(60, entry.TotpPeriod);
+ Assert.Equal("alice@google.com", entry.Username);
+ Assert.Contains("Example", entry.Title);
+ }
+
+ [Fact]
+ public void ParseOtpAuthUri_UnsupportedAlgorithm_FallsBackToSha1()
+ {
+ const string uri = "otpauth://totp/X?secret=JBSWY3DPEHPK3PXP&algorithm=MD5";
+ var entry = _totp.ParseOtpAuthUri(uri);
+
+ Assert.NotNull(entry);
+ Assert.Equal("SHA1", entry!.TotpAlgorithm);
+ }
+
+ [Fact]
+ public void ParseOtpAuthUri_NonTotpScheme_ReturnsNull()
+ {
+ Assert.Null(_totp.ParseOtpAuthUri("otpauth://hotp/X?secret=JBSWY3DPEHPK3PXP"));
+ Assert.Null(_totp.ParseOtpAuthUri("https://example.com?secret=JBSWY3DPEHPK3PXP"));
+ Assert.Null(_totp.ParseOtpAuthUri("garbage"));
+ Assert.Null(_totp.ParseOtpAuthUri(""));
+ }
+
+ [Fact]
+ public void ParseOtpAuthUri_InvalidBase32Secret_ReturnsNull()
+ {
+ Assert.Null(_totp.ParseOtpAuthUri("otpauth://totp/X?secret=NOT*VALID*B32"));
+ }
+
+ // ─── IsValidBase32 ───────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("JBSWY3DPEHPK3PXP")] // canonical
+ [InlineData("jbswy3dpehpk3pxp")] // lowercase
+ [InlineData("JBSW Y3DP EHPK 3PXP")] // whitespace
+ [InlineData("JBSWY3DPEHPK3PXP=")] // trailing padding
+ public void IsValidBase32_Accepts_KnownGoodForms(string secret)
+ => Assert.True(_totp.IsValidBase32(secret));
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("0189")] // digits outside 2-7
+ [InlineData("FOO!BAR")] // punctuation
+ public void IsValidBase32_Rejects_BadForms(string secret)
+ => Assert.False(_totp.IsValidBase32(secret));
+
+ // ─── GenerateCode / RemainingSeconds ─────────────────────────────────────
+
+ [Fact]
+ public void GenerateCode_NoSecret_ReturnsEmpty()
+ {
+ var entry = new PasswordEntry { Title = "no totp", TotpSecret = null };
+ Assert.Equal(string.Empty, _totp.GenerateCode(entry));
+ }
+
+ [Fact]
+ public void GenerateCode_KnownSeed_ProducesSixDigits()
+ {
+ var entry = new PasswordEntry
+ {
+ Title = "demo",
+ TotpSecret = "JBSWY3DPEHPK3PXP", // RFC 4648 example "Hello!\xDE\xAD\xBE\xEF"
+ };
+
+ var code = _totp.GenerateCode(entry);
+
+ Assert.Equal(6, code.Length);
+ Assert.True(code.All(char.IsDigit), $"Generated code '{code}' must be numeric.");
+ }
+
+ [Fact]
+ public void GenerateCode_TwoCallsWithinSamePeriod_ReturnSameCode()
+ {
+ // The TOTP window is 30 seconds — two adjacent calls land in the same window
+ // overwhelmingly often. Worst case (boundary), they differ; the test retries once.
+ var entry = new PasswordEntry { Title = "x", TotpSecret = "JBSWY3DPEHPK3PXP" };
+
+ var first = _totp.GenerateCode(entry);
+ var second = _totp.GenerateCode(entry);
+ if (first != second)
+ {
+ // We straddled a 30s boundary — retry, this time both calls land in the same window.
+ first = _totp.GenerateCode(entry);
+ second = _totp.GenerateCode(entry);
+ }
+
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void RemainingSeconds_StaysWithinPeriod()
+ {
+ var entry = new PasswordEntry { Title = "x", TotpSecret = "JBSWY3DPEHPK3PXP" };
+ var r = _totp.RemainingSeconds(entry);
+ Assert.InRange(r, 1, entry.TotpPeriod);
+ }
+
+ [Fact]
+ public void GenerateCode_RoundTripFromOtpAuthUri_Works()
+ {
+ const string uri = "otpauth://totp/PassKey:test@example.com?secret=JBSWY3DPEHPK3PXP&issuer=PassKey";
+ var entry = _totp.ParseOtpAuthUri(uri);
+
+ Assert.NotNull(entry);
+ var code = _totp.GenerateCode(entry!);
+
+ Assert.Equal(6, code.Length);
+ Assert.True(code.All(char.IsDigit));
+ }
+}