/// <summary> /// Replaces an entry within its parent's collection. /// </summary> /// <param name="document">The document being updated.</param> /// <param name="parent">The parent to update.</param> /// <param name="child">The entry to use as a replacement.</param> /// <param name="touchesNode">Whether to treat the swap as an "update" (vs a revert).</param> protected override void SwapIntoParent(KdbxDocument document, IKeePassGroup parent, IKeePassEntry child, bool touchesNode) { if (document == null) { throw new ArgumentNullException(nameof(document)); } if (parent == null) { throw new ArgumentNullException(nameof(parent)); } if (child == null) { throw new ArgumentNullException(nameof(child)); } // Otherwise, we need to find the equivalent existing child (by UUID) and // update that way. IKeePassNode matchedNode = parent.Children.First(g => g.Uuid.Equals(child.Uuid)); IKeePassEntry matchedEntry = matchedNode as IKeePassEntry; DebugHelper.Assert(matchedEntry != null); matchedEntry.SyncTo(child, touchesNode); }
public async Task DowngradeCipherSettings() { DateTime lastPasswordChange = this.document.Metadata.MasterKeyChanged.Value; Assert.AreEqual(EncryptionAlgorithm.ChaCha20, this.settingsVm.Cipher, "ChaCha20 should be the encryption algorithm before the test starts"); this.writer.Cipher = EncryptionAlgorithm.Aes; Assert.IsInstanceOfType(this.settingsVm.GetKdfParameters(), typeof(Argon2Parameters), "Argon2 should be the KDF before the test starts according to the VM"); Assert.IsInstanceOfType(this.writer.KdfParameters, typeof(Argon2Parameters), "Argon2 should be the KDF before the test starts according to the KdbxWriter"); this.settingsVm.KdfGuid = AesParameters.AesUuid; this.settingsVm.KdfIterations = 6001; Assert.IsInstanceOfType(this.writer.KdfParameters, typeof(AesParameters), "Changes to the settings VM should be reflected in the KdbxWriter"); Assert.IsTrue(await this.persistenceService.Save(this.document)); KdbxReader reader = new KdbxReader(); using (IRandomAccessStream stream = await this.saveFile.AsIStorageFile.OpenReadAsync()) { await reader.ReadHeaderAsync(stream, CancellationToken.None); Assert.AreEqual(EncryptionAlgorithm.Aes, reader.HeaderData.Cipher, "New reader should have the correct cipher"); AesParameters aesParams = reader.HeaderData.KdfParameters as AesParameters; Assert.IsNotNull(aesParams, "Database should have properly persisted with AES"); Assert.AreEqual(6001, (int)aesParams.Rounds, "AES iteration count should have been persisted correctly"); KdbxDecryptionResult decryption = await reader.DecryptFileAsync(stream, this.dbPassword, this.dbKeyFile, CancellationToken.None); Assert.AreEqual(KdbxParserCode.Success, decryption.Result.Code); KdbxDocument document = decryption.GetDocument(); Assert.AreEqual(lastPasswordChange, document.Metadata.MasterKeyChanged.Value, "MasterKeyChanged timestamp should not have changed"); } }
/// <summary> /// Creates a ViewModel wrapping a brand new KdbxGroup as a child of the specified parent group. /// </summary> /// <param name="resourceProvider">IResourceProvider for localizing strings.</param> /// <param name="navigationViewModel">A ViewModel used for tracking navigation history.</param> /// <param name="persistenceService">A service used for persisting the document.</param> /// <param name="clipboardService">A service used for accessing the clipboard.</param> /// <param name="settingsService">A service used for accessing app settings.</param> /// <param name="document">A KdbxDocument representing the database we are working on.</param> /// <param name="parentGroup">The IKeePassGroup to use as a parent for the new group.</param> /// <param name="rng">A random number generator used to protect strings in memory.</param> public EntryDetailsViewModel( IResourceProvider resourceProvider, IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, ISensitiveClipboardService clipboardService, IAppSettingsService settingsService, KdbxDocument document, IKeePassGroup parentGroup, IRandomNumberGenerator rng ) : this( resourceProvider, navigationViewModel, persistenceService, clipboardService, settingsService, document, new KdbxEntry(parentGroup, rng, document.Metadata), true, false, rng ) { if (parentGroup == null) { throw new ArgumentNullException(nameof(parentGroup)); } if (rng == null) { throw new ArgumentNullException(nameof(rng)); } }
public async Task Initialize() { CancellationTokenSource cts = new CancellationTokenSource(); MethodInfo testMethod = GetType().GetRuntimeMethod( TestContext.TestName, new Type[0] ); var specAttr = testMethod.GetCustomAttribute <DetailsForAttribute>(); var dataAttr = testMethod.GetCustomAttribute <TestDataAttribute>(); Assert.IsTrue(specAttr != null || dataAttr != null); try { Utils.DatabaseInfo databaseInfo = await Utils.GetDatabaseInfoForTest(TestContext); KdbxReader reader = new KdbxReader(); using (IRandomAccessStream stream = await databaseInfo.Database.AsIStorageFile.OpenReadAsync()) { Assert.IsFalse((await reader.ReadHeaderAsync(stream, cts.Token)).IsError); KdbxDecryptionResult decryption = await reader.DecryptFileAsync(stream, databaseInfo.Password, databaseInfo.Keyfile, cts.Token); Assert.IsFalse(decryption.Result.IsError); this.document = decryption.GetDocument(); if (specAttr != null && (dataAttr == null || !dataAttr.SkipInitialization)) { IDatabaseNavigationViewModel navVm = new DatabaseNavigationViewModel(); navVm.SetGroup(this.document.Root.DatabaseGroup); IDatabasePersistenceService persistenceService = new DummyPersistenceService(); this.instantiationTime = DateTime.Now; if (specAttr.IsNew) { this.expectedParent = this.document.Root.DatabaseGroup; this.viewModel = GetNewViewModel(navVm, persistenceService, this.document, this.expectedParent); } else { this.expectedParent = this.document.Root.DatabaseGroup; this.viewModel = GetExistingViewModel( navVm, persistenceService, this.document, specAttr.IsOpenedReadOnly ); } } else { this.expectedParent = null; Assert.IsTrue(dataAttr.SkipInitialization); } } } catch (InvalidOperationException) { } }
private async Task RoundTrip() { ReaderResult initialHeaderResult = await this.reader.ReadHeaderAsync(await this.thisTestInfo.Database.AsIStorageFile.OpenReadAsync(), CancellationToken.None); Assert.AreEqual(ReaderResult.Success, initialHeaderResult, "Initial header read should be successful"); KdbxDecryptionResult result = await this.reader.DecryptFileAsync(await this.thisTestInfo.Database.AsIStorageFile.OpenReadAsync(), this.thisTestInfo.Password, this.thisTestInfo.Keyfile, CancellationToken.None); Assert.AreEqual(ReaderResult.Success, result.Result, "File should have initially decrypted properly"); KdbxDocument kdbxDoc = result.GetDocument(); IKdbxWriter writer = this.reader.GetWriter(); using (var stream = new InMemoryRandomAccessStream()) { bool writeResult = await writer.WriteAsync(stream, kdbxDoc, CancellationToken.None); Assert.IsTrue(writeResult, "File should have written successfully"); stream.Seek(0); KdbxReader newReader = new KdbxReader(); ReaderResult result2 = await newReader.ReadHeaderAsync(stream, CancellationToken.None); Assert.AreEqual(ReaderResult.Success, result2, "Header should been read back successfully after write"); KdbxDecryptionResult result3 = await newReader.DecryptFileAsync(stream, this.thisTestInfo.Password, this.thisTestInfo.Keyfile, CancellationToken.None); Assert.AreEqual(ReaderResult.Success, result3.Result, "File should have decrypted successfully after write"); KdbxDocument roundTrippedDocument = result3.GetDocument(); Assert.AreEqual(kdbxDoc, roundTrippedDocument, "Round-tripped document should be equal to original document"); } }
/// <summary> /// Replaces a group within its parent's collection. /// </summary> /// <param name="document">The document being updated.</param> /// <param name="parent">The parent to update.</param> /// <param name="child">The group to use as a replacement.</param> /// <param name="touchesNode">Whether to treat the swap as an "update" (vs a revert).</param> protected override void SwapIntoParent(KdbxDocument document, IKeePassGroup parent, IKeePassGroup child, bool touchesNode) { if (document == null) { throw new ArgumentNullException("document"); } if (child == null) { throw new ArgumentNullException("child"); } if (child.Parent != parent) { throw new ArgumentException("child.Parent != parent"); } if (parent == null) { // If there is no parent, we are updating the root database group. // So, just update it. document.Root.DatabaseGroup.SyncTo(child, touchesNode); } else { // Otherwise, we need to find the equivalent existing child (by UUID) and // update that way. IKeePassNode matchedNode = parent.Children.First(node => node.Uuid.Equals(child.Uuid)); IKeePassGroup matchedGroup = matchedNode as IKeePassGroup; DebugHelper.Assert(matchedGroup != null); matchedGroup.SyncTo(child, touchesNode); } }
public async Task ChangePassword() { string newPw = "TestPW"; this.masterKeyVm.MasterPassword = newPw; Assert.IsFalse(this.masterKeyVm.ConfirmCommand.CanExecute(null), "Should not be able to confirm new password until password is entered twice"); this.masterKeyVm.ConfirmedPassword = newPw; Assert.IsTrue(this.masterKeyVm.ConfirmCommand.CanExecute(null), "Should be able to confirm new password when second password matches"); this.masterKeyVm.ConfirmedPassword = "******"; Assert.IsFalse(this.masterKeyVm.ConfirmCommand.CanExecute(null), "Mismatched passwords should not be able to be confirmed"); this.masterKeyVm.ConfirmedPassword = newPw; DateTime lastPasswordChange = this.document.Metadata.MasterKeyChanged.Value; this.masterKeyVm.ConfirmCommand.Execute(null); DateTime passwordChangeTime = DateTime.Parse(this.document.Metadata.MasterKeyChanged.Value.ToString()); Assert.IsTrue(passwordChangeTime > lastPasswordChange, "MasterKeyChanged value should have changed in document metadata"); Assert.IsTrue(await this.persistenceService.Save(this.document)); KdbxReader reader = new KdbxReader(); using (IRandomAccessStream stream = await this.saveFile.AsIStorageFile.OpenReadAsync()) { await reader.ReadHeaderAsync(stream, CancellationToken.None); KdbxDecryptionResult decryption = await reader.DecryptFileAsync(stream, newPw, null, CancellationToken.None); Assert.AreEqual(KdbxParserCode.Success, decryption.Result.Code, "Database should decrypt with the new credentials"); KdbxDocument document = decryption.GetDocument(); Assert.AreEqual(passwordChangeTime, document.Metadata.MasterKeyChanged.Value, "MasterKeyChanged timestamp should have been persisted"); } }
public async Task Init() { // Get database from test attributes Utils.DatabaseInfo dbInfo = await Utils.GetDatabaseInfoForTest(TestContext); this.dbPassword = dbInfo.Password; this.dbKeyFile = dbInfo.Keyfile; // Assert that databases named *ReadOnly* are actually readonly after a clone if (dbInfo.Database.Name.IndexOf("ReadOnly", StringComparison.OrdinalIgnoreCase) >= 0) { Assert.IsFalse( await dbInfo.Database.CheckWritableAsync(), $"This file is expected to be read-only; please verify this before testing: {dbInfo.Database.Name}" ); } this.saveFile = (await dbInfo.Database.AsIStorageFile.CopyAsync( ApplicationData.Current.TemporaryFolder, $"PersistenceTestDb-{Guid.NewGuid()}.kdbx", NameCollisionOption.ReplaceExisting )).AsWrapper(); // Use a KdbxReader to parse the database and get a corresponding writer KdbxReader reader = new KdbxReader(); using (IRandomAccessStream stream = await this.saveFile.AsIStorageFile.OpenReadAsync()) { await reader.ReadHeaderAsync(stream, CancellationToken.None); KdbxDecryptionResult decryption = await reader.DecryptFileAsync(stream, dbInfo.Password, dbInfo.Keyfile, CancellationToken.None); Assert.AreEqual(KdbxParserCode.Success, decryption.Result.Code); this.document = decryption.GetDocument(); } // Construct services we can use for the test this.writer = reader.GetWriter(); this.persistenceService = new DefaultFilePersistenceService( this.writer, this.writer, new StorageFileDatabaseCandidate(this.saveFile, true), new MockSyncContext(), true ); this.credentialStorage = new MockCredentialProvider(); this.masterKeyVm = new MasterKeyChangeViewModel( this.document, this.saveFile, new DatabaseCredentialProvider(this.persistenceService, this.credentialStorage), new MockFileService() ); this.settingsVm = new DatabaseSettingsViewModel(this.writer); }
/// <summary> /// Initializes dependencies. /// </summary> /// <param name="document">The document whose master key may be updated.</param> /// <param name="databaseFile">The underlying file to persist changes to.</param> /// <param name="credentialProvider">A provider that manages changes to credentials for the database.</param> /// <param name="fileService">The service used to pick a new keyfile.</param> public MasterKeyChangeViewModel( KdbxDocument document, ITestableFile databaseFile, IDatabaseCredentialProvider credentialProvider, IFileAccessService fileService ) : base(fileService) { this.document = document ?? throw new ArgumentNullException(nameof(document)); this.databaseFile = databaseFile; this.credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); }
/// <summary> /// Creates a ViewModel wrapping a brand new KdbxGroup as a child of the specified parent group. /// </summary> /// <param name="navigationViewModel">A ViewModel used for tracking navigation history.</param> /// <param name="persistenceService">A service used for persisting the document.</param> /// <param name="document">A KdbxDocument representing the database we are working on.</param> /// <param name="parentGroup">The IKeePassGroup to use as a parent for the new group.</param> public GroupDetailsViewModel( IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, KdbxDocument document, IKeePassGroup parentGroup ) : base(navigationViewModel, persistenceService, document, new KdbxGroup(parentGroup), true, false) { if (parentGroup == null) { throw new ArgumentNullException("parentGroup"); } }
/// <summary> /// Creates a ViewModel wrapping an existing KdbxGroup. /// <param name="navigationViewModel">A ViewModel used for tracking navigation history.</param> /// <param name="persistenceService">A service used for persisting the document.</param> /// <param name="document">A KdbxDocument representing the database we are working on.</param> /// <param name="groupToEdit">The group being viewed.</param> /// <param name="isReadOnly">Whether to open the group in read-only mode.</param> public GroupDetailsViewModel( IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, KdbxDocument document, IKeePassGroup groupToEdit, bool isReadOnly ) : base(navigationViewModel, persistenceService, document, groupToEdit, false, isReadOnly) { if (groupToEdit == null) { throw new ArgumentNullException("groupToEdit"); } }
/// <summary> /// Initializes the EventArgs with the provided parameters. /// </summary> /// <param name="document">A model representing the decrypted XML document.</param> /// <param name="candidate">The file corresponding to the opened document.</param> /// <param name="persistenceService">A service that can persist the document.</param> /// <param name="rng">A random number generator that can encrypt protected strings for the document.</param> /// <param name="keyChangeVmFactory">Factory used to generate the value of <see cref="KeyChangeViewModel"/>.</param> public DocumentReadyEventArgs( KdbxDocument document, IDatabaseCandidate candidate, IDatabasePersistenceService persistenceService, IRandomNumberGenerator rng, IMasterKeyChangeViewModelFactory keyChangeVmFactory) { Document = document; Candidate = candidate; PersistenceService = persistenceService; Rng = rng; KeyChangeViewModel = keyChangeVmFactory?.Assemble(document, PersistenceService, candidate.File) ?? throw new ArgumentNullException(nameof(keyChangeVmFactory)); }
public async Task Initialize() { Utils.DatabaseInfo databaseInfo = await Utils.GetDatabaseInfoForTest(TestContext); KdbxReader reader = new KdbxReader(); using (IRandomAccessStream stream = await databaseInfo.Database.AsIStorageFile.OpenReadAsync()) { Assert.IsFalse((await reader.ReadHeaderAsync(stream, CancellationToken.None)).IsError); KdbxDecryptionResult decryption = await reader.DecryptFileAsync(stream, databaseInfo.Password, databaseInfo.Keyfile, CancellationToken.None); Assert.IsFalse(decryption.Result.IsError); this.document = decryption.GetDocument(); this.rng = reader.HeaderData.GenerateRng(); } }
/// <summary> /// Creates a ViewModel wrapping an existing KdbxGroup. /// </summary> /// <param name="resourceProvider">IResourceProvider for localizing strings.</param> /// <param name="navigationViewModel">A ViewModel used for tracking navigation history.</param> /// <param name="persistenceService">A service used for persisting the document.</param> /// <param name="clipboardService">A service used for accessing the clipboard.</param> /// <param name="settingsService">A service used to access app settings.</param> /// <param name="document">A KdbxDocument representing the database we are working on.</param> /// <param name="entryToEdit">The entry being viewed.</param> /// <param name="isReadOnly">Whether to open the group in read-only mode.</param> /// <param name="rng">A random number generator used to protect strings in memory.</param> public EntryDetailsViewModel( IResourceProvider resourceProvider, IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, ISensitiveClipboardService clipboardService, IAppSettingsService settingsService, KdbxDocument document, IKeePassEntry entryToEdit, bool isReadOnly, IRandomNumberGenerator rng ) : this(resourceProvider, navigationViewModel, persistenceService, clipboardService, settingsService, document, entryToEdit, false, isReadOnly, rng) { if (entryToEdit == null) { throw new ArgumentNullException(nameof(entryToEdit)); } }
private async Task ExpectUnlockError(KdbxParserCode error, bool expectIdentical = true) { CancellationTokenSource cts = new CancellationTokenSource(); KdbxDecryptionResult result = await this.reader.DecryptFileAsync(await this.thisTestInfo.Database.AsIStorageFile.OpenReadAsync(), this.thisTestInfo.Password, this.thisTestInfo.Keyfile, cts.Token); if (result.Result == ReaderResult.Success) { KdbxDocument oldDocument = result.GetDocument(); XElement newXml = oldDocument.ToXml(this.reader.HeaderData.GenerateRng(), result.Parameters); KdbxDocument newDocument = new KdbxDocument( newXml, this.reader.HeaderData.ProtectedBinaries, this.reader.HeaderData.GenerateRng(), result.Parameters ); Assert.AreEqual(oldDocument, newDocument); } Assert.AreEqual(error, result.Result.Code); }
/// <summary> /// Initializes the instance. /// </summary> /// <param name="syncContext">Context to use for marshalling to the UI thread.</param> /// <param name="timerFactory">Used to create a timer.</param> /// <param name="file">The file on disk represented by this database.</param> /// <param name="fileIsSample">Whether this file is a sample file.</param> /// <param name="document">The decrypted database.</param> /// <param name="resourceProvider">A IResourceProvider for the View.</param> /// <param name="rng">A random number generator used to protect strings.</param> /// <param name="navigationViewModel">A ViewModel representing the navigation of the database.</param> /// <param name="masterKeyViewModel">A ViewModel that allows configuring the database's master key.</param> /// <param name="persistenceService">A service used to save the database.</param> /// <param name="identityService">A service used to authenticate the user.</param> /// <param name="credentialStorage">A service used to update saved credentials.</param> /// <param name="settingsService">A service used to access app settings.</param> /// <param name="clipboardService">A service used to access the clipboard for credentials.</param> public DatabaseParentViewModel( ISyncContext syncContext, ITimerFactory timerFactory, ITestableFile file, bool fileIsSample, KdbxDocument document, IResourceProvider resourceProvider, IRandomNumberGenerator rng, IDatabaseNavigationViewModel navigationViewModel, IMasterKeyViewModel masterKeyViewModel, IDatabasePersistenceService persistenceService, IIdentityVerificationService identityService, ICredentialStorageProvider credentialStorage, IAppSettingsService settingsService, ISensitiveClipboardService clipboardService ) : base(document, persistenceService) { if (timerFactory == null) { throw new ArgumentNullException(nameof(timerFactory)); } this.syncContext = syncContext ?? throw new ArgumentNullException(nameof(syncContext)); this.idleTimer = timerFactory.Assemble(TimeSpan.FromSeconds(1)); this.file = file ?? throw new ArgumentNullException(nameof(file)); this.fileIsSample = fileIsSample; this.document = document ?? throw new ArgumentNullException(nameof(document)); this.resourceProvider = resourceProvider ?? throw new ArgumentNullException(nameof(resourceProvider)); this.rng = rng ?? throw new ArgumentNullException(nameof(rng)); this.navigationViewModel = navigationViewModel ?? throw new ArgumentNullException(nameof(navigationViewModel)); this.settingsViewModel = new DatabaseSettingsViewModel(PersistenceService.SettingsProvider); this.masterKeyViewModel = masterKeyViewModel ?? throw new ArgumentNullException(nameof(masterKeyViewModel)); this.identityService = identityService ?? throw new ArgumentNullException(nameof(identityService)); this.credentialProvider = credentialStorage ?? throw new ArgumentNullException(nameof(credentialStorage)); this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService)); }
public async Task Initialize() { try { CancellationTokenSource cts = new CancellationTokenSource(); Utils.DatabaseInfo databaseInfo = await Utils.GetDatabaseInfoForTest(TestContext); KdbxReader reader = new KdbxReader(); using (IRandomAccessStream stream = await databaseInfo.Database.AsIStorageFile.OpenReadAsync()) { Assert.IsFalse((await reader.ReadHeaderAsync(stream, cts.Token)).IsError); KdbxDecryptionResult decryption = await reader.DecryptFileAsync(stream, databaseInfo.Password, databaseInfo.Keyfile, cts.Token); Assert.IsFalse(decryption.Result.IsError); this.document = decryption.GetDocument(); } } catch (InvalidOperationException) { } this.viewModel = new DatabaseNavigationViewModel(); }
private async Task RaiseDocumentReady(KdbxDocument document, IDatabaseCandidate candidate) { DebugHelper.Assert(HasGoodHeader); if (!HasGoodHeader) { throw new InvalidOperationException("Document cannot be ready, because the KdbxReader does not have good HeaderData."); } IDatabasePersistenceService persistenceService; if (IsSampleFile) { persistenceService = new DummyPersistenceService(); } else { IKdbxWriter writer = this.kdbxReader.GetWriter(); persistenceService = new DefaultFilePersistenceService( writer, writer, candidate, this.syncContext, await candidate.File.CheckWritableAsync() ); } DocumentReady?.Invoke( this, new DocumentReadyEventArgs( document, candidate, persistenceService, this.kdbxReader.HeaderData.GenerateRng(), this.keyChangeVmFactory ) ); }
// Internal constructor for initializing fields and checking edge cases private KdbxDecryptionResult(ReaderResult error, KdbxSerializationParameters kdbxParameters, KdbxDocument document, IBuffer rawKey) { DebugHelper.Assert(error != null); if (error == null) { throw new ArgumentNullException(nameof(error)); } if (error != ReaderResult.Success) { DebugHelper.Assert(document == null); if (document != null) { throw new ArgumentException("If error is defined, the other arguments must be null"); } } else { // Result is guaranteed to be Success at this point DebugHelper.Assert(document != null); if (document == null) { throw new ArgumentNullException(nameof(document)); } if (rawKey == null) { throw new ArgumentNullException(nameof(rawKey)); } } Result = error; this.kdbxParameters = kdbxParameters; this.kdbxDocument = document; this.rawKey = rawKey; }
/// <summary> /// Initializes the base class from a subclass. /// </summary> /// <param name="navigationViewModel">A ViewModel used for tracking navigation history.</param> /// <param name="persistenceService">A service used for persisting the document.</param> /// <param name="document">A KdbxDocument representing the database we are working on.</param> /// <param name="item">The item this ViewModel wraps.</param> /// <param name="isNew">Whether the child is being created for the first time.</param> /// <param name="isReadOnly">Whether the child is being accessed as read-only.</param> protected NodeDetailsViewModel( IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, KdbxDocument document, T item, bool isNew, bool isReadOnly ) : base(document, persistenceService) { if (isNew && item.Parent == null) { throw new ArgumentException("Cannot create a new node with no parent!"); } if (navigationViewModel.ActiveGroup != item.Parent) { throw new ArgumentException("The database's active group must be the node's parent!"); } NavigationViewModel = navigationViewModel ?? throw new ArgumentNullException(nameof(navigationViewModel)); Document = document ?? throw new ArgumentNullException(nameof(document)); IsNew = isNew; if (!isNew) { this.masterCopy = GetClone(item); WorkingCopy = GetClone(this.masterCopy); } else { this.masterCopy = null; WorkingCopy = GetClone(item); } IsReadOnly = isReadOnly; }
/// <summary> /// Generats a ViewModel representing an existing child in the DOM. /// </summary> /// <param name="navigationViewModel"></param> /// <param name="persistenceService"></param> /// <param name="document"></param> /// <param name="openForReadOnly"></param> /// <returns>A ViewModel representing an existing child in the DOM.</returns> protected abstract TViewModel GetExistingViewModel( IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, KdbxDocument document, bool openForReadOnly );
/// <summary> /// Writes a document to the specified stream. /// </summary> /// <param name="file">The stream to write to.</param> /// <param name="document">The document to write.</param> /// <param name="token">A token allowing the operation to be cancelled.</param> /// <returns>Whether the write succeeded.</returns> public async Task <bool> WriteAsync(IOutputStream stream, KdbxDocument document, CancellationToken token) { DebugHelper.Assert(stream != null); if (stream == null) { throw new ArgumentNullException(nameof(stream)); } HeaderData.ProtectedBinaries.Clear(); foreach (ProtectedBinary bin in document?.Metadata?.Binaries?.Binaries ?? Enumerable.Empty <ProtectedBinary>()) { HeaderData.ProtectedBinaries.Add(bin); } using (DataWriter writer = new DataWriter(stream)) { // Configure the DataWriter writer.UnicodeEncoding = UnicodeEncoding.Utf8; writer.ByteOrder = ByteOrder.LittleEndian; // Write the header in-memory first, so that we can generate the header hash. // This is because the header hash is the first thing written into the header. using (InMemoryRandomAccessStream headerStream = new InMemoryRandomAccessStream()) { using (DataWriter headerWriter = new DataWriter(headerStream) { UnicodeEncoding = UnicodeEncoding.Utf8, ByteOrder = ByteOrder.LittleEndian }) { WriteSignature(headerWriter); WriteVersion(headerWriter); await headerWriter.StoreAsync(); await WriteOuterHeaderAsync(headerWriter); await headerWriter.StoreAsync(); headerWriter.DetachStream(); } // Seek to the start of this temporary stream, so we can hash what we have. headerStream.Seek(0); using (DataReader headerReader = new DataReader(headerStream) { UnicodeEncoding = UnicodeEncoding.Utf8, ByteOrder = ByteOrder.LittleEndian }) { await headerReader.LoadAsync((uint)headerStream.Size); HeaderData.FullHeader = headerReader.ReadBuffer((uint)headerStream.Size); var sha256 = HashAlgorithmProvider.OpenAlgorithm(HashAlgorithmNames.Sha256); CryptographicHash hash = sha256.CreateHash(); hash.Append(HeaderData.FullHeader); IBuffer hashedHeaderBuffer = hash.GetValueAndReset(); HeaderData.HeaderHash = CryptographicBuffer.EncodeToBase64String(hashedHeaderBuffer); document.Metadata.HeaderHash = HeaderData.HeaderHash; XDocument xmlDocument = new XDocument(document.ToXml(HeaderData.GenerateRng(), this.parameters)); try { this.rawKey = this.rawKey ?? await KeyHelper.GetRawKey(this.securityTokens); IBuffer transformedKey = await HeaderData.KdfParameters.CreateEngine().TransformKeyAsync(this.rawKey, token); if (transformedKey == null) { throw new OperationCanceledException(); } DebugHelper.Trace("Got transformed k from KDF."); token.ThrowIfCancellationRequested(); writer.WriteBuffer(HeaderData.FullHeader); await writer.StoreAsync(); token.ThrowIfCancellationRequested(); // In KDBX4, after the header is an HMAC-SHA-256 value computed over the header // allowing validation of header integrity. IBuffer hmacKey = HmacBlockHandler.DeriveHmacKey(transformedKey, HeaderData.MasterSeed); HmacBlockHandler hmacHandler = new HmacBlockHandler(hmacKey); if (this.parameters.UseInlineHeaderAuthentication) { // Write plain hash, followed by HMAC writer.WriteBuffer(hashedHeaderBuffer); var algorithm = MacAlgorithmProvider.OpenAlgorithm(MacAlgorithmNames.HmacSha256); CryptographicHash hmacHash = algorithm.CreateHash(hmacHandler.GetKeyForBlock(UInt64.MaxValue)); hmacHash.Append(HeaderData.FullHeader); IBuffer headerMac = hmacHash.GetValueAndReset(); writer.WriteBuffer(headerMac); token.ThrowIfCancellationRequested(); } // Write the encrypted content that comes after the header // For KDBX3 this is the database, for KDBX4 it includes the inner header IBuffer cipherText = await GetCipherTextAsync(xmlDocument, transformedKey, token); if (this.parameters.UseHmacBlocks) { uint blockCount = (cipherText.Length + HmacBlockHandler.BlockSize - 1) / HmacBlockHandler.BlockSize; for (uint i = 0; i < blockCount; i++) { await hmacHandler.WriteCipherBlockAsync(writer, cipherText, i *HmacBlockHandler.BlockSize, HmacBlockHandler.BlockSize, i); } // We signal we're done by writing an empty HMAC "terminator" block await hmacHandler.WriteTerminatorAsync(writer, blockCount); } else { writer.WriteBuffer(cipherText); await writer.StoreAsync(); } } catch (OperationCanceledException) { return(false); } } } await stream.FlushAsync(); writer.DetachStream(); return(true); } }
/// <summary> /// Replaces a child within its parent's collection. /// </summary> /// <param name="document">The document being updated.</param> /// <param name="parent">The parent to update.</param> /// <param name="child">The child to use as a replacement.</param> /// <param name="touchesNode">Whether to treat the swap as an "update" (vs a revert).</param> protected abstract void SwapIntoParent(KdbxDocument document, IKeePassGroup parent, T child, bool touchesNode);
/// <summary> /// Uses provided options to generate a database file. /// </summary> protected override async Task HandleCredentialsAsync(string confirmedPassword, ITestableFile chosenKeyFile) { CancellationTokenSource cts = new CancellationTokenSource(); IKdbxWriter writer = this.writerFactory.Assemble( confirmedPassword, chosenKeyFile, Settings.Cipher, Settings.GetKdfParameters() ); IRandomNumberGenerator rng = writer.HeaderData.GenerateRng(); KdbxDocument newDocument = new KdbxDocument(new KdbxMetadata("PassKeep Database")); if (!CreateEmpty) { IList <IKeePassGroup> groups = new List <IKeePassGroup> { new KdbxGroup(newDocument.Root.DatabaseGroup), new KdbxGroup(newDocument.Root.DatabaseGroup), new KdbxGroup(newDocument.Root.DatabaseGroup), new KdbxGroup(newDocument.Root.DatabaseGroup), new KdbxGroup(newDocument.Root.DatabaseGroup), new KdbxGroup(newDocument.Root.DatabaseGroup) }; groups[0].Title.ClearValue = "General"; groups[1].Title.ClearValue = "Windows"; groups[2].Title.ClearValue = "Network"; groups[3].Title.ClearValue = "Internet"; groups[4].Title.ClearValue = "eMail"; groups[5].Title.ClearValue = "Homebanking"; groups[0].IconID = 48; groups[1].IconID = 38; groups[2].IconID = 3; groups[3].IconID = 1; groups[4].IconID = 19; groups[5].IconID = 37; foreach (IKeePassGroup group in groups) { newDocument.Root.DatabaseGroup.Children.Add(group); } IList <IKeePassEntry> entries = new List <IKeePassEntry> { new KdbxEntry(newDocument.Root.DatabaseGroup, rng, newDocument.Metadata), new KdbxEntry(newDocument.Root.DatabaseGroup, rng, newDocument.Metadata) }; entries[0].Title.ClearValue = "Sample Entry"; entries[1].Title.ClearValue = "Sample Entry #2"; entries[0].UserName.ClearValue = "User Name"; entries[1].UserName.ClearValue = "Michael321"; entries[0].Password.ClearValue = "Password"; entries[1].Password.ClearValue = "12345"; entries[0].Url.ClearValue = "http://keepass.info/"; entries[1].Url.ClearValue = "http://keepass.info/help/kb/testform.html"; entries[0].Notes.ClearValue = "Notes"; foreach (IKeePassEntry entry in entries) { newDocument.Root.DatabaseGroup.Children.Add(entry); } } using (IRandomAccessStream stream = await File.AsIStorageFile.OpenAsync(FileAccessMode.ReadWrite)) { Task <bool> writeTask = writer.WriteAsync(stream, newDocument, cts.Token); this.taskNotificationService.PushOperation(writeTask, cts, AsyncOperationType.DatabaseEncryption); if (await writeTask) { this.futureAccessList.Add(File, File.AsIStorageItem.Name); IDatabaseCandidate candidate = await this.candidateFactory.AssembleAsync(File); IDatabasePersistenceService persistenceService = new DefaultFilePersistenceService( writer, writer, candidate, this.syncContext, true); DocumentReady?.Invoke( this, new DocumentReadyEventArgs( newDocument, candidate, persistenceService, writer.HeaderData.GenerateRng(), this.keyChangeVmFactory ) ); } } }
/// <summary> /// Does nothing. /// </summary> /// <param name="document">The KdbxDocument to persist.</param> /// <returns>A Task that will evaluate to true.</returns> public Task <bool> Save(KdbxDocument document) => Task.FromResult(true);
/// <summary> /// Attempts to asynchronously persist the document to its default location. /// If a save is already in progress, it is cancelled and the more recent save should /// override it. /// </summary> /// <param name="document">The KdbxDocument to persist.</param> /// <returns>A Task representing whether the save was successful.</returns> public async Task <bool> Save(KdbxDocument document) { if (document == null) { throw new ArgumentNullException(nameof(document)); } if (!CanSave) { return(false); } // Lock to avoid a race condition between checking not null and cancelling bool firePropertyChanged = false; lock (this.ctsLock) { if (IsSaving) { this.currentSaveCts.Cancel(); } else { // We only fire PropertyChanged for false -> true if the // transition is actually happening. If we are pre-empting // a save in progress, then it is not useful to fire the // event. firePropertyChanged = true; } this.pendingRequests++; } // Cancelling above may cause a previous save to wrap up faster, but we do still need // to wait for it to wrap up. await this.saveSemaphore.WaitAsync(); // Inside the semaphore it is impossible for a save to already be in progress. // This is because we clean up the current save at the end of the semaphore, so if // we just entered it, there is no pending operation. // However, we still want to lock around the CTS in case another save starts right // away and needs to cancel this one. lock (this.ctsLock) { DebugHelper.Assert(this.currentSaveCts == null); this.pendingRequests--; if (this.pendingRequests == 0) { // If pendingRequests > 0, then at least one more recent call to // Save is currently stalled at the semaphore. // We only kick off this save if that's NOT true. this.currentSaveCts = new CancellationTokenSource(); } } // Only proceed with this save attempt if there are no pending requests... // otherwise this block is skipped and we immediately release the semaphore // and return false. bool writeResult = false; if (this.currentSaveCts != null) { if (firePropertyChanged) { #pragma warning disable CS4014 // No need to await this to continue saving. this.syncContext.Post(() => OnPropertyChanged(nameof(IsSaving))); #pragma warning restore CS4014 } // Do the write to a temporary file until it's finished successfully. StorageFile outputFile = await GetTemporaryFile(); using (IRandomAccessStream fileStream = await outputFile.OpenAsync(FileAccessMode.ReadWrite)) { using (IOutputStream outputStream = fileStream.GetOutputStreamAt(0)) { writeResult = await this.fileWriter.WriteAsync(fileStream, document, this.currentSaveCts.Token); } } if (writeResult) { Task replaceTask = this.defaultSaveFile.ReplaceWithAsync(outputFile); try { await replaceTask; } catch (Exception) { } } try { // Make a good-faith effort to delete the temp file, due // to reports that Windows might not handle this automatically. await outputFile.DeleteAsync(); } catch (Exception e) { DebugHelper.Trace($"Caught exception during temp file cleanup: {e}"); } // At this point we are done with all file IO - clean up and let any // pending saves do their thing. firePropertyChanged = false; lock (this.ctsLock) { this.currentSaveCts.Dispose(); this.currentSaveCts = null; if (this.pendingRequests == 0) { firePropertyChanged = true; } } // We only update IsSaving if nothing else is pending - if another save // is already queued, it's just going to flip this back to true immediately. if (firePropertyChanged) { #pragma warning disable CS4014 // No need to await this to continue saving. this.syncContext.Post(() => OnPropertyChanged(nameof(IsSaving))); #pragma warning restore CS4014 } } this.saveSemaphore.Release(); return(writeResult); }
/// <summary> /// Passes provided parameters to the base constructor and initializes commands. /// </summary> /// <param name="resourceProvider">IResourceProvider for localizing strings.</param> /// <param name="navigationViewModel"></param> /// <param name="persistenceService"></param> /// <param name="clipboardService"></param> /// <param name="settingsService"></param> /// <param name="document"></param> /// <param name="entry"></param> /// <param name="isNew"></param> /// <param name="isReadOnly"></param> /// <param name="rng"></param> private EntryDetailsViewModel( IResourceProvider resourceProvider, IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, ISensitiveClipboardService clipboardService, IAppSettingsService settingsService, KdbxDocument document, IKeePassEntry entry, bool isNew, bool isReadOnly, IRandomNumberGenerator rng ) : base(navigationViewModel, persistenceService, document, entry, isNew, isReadOnly) { this.resourceProvider = resourceProvider; this.clipboardService = clipboardService; this.settingsService = settingsService; this.rng = rng; this.copyFieldValueCommand = new TypedCommand <IProtectedString>( str => { clipboardService.CopyCredential(str.ClearValue, ClipboardOperationType.Other); } ); this.deleteFieldCommand = new TypedCommand <IProtectedString>( str => !IsReadOnly && PersistenceService.CanSave, str => { DebugHelper.Assert(!IsReadOnly); WorkingCopy.Fields.Remove(str); } ); this.editFieldCommand = new AsyncTypedCommand <IProtectedString>( str => PersistenceService.CanSave, async str => { IsReadOnly = false; await UpdateFieldEditorViewModel(new FieldEditorViewModel(str, this.resourceProvider)); } ); this.newFieldCommand = new AsyncActionCommand( () => PersistenceService.CanSave, async() => { IsReadOnly = false; await UpdateFieldEditorViewModel(new FieldEditorViewModel(this.rng, this.resourceProvider)); } ); this.commitFieldCommand = new AsyncActionCommand( () => FieldEditorViewModel?.CommitCommand.CanExecute(WorkingCopy) ?? false, async() => { FieldEditorViewModel.CommitCommand.Execute(WorkingCopy); await UpdateFieldEditorViewModel(null); } ); PropertyChanged += (s, e) => { if (e.PropertyName == nameof(IsReadOnly)) { ((TypedCommand <IProtectedString>)DeleteFieldCommand).RaiseCanExecuteChanged(); } else if (e.PropertyName == nameof(WorkingCopy)) { OnPropertyChanged(nameof(WorkingCopyViewModel)); } }; }
/// <summary> /// Asynchronously attempts to unlock the document file. /// </summary> /// <remarks> /// Algorithm is as of this writing (11/5/2012): /// 0. Use UTF8 encoding with no BOM. /// 1. Read header. /// 2. Compute SHA256 hash of header. /// 3. Decrypt the rest of the viewModel using header parameters. /// Relies on: /// a. MasterSeed.Length == 32 /// Write masterseed to stream /// b. GenerateKey32(_transformSeed, KeyEncryptionRounds) /// Create raw32 (CreateRawCompositeKey32) /// Concatenate all data and Sha256 /// TransformKey(raw32, _transformSeed, numRounds) /// Init Rijndael: /// 128 bit (16 byte) blocks /// ECB mode /// k = _transformSeed /// For numRounds: /// Transform in place raw32[0:15] /// Transform in place raw32[16:31] /// c. Write 32 bytes of Key32 to stream /// d. aesKey = Sha256 the stream /// e. DecryptStream with aesKey and _encryptionIV /// 4. Verify the first 32 bytes of the decrypted viewModel match up with /// "StreamStartBytes" from the header. /// 5. Read from the decrypted viewModel as a "HashedBlockStream" /// /// File format at the time of this writing (11/5/2012): /// /// 4 bytes: SIG1 /// 4 bytes: SIG2 /// Failure to match these constants results in a parse Result. /// /// 4 bytes: File version /// /// Header fields: /// 1 byte: Field ID /// 2 bytes: Field size (n) /// n bytes: Data /// </remarks> /// <param name="stream">An IRandomAccessStream containing the document to unlock (including the header).</param> /// <param name="rawKey">The aggregate raw key to use for decrypting the database.</param> /// <param name="token">A token allowing the parse to be cancelled.</param> /// <returns>A Task representing the result of the descryiption operation.</returns> public async Task <KdbxDecryptionResult> DecryptFile(IRandomAccessStream stream, IBuffer rawKey, CancellationToken token) { if (HeaderData == null) { throw new InvalidOperationException("Cannot decrypt database before ReadHeader has been called."); } // Init a SHA256 hash buffer and append the master seed to it HashAlgorithmProvider sha256 = HashAlgorithmProvider.OpenAlgorithm(HashAlgorithmNames.Sha256); CryptographicHash hash = sha256.CreateHash(); hash.Append(HeaderData.MasterSeed); this.rawKey = rawKey; this.logger.LogEvent("KdbxReader.GotRawKey", EventVerbosity.Verbose); // Transform the key (this can take a while) IBuffer transformedKey; try { IKdfEngine kdf = HeaderData.KdfParameters.CreateEngine(); LoggingFields fields = new LoggingFields(); fields.AddString("KdfEngine", kdf.GetType().Name); this.logger.LogEvent("KdbxReader.StartingKeyTransform", fields, EventVerbosity.Info); transformedKey = await HeaderData.KdfParameters.CreateEngine().TransformKeyAsync(rawKey, token) .ConfigureAwait(false); if (transformedKey == null) { throw new OperationCanceledException(); } this.logger.LogEvent("KdbxReader.KeyTransformSucceeded", EventVerbosity.Info); } catch (OperationCanceledException) { return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.OperationCancelled))); } // In KDBX4, after the header is an HMAC-SHA-256 value computed over the header // allowing validation of header integrity. IBuffer hmacKey = HmacBlockHandler.DeriveHmacKey(transformedKey, HeaderData.MasterSeed); HmacBlockHandler hmacHandler = new HmacBlockHandler(hmacKey); IBuffer expectedMac = null; if (this.parameters.UseInlineHeaderAuthentication) { var algorithm = MacAlgorithmProvider.OpenAlgorithm(MacAlgorithmNames.HmacSha256); CryptographicHash hmacHash = algorithm.CreateHash(hmacHandler.GetKeyForBlock(UInt64.MaxValue)); DebugHelper.Assert(HeaderData.FullHeader != null); hmacHash.Append(HeaderData.FullHeader); expectedMac = hmacHash.GetValueAndReset(); } // Hash transformed k (with the master seed) to get final cipher k hash.Append(transformedKey); IBuffer cipherKey = hash.GetValueAndReset(); this.logger.LogEvent("KdbxReader.GotFinalCipherKey", EventVerbosity.Info); // Decrypt the document starting from the end of the header ulong headerLength = HeaderData.FullHeader.Length; if (this.parameters.UseInlineHeaderAuthentication) { // KDBX4 has a hash at the end of the header headerLength += 32; } stream.Seek(headerLength); if (expectedMac != null) { using (DataReader macReader = GetReaderForStream(stream)) { await macReader.LoadAsync(expectedMac.Length); IBuffer actualMac = macReader.ReadBuffer(expectedMac.Length); for (uint i = 0; i < expectedMac.Length; i++) { if (expectedMac.GetByte(i) != actualMac.GetByte(i)) { this.logger.LogEvent("KdbxReader.HmacFailure", EventVerbosity.Critical); return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.CouldNotDecrypt))); } } macReader.DetachStream(); } } IBuffer cipherText; try { cipherText = await GetCipherText(stream, hmacHandler); } catch (FormatException ex) { this.logger.LogEvent("KdbxReader.DataIntegrityFailure", ex.ToLoggingFields(), EventVerbosity.Critical); return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.DataIntegrityProblem, ex))); } IBuffer decryptedFile = DecryptDatabaseData(cipherText, cipherKey); if (decryptedFile == null) { this.logger.LogEvent("KdbxReader.DecryptionFailure", EventVerbosity.Critical); return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.CouldNotDecrypt))); } this.logger.LogEvent("KdbxReader.DecryptionSucceeded", EventVerbosity.Info); // Verify first 32 bytes of the clear data; if StreamStartBytes wasn't set // (e.g. due to KDBX4), nothing happens here. for (uint i = 0; i < (HeaderData.StreamStartBytes?.Length ?? 0); i++) { byte actualByte = decryptedFile.GetByte(i); byte expectedByte = HeaderData.StreamStartBytes.GetByte(i); if (actualByte != expectedByte) { this.logger.LogEvent("KdbxReader.PlaintextValidationFailure", EventVerbosity.Critical); return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.FirstBytesMismatch))); } } this.logger.LogEvent("KdbxReader.PlaintextValidationSucceeded", EventVerbosity.Verbose); IBuffer plainText = await UnhashAndInflate(decryptedFile); if (plainText == null) { return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.CouldNotInflate))); } // Update HeaderData with info from the inner header, if relevant if (this.parameters.UseInnerHeader) { using (IRandomAccessStream plainTextStream = plainText.AsStream().AsRandomAccessStream()) { using (DataReader reader = GetReaderForStream(plainTextStream)) { ReaderResult innerHeaderResult = await ReadInnerHeader(reader, HeaderData); if (innerHeaderResult != ReaderResult.Success) { LoggingFields fields = new LoggingFields(); fields.AddInt32("Code", (int)innerHeaderResult.Code); this.logger.LogEvent("KdbxReader.InnerHeaderReadFailure", fields, EventVerbosity.Critical); return(new KdbxDecryptionResult(innerHeaderResult)); } // Update plainText to point to the remainder of the buffer uint bytesRemaining = plainText.Length - (uint)plainTextStream.Position; await reader.LoadAsync(bytesRemaining); plainText = reader.ReadBuffer(bytesRemaining); } } } XDocument finalTree = null; try { finalTree = XDocument.Load(plainText.AsStream()); } catch (XmlException) { return(null); } if (finalTree == null) { return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.MalformedXml))); } try { KdbxDocument parsedDocument = await Task.Run(() => new KdbxDocument(finalTree.Root, HeaderData.ProtectedBinaries, HeaderData.GenerateRng(), this.parameters)); // Validate the final parsed header hash before returning if (this.parameters.UseXmlHeaderAuthentication && !String.IsNullOrEmpty(parsedDocument.Metadata.HeaderHash) && parsedDocument.Metadata.HeaderHash != HeaderData.HeaderHash) { return(new KdbxDecryptionResult(new ReaderResult(KdbxParserCode.BadHeaderHash))); } return(new KdbxDecryptionResult(this.parameters, parsedDocument, this.rawKey)); } catch (KdbxParseException e) { return(new KdbxDecryptionResult(e.Error)); } }
/// <summary> /// Generates a ViewModel representing a new child. /// </summary> /// <param name="navigationViewModel"></param> /// <param name="persistenceService"></param> /// <param name="document"></param> /// <param name="parent"></param> /// <returns>A ViewModel representing a new child not already in the tree.</returns> protected abstract TViewModel GetNewViewModel( IDatabaseNavigationViewModel navigationViewModel, IDatabasePersistenceService persistenceService, KdbxDocument document, IKeePassGroup parent );
/// <summary> /// Initializes the ViewModel base. /// </summary> /// <param name="document">The document that will be saved.</param> /// <param name="persistenceService">The service to use for document writing.</param> protected DatabasePersistenceViewModel(KdbxDocument document, IDatabasePersistenceService persistenceService) { this.document = document ?? throw new ArgumentNullException(nameof(document)); PersistenceService = persistenceService ?? throw new ArgumentNullException(nameof(persistenceService)); }