Skip to content

Token storage: harden the config file, and give users a way to keep the token out of it #5

Description

@solrevdev

Review of how bbx stores the API token today, the gaps, and seven options. No code was changed; this is the write-up. Review date: 2026-08-06.

Pick what goes in the PR

Tick the options to implement. My recommendation is 1 + 2 now, 3 next; see Recommendation for why.

  • Option 1 Harden the current file: warn on loose permissions, sort out Windows (recommended now)
  • Option 2 BBX_API_TOKEN / BBX_EMAIL environment override (recommended now)
  • Option 3 token_command hook, so the token lives in a keychain or password manager (recommended next)
  • Option 4 Shell out to the OS store directly from bbx (not recommended)
  • Option 5 Native credential-store integration via a .NET library (later, on demand)
  • Option 6 Windows-only: encrypt the file with DPAPI (later, on demand)
  • Docs only: add the per-OS keychain wrapper recipes and leave the code alone

What the app does today

The token comes in through bbx auth login. The flow, in order of preference:

  1. Interactive prompt. SecretInput.ReadSecret() intercepts keystrokes, so the
    token never echoes to the terminal (src/Bbx/Auth/SecretInput.cs).
  2. Piped stdin. If input is redirected it reads one line, so
    pbpaste | bbx auth login --email x keeps the token out of history.
  3. A --token flag. The help text warns against it because it lands in shell
    history.

AuthGate runs before every non-auth command and triggers the same prompt on
first use, on a TTY only (src/Bbx/Auth/AuthGate.cs). LoginApiTokenHandler
verifies the token against /2.0/user before saving anything, so a bad paste
never gets persisted.

Storage is FileCredentialStore (src/Bbx/Auth/FileCredentialStore.cs):

  • Plaintext JSON at ~/.config/bbx/config.json holding email, token and
    default workspace.
  • On Unix the directory is chmod 700 and the file 600, set at create time via
    UnixCreateMode, so there is no window where the file is world-readable.
  • Writes are atomic: temp file with FileMode.CreateNew, flush to disk, then
    File.Move over the target. A crash mid-write cannot leave a torn or
    world-readable config.
  • bbx auth token prints email:token to stdout on purpose, for curl and
    scripts. Same footgun as gh auth token; fine, but worth remembering.

There is no environment-variable override. BBX_NO_INTERACTIVE and
BBX_JSON_COMPACT exist; a BBX_API_TOKEN does not.

How secure is that?

For a plaintext-file design, this is close to as good as it gets. Permissions
are set at create time, writes are atomic, the token is never logged, and the
prompt does not echo. That matches what gh did for years and what many CLIs
still do.

The honest gaps:

  1. Plaintext at rest. Anything that reads the disk gets the token: full
    backups, disk images, a stolen unencrypted laptop, malware running as the
    user. FileVault/BitLocker covers the stolen-laptop case on modern machines.
  2. Windows gets no explicit protection. The UnixCreateMode branch is
    skipped and the file relies on default profile ACLs. Usually fine, but
    nothing enforces it, and ~/.config is a non-standard spot on Windows.
  3. No permission check on load. If the user or a sync tool later chmods the
    file to 644, bbx reads it without complaint. ssh refuses a loose key file;
    bbx could at least warn.
  4. Scope of the credential is wide. An Atlassian API token is close to a
    password. Whatever the storage, the blast radius on leak is the whole
    account, which raises the value of doing better than plaintext.

Also worth being honest about the ceiling: on Linux and Windows, any process
running as the same user can read the OS credential store too (Secret Service
answers same-user callers; DPAPI decrypts for the same user). macOS is the
exception, because Keychain prompts per application. So a keychain mostly buys
protection at rest (backups, disk reads, sync accidents), not protection
from a compromised user account.

Options

1. Harden the current file (small, do regardless)

  • Warn on load when the file is group/world readable on Unix, like ssh does.
  • On Windows, either set an explicit DACL or, better, see option 5.
  • Keep everything else as is.

Effort: hours. Cross-platform: yes. User setup: none.

2. Environment-variable override: BBX_API_TOKEN + BBX_EMAIL

Read env vars before the config file, the way GH_TOKEN works. This is the
single cheapest change with the biggest reach:

  • CI and Pipelines get a clean path with no config file on disk at all.
  • It unlocks every external secret store via a wrapper, with zero native code
    in bbx. This is exactly the orchat pattern (solrevdev.orchat README): the
    app only reads OPENROUTER_API_KEY, and thin wrappers fetch it from the
    macOS Keychain (security find-generic-password), Linux libsecret
    (secret-tool), or PowerShell SecretManagement on Windows.

Caveats: an exported variable is visible to every child process, and inline
use can land in shell history. Documented as "for CI and wrappers", it is a
strict improvement.

Effort: hours. Cross-platform: yes. User setup: none (opt-in).

3. A token command hook: token_command in config or BBX_TOKEN_COMMAND

bbx executes a user-supplied command and uses its stdout as the token:

{ "username": "john@solrevdev.com",
  "token_command": "security find-generic-password -s BBX_API_TOKEN -w" }

Works with the macOS Keychain, secret-tool, pass, 1Password (op read),
Bitwarden, anything. bbx ships no native dependencies and the config file no
longer contains a secret. This is the kubectl/docker credential-helper model,
shrunk to one line. Pairs well with option 2: the env var for CI, the command
for humans.

Caveats: the user must put the token into their store themselves (one
documented command per OS), and executing a string from a config file needs a
clear docs note that the config is trusted input. Headless Linux without a
Secret Service daemon just keeps using the file, which stays supported as the
fallback.

Effort: a day or two with tests and docs. Cross-platform: yes. User setup:
one command, optional.

4. Shell out to the OS store directly from bbx

bbx auth login writes to the Keychain via security on macOS, secret-tool
on Linux, PowerShell SecretManagement or cmdkey on Windows, and reads it
back the same way. The orchat wrappers, moved inside the tool.

This is the "just works" version of option 3, but the edges are real:
secret-tool needs libsecret, D-Bus and an unlocked keyring, which headless
servers and containers do not have; Windows has no single guaranteed CLI; and
parsing CLI output across OS versions is a maintenance tax. It would need a
plaintext fallback anyway, so it adds the complexity of option 5 without the
reliability of a library.

Effort: several days plus ongoing breakage. Verdict: skip in favour of 3 or 5.

5. Native credential-store integration via a .NET library

The full gh treatment: token goes into the platform store by default, file
fallback with a warning. Prior art in .NET is solid:

  • Git Credential Manager (git-ecosystem/git-credential-manager) is a .NET
    tool doing exactly this for Bitbucket already: macOS Keychain, Windows
    Credential Manager, libsecret, plus a DPAPI-encrypted file and a plaintext
    file as fallbacks. Its source is the map for any store abstraction.
  • Microsoft.Identity.Client.Extensions.Msal ships a cross-platform
    protected storage helper (Keychain / DPAPI / libsecret) used by Microsoft's
    own CLIs, and is usable outside MSAL.

Best default UX and the strongest at-rest story, at the price of native
interop, a fallback path that must exist anyway (headless Linux), extra
failure modes at dotnet tool install time, and per-OS testing. The macOS
Keychain also ties entries to the signing identity of the binary, and .NET
global tools run through the shared dotnet host, which blunts the per-app
ACL benefit and can cause re-prompts after SDK updates.

Effort: a week plus. Verdict: right destination if bbx grows an audience;
premature today.

6. Windows-only: encrypt the file with DPAPI

ProtectedData.Protect (NuGet System.Security.Cryptography.ProtectedData)
encrypts the token with a per-user OS key. No prompts, no services, no user
setup, works headless. It is the exact Windows counterpart of the Unix 600
file and closes gap 2 outright. GCM uses the same API for its "encrypted file"
store.

Effort: a day. Cross-platform: Windows only, by design, alongside option 1.

7. Rejected

  • Passphrase-encrypted config. Kills non-interactive use, and agents and
    scripts are the point of bbx.
  • OAuth. Already removed for cause: Bitbucket makes every user create
    their own consumer, which needs admin rights and more work than pasting a
    token.
  • Do nothing about Windows. Cheap to fix; no reason to leave it.

Recommendation

Threat-model first: the config file only falls to someone who can already read
the user's home directory, and on that machine class the OS keychain adds
little except at rest. So spend effort where the leverage is:

  1. Now: option 1 (permission warning, Windows story) and option 2
    (BBX_API_TOKEN/BBX_EMAIL). Small, no user cost, unblocks CI and
    keychain wrappers immediately. Add a docs section with the per-OS wrapper
    recipes, lifted from orchat.
  2. Next: option 3 (token_command). One config line gets the secret out
    of the file entirely, on every OS, with no native code.
  3. Later, on demand: option 6 for Windows at-rest parity, and option 5
    only if users ask for keychain-by-default. GCM's store code is the
    reference when that day comes.

Questions and answers

Q. Writing to the stores, and how the token command hook works

Read GitHub issue five using the gh tool. Look at point option number two,
which I'll paste below. The reference looks good from a
reading-the-variable or reading-the-value point of view. I like that it uses
the macOS keychain security find-generic-password, Linux's libsecret, or
the PowerShell secret management. Great for reading.

Is there an idiomatic way of writing that file so that, instead of pasting
the password, which is starred in the console, we write to each of those
three password management tools:

  • the macOS keychain
  • Linux libsecret
  • PowerShell secret management?

Also how does the token command hook actually work? Let's say I've got
LastPass installed. How does the command actually deal with these tools? Do
these tools, when installed, notice something's happened and then pop up a
dialogue or something?

A1. Writing to each store

Yes, all three have an idiomatic write, and all three have a form where the
secret never appears in argv or shell history. That matters: the read side is
safe by construction, the write side is where people leak the token.

macOS Keychain

security add-generic-password -a "$USER" -s bbx-api-token -U -w
# prompts "password data for new item:" twice, no echo

-U updates an existing item instead of failing. Read back with
security find-generic-password -s bbx-api-token -w.

Do not write -w "$TOKEN". Anything after -w is in argv and visible to
ps for every user on the box. For a scripted write, feed it through
security -i, which reads commands from stdin:

printf 'add-generic-password -a %s -s bbx-api-token -U -w %s\n' "$USER" "$TOKEN" | security -i

Linux libsecret

secret-tool store --label='bbx API token' service bbx account john@solrevdev.com
# prompts "Password:" with no echo

secret-tool store reads the secret from stdin by design and has no flag to
pass it inline, so it is the best behaved of the three. Scripted:

printf '%s' "$TOKEN" | secret-tool store --label='bbx API token' service bbx account john@solrevdev.com

Use printf '%s', not echo, or you store a trailing newline. Read back with
secret-tool lookup service bbx account john@solrevdev.com.

PowerShell SecretManagement

Install-Module Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser
Register-SecretVault -Name BbxVault -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
Set-Secret -Name bbx-api-token -Secret (Read-Host 'API token' -AsSecureString)

Read-Host -AsSecureString is the no-echo prompt. Read back with
Get-Secret -Name bbx-api-token -AsPlainText.

One wrinkle worth documenting: Microsoft.PowerShell.SecretStore locks itself
behind its own password by default and will prompt on every read. For
unattended use:

Set-SecretStoreConfiguration -Authentication None -Interaction None

That drops it to a file encrypted with a per-user key, which is roughly
DPAPI-grade, the same place option 6 lands.

Should bbx auth login do the writing?

No. That is option 4 wearing a different hat, and it buys one command's worth
of convenience for a permanent per-OS maintenance cost. Keep the write manual
and documented, three recipes, one per OS. Migration for an existing user is a
pipe, since bbx auth token already prints to stdout:

bbx auth token | cut -d: -f2- | tr -d '\n' | secret-tool store --label='bbx' service bbx account john@solrevdev.com

A2. How the token_command hook actually works

It is dumb and pull-based, which is the point. Nothing registers, nothing
subscribes, no password manager is aware bbx exists. On each invocation bbx
forks the command, reads stdout, trims the trailing newline, and uses that
string as the token. Non-zero exit means fail with the command's stderr
attached.

Nothing pops up because of bbx. Any dialogue you see comes from the helper CLI,
and only because that CLI decided to ask. Who asks, and when:

Helper Prompts when What you see
security find-generic-password -w Item's ACL does not list the caller GUI Allow / Always Allow sheet
secret-tool lookup Login keyring locked, or no D-Bus gnome-keyring unlock dialogue, or hard failure
lpass show --password bbx Agent session expired (LPASS_AGENT_TIMEOUT, 1h default) Master password on the terminal, or a pinentry dialogue if configured
op read op://Private/bbx/credential First use per session Touch ID sheet from the 1Password desktop app

So for the LastPass case: you run lpass login john@solrevdev.com once,
lpass-agent holds the decrypted session in memory for an hour, and inside
that hour lpass show --password bbx-api-token returns instantly with no
prompt at all. After it expires the next bbx command triggers a master password
prompt.

Three implementation consequences:

  1. Stdin and stderr must be inherited, not captured. Capture stdout only.
    If bbx swallows stdin the helper's prompt has nowhere to read from and you
    get a hang instead of a password field. This is the single detail that
    decides whether the feature feels good or broken.
  2. Set a timeout, and make it generous. security returns in
    milliseconds; a Touch ID sheet or a typed master password can take 30
    seconds. 120s with a clear timeout message is about right. Anything shorter
    will bite op and lpass users.
  3. Do not cache the result. bbx is a one-shot process, so the hook runs on
    every invocation. Caching the token to disk would rebuild the plaintext file
    the option exists to remove. Helpers with agents already solve this
    themselves, and that is their job, not ours.

One bonus that argues for the hook over options 4 and 5 on macOS specifically.
With token_command, the process touching the Keychain is security, a stable
Apple-signed binary that put the item there in the first place, so it is
already on the item's ACL and never prompts. With a native Keychain call from a
.NET global tool, the caller is the shared dotnet host, whose identity shifts
under SDK updates, so users get re-prompted for no visible reason. Option 5
notes this; it is a stronger point than it reads there.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions