CLI credential storage

Audience: Operators, security reviewers, and CI authors who manage stella CLI credentials on workstations, servers, and pipelines. Scope: How the CLI encrypts and persists tokens and secrets per OS, the --no-save posture, 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

OSBackendOn-disk artefact
WindowsDPAPI (ProtectedData.Protect)~/.stellaops/profiles.<name>.secrets.bin (ciphertext)
Linuxlibsecret (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)
macOSKeychain (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

Linux — libsecret

Linux — machine-id fallback (degraded)

macOS — Keychain

--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:

Migration

On first read after upgrade, CliProfileManager.LoadStoreAsync runs the migration helper:

  1. Parse ~/.stellaops/profiles.json as a generic JSON tree.
  2. For each profile, scan top-level keys and a nested settings object for any of the legacy token-shaped names: accessToken, refreshToken, dpopPrivateKeyPem, tenantBearerTokens.
  3. If any are found, build a CliProfileSecrets and write it through the active ISecretProtector. Verify the round-trip succeeds before touching the cleartext file.
  4. Once verified, atomically rewrite the cleartext file with the token-shaped fields removed (write to profiles.json.tmp, then File.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