This document describes PassKey’s solution structure, dependency graph, and key design patterns.
PassKey.sln
├── src/
│ ├── PassKey.Core/ # Domain models, crypto, services
│ ├── PassKey.Desktop/ # WinUI 3 app (MVVM, DI)
│ ├── PassKey.BrowserHost/ # Native Messaging bridge
│ └── PassKey.Tests/ # xUnit test suite
└── extensions/
├── chrome/ # Chrome Manifest V3 extension
└── firefox/ # Firefox Manifest V3 extension
Konscious.Security.Cryptography.Argon2, System.Security.Cryptography, Microsoft.Data.Sqlite (via interface)InternalsVisibleTo), PassKey.BrowserHost (with InternalsVisibleTo), MoqPassKey.Desktop ────► PassKey.Core
▲
PassKey.BrowserHost ────────┘
▲
PassKey.Tests ──────────────┘
PassKey.Core has no dependency on any UI framework. PassKey.Desktop and PassKey.BrowserHost both depend on Core. PassKey.Tests references Core and BrowserHost.
PassKey uses a ViewModel-first navigation pattern:
ShellViewModel manages the current page via a CurrentViewModel property.CurrentViewModel to a new ViewModel instance.MainWindow uses a ContentControl bound to CurrentViewModel, with DataTemplate selectors mapping each ViewModel type to its corresponding View.SetViewModel() in code-behind and set DataContext.Benefits:
All services are registered in the DI container at startup (App.xaml.cs). Dependencies are injected via constructor parameters.
Rules:
App.Current or a static container).[Inject] attributes — constructor parameters only.WinUI 3 does not allow multiple ContentDialog instances to be open simultaneously. PassKey uses a DialogQueueService with a Queue<Func<Task>> and a serial pump:
Func<Task> that shows and awaits a ContentDialog).Why not SemaphoreSlim? Using
SemaphoreSlim.WaitAsync()on the UI thread causes deadlocks because the dialog’s dismissal callback cannot run while the UI thread is blocked.
ViewModels can push/pop sub-pages via INavigationStack:
Push(viewModel) — Navigate to a child page.Pop() — Return to the previous page.User enters master password
│
▼
KDF (Argon2id or PBKDF2) + salt from VaultMetadata
│
▼
KEK (32 bytes) ── AES-GCM Decrypt ──► DEK (32 bytes)
│
Stored in PinnedSecureBuffer
│
AES-GCM Decrypt vault blob
│
▼
Vault (JSON → objects)
On save:
Vault objects → JSON → AES-GCM Encrypt with DEK → blob → VaultData table
On master password change:
New password → KDF → new KEK → AES-GCM Encrypt DEK → update VaultMetadata
(vault blob is NOT re-encrypted)
┌─────────────────┐ Native Messaging ┌──────────────────┐ Named Pipe ┌─────────────────┐
│ Browser │ (stdio: 4-byte len + │ PassKey │ (local IPC) │ PassKey │
│ Extension │ JSON payload) │ BrowserHost │ │ Desktop │
│ │◄───────────────────────►│ │◄────────────────►│ │
│ - popup.js │ │ - stdin/stdout │ │ - BrowserIpc │
│ - content.js │ │ - pipe client │ │ Service │
│ - background.js│ │ │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
| Message | Direction | Description |
|---|---|---|
get-credentials |
Extension → Desktop | Get credentials matching a URL |
get-all-credentials |
Extension → Desktop | Get all vault credentials |
unlock-vault |
Extension → Desktop | Unlock vault with master password |
show-window |
Extension → Desktop | Bring Desktop app to foreground |