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)); + } +}