diff --git a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs index 7ff80f03d..220ba1a9f 100644 --- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs +++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -86,6 +86,90 @@ public void GnuPassCredentialStore_Remove_NotFound_ReturnsFalse() Assert.False(result); } + [PosixFact] + public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory() + { + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + string storeRoot = InitializePasswordStoreWithGpgIdInSubdirectory(fs, gpg, TestNamespace); + + var collection = new GpgPassCredentialStore(fs, gpg, storeRoot, TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName = "john.doe"; + string password = Guid.NewGuid().ToString("N"); + + try + { + // Write + collection.AddOrUpdate(service, userName, password); + + // Read + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, outCredential.Account); + Assert.Equal(password, outCredential.Password); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + collection.Remove(service, userName); + } + } + + [PosixFact] + public void GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpgId() + { + // Verify that when two subdirectories each have their own .gpg-id, encrypting a credential + // under one subdirectory uses that subdirectory's GPG identity, not the other one. + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + + const string personalUserId = "personal@example.com"; + const string workUserId = "work@example.com"; + + // Only register the personal key; if the wrong (work) key is picked, EncryptFile will throw. + gpg.GenerateKeys(personalUserId); + + string personalSubDir = Path.Combine(storePath, "personal"); + string workSubDir = Path.Combine(storePath, "work"); + + fs.Directories.Add(storePath); + fs.Directories.Add(personalSubDir); + fs.Directories.Add(workSubDir); + fs.Files[Path.Combine(personalSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(personalUserId); + fs.Files[Path.Combine(workSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(workUserId); + + // Use "personal" namespace so credentials are stored under storePath/personal/... + var collection = new GpgPassCredentialStore(fs, gpg, storePath, "personal"); + + string service = $"https://example.com/{Guid.NewGuid():N}"; + const string userName = "john.doe"; + string password = Guid.NewGuid().ToString("N"); + + try + { + // Write - should pick personal/.gpg-id (personalUserId), not work/.gpg-id (workUserId) + collection.AddOrUpdate(service, userName, password); + + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, outCredential.Account); + Assert.Equal(password, outCredential.Password); + } + finally + { + collection.Remove(service, userName); + } + } + private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) { string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -102,5 +186,27 @@ private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) return storePath; } + + private static string InitializePasswordStoreWithGpgIdInSubdirectory(TestFileSystem fs, TestGpg gpg, string subdirectory) + { + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + string userId = "gcm-test@example.com"; + + // Place .gpg-id only in the namespace subdirectory (not the store root), + // simulating a pass store where the root has no .gpg-id but submodules do. + string subDirPath = Path.Combine(storePath, subdirectory); + string gpgIdPath = Path.Combine(subDirPath, ".gpg-id"); + + // Ensure we have a GPG key for use with testing + gpg.GenerateKeys(userId); + + // Init the password store with .gpg-id only in the subdirectory + fs.Directories.Add(storePath); + fs.Directories.Add(subDirPath); + fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId); + + return storePath; + } } } diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index 11dc83818..95d26df32 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -291,18 +291,6 @@ private void ValidateGpgPass(out string storeRoot, out string execPath) storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".password-store"); } - // Check we have a GPG ID to sign credential files with - string gpgIdFile = Path.Combine(storeRoot, ".gpg-id"); - if (!_context.FileSystem.FileExists(gpgIdFile)) - { - var format = - "Password store has not been initialized at '{0}'; run `pass init ` to initialize the store."; - var message = string.Format(format, storeRoot); - _context.Trace2.WriteError(message); - throw new Exception(message + Environment.NewLine + - $"See {Constants.HelpUrls.GcmCredentialStores} for more information." - ); - } } private void ValidateCredentialCache(out string options) diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index 6ed56c693..debc9c815 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -21,19 +21,33 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot protected override string CredentialFileExtension => ".gpg"; - private string GetGpgId() + private string GetGpgId(string credentialFullPath) { - string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id"); - if (!FileSystem.FileExists(gpgIdPath)) + // Walk up from the credential's directory to the store root, looking for a .gpg-id file. + // This mimics the behaviour of GNU Pass, which uses the nearest .gpg-id in the directory hierarchy. + string dir = Path.GetDirectoryName(credentialFullPath); + while (dir != null) { - throw new Exception($"Cannot find GPG ID in '{gpgIdPath}'; password store has not been initialized"); - } + string gpgIdPath = Path.Combine(dir, ".gpg-id"); + if (FileSystem.FileExists(gpgIdPath)) + { + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) + { + return reader.ReadLine(); + } + } - using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = new StreamReader(stream)) - { - return reader.ReadLine(); + // Stop after checking the store root + if (FileSystem.IsSamePath(dir, StoreRoot)) + { + break; + } + + dir = Path.GetDirectoryName(dir); } + + throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store."); } protected override bool TryDeserializeCredential(string path, out FileCredential credential) @@ -68,7 +82,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential protected override void SerializeCredential(FileCredential credential) { - string gpgId = GetGpgId(); + string gpgId = GetGpgId(credential.FullPath); var sb = new StringBuilder(credential.Password); sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine);