Skip to content
Merged
106 changes: 106 additions & 0 deletions src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -102,5 +186,27 @@ private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg)

return storePath;
}

private static string InitializePasswordStoreWithGpgIdInSubdirectory(TestFileSystem fs, TestGpg gpg, string subdirectory)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is now only used in one test. Can we inline this in the GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory test, so that it matches the other test (GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpgId) that does all the store/.gpg-id setup in the test method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b641577. The InitializePasswordStoreWithGpgIdInSubdirectory helper has been removed and its logic inlined directly into GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory, matching the style of the MultipleGpgIds test.

{
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;
}
}
}
12 changes: 0 additions & 12 deletions src/shared/Core/CredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gpg-id>` 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)
Expand Down
34 changes: 24 additions & 10 deletions src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gpg-id>` to initialize the store.");
}

protected override bool TryDeserializeCredential(string path, out FileCredential credential)
Expand Down Expand Up @@ -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);
Expand Down
Loading