Skip to content

Delegate GitHub PAT storage to ktsu.CredentialCache #411

Description

@matt-edmondson

What's hand-rolled

ProjectDirectorOptions (a AppData<ProjectDirectorOptions>, serialized to JSON under %AppData% by ktsu.AppDataStorage) carries a personal access token per configured GitHub owner in plain fields with no [JsonIgnore]:

  • public GitHubLogin GitHubLogin { get; set; } = new();
    public GitHubToken GitHubToken { get; set; } = new();
    public OpenAIToken OpenAIToken { get; set; } = new();
    public Dictionary<string, bool> PanelStates { get; init; } = [];
    public Dictionary<string, List<float>> DividerStates { get; init; } = [];
    public Dictionary<GitHubOwnerName, GitHubToken> GitHubOwners { get; init; } = [];
    GitHubLogin, GitHubToken, and Dictionary<GitHubOwnerName, GitHubToken> GitHubOwners

Those tokens are written when a new owner is added and read every time repos are synced:

  • GitHubClient = new(new ProductHeaderValue("ktsu.ProjectDirector"));
    if (!string.IsNullOrEmpty(Options.GitHubLogin) && !string.IsNullOrEmpty(Options.GitHubToken))
    {
    GitHubClient.Credentials = new(Options.GitHubLogin, Options.GitHubToken);
    — initial GitHubClient.Credentials set from Options.GitHubLogin/Options.GitHubToken
  • _ = Options.GitHubOwners.TryAdd(newName, GitHubToken.Create<GitHubToken>(string.Empty));
    — a new owner's PAT is seeded as GitHubToken.Create<GitHubToken>(string.Empty) into Options.GitHubOwners
  • private void ScanRemoteAccountsForRepos()
    {
    Dictionary<GitHubOwnerName, GitHubToken> knownOwners = Options.GitHubOwners;
    foreach ((GitHubOwnerName owner, GitHubToken pat) in knownOwners)
    {
    if (!string.IsNullOrEmpty(pat) || (!string.IsNullOrEmpty(Options.GitHubLogin) && !string.IsNullOrEmpty(Options.GitHubToken)))
    {
    GitHubClient.Credentials = !string.IsNullOrEmpty(pat) ? new(owner, pat) : new(Options.GitHubLogin, Options.GitHubToken);
    }
    ScanRemoteAccountsForRepos reads each owner's PAT back out of Options.GitHubOwners to authenticate GitHubClient

AppData<T> serializes the whole ProjectDirectorOptions object as-is, so every owner's PAT lands in the plaintext settings JSON on disk alongside window-state and UI preferences.

What ktsu.CredentialCache provides

ktsu.CredentialCache.CredentialCache caches credentials in memory and persists each one through the host's native secret store (Windows Credential Manager / macOS Keychain / libsecret), rather than a plain file:
https://github.com/ktsu-dev/CredentialCache/blob/5db1e8aaa1b84f1521711123808563481b4ddb4c/CredentialCache/CredentialCache.cs

Relevant members:

A PersonaGUID per GitHubOwnerName (or a deterministic mapping between the two, stored in ProjectDirectorOptions instead of the token itself) would let Options.GitHubOwners keep tracking which owners are configured while the PAT itself never touches the settings JSON.

Why it's worth it

This is the same plaintext-PAT-in-AppData-JSON pattern already flagged and filed against ktsu-dev/BuildMonitor#278 and ktsu-dev/OAICLI#42 earlier in this same audit rotation — ktsu.CredentialCache exists specifically to route secrets like this through the OS's own credential store instead of a file on disk.

Compatibility

  • Subject targets: ProjectDirector.csprojnet10.0.
  • ktsu.CredentialCache targets: net9.0;net10.0.
  • Dependency direction: ktsu.CredentialCache's Directory.Packages.props has no reference to ktsu.ProjectDirector, so no cycle.

Sketch

// before (ProjectDirectorOptions, serialized to plaintext JSON)
public GitHubToken GitHubToken { get; set; } = new();
public Dictionary<GitHubOwnerName, GitHubToken> GitHubOwners { get; init; } = [];

// after — Options keeps only which owners are configured, not their secrets
public Dictionary<GitHubOwnerName, PersonaGUID> GitHubOwnerPersonas { get; init; } = [];

// storing a PAT
CredentialCache.Instance.AddOrReplace(persona, new CredentialWithToken { Token = CredentialToken.Create<CredentialToken>(pat) });

// reading it back
if (CredentialCache.Instance.TryGet(persona, out Credential? credential) && credential is CredentialWithToken tokenCredential)
{
    GitHubClient.Credentials = new(owner, tokenCredential.Token);
}

Caveats

This changes ProjectDirectorOptions's public shape (GitHubToken/GitHubOwners as token-bearing fields go away) and the settings JSON format, so existing users would need their PAT re-entered once after upgrading (there's no PAT already in the OS credential store to migrate from). Options.GitHubLogin alone is not a secret and can stay as-is.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions