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
30 changes: 30 additions & 0 deletions src/PassKey.Core/Models/PasswordEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,34 @@ public sealed class PasswordEntry : IVaultEntry

/// <summary>Gets or sets the UTC timestamp of the last modification to this entry.</summary>
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.

/// <summary>
/// Base32-encoded shared secret (the "seed") used to derive TOTP codes,
/// or <see langword="null"/> 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).
/// </summary>
public string? TotpSecret { get; set; }

/// <summary>
/// HMAC algorithm used to compute the TOTP HOTP value. Defaults to <c>"SHA1"</c>
/// for compatibility with Google Authenticator, Microsoft Authenticator and the
/// vast majority of services (RFC 6238 §1.2). Other accepted values: <c>"SHA256"</c>,
/// <c>"SHA512"</c>.
/// </summary>
public string TotpAlgorithm { get; set; } = "SHA1";

/// <summary>Number of digits in the generated code (RFC 6238 §5.3). Default 6.</summary>
public int TotpDigits { get; set; } = 6;

/// <summary>Time-step in seconds (RFC 6238 §5.2). Default 30.</summary>
public int TotpPeriod { get; set; } = 30;
}
2 changes: 2 additions & 0 deletions src/PassKey.Core/PassKey.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<!-- RFC 6238 TOTP (Time-based One-Time Password) — used by TotpService for 2FA codes. -->
<PackageReference Include="Otp.NET" Version="1.4.1" />
</ItemGroup>

</Project>
69 changes: 60 additions & 9 deletions src/PassKey.Core/Services/BitwardenImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ namespace PassKey.Core.Services;

public sealed class BitwardenImporter : IBitwardenImporter
{
private readonly ITotpService? _totp;

/// <summary>
/// Initialises the importer. Pass an <see cref="ITotpService"/> to enable
/// structured TOTP import from the Bitwarden <c>login.totp</c> field; otherwise
/// the importer falls back to the legacy v1.x behaviour and appends the raw
/// TOTP string to the entry notes.
/// </summary>
public BitwardenImporter(ITotpService? totp = null)
{
_totp = totp;
}

public Vault ParseBitwarden(string jsonContent)
{
ArgumentNullException.ThrowIfNull(jsonContent);
Expand Down Expand Up @@ -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)
/// <summary>
/// Bitwarden stores TOTP either as a raw Base32 secret or as an <c>otpauth://</c> URI
/// in the <c>login.totp</c> field. PassKey 2.0 parses both shapes into the structured
/// <see cref="PasswordEntry.TotpSecret"/> family of fields so the user gets a live code
/// in the UI instead of a string in the notes blob.
/// </summary>
/// <remarks>
/// When no <see cref="ITotpService"/> was injected (legacy callers, future tests),
/// the value is appended to the notes — preserving the original v1.x behaviour.
/// Steam Guard tokens (<c>steam://</c>) are not standard TOTP and are kept as notes.
/// </remarks>
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)
Expand Down
52 changes: 52 additions & 0 deletions src/PassKey.Core/Services/ITotpService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using PassKey.Core.Models;

namespace PassKey.Core.Services;

/// <summary>
/// Generates RFC 6238 TOTP (Time-based One-Time Password) codes for vault entries.
/// </summary>
/// <remarks>
/// 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; <see cref="PasswordEntry.TotpAlgorithm"/> /
/// <see cref="PasswordEntry.TotpDigits"/> / <see cref="PasswordEntry.TotpPeriod"/>
/// can be customised per entry for services that deviate (e.g. Steam, some bank tokens).
/// </remarks>
public interface ITotpService
{
/// <summary>
/// Computes the current TOTP code for <paramref name="entry"/>.
/// </summary>
/// <param name="entry">The vault entry whose <see cref="PasswordEntry.TotpSecret"/> drives the derivation.</param>
/// <returns>
/// A zero-padded numeric string of length <see cref="PasswordEntry.TotpDigits"/>
/// (e.g. <c>"123456"</c>), or <see cref="string.Empty"/> if the entry has no TOTP
/// configured or the stored seed is malformed.
/// </returns>
string GenerateCode(PasswordEntry entry);

/// <summary>
/// Returns the number of seconds remaining before the current code rolls over
/// to the next one (always between 1 and <see cref="PasswordEntry.TotpPeriod"/>).
/// </summary>
int RemainingSeconds(PasswordEntry entry);

/// <summary>
/// Parses an <c>otpauth://</c> URI (as encoded inside a QR code) into the
/// TOTP fields of a brand-new <see cref="PasswordEntry"/>. Returns the parsed
/// entry or <see langword="null"/> if the URI is not a recognisable TOTP otpauth URL.
/// </summary>
/// <remarks>
/// The format follows <see href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format"/>:
/// <code>otpauth://totp/Issuer:Account?secret=BASE32&amp;issuer=Issuer&amp;algorithm=SHA1&amp;digits=6&amp;period=30</code>
/// Only the <c>secret</c> parameter is required.
/// </remarks>
PasswordEntry? ParseOtpAuthUri(string uri);

/// <summary>
/// Validates that <paramref name="secret"/> is a syntactically correct Base32 string
/// (RFC 4648, uppercase A–Z and 2–7, optional <c>=</c> padding, whitespace ignored).
/// </summary>
bool IsValidBase32(string secret);
}
76 changes: 74 additions & 2 deletions src/PassKey.Core/Services/OnePuxImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ namespace PassKey.Core.Services;

public sealed class OnePuxImporter : IOnePuxImporter
{
private readonly ITotpService? _totp;

/// <summary>
/// Initialises the importer. Pass an <see cref="ITotpService"/> to enable
/// structured TOTP import for login items (extracting <c>otpauth://</c> URIs
/// from item sections); without it the URIs remain only in the notes blob.
/// </summary>
public OnePuxImporter(ITotpService? totp = null)
{
_totp = totp;
}

public Vault ParseOnePux(string exportDataJson)
{
ArgumentNullException.ThrowIfNull(exportDataJson);
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -92,6 +108,62 @@ private static bool HasLoginFields(OnePuxLoginField[]? fields)
string.Equals(f.Designation, "password", StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Searches the item's sections for a one-time password (TOTP) URI and applies it
/// to <paramref name="entry"/>. Supports three field shapes seen in 1Password 1pux
/// exports:
/// <list type="bullet">
/// <item><c>value.totp</c> with an <c>otpauth://</c> URI (newest exports);</item>
/// <item><c>value.string</c> or <c>value.concealed</c> with an <c>otpauth://</c>
/// URI and the field title matching "one-time password" or "totp" (older exports).</item>
/// </list>
/// Falls back to a no-op when no <see cref="ITotpService"/> was injected (the URI,
/// if any, remains visible in the entry notes blob).
/// </summary>
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;
Expand Down
8 changes: 8 additions & 0 deletions src/PassKey.Core/Services/OnePuxJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ public sealed class OnePuxFieldValue
public string? Concealed { get; set; }
public OnePuxAddress? Address { get; set; }
public OnePuxDate? Date { get; set; }

/// <summary>
/// Some 1Password 1pux exports use a dedicated <c>totp</c> field for one-time
/// password URIs (full <c>otpauth://...</c> form). Older exports embed the URI
/// in <see cref="String"/> or <see cref="Concealed"/> with the field title set
/// to "one-time password" — the importer handles both shapes.
/// </summary>
public string? Totp { get; set; }
}

public sealed class OnePuxAddress
Expand Down
Loading
Loading