Skip to content

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 uses XmlSerializer.
  • Why: frontend plumbing, no use in the lib.

ProtectedString keeps no plaintext

  • What: a protected ProtectedString only holds its encrypted UTF-8 form (ProtectedBinary). Built from a string, 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 Debug DebuggerDisplay). Fields readonly, Equals in 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: ReadSafe of 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 a string goes through UTF-8 at construction, as at save time (a lone surrogate becomes U+FFFD earlier).
  • Checks: ProtectedObjectsTests.ProtectedStringKeepsNoPlaintext (both constructors, after ReadString / 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_secret on Linux, CryptProtectMemory wrap on Windows); unprotected ones a plain copy. ReadData decrypts 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 managed byte[], the disabled ProtectedMemory (DPAPI) stub, the 16-byte padding, the per-object IDs, and the plugin hook ProtectedBinary.ExtCrypt (PbCryptDelegate, PbCryptFlags). Equals compares 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: ExtCrypt dropped (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.ReadString caching: 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 of ReadData on 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.Delegates is gone. GAction<...> became Action<...>, GFunc<...> Func<...>, VoidDelegate Action, GroupHandler Func<PwGroup, bool>, EntryHandler Func<PwEntry, bool>, StrPwEntryDelegate Func<string, PwEntry, string> (SearchParameters.DataTransformationFn). 28 files in the lib, the WinForms app and TrlUtil. The traversal contract (return false stops it) is now documented on the TraverseTree parameters.
  • Why: GAction / GFunc were copies written because .NET 2.0 only had Action<T>; the others were plain Func / Action shapes. Distinct types could not be passed where a Func / Action is 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 TraverseTree needed no change.

Old .NET Framework workarounds removed (Random seed, IntPtr arithmetic)

  • What: CryptoRandom.NewWeakRandom returns new Random(): the time-based seed, its global counter and the .NET 2.0 guard against new Random(int.MinValue) are gone. MemUtil.ZeroMemory(IntPtr, long) advances the pointer with nint arithmetic; MemUtil.AddPtr (written because .NET 2.0 had no IntPtr +) is deleted.
  • Why: an unseeded Random on .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.SecureBuffer instead of a ulong[]; the per-lane scratch blocks and the final block are stackalloc; the block helpers take Span<ulong>. The GC.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. SecureBuffer is never moved, locked in RAM and excluded from core dumps where the OS allows it (best-effort, see SecureMemoryFlags), and wiped on dispose. Nothing to collect any more.
  • Decisions:
  • SecureBuffer, not plain NativeMemory: same cost, plus mlock / MADV_DONTDUMP / VirtualLock / WER exclusion. When locking fails (RLIMIT_MEMLOCK, Windows working set), the memory is still used, only unlocked.
  • Blocks above 2 GiB (int length) 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: KdbxFile always 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), HashedBlockStream and the stream start bytes on write, the KDBX 3.1 outer header fields (TransformSeed, TransformRounds, InnerRandomStreamKey, StreamStartBytes, InnerRandomStreamID), 2-byte header field lengths, the HeaderHash meta element and the binary pool in the KDBX file.
  • Why: KDBX 4.x only. GetMinKdbxVersion never returned less than 4.0 without ForceVersion, 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, HeaderHash check): 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, KdfParameters present, 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, formerly KeePassLibC) are gone: NativeLib.TransformKey256 / TransformKeyBenchmark256 (AES-KDF), NativeLib.IsLibraryInstalled, NativeLib.AllowNative, NativeLib.FileName and the DLL names, NativeMethods.AuxProtectProcessWithDacl and the 9 imports, NativeBufferEx (only used by them), the native self-test. AesKdf always runs the managed code. The lib no longer needs AllowUnsafeBlocks. 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 forced AllowNative = 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) are System.Drawing.Color. GfxUtil, ScaleTransformFlags and 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.Color is 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 gives Color.Empty and never throws, because a bad color written by another app must not prevent a database from opening (SKColor.Parse used to throw). Colors are written as #RRGGBB.
  • Checks: tests/KeePassCoreLib.Tests/Serialization/ColorAndIconTests.cs (round trip, #RRGGBB format, 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 EFS File.Decrypt of 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.cs uses File.GetUnixFileMode / SetUnixFileMode.
  • Why: Mono.Posix was loaded by reflection and its Debug.Assert killed 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.MakeRelativePath uses Path.GetRelativePath on 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: MakeAbsolutePath still resolves the old form. Self-test UrlUtil-R1 updated.
  • 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 as StrCmpLogicalW on 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 old CompareNaturally, CreateAesCore AesCng probe, Synchronize, RTF helpers, IOConnectionInfo string serialization, XSLT context, ReadBase64RawInChunks, OwnLockFile, Clear() stubs, the empty KeePassDebug_WriteXml block) and the Mono comments (Mono 1.2.6, Mono < 2.10, "may throw with Mono", ximian link). With the Mono < 2.10 comments the catch (NotImplementedException) { } around CachePolicy went too (the general catch remains).
  • Kept: explanatory comments (algorithms, spec references, test vector sources), the unsupported VariantDictionary type codes (format documentation), the PUT outside Windows note (open IO decision), the ProtectedMemory TODOs (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 KeePassLibC imports in Native/NativeMethods.cs (AesKdfTransformHalf, AesKdfTransformBenchmarkHalf, AuxProtectProcessWithDacl, one per x86 / x64 / ARM64 DLL) are source-generated [LibraryImport] with CallConvCdecl; NativeLib.DllCallingConvention removed. The lib now sets AllowUnsafeBlocks (required by the generator). No DllImport left.
  • Why: project rule; no runtime marshalling stub, trimming / AOT friendly.
  • Checks: same entry points, signatures blittable (IntPtr, uint, ulong, bool as Win32 BOOL); 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 && !KeePassLibSD became #if DEBUG. Same preprocessor script as the app. Two UAP branches were taken instead, being the plain .NET form of the same behavior: XmlUtilEx.CreateXmlReaderSettings sets DtdProcessing.Ignore directly (no reflection, no obsolete ProhibitDtd; MemUtil.GetEnumValue removed with it).
  • Why: UWP (KeePassUAP) and .NET Compact Framework (KeePassLibSD) builds do not exist in this fork; the dead branches referenced BouncyCastle, EnvironmentExt and KeePassCoreLibSD.
  • Checks: no behavior change; same build warnings as before; all lib tests pass.

Mono code removed (and libgcrypt)

  • What: MonoWorkarounds, NativeLib.MonoVersion and NativeLib.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 in XDG_RUNTIME_DIR on Unix, ProtectedMemory (Windows only), native DLL gating. HTTP writes use PUT outside Windows and POST on 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 the MonoVersion assert); the CLI no longer needs NativeLib.AllowNative = false.

Argon2

  • What:
  • Argon2d is the default KDF for new databases (first engine in KdfPool).
  • Review of Argon2Kdf*.cs: worker exceptions propagate; the CancellationToken is checked per slice and every 64 blocks; memory, H0 and scratch blocks are wiped in finally; lanes run with Parallel.For; Blake2b.Dispose wipes 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.ValidateForWrite runs in PwDatabase.Save before the file is opened (without file transactions, opening truncates it) and in KdbxFile.Save. Reading still works.
  • Checks: Argon2Tests (RFC 9106 vectors, 43 libargon2 vectors up to p = 255, cancellation, failed lane), KdfPoolTests, Argon2WriteValidationTests with the fixture TestData/argon2d-16KiB-p4-legacy.kdbx (password legacy). 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.AreParametersWeak returns true whatever 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. A null argument 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 in new Exception(null, ex), whose message "Exception of type 'System.Exception' was thrown." was all the user saw. The Debug.Assert(false) in those catch blocks went too (it killed a Debug test host, and fired on a cancelled GetBestParameters); GetBestParameters now throws OperationCanceledException first when cancelled, like the transform.
  • Write validation: AesKdf.ValidateForWrite refuses 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-bit int and only accepts 1 <= 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: Randomize replaces 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 a Debug.Assert.
  • Threads: the two 16-byte halves (transform and GetBestParameters) run with Parallel.Invoke (RunHalves) instead of two new Thread per call; the halves throw instead of returning their exception. RunHalves returns 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 Aes is 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

  1. KDBX < 4 read path (write path done, see Done):
  2. remove HashedBlockStream (KDBX 3 only, read only now), the KDBX 3.1 header fields, the stream start bytes and the HeaderHash check;
  3. keep a clear error for KDB / KDBX 3 files (OldFormatException);
  4. check first that no KDBX 4 file from another app uses Salsa20 for the inner stream before removing it on read;
  5. 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.
  6. 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 (also data: URIs), Stream OpenWrite (remote: buffered, uploaded when the stream is disposed, an upload error is thrown there), FileExists(ioc, bThrowErrors = false) (false for a missing file; other errors swallowed only when asked, as legacy), DeleteFile, RenameFile. Event IOAccessPre with sender = the instance and the right type (Move for a rename). A save never fails silently.
  • Providers behind IStorageProvider (public), chosen by ioc.IsLocalFile() first (relative paths never go through Uri), then by URL scheme from a registry (RegisterProvider(scheme, factory), for a later optional package such as KeePassCore.IO.Remote):
  • LocalStorageProvider: paths and file:// URLs, same FileStream modes as legacy.
  • HttpStorageProvider (WebDAV): GET, HEAD (falls back to GET when the server refuses HEAD), PUT, DELETE, MOVE (Destination, Overwrite: T); 404 gives FileNotFoundException; IocProperties mapped 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 from IocProperties, 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: SetProxy dropped manual proxy credentials when the auth type was Auto; the default (no SetProxy call) is now the system proxy, as legacy, and None is kept as such; IOConnectionInfo.Properties setter never stored its value (CloneDeep shared the properties with the original, loaded properties were lost).
  • Removed from the draft: IORequest and its three classes, the async byte[] methods, CopyData (no caller), the dead ConfigureWebRequest, 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 PUT on every OS (WebDAV standard; Windows used POST through WebClient).
  • One HttpClient / handler per operation, not one shared per settings set: credentials belong to each IOConnectionInfo, 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 FileStream keeps 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:

  1. 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).
  2. FTP transactions: FileTransactionEx skips the transaction for ftp: URLs on Windows only (.NET 4.0 FtpWebRequest rename 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.
  3. Delete IOConnectionLegacy.cs; rename IOConnectionEx to IOConnection.
  4. 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. SavingWorksOnceTheMemoryIsRaised shows 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 with Argon2Tests.
  • AES-KDF with Aes.EncryptCbc(Span) (may close the ~10 % gap with libgcrypt).
  • Rerun tests/benchmarks/KdfBenchmark after 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

  1. Rename to KeePassCore (after the app is renamed KeePassCore.WinApp).
  2. 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