Skip to content
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)
{
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