Isolate the Credential Manager vault per data directory (#60) #61
No files matched your search
+1
-1
@@ -36,7 +36,7 @@ gclo is maintained by a single person in their spare time, so response times are
|
||||
gclo works with GitHub Personal Access Tokens, so token safety is part of the design:
|
||||
|
||||
- **Quick Sync tokens are kept in memory only.** A token pasted into Quick Sync is used for the GitHub API and as the git HTTPS credential for the duration of the session, and is never written to disk, settings, or logs.
|
||||
- **Saved-account tokens and the default token are persisted — in the Windows Credential Manager, nowhere else.** When you save an account (in the app or with `gclo accounts add`), or set a default token in Settings, the token is stored as a generic credential in the per-user Windows Credential Manager (DPAPI-protected, non-roaming) under a `gclo:account:<id>` target, and removed when the account or token is deleted. It is **never** written to `settings.json`, `accounts.json`, or any log — those files hold only non-secret metadata (organization, target folder, parallelism, theme, last-sync summary). This is why accounts are Windows-only.
|
||||
- **Saved-account tokens and the default token are persisted — in the Windows Credential Manager, nowhere else.** When you save an account (in the app or with `gclo accounts add`), or set a default token in Settings, the token is stored as a generic credential in the per-user Windows Credential Manager (DPAPI-protected, non-roaming) under a `gclo:account:<id>` target (`gclo:<scope>:account:<id>` when `GCLO_DATA_DIR` points the app at a non-default data directory, so an isolated data directory - the UI test suite, a portable setup - never sees the default profile's tokens), and removed when the account or token is deleted. It is **never** written to `settings.json`, `accounts.json`, or any log — those files hold only non-secret metadata (organization, target folder, parallelism, theme, last-sync summary). This is why accounts are Windows-only.
|
||||
- **The trust boundary of stored tokens is the Windows user account.** Credential Manager protects a stored token from other users and from offline access, not from code running as the same user: any process you run can read it with `CredRead`, exactly as it can read your git credentials or browser cookies. Treat a machine where untrusted code runs as your user as a machine where the token is exposed.
|
||||
- **The app never redisplays a stored token.** Editing an account or opening a connection flyout shows an empty token box with a placeholder; the stored value is read from Credential Manager only when it must be sent to GitHub (a sync, an organization lookup, a validation on save). Activity-log entries about tokens record only that one was entered or replaced, and its length.
|
||||
- **The CLI refuses tokens on the command line.** There is deliberately no `--token <value>` option; tokens are accepted only via environment variable (`--token-env`, default `GITHUB_TOKEN`), a file (`--token-file`), or standard input (`--token-stdin`). See [docs/CLI.md](docs/CLI.md).
|
||||
|
||||
@@ -22,6 +22,67 @@ public sealed class VaultEdgeTests
|
||||
public void Constructor_NotWindows_ThrowsPlatformNotSupported()
|
||||
=> Assert.Throws<PlatformNotSupportedException>(() => new CredentialManagerVault(isWindows: false));
|
||||
|
||||
// ---------------------------------------------------------------- data-root scoping (#60)
|
||||
|
||||
[Fact]
|
||||
public void ScopeFor_DefaultDataRoot_IsNull_SoExistingEntriesKeepTheirNames()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "gclo-default");
|
||||
|
||||
Assert.Null(CredentialManagerVault.ScopeFor(root, root));
|
||||
Assert.Null(CredentialManagerVault.ScopeFor(root + Path.DirectorySeparatorChar, root));
|
||||
Assert.Null(CredentialManagerVault.ScopeFor(root.ToUpperInvariant(), root.ToLowerInvariant()));
|
||||
Assert.Equal($"gclo:account:{Guid.Empty:N}", CredentialManagerVault.TargetName(Guid.Empty, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeFor_OtherDataRoot_IsAStableHash_ThatIgnoresCaseAndTrailingSeparators()
|
||||
{
|
||||
string def = Path.Combine(Path.GetTempPath(), "gclo-default");
|
||||
string other = Path.Combine(Path.GetTempPath(), "gclo-uitests", "abc");
|
||||
|
||||
string? scope = CredentialManagerVault.ScopeFor(other, def);
|
||||
|
||||
Assert.NotNull(scope);
|
||||
Assert.Matches("^[0-9a-f]{16}$", scope);
|
||||
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other + "\\", def));
|
||||
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other.ToUpperInvariant(), def));
|
||||
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other.Replace('\\', '/'), def));
|
||||
Assert.NotEqual(scope, CredentialManagerVault.ScopeFor(other + "2", def));
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
Assert.Equal($"gclo:{scope}:account:{id:N}", CredentialManagerVault.TargetName(id, scope));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedVault_DoesNotSeeTheUnscopedEntry_AndCleansUpItsOwn()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var unscoped = new CredentialManagerVault(isWindows: true, scope: null);
|
||||
var scoped = new CredentialManagerVault(isWindows: true, scope: "0123456789abcdef");
|
||||
try
|
||||
{
|
||||
unscoped.Store(id, "ghp_default_profile");
|
||||
Assert.Null(scoped.TryRetrieve(id)); // another data root never sees the default profile's token
|
||||
|
||||
scoped.Store(id, "ghp_isolated");
|
||||
Assert.Equal("ghp_isolated", scoped.TryRetrieve(id));
|
||||
Assert.Equal("ghp_default_profile", unscoped.TryRetrieve(id)); // and vice versa
|
||||
}
|
||||
finally
|
||||
{
|
||||
unscoped.Delete(id);
|
||||
scoped.Delete(id);
|
||||
}
|
||||
Assert.Null(unscoped.TryRetrieve(id));
|
||||
Assert.Null(scoped.TryRetrieve(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_TokenLargerThanCredManBlobLimit_SurfacesWin32Error()
|
||||
{
|
||||
|
||||
@@ -48,6 +48,10 @@ public sealed class AppSession : IDisposable
|
||||
{
|
||||
App = Application.Launch(startInfo);
|
||||
MainWindow = WaitForMainWindow();
|
||||
// Snapshot the launch state before any test types: the Quick Sync page is
|
||||
// up once its connect card is, and a pre-fill logs synchronously on the way.
|
||||
WaitForElement("ConnectTokenBox", TimeSpan.FromSeconds(30));
|
||||
TokenPrefilledAtLaunch = LogContains("Token entered in workspace");
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -64,6 +68,41 @@ public sealed class AppSession : IDisposable
|
||||
/// <summary>The temp directory the app sees as its data root (via GCLO_DATA_DIR).</summary>
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the app had logged a token being entered by the time its Quick Sync
|
||||
/// page was up, before any test typed one: the workspace was pre-filled from a
|
||||
/// default token the developer saved in their real profile. The vault is scoped
|
||||
/// per data directory precisely so this never happens (#60); the fresh-data-dir
|
||||
/// smoke test asserts it.
|
||||
/// </summary>
|
||||
public bool TokenPrefilledAtLaunch { get; }
|
||||
|
||||
private bool LogContains(string fragment)
|
||||
{
|
||||
string logs = Path.Combine(DataDirectory, "logs");
|
||||
if (!Directory.Exists(logs))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (string file in Directory.EnumerateFiles(logs, "*.log"))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
using var reader = new StreamReader(stream);
|
||||
if (reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// mid-write; treat as not found
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The UIA3 automation used for every lookup and pattern call.</summary>
|
||||
public UIA3Automation Automation { get; }
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ public sealed partial class SmokeTests : UiTestBase
|
||||
{
|
||||
Assert.NotNull(Session.WaitForElement("ConnectTokenBox"));
|
||||
Assert.NotNull(Session.WaitForElement("LoadReposButton"));
|
||||
// The fresh data dir must also mean a fresh vault: a default token saved in the
|
||||
// developer's real profile must not pre-fill Quick Sync (#60). The session
|
||||
// snapshots this at launch, before any test types a token.
|
||||
Assert.False(
|
||||
Session.TokenPrefilledAtLaunch,
|
||||
"Quick Sync was pre-filled with a token from outside the test data directory.");
|
||||
|
||||
AutomationElement syncAll = Session.WaitForElement("NavSyncAll");
|
||||
Assert.False(syncAll.IsEnabled, "'Sync all' should be disabled while no accounts exist.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
@@ -11,6 +12,14 @@ namespace gclo.ViewModels;
|
||||
/// blob is the token's UTF-16 bytes, under the target name "gclo:account:<id>".
|
||||
/// Talks to advapi32 directly, so it works packaged or unpackaged. Construction on a
|
||||
/// non-Windows OS throws <see cref="PlatformNotSupportedException"/>.
|
||||
/// <para>
|
||||
/// The store is machine-wide while everything else gclo keeps follows
|
||||
/// <see cref="GcloPaths.DataRoot"/>, so a process pointed at another data directory
|
||||
/// (GCLO_DATA_DIR: the UI test suite, a portable setup) uses target names scoped to
|
||||
/// that directory, "gclo:<scope>:account:<id>", and never sees the default
|
||||
/// profile's tokens (#60). The default data root keeps the unscoped names, so existing
|
||||
/// entries stay valid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class CredentialManagerVault : ITokenVault
|
||||
@@ -19,22 +28,53 @@ public sealed class CredentialManagerVault : ITokenVault
|
||||
private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot
|
||||
private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND
|
||||
|
||||
/// <summary>Fails fast on platforms without a Credential Manager.</summary>
|
||||
/// <summary>Target-name scope for this process's data root; null for the default root.</summary>
|
||||
private readonly string? _scope;
|
||||
|
||||
/// <summary>
|
||||
/// Fails fast on platforms without a Credential Manager. Targets are scoped to the
|
||||
/// current <see cref="GcloPaths.DataRoot"/> (unscoped for the default root).
|
||||
/// </summary>
|
||||
public CredentialManagerVault()
|
||||
: this(OperatingSystem.IsWindows())
|
||||
: this(OperatingSystem.IsWindows(), ScopeFor(GcloPaths.DataRoot, GcloPaths.DefaultDataRoot))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the platform check is a parameter so both arms are coverable.</summary>
|
||||
internal CredentialManagerVault(bool isWindows)
|
||||
/// <summary>Test seam: the platform check and the scope are parameters so every arm is coverable.</summary>
|
||||
internal CredentialManagerVault(bool isWindows, string? scope = null)
|
||||
{
|
||||
if (!isWindows)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"Account token storage requires the Windows Credential Manager.");
|
||||
}
|
||||
_scope = scope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scope segment for a data root: null when it is the default root (existing
|
||||
/// "gclo:account:<id>" entries keep working), otherwise 16 hex characters of the
|
||||
/// SHA-256 of the full, case-folded path with any trailing separators removed, so
|
||||
/// "C:\data", "c:\data\" and "C:/data" all share one scope.
|
||||
/// </summary>
|
||||
internal static string? ScopeFor(string dataRoot, string defaultDataRoot)
|
||||
{
|
||||
string normalized = Normalize(dataRoot);
|
||||
if (normalized == Normalize(defaultDataRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
|
||||
return Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
|
||||
|
||||
static string Normalize(string path)
|
||||
=> Path.GetFullPath(path).Replace('/', '\\').TrimEnd('\\').ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>The Credential Manager target name for an account under a scope.</summary>
|
||||
internal static string TargetName(Guid accountId, string? scope)
|
||||
=> scope is null ? $"gclo:account:{accountId:N}" : $"gclo:{scope}:account:{accountId:N}";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Store(Guid accountId, string token)
|
||||
{
|
||||
@@ -124,7 +164,7 @@ public sealed class CredentialManagerVault : ITokenVault
|
||||
}
|
||||
}
|
||||
|
||||
private static string TargetName(Guid accountId) => $"gclo:account:{accountId:N}";
|
||||
private string TargetName(Guid accountId) => TargetName(accountId, _scope);
|
||||
|
||||
/// <summary>
|
||||
/// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout
|
||||
|
||||
@@ -17,6 +17,9 @@ public static class GcloPaths
|
||||
public static string DataRoot =>
|
||||
Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir
|
||||
? dir
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
|
||||
: DefaultDataRoot;
|
||||
|
||||
/// <summary>%LOCALAPPDATA%\gclo: the root used when GCLO_DATA_DIR is not set.</summary>
|
||||
public static string DefaultDataRoot =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
|
||||
}
|
||||
Reference in new issue
Block a user