CLI credential storage
Audience: Operators, security reviewers, and CI authors who manage
stellaCLI credentials on workstations, servers, and pipelines. Scope: How the CLI encrypts and persists tokens and secrets per OS, the--no-saveposture, the migration off legacy plaintext storage, and an operator checklist.
Sprint: SPRINT_20260501_004_Cli_token_encryption Module: CLI (src/Cli/) Audit finding closed: B4 (Pass-2 audit, docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md §B4)
This page documents how the Stella Ops CLI persists access tokens, refresh tokens, and any other secret material for a profile. It replaces the legacy plaintext ~/.stellaops/profiles.json storage.
TL;DR
| OS | Backend | On-disk artefact |
|---|---|---|
| Windows | DPAPI (ProtectedData.Protect) | ~/.stellaops/profiles.<name>.secrets.bin (ciphertext) |
| Linux | libsecret (Secret Service API) | OS keystore owns ciphertext; sibling .secrets.bin is a tombstone marker |
| Linux (no libsecret) | /etc/machine-id HKDF + AES-GCM-256 fallback | ~/.stellaops/profiles.<name>.secrets.bin (AES-GCM ciphertext, nonce-prefixed) |
| macOS | Keychain (SecItemAdd / SecItemCopyMatching) | OS keystore owns ciphertext |
The cleartext file at ~/.stellaops/profiles.json continues to hold operational metadata only: profile name, backend URL, authority URL, client ID, scopes, telemetry opt-in. It must never contain a token-shaped field. CliProfileManager defensively scrubs any token-shaped key (accessToken, refreshToken, dpopPrivateKeyPem, tenantBearerTokens) that ends up in the cleartext store on save.
Architecture
CliProfileManager
├── reads/writes ~/.stellaops/profiles.json (cleartext metadata)
└── reads/writes ISecretProtector (encrypted secrets)
│
├── Windows → WindowsDpapiSecretProtector (file: profiles.<name>.secrets.bin)
├── Linux → LinuxLibsecretSecretProtector (OS keystore via dbus)
│ └── fallback: MachineIdFallbackSecretProtector
│ (file: profiles.<name>.secrets.bin, AES-GCM-256)
└── macOS → MacOsKeychainSecretProtector (OS keystore via Security.framework)
All AEAD operations route through IAeadAlgorithm from src/__Libraries/StellaOps.Cryptography/IAeadAlgorithm.cs (architecture conformance — see AeadBclCallSiteConformanceTests). HKDF-SHA-256 in the machine-id fallback routes through IHmacAlgorithm.
The label format is stellaops:profile:<name>:secrets. Profiles with different names produce independent ciphertexts even when the underlying master key is the same (DPAPI entropy = SHA-256(label); machine-id fallback uses the label as AEAD associated-data).
Behaviour by OS
Windows — DPAPI
- Per-user encryption (
DataProtectionScope.CurrentUser). The ciphertext is bound to the current user’s Windows SID. - Limitation: if the user runs
stellaas Administrator (different SID), the ciphertext does not decrypt. Match the user-context between invocations. - The ciphertext blob is written to
~/.stellaops/profiles.<name>.secrets.binalongside the cleartextprofiles.json.
Linux — libsecret
- Uses the user’s default GNOME / KDE / freedesktop keyring via the Secret Service API. Items stored with attribute
application=stellaops-cli,label=<full-label>. - Requires libsecret-1 (
libsecret-1.so.0) and a running dbus session. - On headless servers (SSH, no GUI) libsecret is usually unavailable. Either install
gnome-keyringand start a session, or usestella login --no-save(CI), or accept the machine-id fallback (degraded — see below).
Linux — machine-id fallback (degraded)
- When libsecret cannot be loaded, the protector falls back to deriving an AES-256 key from
/etc/machine-id+ the user’s UID via HKDF-SHA-256 (RFC 5869), routed throughIHmacAlgorithm. - The actual encryption is
IAeadAlgorithm.AesGcm256. Output layout:nonce(12) || ciphertext || tag(16). The label is bound as AEAD associated-data. - This is a degraded mode.
/etc/machine-idis world-readable on most distros, so an attacker who already has read access to the user’s home directory can derive the same key. Treat this fallback as raising the difficulty floor, not as a substitute for a real OS keystore. - The CLI logs a warning every time it uses the fallback.
- To forbid the fallback (recommended for CI), set
STELLAOPS_REQUIRE_OS_SECRET_STORE=truein the environment. The factory then throwsPlatformNotSupportedExceptionrather than silently degrading.
macOS — Keychain
- Items are stored under
kSecClassGenericPasswordwithkSecAttrService = "stellaops-cli"andkSecAttrAccount = <full-label>. - Limitation: Keychain may prompt the user for permission on first read. For non-interactive flows, use
stella login --no-save.
--no-save mode
stella login --no-save keeps tokens in the CLI process memory only. No secret file is written, no OS keystore item is created. The token is discarded when the CLI exits.
When STELLAOPS_LOGIN_NO_SAVE=1 (or true/yes) is set in the environment, --no-save is the default. This is the recommended posture for:
- CI pipeline runs (
Workflow → stella ...). - Kiosk / shared-host invocations where the user must not leave a persistent secret behind.
- Hardened headless containers without libsecret.
Migration
On first read after upgrade, CliProfileManager.LoadStoreAsync runs the migration helper:
- Parse
~/.stellaops/profiles.jsonas a generic JSON tree. - For each profile, scan top-level keys and a nested
settingsobject for any of the legacy token-shaped names:accessToken,refreshToken,dpopPrivateKeyPem,tenantBearerTokens. - If any are found, build a
CliProfileSecretsand write it through the activeISecretProtector. Verify the round-trip succeeds before touching the cleartext file. - Once verified, atomically rewrite the cleartext file with the token-shaped fields removed (write to
profiles.json.tmp, thenFile.Move(..., overwrite: true)).
The migration is idempotent. A crash between the protector write and the cleartext rewrite leaves the legacy file untouched, so the next CLI run completes the migration cleanly. The protector cache in memory is process-scoped, so successive runs reach the same on-disk state.
A frozen fixture lives at src/Cli/__Tests/StellaOps.Cli.Tests/Fixtures/LegacyProfiles/profiles.json; CliProfileMigrationTests exercises the full happy-path, crash-mid-migration, and idempotency scenarios.
Operator checklist
- [ ] Upgrade the CLI binary on each host.
- [ ] Run any
stellacommand that loads a profile (e.g.,stella auth status). The migration runs automatically. - [ ] Confirm
~/.stellaops/profiles.jsonno longer contains a string that looks like a JWT or refresh-token. (grep -E 'eyJ|^rt-' ~/.stellaops/profiles.json) - [ ] On Linux without libsecret, decide whether to install
gnome-keyring+ dbus or setSTELLAOPS_REQUIRE_OS_SECRET_STORE=trueand use--no-savefor CI flows.
Related
- Implementation source:
src/Cli/StellaOps.Cli/Configuration/Secrets/ - Tests:
src/Cli/__Tests/StellaOps.Cli.Tests/Configuration/Secrets/ - AEAD primitive:
src/__Libraries/StellaOps.Cryptography/IAeadAlgorithm.cs - Audit finding:
docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md§B4 - Release notes:
docs/releases/release-notes-cli-credential-storage.md
