/* KeePass Password Safe - The Open-Source Password Manager Copyright (C) 2003-2020 Dominik Reichl This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Text; #if !ModernKeePassLib && !KeePassLibSD using System.Security.AccessControl; #endif using ModernKeePassLib.Cryptography; using ModernKeePassLib.Delegates; using ModernKeePassLib.Native; using ModernKeePassLib.Resources; using ModernKeePassLib.Utility; namespace ModernKeePassLib.Serialization { public sealed class FileTransactionEx : IDisposable { private bool m_bTransacted; private IOConnectionInfo m_iocBase; // Null means disposed private IOConnectionInfo m_iocTemp; private IOConnectionInfo m_iocTxfMidFallback = null; // Null <=> TxF not used private bool m_bMadeUnhidden = false; private List m_lToDelete = new List(); internal const string StrTempSuffix = ".tmp"; private static readonly string StrTxfTempPrefix = PwDefs.ShortProductName + "_TxF_"; internal const string StrTxfTempSuffix = ".tmp"; private static Dictionary g_dEnabled = new Dictionary(StrUtil.CaseIgnoreComparer); private static bool g_bExtraSafe = false; internal static bool ExtraSafe { get { return g_bExtraSafe; } set { g_bExtraSafe = value; } } public FileTransactionEx(IOConnectionInfo iocBaseFile) : this(iocBaseFile, true) { } public FileTransactionEx(IOConnectionInfo iocBaseFile, bool bTransacted) { if(iocBaseFile == null) throw new ArgumentNullException("iocBaseFile"); m_bTransacted = bTransacted; m_iocBase = iocBaseFile.CloneDeep(); if(m_iocBase.IsLocalFile()) m_iocBase.Path = UrlUtil.GetShortestAbsolutePath(m_iocBase.Path); string strPath = m_iocBase.Path; #if !ModernKeePassLib if(m_iocBase.IsLocalFile()) { try { if(File.Exists(strPath)) { // Symbolic links are realized via reparse points; // https://msdn.microsoft.com/en-us/library/windows/desktop/aa365503.aspx // https://msdn.microsoft.com/en-us/library/windows/desktop/aa365680.aspx // https://msdn.microsoft.com/en-us/library/windows/desktop/aa365006.aspx // Performing a file transaction on a symbolic link // would delete/replace the symbolic link instead of // writing to its target FileAttributes fa = File.GetAttributes(strPath); if((long)(fa & FileAttributes.ReparsePoint) != 0) m_bTransacted = false; } else { // If the base and the temporary file are in different // folders and the base file doesn't exist (i.e. we can't // backup the ACL), a transaction would cause the new file // to have the default ACL of the temporary folder instead // of the one of the base folder; therefore, we don't use // a transaction when the base file doesn't exist (this // also results in other applications monitoring the folder // to see one file creation only) m_bTransacted = false; } } catch(Exception) { Debug.Assert(false); } } #endif #if !ModernKeePassLib // Prevent transactions for FTP URLs under .NET 4.0 in order to // avoid/workaround .NET bug 621450: // https://connect.microsoft.com/VisualStudio/feedback/details/621450/problem-renaming-file-on-ftp-server-using-ftpwebrequest-in-net-framework-4-0-vs2010-only if(strPath.StartsWith("ftp:", StrUtil.CaseIgnoreCmp) && (Environment.Version.Major >= 4) && !NativeLib.IsUnix()) m_bTransacted = false; #endif foreach(KeyValuePair kvp in g_dEnabled) { if(strPath.StartsWith(kvp.Key, StrUtil.CaseIgnoreCmp)) { m_bTransacted = kvp.Value; break; } } if(m_bTransacted) { m_iocTemp = m_iocBase.CloneDeep(); m_iocTemp.Path += StrTempSuffix; TxfPrepare(); // Adjusts m_iocTemp } else m_iocTemp = m_iocBase; } ~FileTransactionEx() { Dispose(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool bDisposing) { m_iocBase = null; if(!bDisposing) return; try { foreach(IOConnectionInfo ioc in m_lToDelete) { if(IOConnection.FileExists(ioc, false)) IOConnection.DeleteFile(ioc); } m_lToDelete.Clear(); } catch(Exception) { Debug.Assert(false); } } public Stream OpenWrite() { if(m_iocBase == null) { Debug.Assert(false); throw new ObjectDisposedException(null); } if(!m_bTransacted) m_bMadeUnhidden |= UrlUtil.UnhideFile(m_iocTemp.Path); return IOConnection.OpenWrite(m_iocTemp); } public void CommitWrite() { if(m_iocBase == null) { Debug.Assert(false); throw new ObjectDisposedException(null); } if(!m_bTransacted) { if(m_bMadeUnhidden) UrlUtil.HideFile(m_iocTemp.Path, true); } else CommitWriteTransaction(); m_iocBase = null; // Dispose } private void CommitWriteTransaction() { if(g_bExtraSafe) { if(!IOConnection.FileExists(m_iocTemp)) throw new FileNotFoundException(m_iocTemp.Path + MessageService.NewLine + KLRes.FileSaveFailed); } bool bMadeUnhidden = UrlUtil.UnhideFile(m_iocBase.Path); #if !ModernKeePassLib // 'All' includes 'Audit' (SACL), which requires SeSecurityPrivilege, // which we usually don't have and therefore get an exception; // trying to set 'Owner' or 'Group' can result in an // UnauthorizedAccessException; thus we restore 'Access' (DACL) only const AccessControlSections acs = AccessControlSections.Access; #endif bool bEfsEncrypted = false; byte[] pbSec = null; DateTime? otCreation = null; SimpleStat sStat = null; bool bBaseExists = IOConnection.FileExists(m_iocBase); if(bBaseExists && m_iocBase.IsLocalFile()) { // FileAttributes faBase = FileAttributes.Normal; try { #if !(ModernKeePassLib || KeePassUAP) FileAttributes faBase = File.GetAttributes(m_iocBase.Path); bEfsEncrypted = ((long)(faBase & FileAttributes.Encrypted) != 0); try { if(bEfsEncrypted) File.Decrypt(m_iocBase.Path); } // For TxF catch(Exception) { Debug.Assert(false); } otCreation = File.GetCreationTimeUtc(m_iocBase.Path); sStat = SimpleStat.Get(m_iocBase.Path); // May throw with Mono FileSecurity sec = File.GetAccessControl(m_iocBase.Path, acs); if(sec != null) pbSec = sec.GetSecurityDescriptorBinaryForm(); #endif } catch(Exception) { Debug.Assert(NativeLib.IsUnix()); } // if((long)(faBase & FileAttributes.ReadOnly) != 0) // throw new UnauthorizedAccessException(); } if(!TxfMove()) { if(bBaseExists) IOConnection.DeleteFile(m_iocBase); IOConnection.RenameFile(m_iocTemp, m_iocBase); } else { Debug.Assert(pbSec != null); } // TxF success => NTFS => has ACL try { // If File.GetCreationTimeUtc fails, it may return a // date with year 1601, and Unix times start in 1970, // so testing for 1971 should ensure validity; // https://msdn.microsoft.com/en-us/library/system.io.file.getcreationtimeutc.aspx #if !ModernKeePassLib if(otCreation.HasValue && (otCreation.Value.Year >= 1971)) File.SetCreationTimeUtc(m_iocBase.Path, otCreation.Value); if(sStat != null) SimpleStat.Set(m_iocBase.Path, sStat); if(bEfsEncrypted) { try { File.Encrypt(m_iocBase.Path); } catch(Exception) { Debug.Assert(false); } } // File.SetAccessControl(m_iocBase.Path, secPrev); // Directly calling File.SetAccessControl with the previous // FileSecurity object does not work; the binary form // indirection is required; // https://sourceforge.net/p/keepass/bugs/1738/ // https://msdn.microsoft.com/en-us/library/system.io.file.setaccesscontrol.aspx if((pbSec != null) && (pbSec.Length != 0)) { FileSecurity sec = new FileSecurity(); sec.SetSecurityDescriptorBinaryForm(pbSec, acs); File.SetAccessControl(m_iocBase.Path, sec); } #endif } catch(Exception) { Debug.Assert(false); } if(bMadeUnhidden) UrlUtil.HideFile(m_iocBase.Path, true); } // For plugins public static void Configure(string strPrefix, bool? obTransacted) { if(string.IsNullOrEmpty(strPrefix)) { Debug.Assert(false); return; } if(obTransacted.HasValue) g_dEnabled[strPrefix] = obTransacted.Value; else g_dEnabled.Remove(strPrefix); } private static bool TxfIsSupported(char chDriveLetter) { if(chDriveLetter == '\0') return false; #if ModernKeePassLib return true; #else try { string strRoot = (new string(chDriveLetter, 1)) + ":\\"; const int cch = NativeMethods.MAX_PATH + 1; StringBuilder sbName = new StringBuilder(cch + 1); uint uSerial = 0, cchMaxComp = 0, uFlags = 0; StringBuilder sbFileSystem = new StringBuilder(cch + 1); if(!NativeMethods.GetVolumeInformation(strRoot, sbName, (uint)cch, ref uSerial, ref cchMaxComp, ref uFlags, sbFileSystem, (uint)cch)) { Debug.Assert(false, (new Win32Exception()).Message); return false; } return ((uFlags & NativeMethods.FILE_SUPPORTS_TRANSACTIONS) != 0); } catch(Exception) { Debug.Assert(false); } return false; #endif } private void TxfPrepare() { try { if(NativeLib.IsUnix()) return; if(!m_iocBase.IsLocalFile()) return; if(TxfIsUnusable()) return; string strID = StrUtil.AlphaNumericOnly(Convert.ToBase64String( CryptoRandom.Instance.GetRandomBytes(16))); string strTempDir = UrlUtil.GetTempPath(); // See also ClearOld method string strTemp = UrlUtil.EnsureTerminatingSeparator(strTempDir, false) + StrTxfTempPrefix + strID + StrTxfTempSuffix; char chB = UrlUtil.GetDriveLetter(m_iocBase.Path); char chT = UrlUtil.GetDriveLetter(strTemp); if(!TxfIsSupported(chB)) return; if((chT != chB) && !TxfIsSupported(chT)) return; m_iocTxfMidFallback = m_iocTemp; m_iocTemp = IOConnectionInfo.FromPath(strTemp); m_lToDelete.Add(m_iocTemp); } catch(Exception) { Debug.Assert(false); m_iocTxfMidFallback = null; } } private bool TxfMove() { if(m_iocTxfMidFallback == null) return false; if(TxfMoveWithTx()) return true; // Move the temporary file onto the base file's drive first, // such that it cannot happen that both the base file and // the temporary file are deleted/corrupted #if !ModernKeePassLib const uint f = (NativeMethods.MOVEFILE_COPY_ALLOWED | NativeMethods.MOVEFILE_REPLACE_EXISTING); bool b = NativeMethods.MoveFileEx(m_iocTemp.Path, m_iocTxfMidFallback.Path, f); if(b) b = NativeMethods.MoveFileEx(m_iocTxfMidFallback.Path, m_iocBase.Path, f); if(!b) throw new Win32Exception(); Debug.Assert(!File.Exists(m_iocTemp.Path)); Debug.Assert(!File.Exists(m_iocTxfMidFallback.Path)); #endif return true; } private bool TxfMoveWithTx() { #if !ModernKeePassLib IntPtr hTx = new IntPtr((int)NativeMethods.INVALID_HANDLE_VALUE); Debug.Assert(hTx.ToInt64() == NativeMethods.INVALID_HANDLE_VALUE); try { string strTx = PwDefs.ShortProductName + " TxF - " + StrUtil.AlphaNumericOnly(Convert.ToBase64String( CryptoRandom.Instance.GetRandomBytes(16))); const int mchTx = NativeMethods.MAX_TRANSACTION_DESCRIPTION_LENGTH; if(strTx.Length >= mchTx) strTx = strTx.Substring(0, mchTx - 1); hTx = NativeMethods.CreateTransaction(IntPtr.Zero, IntPtr.Zero, 0, 0, 0, 0, strTx); if(hTx.ToInt64() == NativeMethods.INVALID_HANDLE_VALUE) { Debug.Assert(false, (new Win32Exception()).Message); return false; } if(!NativeMethods.MoveFileTransacted(m_iocTemp.Path, m_iocBase.Path, IntPtr.Zero, IntPtr.Zero, (NativeMethods.MOVEFILE_COPY_ALLOWED | NativeMethods.MOVEFILE_REPLACE_EXISTING), hTx)) { Debug.Assert(false, (new Win32Exception()).Message); return false; } if(!NativeMethods.CommitTransaction(hTx)) { Debug.Assert(false, (new Win32Exception()).Message); return false; } Debug.Assert(!File.Exists(m_iocTemp.Path)); return true; } catch(Exception) { Debug.Assert(false); } finally { if(hTx.ToInt64() != NativeMethods.INVALID_HANDLE_VALUE) { try { if(!NativeMethods.CloseHandle(hTx)) { Debug.Assert(false); } } catch(Exception) { Debug.Assert(false); } } } #endif return false; } private bool TxfIsUnusable() { #if !ModernKeePassLib try { string strReleaseId = (Registry.GetValue( "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "ReleaseId", string.Empty) as string); // Due to a bug in Microsoft's 'cldflt.sys' driver, a TxF transaction // results in a Blue Screen of Death on Windows 10 1903/1909; // https://www.windowslatest.com/2019/10/20/windows-10-update-issues-bsod-broken-apps-and-defender-atp/ // https://sourceforge.net/p/keepass/discussion/329221/thread/924b94ea48/ // This bug is fixed by the Windows update 4530684; // https://support.microsoft.com/en-us/help/4530684/windows-10-update-kb4530684 // if(strReleaseId == "1903") return true; // if(strReleaseId == "1909") return true; if(strReleaseId != "1809") return false; // On Windows 10 1809, OneDrive crashes if the file is // in a OneDrive folder; // https://sourceforge.net/p/keepass/discussion/329220/thread/672ffecc65/ // https://sourceforge.net/p/keepass/discussion/329221/thread/514786c23a/ string strFile = m_iocBase.Path; GFunc fMatch = delegate(string strRoot, string strSfx) { if(string.IsNullOrEmpty(strRoot)) return false; string strPfx = UrlUtil.EnsureTerminatingSeparator( strRoot, false) + strSfx; return strFile.StartsWith(strPfx, StrUtil.CaseIgnoreCmp); }; GFunc fMatchEnv = delegate(string strEnv, string strSfx) { return fMatch(Environment.GetEnvironmentVariable(strEnv), strSfx); }; string strKnown = NativeMethods.GetKnownFolderPath( NativeMethods.FOLDERID_SkyDrive); if(fMatch(strKnown, string.Empty)) return true; if(fMatchEnv("USERPROFILE", "OneDrive\\")) return true; if(fMatchEnv("OneDrive", string.Empty)) return true; if(fMatchEnv("OneDriveCommercial", string.Empty)) return true; if(fMatchEnv("OneDriveConsumer", string.Empty)) return true; using(RegistryKey kAccs = Registry.CurrentUser.OpenSubKey( "Software\\Microsoft\\OneDrive\\Accounts", false)) { string[] vAccs = (((kAccs != null) ? kAccs.GetSubKeyNames() : null) ?? new string[0]); foreach(string strAcc in vAccs) { if(string.IsNullOrEmpty(strAcc)) { Debug.Assert(false); continue; } using(RegistryKey kTenants = kAccs.OpenSubKey( strAcc + "\\Tenants", false)) { string[] vTenants = (((kTenants != null) ? kTenants.GetSubKeyNames() : null) ?? new string[0]); foreach(string strT in vTenants) { if(string.IsNullOrEmpty(strT)) { Debug.Assert(false); continue; } using(RegistryKey kT = kTenants.OpenSubKey(strT, false)) { string[] vPaths = (((kT != null) ? kT.GetValueNames() : null) ?? new string[0]); foreach(string strPath in vPaths) { if((strPath == null) || (strPath.Length < 4) || (strPath[1] != ':')) { Debug.Assert(false); continue; } if(fMatch(strPath, string.Empty)) return true; } } } } } } } catch(Exception) { Debug.Assert(false); } #endif return false; } #if !ModernKeePassLib internal static void ClearOld() { try { // See also TxfPrepare method DirectoryInfo di = new DirectoryInfo(UrlUtil.GetTempPath()); List l = UrlUtil.GetFileInfos(di, StrTxfTempPrefix + "*" + StrTxfTempSuffix, SearchOption.TopDirectoryOnly); foreach(FileInfo fi in l) { if(fi == null) { Debug.Assert(false); continue; } if(!fi.Name.StartsWith(StrTxfTempPrefix, StrUtil.CaseIgnoreCmp) || !fi.Name.EndsWith(StrTxfTempSuffix, StrUtil.CaseIgnoreCmp)) continue; if((DateTime.UtcNow - fi.LastWriteTimeUtc).TotalDays > 1.0) fi.Delete(); } } catch(Exception) { Debug.Assert(false); } } #endif } }