KeePassCoreLib roadmap¶
src/KeePassCoreLib, net10.0. Will be renamed KeePassCore once the WinForms app is renamed
(see repository.roadmap.md).
Goal and rules¶
- Goal: a fully multiplatform library (Windows, Linux, macOS) to read and write KeePass databases, free of any frontend (WinForms, WinUI, ...) and of OS-specific code.
- Hard constraint: never break KDBX 4.x databases. Files written by the lib must still open in KeePassXC, KeePassDX, Strongbox, KeePass 2.x, pykeepass. Every format change needs a test.
- KDBX 4.x only. KDB (KeePass 1.x) and KDBX 3.1 and older are to be removed, not maintained. Spec: https://keepass.info/help/kb/kdbx.html.
- Crypto: prefer the stronger modern option (AES-256, ChaCha20, Argon2) and remove weak or
legacy algorithms where KDBX 4 allows it. No native C/C++ library: P/Invoke to OS libraries
only, with
[LibraryImport]. - Tests: only valuable ones (a rule, a flow, a requirement, a specification, a fixed bug).
Status¶
| Area | Status |
|---|---|
| Graphics (SkiaSharp) out of the lib | Done |
Mono code (MonoWorkarounds, MonoVersion, IsUnix()) and libgcrypt |
Done |
Windows-only native calls (TxF, PathRelativePathTo, StrCmpLogicalW) |
Done |
Native KeePassLibC DLL (AES-KDF, process DACL) |
Done |
Windows-only code still in the lib (DPAPI, KcpUserAccount) |
To do |
#if KeePassUAP / KeePassLibSD blocks |
Done |
DllImport → [LibraryImport] |
Done |
| Mono comments and commented-out code | Done |
| KDBX < 4 code | Write path done; read path waits for the migration tool |
| KDFs (Argon2, AES-KDF) | Reviewed and tested, open items |
IO (IOConnection → IOConnectionEx) |
API and providers done, call sites not switched yet |
Memory protection (ProtectedBinary / ProtectedString) |
Done (KeePassCore.Security.Memory) |
| Release (rename, NuGet) | To do |
Done¶
IXmlSerializerEx removed¶
- What: the interface (only used by the WinForms app to pass its generated XML reader to
KPTranslation) is deleted with that reader; the app usesXmlSerializer. - Why: frontend plumbing, no use in the lib.
ProtectedString keeps no plaintext¶
- What: a protected
ProtectedStringonly holds its encrypted UTF-8 form (ProtectedBinary). Built from astring, it encodes and encrypts it and does not keep the string;ReadString()decrypts at every call and caches nothing (it used to keep the decoded string and drop the encrypted copy at the first read, even from the DebugDebuggerDisplay). Fields readonly,Equalsin constant time. - Why: upstream design ("the text is in memory anyway"): a password typed or shown once stayed a plain managed string for the life of the entry. Now the strings handed out belong to the caller and the GC can reclaim them.
- Decision: cost accepted:
ReadSafeof a protected password 2.6 µs instead of 79 ns (cached), creation from a string 2.7 µs instead of 20 ns (Release, 1,000,000 calls). Password search is off by default and the entry list shows********(ReadSafeEx). Text built from astringgoes through UTF-8 at construction, as at save time (a lone surrogate becomes U+FFFD earlier). - Checks:
ProtectedObjectsTests.ProtectedStringKeepsNoPlaintext(both constructors, afterReadString/ReadChars/ReadUtf8; failed with the old code),ProtectedStringOperationsKeepTextAndProtection(non-ASCII,Insert/Remove/+/Trim); a database with a protected password built from a string reads back in pykeepass.
ProtectedBinary on KeePassCore.Security.Memory¶
- What: protected values are an
EncryptedBuffer(AES-256-GCM, per-process key in locked, non-dumpable memory:memfd_secreton Linux,CryptProtectMemorywrap on Windows); unprotected ones a plain copy.ReadDatadecrypts straight into the returned array; no lock, no in-place decrypt / re-encrypt.ProtectedString(which wraps it) is unchanged. Removed: the ChaCha20 path and its key in a managedbyte[], the disabledProtectedMemory(DPAPI) stub, the 16-byte padding, the per-object IDs, and the plugin hookProtectedBinary.ExtCrypt(PbCryptDelegate,PbCryptFlags).Equalscompares in constant time. - Why: the ChaCha20 key was readable in any memory dump; one protection path on every OS, the one already reviewed in the memory lib.
- Decisions:
ExtCryptdropped (no user; KeePass 2.x plugins do not load anyway). Overhead: 28 bytes per protected value (nonce + tag) instead of padding to 16 bytes.ProtectedString.ReadStringcaching: next step (see above). - Checks:
Security/ProtectedObjectsTests(round trip for lengths 0 to 1000, offset / size, no plaintext in the object's fields, equality, KDBX round trip of a protected password and attachment); the Debug self-test still passes; a database written with a protected password (non-ASCII) and attachment reads back in pykeepass. Cost ofReadDataon a 16-byte value (Release, 1,000,000 reads): 2.5 µs instead of 11.3 µs; creation 2.5 µs instead of 5.8 µs.
Own delegates replaced with System.Action / Func (Handlers.cs deleted)¶
- What:
KeePassCoreLib.Delegatesis gone.GAction<...>becameAction<...>,GFunc<...>Func<...>,VoidDelegateAction,GroupHandlerFunc<PwGroup, bool>,EntryHandlerFunc<PwEntry, bool>,StrPwEntryDelegateFunc<string, PwEntry, string>(SearchParameters.DataTransformationFn). 28 files in the lib, the WinForms app and TrlUtil. The traversal contract (returnfalsestops it) is now documented on theTraverseTreeparameters. - Why:
GAction/GFuncwere copies written because .NET 2.0 only hadAction<T>; the others were plainFunc/Actionshapes. Distinct types could not be passed where aFunc/Actionis expected. - Decision: all of
Handlers.cs, not only the .NET 3.5 copies: same effort, no half-migrated namespace left. Public API change (plugins built against KeePass 2.x already do not load). - Checks: same build warnings; lib, app and memory tests pass. Lambdas and local functions
passed to
TraverseTreeneeded no change.
Old .NET Framework workarounds removed (Random seed, IntPtr arithmetic)¶
- What:
CryptoRandom.NewWeakRandomreturnsnew Random(): the time-based seed, its global counter and the .NET 2.0 guard againstnew Random(int.MinValue)are gone.MemUtil.ZeroMemory(IntPtr, long)advances the pointer withnintarithmetic;MemUtil.AddPtr(written because .NET 2.0 had noIntPtr+) is deleted. - Why: an unseeded
Randomon .NET gets a random seed from the system, so every instance differs without our own seeding; the weak random is never used for secrets (self-test, KDF benchmarks, TrlUtil preview). Less code, same behavior. - Checks: build warnings unchanged; lib, app and memory tests pass.
Argon2 block memory off the managed heap¶
- What: the Argon2 blocks (64 MiB at the default, up to 2 GiB) live in a
KeePassCore.Security.Memory.SecureBufferinstead of aulong[]; the per-lane scratch blocks and the final block arestackalloc; the block helpers takeSpan<ulong>. TheGC.Collect()after a transform above 100 MiB is gone. - Why: on the managed heap the GC may move the array and leave unwiped copies of key-derived
data behind; it could also be swapped or written to a core dump.
SecureBufferis never moved, locked in RAM and excluded from core dumps where the OS allows it (best-effort, seeSecureMemoryFlags), and wiped on dispose. Nothing to collect any more. - Decisions:
SecureBuffer, not plainNativeMemory: same cost, plusmlock/MADV_DONTDUMP/VirtualLock/ WER exclusion. When locking fails (RLIMIT_MEMLOCK, Windows working set), the memory is still used, only unlocked.- Blocks above 2 GiB (
intlength) are refused (p.Parallelism). Only a parallelism far beyond the memory (RFC 9106 minimum not met) gets there: the array version needed up to 16 GiB for such files. - Checks:
Argon2Tests.BlockMemoryIsNotOnTheManagedHeap(64 MiB transform allocates less than 1 MiB on the managed heap; failed with the array); RFC 9106 and libargon2 vectors still bit-exact; white-box failed-lane test adapted. Benchmark (same session, 9 runs, twice): not slower, rather faster (default 87-90 vs 113-124 ms, 1 GiB 655 vs 695-712 ms).
KDBX 3.1 write path removed¶
- What:
KdbxFilealways writes KDBX 4 (4.0, or 4.1 when 4.1 data is present). Removed:FileVersion32_3_1,ForceVersion(no caller left since the app dropped the KDBX 3.1 export), the Salsa20 inner stream on write (always ChaCha20, 64-byte key),HashedBlockStreamand the stream start bytes on write, the KDBX 3.1 outer header fields (TransformSeed,TransformRounds,InnerRandomStreamKey,StreamStartBytes,InnerRandomStreamID), 2-byte header field lengths, theHeaderHashmeta element and the binary pool in the KDBX file. - Why: KDBX 4.x only.
GetMinKdbxVersionnever returned less than 4.0 withoutForceVersion, so the written bytes of a KDBX 4 file are unchanged. - Kept on purpose: the KDBX 3.1 read path (
HashedBlockStream, Salsa20 and ArcFour inner streams, AES-KDF from the outer header,HeaderHashcheck): it moves to the migration tool (see To do 2.1). The plain XML export still writes the binary pool and text dates (not KDBX). - Checks:
KdbxWriteFormatTests.SavedFileHasAKdbx4OuterHeader(version 4, 4-byte field lengths,KdfParameterspresent, no KDBX 3.1 field); all lib tests pass; no new build warning.
Native KeePassLibC removed¶
- What: the calls to the native DLL of KeePass 2.x (
KeePassLibN.*.dll, formerlyKeePassLibC) are gone:NativeLib.TransformKey256/TransformKeyBenchmark256(AES-KDF),NativeLib.IsLibraryInstalled,NativeLib.AllowNative,NativeLib.FileNameand the DLL names,NativeMethods.AuxProtectProcessWithDacland the 9 imports,NativeBufferEx(only used by them), the native self-test.AesKdfalways runs the managed code. The lib no longer needsAllowUnsafeBlocks.MAX_PATH/INVALID_HANDLE_VALUE(Windows constants only used by the app) moved to the app. - Why: this fork does not ship the DLL, so the managed path already ran; the native AES-KDF could not be cancelled (denial of service with a crafted round count); no native library to ship (project rule).
- Checks: build warnings unchanged; lib and app tests pass;
tests/KeePassCoreLib.Tests/ManagedCodeOnly.cs(which forcedAllowNative = false) deleted. - App side: see the app roadmap (process protection, "native key transformations" option).
Graphics removed from the lib¶
- What: SkiaSharp removed from the lib. Custom icons are raw PNG bytes
(
PwCustomIcon.ImageDataPng); colors (PwDatabase.Color,PwEntry.ForegroundColor/BackgroundColor) areSystem.Drawing.Color.GfxUtil,ScaleTransformFlagsand the icon image cache moved to the WinForms app. - Why: decoding and scaling images is a frontend concern; the lib must not depend on a graphics library.
- Decisions:
System.Drawing.Coloris a plain struct of the base framework (no GDI+), the same type as upstream KeePass.- Colors are read with
StrUtil.ColorFromHtml: an invalid or non-opaque value givesColor.Emptyand never throws, because a bad color written by another app must not prevent a database from opening (SKColor.Parseused to throw). Colors are written as#RRGGBB. - Checks:
tests/KeePassCoreLib.Tests/Serialization/ColorAndIconTests.cs(round trip,#RRGGBBformat, invalid color still opens, icon bytes kept as is).
Atomic transacted save (FileTransactionEx)¶
- What: a local save now replaces the database with
File.Move(db.tmp, db, true)(rename(2)/MoveFileEx, same folder, hence same volume). TxF (Windows transactional NTFS) removed with its OneDrive / registry checks,ClearOld, the native imports only it used, and the EFSFile.Decryptof the old database. - Why: outside TxF the commit deleted the database, then renamed the temporary file: if the rename failed, the database was lost. TxF is deprecated by Microsoft. The EFS decrypt only existed for TxF and briefly wrote the old database decrypted to disk.
- Checks:
TransactedSaveTests.FailedCommitKeepsThePreviousFile(written first: it failed with the old code, the file was gone). ACL, creation time, Unix permissions and the hidden attribute are still restored. - Not changed: remote URLs still delete then rename (see IO below).
SimpleStat without Mono.Posix¶
- What:
Native/SimpleStat.csusesFile.GetUnixFileMode/SetUnixFileMode. - Why: Mono.Posix was loaded by reflection and its
Debug.Assertkilled Debug builds on Unix at every transacted save. - Decision: owner / group are no longer restored (no .NET API; the new file already belongs to the current user). Permissions such as 0600 are kept.
- Checks:
TransactedSaveTests.SaveKeepsUnixPermissions.
PathRelativePathTo → Path.GetRelativePath¶
- What:
UrlUtil.MakeRelativePathusesPath.GetRelativePathon every OS; the Windows P/Invoke and the hand-written Unix loop are gone. - Why: one code path, OS rules (separators, drives, UNC, case) handled by .NET.
- Decision: across two UNC shares (
\\SRV\Apps\…→\\SRV\Documents\…) the result is now the absolute path instead of..\..\Documents\…. Old configuration files are not affected:MakeAbsolutePathstill resolves the old form. Self-testUrlUtil-R1updated. - Checks:
tests/KeePassCoreLib.Tests/Utility/UrlUtilTests.cs(relative then absolute gives the target back; different roots stay absolute and old UNC relative paths resolve: Windows only).
StrCmpLogicalW → managed natural compare¶
- What:
StrUtil.CompareNaturally(numbers by value, case-insensitive) uses its managed algorithm on every OS; the P/Invoke and its availability test are gone. - Why: Linux / macOS already used it; the sort order is now the same everywhere. It stays in the lib because the lib sorts with it (groups, tags, entry comparer).
- Checks:
tests/KeePassCoreLib.Tests/Utility/StrUtilTests.cs(same order asStrCmpLogicalWon Windows).
Mono comments and commented-out code removed¶
- What: about 750 lines removed in 36 files: commented-out code (
//lines and/* */blocks, e.g. the oldCompareNaturally,CreateAesCoreAesCng probe,Synchronize, RTF helpers,IOConnectionInfostring serialization, XSLT context,ReadBase64RawInChunks,OwnLockFile,Clear()stubs, the emptyKeePassDebug_WriteXmlblock) and the Mono comments (Mono 1.2.6, Mono < 2.10, "may throw with Mono", ximian link). With the Mono < 2.10 comments thecatch (NotImplementedException) { }aroundCachePolicywent too (the general catch remains). - Kept: explanatory comments (algorithms, spec references, test vector sources), the unsupported
VariantDictionarytype codes (format documentation), thePUToutside Windows note (open IO decision), theProtectedMemoryTODOs (see Security) and the dev switches (KDBX_BENCHMARK,CRSBENCHMARK,ARGON2_*,SELFTEST_ARGON2_*). - Checks: same build warnings; no new unused member or
using; all lib tests pass.
DllImport → [LibraryImport]¶
- What: the 9
KeePassLibCimports inNative/NativeMethods.cs(AesKdfTransformHalf,AesKdfTransformBenchmarkHalf,AuxProtectProcessWithDacl, one per x86 / x64 / ARM64 DLL) are source-generated[LibraryImport]withCallConvCdecl;NativeLib.DllCallingConventionremoved. The lib now setsAllowUnsafeBlocks(required by the generator). NoDllImportleft. - Why: project rule; no runtime marshalling stub, trimming / AOT friendly.
- Checks: same entry points, signatures blittable (
IntPtr,uint,ulong,boolas Win32BOOL); build warnings unchanged; lib tests pass. Not run against the native DLL: this fork does not ship it, and the imports stay to be removed (see To do 1).
#if KeePassUAP / #if KeePassLibSD resolved¶
- What: the blocks in 39 files are resolved with both symbols undefined (the code compiled so
far);
#if DEBUG && !KeePassLibSDbecame#if DEBUG. Same preprocessor script as the app. Two UAP branches were taken instead, being the plain .NET form of the same behavior:XmlUtilEx.CreateXmlReaderSettingssetsDtdProcessing.Ignoredirectly (no reflection, no obsoleteProhibitDtd;MemUtil.GetEnumValueremoved with it). - Why: UWP (
KeePassUAP) and .NET Compact Framework (KeePassLibSD) builds do not exist in this fork; the dead branches referenced BouncyCastle,EnvironmentExtandKeePassCoreLibSD. - Checks: no behavior change; same build warnings as before; all lib tests pass.
Mono code removed (and libgcrypt)¶
- What:
MonoWorkarounds,NativeLib.MonoVersionandNativeLib.IsUnix()deleted; libgcrypt (AesKdf.GCrypt.cs,NativeMethods.Unix.cs) deleted. - Why:
MonoWorkarounds.IsRequired()meant "not Windows", so on Linux / macOS with .NET the Mono workarounds were active:
| Workaround | Before on Linux / macOS | Now (= .NET on Windows) |
|---|---|---|
| 1468 AES-KDF | libgcrypt | managed AES-KDF (~1 ms slower per unlock, cancellable) |
| 3471228285 argument encoding | Unix shell style | .NET rules |
| 10163 / 688007 legacy IO | stream wrapper, anonymous FTP login | removed |
HashedBlockStream |
catch of a Mono NullReferenceException |
removed |
| Group path display | - |
→ |
- Decisions: real OS differences are kept, written
OperatingSystem.IsWindows(): path separators, quoted paths, temporary folder inXDG_RUNTIME_DIRon Unix,ProtectedMemory(Windows only), native DLL gating. HTTP writes usePUToutside Windows andPOSTon Windows: not purely Mono, kept until the IO rework decides. - Checks: all lib tests pass (AES-KDF known answers against OpenSSL included);
SelfTest.Perform()now passes in Debug on Linux (it was killed by theMonoVersionassert); the CLI no longer needsNativeLib.AllowNative = false.
Argon2¶
- What:
- Argon2d is the default KDF for new databases (first engine in
KdfPool). - Review of
Argon2Kdf*.cs: worker exceptions propagate; theCancellationTokenis checked per slice and every 64 blocks; memory, H0 and scratch blocks are wiped infinally; lanes run withParallel.For;Blake2b.Disposewipes its state. - Native Argon2 path removed (
Argon2Native,argon2_hash*P/Invokes): Argon2 is managed only, always cancellable and wiped. - Memory below 8 KiB × parallelism (RFC 9106 minimum; libargon2, hence KeePassXC and
pykeepass, refuse such files) is rejected on write only:
KdfEngine.ValidateForWriteruns inPwDatabase.Savebefore the file is opened (without file transactions, opening truncates it) and inKdbxFile.Save. Reading still works. - Checks:
Argon2Tests(RFC 9106 vectors, 43 libargon2 vectors up to p = 255, cancellation, failed lane),KdfPoolTests,Argon2WriteValidationTestswith the fixtureTestData/argon2d-16KiB-p4-legacy.kdbx(passwordlegacy). Bit-exact with libargon2 on 161 parameter sets. - Benchmark (i7-1355U, median of 9): on AC power managed is within noise of libargon2 (default 64 MiB, t = 2, p = 2: 68-71 vs 65-66 ms); on battery 8-20 % slower.
AES-KDF¶
- Reported as weak:
AesKdf.AreParametersWeakreturnstruewhatever the rounds (was: below 600,000 rounds). AES-KDF is not memory-hard. Decision: kept (existing databases must open and save) and still offered, but reported as weak; frontends offer the default KDF instead. Anullargument now throws. - WinForms app: the existing weak-settings dialog (on open and in the database settings)
switches to
KdfPool.GetDefaultParameters()(Argon2d) when the KDF is weak even with its own defaults; before, it reset AES-KDF to its defaults, which would now be reported again. - CLI: a warning line in the log after opening.
- Checks:
KdfPoolTests.AesKdfIsWeakWhateverTheRounds,DefaultKdfIsNotWeak. - What: known-answer tests against OpenSSL; the rounds are cancellable (a crafted header can ask for 2^64 - 1 rounds; token checked every 8192 rounds).
- Errors: an error in a worker thread (transform or
GetBestParameters) is rethrown as is (ExceptionDispatchInfo: type, message, stack trace), no longer wrapped innew Exception(null, ex), whose message "Exception of type 'System.Exception' was thrown." was all the user saw. TheDebug.Assert(false)in those catch blocks went too (it killed a Debug test host, and fired on a cancelledGetBestParameters);GetBestParametersnow throwsOperationCanceledExceptionfirst when cancelled, like the transform. - Write validation:
AesKdf.ValidateForWriterefuses missing rounds and rounds outside 1 to 2^31 - 2 (MinRoundsForWrite/MaxRoundsForWrite), before the file is opened, like Argon2. KeePassXC reads the rounds as a signed 32-bitintand only accepts1 <= rounds < INT_MAX(Kdf::setRounds); 0 rounds means no key stretching. Reading is unchanged: such files still open. - Seed: not validated on write, on purpose:
Randomizereplaces it with 32 random bytes right after the validation, so a check would only refuse files that are written correctly. A seed of another length is still hashed with SHA-256 on read (KeePass 2.x behavior), now without aDebug.Assert. - Threads: the two 16-byte halves (transform and
GetBestParameters) run withParallel.Invoke(RunHalves) instead of twonew Threadper call; the halves throw instead of returning their exception.RunHalvesreturns once both halves have stopped, then throws cancellation first, else the original exception. Benchmark unchanged (ratio to libgcrypt 1.04-1.06). - Checks:
AesKdfTests(rounds 0, 1, 2, 8191-8193, 600000;ErrorInARoundKeepsItsTypeAndMessage, failed with the wrapping;ValidateForWriteKeepsRoundsReadableByKeePassXC,ValidateForWriteRefusesMissingRounds,BestParametersCanBeSaved,BestParametersSearchCanBeCancelled). - Benchmark: managed .NET
Aesis 8-10 % slower than libgcrypt 1.12.4, i.e. ~1 ms per unlock at the default 600,000 rounds.
To do¶
1. Move the remaining Windows-specific code to the app¶
| Code | Today | Target |
|---|---|---|
Keys/KcpUserAccount.cs |
DPAPI user account key (a composite key component) | Open decision: DPAPI on Windows (required for existing files), Keychain / Secret Service on macOS / Linux, or Windows-only in the app with key files elsewhere. Needed before a database is opened, so its key cannot be stored in the database |
CryptoUtil.ProtectData / ProtectDataPriv |
DPAPI; returns plaintext on non-Windows | Callers: the app's DpapiString (Windows only, fine) and IPC / clipboard (Windows only). Move to the app |
System.Security.Cryptography.ProtectedData package |
DPAPI | Remove once the rows above are done |
2. Remove legacy code¶
- KDBX < 4 read path (write path done, see Done):
- remove
HashedBlockStream(KDBX 3 only, read only now), the KDBX 3.1 header fields, the stream start bytes and theHeaderHashcheck; - keep a clear error for KDB / KDBX 3 files (
OldFormatException); - check first that no KDBX 4 file from another app uses Salsa20 for the inner stream before removing it on read;
- the KDBX 3.1 read path moves to the planned migration tool (
KeePassCore.Migration.CLI, see the repository roadmap): remove it from the lib together with, or after, that tool. - Code and comments for old .NET Framework versions, to simplify on .NET 10: TLS flags
(
IOConnectionLegacy, .NET 4.5 / 4.7 checks), with the IO rework. The .NET 4.0 FTP transaction bug moved to IO (step 3).
3. IO: replace the static IOConnection (obsolete) with IOConnectionEx¶
State: IOConnectionEx (an instance, a DI singleton in the app) is ready but not used
yet: the 16 call sites still use IOConnection (OpenRead 10, FileExists 9, OpenWrite 6,
DeleteFile 5, ...); half of them are in the lib (PwDatabase, KdbxFile, KcpKeyFile,
FileLock, FileTransactionEx).
Done (API and providers):
- API: synchronous, streams, exceptions, legacy semantics:
Stream OpenRead(alsodata:URIs),Stream OpenWrite(remote: buffered, uploaded when the stream is disposed, an upload error is thrown there),FileExists(ioc, bThrowErrors = false)(falsefor a missing file; other errors swallowed only when asked, as legacy),DeleteFile,RenameFile. EventIOAccessPrewithsender= the instance and the right type (Movefor a rename). A save never fails silently. - Providers behind
IStorageProvider(public), chosen byioc.IsLocalFile()first (relative paths never go throughUri), then by URL scheme from a registry (RegisterProvider(scheme, factory), for a later optional package such asKeePassCore.IO.Remote): LocalStorageProvider: paths andfile://URLs, sameFileStreammodes as legacy.HttpStorageProvider(WebDAV): GET, HEAD (falls back to GET when the server refuses HEAD), PUT, DELETE, MOVE (Destination,Overwrite: T); 404 givesFileNotFoundException;IocPropertiesmapped as legacy (user agent, redirects, pre-auth, timeout, 100-continue); proxy none / system / manual with credentials; invalid certificates when allowed.FtpStorageProvider(FluentFTP sync): host, port and unescaped remote path from the URL,ftps://= implicit TLS, passive / active and timeouts fromIocProperties, the server reply checked after an upload, HTTP CONNECT proxy (manual address entered as host or URL, or the system proxy when it applies to the URL).- Fixed on the way:
SetProxydropped manual proxy credentials when the auth type wasAuto; the default (noSetProxycall) is now the system proxy, as legacy, andNoneis kept as such;IOConnectionInfo.Propertiessetter never stored its value (CloneDeepshared the properties with the original, loaded properties were lost). - Removed from the draft:
IORequestand its three classes, the asyncbyte[]methods,CopyData(no caller), the deadConfigureWebRequest,IOWebRequestPre(it handed out the request object; no user, KeePass 2.x plugins do not load anyway). - Decisions:
- Sync now, async later: every caller is synchronous.
- HTTP writes use
PUTon every OS (WebDAV standard; Windows usedPOSTthroughWebClient). - One
HttpClient/ handler per operation, not one shared per settings set: credentials belong to eachIOConnectionInfo, and a shared handler would keep passwords in a cache key. KeePass does a few requests per save. - A remote upload happens when the write stream is disposed, also after a failed save (as a
local
FileStreamkeeps a truncated file): transacted saves (FileTransactionEx) write a temporary file first. - Checks:
IOConnectionExTests(contract for local files and HTTP on an in-test WebDAV server with Basic authentication: write, exists, read, rename, delete, missing file, failed upload, failed local write, wrong credentials, relative path,file://URL,data:URI, unknown scheme, access events). FTP checked by hand against pyftpdlib (same sequence, a KDBX file written and read back through FTP with a space in the folder and file names, wrong password): no FTP server in the test run.
To do:
- Switch the call sites, local files first (most call sites); then remote. Pass the instance to
PwDatabase.Open / Save,KdbxFile,KcpKeyFile,FileLock,FileTransactionEx, or use a default instance during the transition (the lib has no DI). - FTP transactions:
FileTransactionExskips the transaction forftp:URLs on Windows only (.NET 4.0FtpWebRequestrename bug 621450). Rename works with FluentFTP (checked above): with the switch, test a transacted save (create, then replace an existing file) and remove the guard. - Delete
IOConnectionLegacy.cs; renameIOConnectionExtoIOConnection. - Async API (
AsyncFtpClient,HttpClient.SendAsync) once callers are async.
4. Security¶
- Migration of old KeePass 2.x files whose Argon2 memory is below the RFC 9106 minimum: they open
but cannot be saved as is. Detect on open, tell the user, raise memory to at least 8 KiB ×
parallelism (or reset to defaults), then save.
Argon2WriteValidationTests. SavingWorksOnceTheMemoryIsRaisedshows the minimal operation; the UI part is in the frontends. - Self-test: make it meaningful in Release (most checks are
#if DEBUG).
5. Performance¶
- Argon2 block fill (BlaMka G) with
Vector256<ulong>: should reach or beat libargon2; must stay bit-exact withArgon2Tests. - AES-KDF with
Aes.EncryptCbc(Span)(may close the ~10 % gap with libgcrypt). - Rerun
tests/benchmarks/KdfBenchmarkafter both.
6. Tests to add¶
- KDBX 4 round trip, and fixtures written by KeePassXC, KeePassDX, Strongbox, KeePass 2.x (read, save, re-read).
- Key files (all formats), ciphers (AES, ChaCha20) known-answer tests,
HmacBlockStream,ProtectedString/ProtectedBinary.
7. Release¶
- Rename to
KeePassCore(after the app is renamedKeePassCore.WinApp). - NuGet package.
Decision log¶
| Decision | Why |
|---|---|
| KDBX 4.x only | Maintaining older formats is cost without value; KeePass 2.x writes KDBX 4 |
| KDBX 3.1: write removed first, read kept until the migration tool | Users of old files keep a way in; nothing writes KDBX 3.1 any more |
| Argon2d default for new databases | Memory-hard, KeePass 2.x default too |
| Argon2 below the RFC 9106 minimum: rejected on write, read still works | Such files are refused by libargon2 (KeePassXC, pykeepass); reading must not break existing files |
| Argon2 managed only | Always cancellable and wiped; within noise of libargon2 on AC power |
IOConnectionEx: sync, streams, exceptions; PUT everywhere; one HTTP handler per operation |
Callers are sync and stream-based; a save must never fail silently; no password in a cache key |
ProtectedString: no plain string kept, ReadString not cached |
A password shown once no longer stays in memory for the life of the entry; ~2.6 µs per read accepted |
Protected values: EncryptedBuffer of KeePassCore.Security.Memory, ExtCrypt dropped |
Key out of the managed heap, one path on every OS; no user of the plugin hook |
| AES-KDF kept and offered, always reported as weak | Existing databases must open and save; not memory-hard, so Argon2d is offered instead |
| AES-KDF rounds outside 1 to 2^31 - 2 refused on write, seed not checked | KeePassXC limit; Randomize always writes a fresh 32-byte seed |
Argon2 blocks in SecureBuffer, scratch on the stack |
No GC copies of key-derived data; locked and excluded from dumps where possible; not slower |
No graphics in the lib, colors as System.Drawing.Color |
Frontend-free lib; same type as upstream |
Colors: invalid or non-opaque = Color.Empty, never throws |
A bad value from another app must not prevent opening a file |
Local save = atomic File.Move, TxF removed |
A failed save must never lose the database; TxF is deprecated |
| Mono workarounds off everywhere | There is no Mono runtime; they were active on Linux / macOS by mistake |
MakeRelativePath via Path.GetRelativePath |
One code path; UNC cross-share paths stay absolute, old ones still resolve |
| Natural compare: managed on every OS | Same sort order everywhere; no P/Invoke |
KeePassUAP / KeePassLibSD resolved as undefined |
Those builds do not exist; no behavior change |
P/Invoke with [LibraryImport] only |
Project rule; source-generated marshalling |
No KeePassLibC: managed AES-KDF only |
Not shipped by this fork; not cancellable; no native library to ship |