Credential Hardening Phase 3 β OAuth Display-Once Closeout & SSH-Key Keychain Storage¶
- Authors
- Matt Cockayne
- Date
- 21 June 2026
- Status
- DRAFT
Overview¶
The credential-storage hardening work (2026-04-02-credential-storage-hardening.md, audit finding H-1) shipped Phases 1 and 2. Phase 3 was explicitly deferred in docs/development/security-decisions.md (Β§ H-1, "Deferred to Phase 3"). This specification fulfils that prior intent β it does not contradict any standing decision. The bundled "secrets-provider registry" remains rejected (feature-decisions, 31 March 2026); everything here stays inside the existing credentials.Backend model and adds no new provider-injection seam.
The deferral list named five items:
config migrate-credentialscommand, GitHub OAuth+display-once flow, BDD coverage, migration guide, optional SSH-key keychain storage.
Investigation of the current tree (21 June 2026) shows four of the five already landed after the original spec was written β the spec was even marked IMPLEMENTED, and the security-decisions deferral note is now stale. This spec's job is therefore narrow:
- Reconcile the record: confirm and document that items (a)β(d) below already exist, so the deferral note can be cleared.
- Implement the one genuinely outstanding item: optional OS-keychain storage for the SSH private-key passphrase during
init github.
Already-shipped Phase 3 items (verified β EXCLUDED from new implementation)¶
| Deferred item | Status | Evidence in tree |
|---|---|---|
config migrate-credentials command |
DONE | pkg/cmd/config/migrate.go, migrate_scan.go, migrate_test.go, migrate_coverage_test.go. Supports --dry-run, --target env\|keychain, --yes, --env-var, --skip-verify, --keychain-service; atomic temp+rename rewrite; registered under NewCmdConfig. |
| GitHub OAuth device flow | DONE | pkg/vcs/github/login.go (GHLogin β github.com/cli/oauth device flow, browser routed through pkg/browser.OpenURL). |
| GitHub display-once token flow | DONE | pkg/setup/github/github.go: GitHubAuthConfig.StorageMode three-mode selector, runEnvVarAuth, defaultDisplayOnceForm (note + acknowledge confirm, token never written to disk in env-var mode), captureToken, promptManualGitHubToken headless fallback, writeGitHubCredential (github.auth.env/.keychain/.value). Tested in github_auth_test.go. |
| BDD coverage + migration guide | DONE (per the 2026-04-02 spec's "IMPLEMENTED" close-out) | Gherkin in features/, docs/migration/v1.12-credential-storage.md. |
Because GitHub OAuth + display-once is already complete, item C2(a) requires no code β only a verification pass and a one-line correction to security-decisions.md. The remainder of this spec addresses item C2(b): optional SSH-key keychain storage, the single outstanding gap.
Problem (SSH-key passphrase storage)¶
pkg/setup/github/ssh.go discovers SSH keys by scanning ~/.ssh (a directory listing β accepted in security-decisions L-4), lets the user select an existing key, point at a path, defer to ssh-agent, or generate a new passphrase-protected Ed25519 key (generateKey β keygen.WithPassphrase). The config records only:
The passphrase is collected, used once to encrypt the key on disk, then discarded (generateKey's local passphrase string falls out of scope). There is no GTB-managed place to keep it. In practice the user must either:
- re-type the passphrase on every git/SSH operation, or
- add the key to
ssh-agentmanually, or - store the passphrase in their own OS keychain by hand (macOS
ssh-add --apple-use-keychain, etc.).
GTB already owns a perfectly good OS-keychain abstraction (credentials.Backend, activated by the opt-in pkg/credentials/keychain blank import). The Phase 3 gap is simply: offer to stash a freshly-generated key's passphrase in that keychain, so the user gets the convenience of an encrypted key without a manual keychain dance, and the passphrase still never lands in the plaintext config.
Threat model addendum¶
| Threat | Vector | Impact | This spec |
|---|---|---|---|
| SSH passphrase typed repeatedly / cached insecurely | User stores passphrase in a note, shell history, or weakens it to avoid friction | Private-key compromise β repo/org access | Mitigated: passphrase held in OS keychain, never on disk, never in config |
| SSH passphrase written to config | A naΓ―ve future change could persist it as plaintext | Same as H-1 for tokens | Prevented: SSH passphrase has no literal config mode β keychain or nothing |
| Keychain unavailable / opted-out | Headless Linux without Secret Service, regulated build with no keychain import | Feature simply absent | Graceful: option hidden when Probe fails; passphrase discarded as today |
Goals¶
- Offer, only when a keychain backend is registered and a live
Probesucceeds, to store the passphrase of a newly generated SSH key in the OS keychain. - Record a keychain reference (not the passphrase) in config so the passphrase can be retrieved later without re-prompting.
- Confirm SSH-key passphrases fit the existing
credentials.Backendstring-secret contract (they do βStore(ctx, service, account, secret string)). - Add a
Resolveβ¦helper so consumers (git transport,ssh-agentloading) can fetch the passphrase through the same three-source discipline used elsewhere. - Keep the default (no-keychain) build and behaviour byte-for-byte unchanged.
- Clear the stale Phase 3 deferral note in
security-decisions.md.
Non-Goals¶
- No literal-mode passphrase storage. Unlike API tokens, an SSH passphrase has only two outcomes here: stored in the keychain, or not stored at all (status quo). There is no
github.ssh.key.passphraseliteral config key, ever. - No env-var mode for the passphrase. An env-var holding a long-lived SSH passphrase is worse than the keychain and barely better than literal; out of scope.
- No storage of the private-key bytes themselves in the keychain. The key stays an encrypted file on disk at
github.ssh.key.path; only its passphrase is offered to the keychain. (Discussed and rejected β see Design Decisions.) - No changes to the OAuth/display-once flow,
config migrate-credentials, or the AI/Bitbucket wizards. - No new
Backendmethod. SSH passphrases use the existingStore/Retrieve/Delete. - No ssh-agent re-implementation. Loading the decrypted key into an agent is a follow-up consideration, not this spec.
Design Decisions¶
D1 β Store the passphrase, not the key bytes. The Backend contract is a string secret keyed by service/account. An SSH passphrase is a short string β a natural fit. The private key is already an encrypted file with 0600 perms at a path the config records; duplicating its bytes into the keychain doubles the secret's footprint and the blast radius without benefit. Storing only the passphrase means the on-disk artefact remains the canonical key and the keychain holds the one piece that unlocks it.
D2 β Offer keychain storage only for generated keys. When the user selects a pre-existing key or ssh-agent, GTB neither knows nor should ask for the passphrase β prompting for an existing key's passphrase just to cache it is surprising and risky. The offer appears only on the generate branch (handleSSHKeySelection β generateKey), where the wizard already holds the passphrase it just used to encrypt the key.
D3 β Reuse the existing keychain gate. The offer is shown only when credentials.Probe(ctx) returns true (backend registered and a live canary round-trip succeeds), exactly as the AI/GitHub-token wizards gate ModeKeychain. No new availability logic.
D4 β Keychain account naming follows the resolved Phase 2 convention. Service = tool name, account = a stable SSH-scoped identifier. Per Resolved Decision #1 of the parent spec (<toolname>/<key-path>, no gtb/ prefix), the account is github.ssh.passphrase. The config records github.ssh.key.keychain: "<toolname>/github.ssh.passphrase". A single account is sufficient because the wizard manages one GTB-generated key per tool; regenerating overwrites (Store overwrites by contract).
D5 β No literal/env fallback for the passphrase. Resolution is keychain-only (see Public API). If the keychain entry is missing or unreachable, the resolver returns ("", ErrCredentialNotFound)/wrapped error and the caller falls back to interactive prompt or ssh-agent β i.e. today's behaviour. This deliberately departs from the token three-source cascade because the other two sources are explicit non-goals here.
D6 β Best-effort passphrase zeroing, documented limits. Consistent with security-decisions M-4: Go string immutability makes reliable scrubbing impractical. The wizard clears its last reference after Store; we make no stronger claim.
D7 β Stale deferral reconciliation. The 2026-04-02 spec is IMPLEMENTED but security-decisions.md Β§ H-1 still lists Phase 3 as deferred and its status line reads "Remediated β Phases 1 and 2 of 3". On completion of this spec, that entry becomes "Remediated β Phase 3 complete" and the deferral bullet is removed.
Public API¶
New file: pkg/setup/github/ssh_keychain.go¶
A small helper, in the same package as the existing SSH flow, so it can reach the wizard's forms and the package-private generateKey plumbing without widening any public surface.
package github
// sshPassphraseAccount is the keychain account under which a
// GTB-generated SSH key's passphrase is stored. Combined with the
// tool name as the service, per Resolved Decision #1 of the parent
// hardening spec (no gtb/ prefix).
const sshPassphraseAccount = "github.ssh.passphrase"
// offerSSHPassphraseKeychain asks the user β only when a keychain
// backend is registered and a live Probe succeeds β whether to store
// the just-generated key's passphrase in the OS keychain. On accept it
// stores the secret and records a keychain reference in config under
// "github.ssh.key.keychain". On decline (or when keychain is
// unavailable) it is a no-op and the passphrase is discarded as today.
//
// The passphrase is never written to config in any mode. Errors from
// the keychain Store are surfaced with a hint but are non-fatal: the
// user keeps a working (passphrase-protected) key either way.
func offerSSHPassphraseKeychain(
ctx context.Context,
p *props.Props,
cfg config.Containable,
toolName, passphrase string,
confirmForm func(*bool) *huh.Form, // injectable for tests; nil β default
) error
New: exported resolver in pkg/setup/github (or pkg/vcs/github, TBD β see Open Questions)¶
// ResolveSSHPassphrase returns the passphrase for a GTB-managed SSH key
// from the OS keychain, using the reference recorded at
// "github.ssh.key.keychain". Resolution is keychain-only by design (no
// literal or env-var fallback for SSH passphrases β see spec Non-Goals):
//
// * No keychain reference in config β ("", nil) [not configured]
// * Reference present, entry missing/unreachable β ("", err) wrapping
// credentials.ErrCredentialNotFound / the backend error
//
// Callers that get ("", nil) or an error should fall back to an
// interactive passphrase prompt or ssh-agent, exactly as before this
// feature existed.
func ResolveSSHPassphrase(ctx context.Context, cfg config.Containable) (string, error)
Modified: pkg/setup/github/github.go / ssh.go¶
generateKey(inssh.go) gains a call toofferSSHPassphraseKeychainafter the key is written and before returning, threading thepassphraseit already holds. The passphrase local is zeroed (best-effort) after the offer.IsConfigured/hasAnyGitHubCredentialare unchanged β SSH-key presence is already tracked viagithub.ssh.key.path/type; the keychain reference is supplementary.- A new config key
github.ssh.key.keychainis written only when the user opts in.
No change¶
credentials.Backend,Store/Retrieve/Delete,Probe,Modetaxonomy β all reused as-is.pkg/vcs/auth.gotoken resolution β untouched (SSH passphrase is a separate concern from VCS tokens).- AI / Bitbucket wizards,
config migrate-credentialsβ untouched.
Internal Implementation¶
Generated-key flow (the only changed path)¶
handleSSHKeySelection(targetKey == "generate")
ββ generateKey(props, cfg)
1. prompt passphrase (existing, min 12 chars)
2. generateAndSaveSSHKey β encrypted key file @ keypath (0600), pub @ 0644 (existing)
3. prompt "Upload key to GitHub?" + optional upload (existing)
4. NEW: offerSSHPassphraseKeychain(ctx, props, cfg, props.Tool.Name, passphrase, nil)
ββ if !credentials.Probe(ctx) β return nil (option not shown)
ββ confirm form: "Store this key's passphrase in your OS keychain
β so you aren't prompted each time?" (default: Yes)
ββ if declined β return nil (passphrase discarded)
ββ credentials.Store(ctx, toolName, "github.ssh.passphrase", passphrase)
β ββ on error: log.Warn + WithHint, return nil (non-fatal)
ββ cfg.Set("github.ssh.key.keychain", toolName+"/github.ssh.passphrase")
5. NEW: passphrase = "" (best-effort zero, per M-4)
The ctx carries credentials.KeychainOpTimeout (5 s) so a locked or slow keychain cannot stall init.
Resolution¶
func ResolveSSHPassphrase(ctx context.Context, cfg config.Containable) (string, error) {
ref := strings.TrimSpace(cfg.GetString("github.ssh.key.keychain"))
if ref == "" {
return "", nil // not configured for keychain β caller prompts / uses agent
}
service, account, ok := strings.Cut(ref, "/")
if !ok || service == "" || account == "" {
return "", errors.Newf("malformed keychain reference %q", ref)
}
secret, err := credentials.Retrieve(ctx, service, account)
if err != nil {
// Wrapped β never embeds the secret. ErrCredentialNotFound flows
// through so callers can errors.Is and fall back cleanly.
return "", errors.Wrap(err, "retrieve SSH passphrase from keychain")
}
return secret, nil
}
Config output¶
Opted in (keychain available):
github:
ssh:
key:
path: /home/user/.ssh/id_mytool_20260621120000
type: file
keychain: "mytool/github.ssh.passphrase" # reference only; passphrase in OS keychain
Declined / keychain unavailable (status quo):
Error Handling¶
| Scenario | Behaviour |
|---|---|
| Keychain backend not registered (default build) | Probe false β offer never shown; passphrase discarded as today. No error. |
Keychain registered but Probe fails (locked / headless / no Secret Service) |
Offer never shown; no error. |
| User declines the offer | No-op; passphrase discarded; no keychain key written. |
Store fails after user opted in |
log.Warn + WithHint ("Keychain storage failed; your key still works β you'll be prompted for the passphrase, or add it to ssh-agent."); non-fatal, no keychain key written. |
github.ssh.key.keychain present but entry missing at resolve time |
ResolveSSHPassphrase returns wrapped ErrCredentialNotFound; caller falls back to prompt/agent. |
Malformed keychain reference |
ResolveSSHPassphrase returns descriptive error (no secret content). |
All errors use cockroachdb/errors with WithHint; no error message ever embeds the passphrase (R2-equivalent).
Testing Strategy¶
Unit tests (pkg/setup/github)¶
| Area | Test |
|---|---|
offerSSHPassphraseKeychain |
Probe-false β no-op, no config key, no Store call (fake backend asserts zero calls). |
offerSSHPassphraseKeychain |
Probe-true + confirm Yes β Store called with (toolName, "github.ssh.passphrase", passphrase); config gains github.ssh.key.keychain. |
offerSSHPassphraseKeychain |
Probe-true + confirm No β no Store, no config key. |
offerSSHPassphraseKeychain |
Store error β non-fatal, warns, no config key written. |
ResolveSSHPassphrase |
No ref β ("", nil). |
ResolveSSHPassphrase |
Valid ref + present entry β returns secret. |
ResolveSSHPassphrase |
Valid ref + missing entry β wrapped ErrCredentialNotFound (assert errors.Is). |
ResolveSSHPassphrase |
Malformed ref β error, no panic, no secret in message. |
generateKey |
End-to-end with injected forms + fake backend: key written, passphrase offered, config shape correct. Existing generateKey tests must still pass unchanged when keychain is absent. |
Use the in-memory fake from pkg/credentials/credtest (registered via RegisterBackend in the test, reset in cleanup) β mirrors the existing keychain-mode wizard tests. t.Parallel() where backend registration allows; serialise the registration-sensitive cases.
Security-specific tests¶
| Test | Purpose |
|---|---|
TestSSHPassphraseNeverInConfig |
After every generate path (opt-in and decline), assert the config contains no key whose value equals the passphrase; only github.ssh.key.keychain (a reference) may appear. |
TestSSHPassphraseNotLogged |
Run generate with a recognisable passphrase under a capturing DEBUG logger; assert the passphrase string appears in no log entry at any level. |
TestResolveSSHPassphraseErrorRedaction |
Resolver errors never contain the secret. |
Integration tests¶
| Tag | Scope |
|---|---|
INT_TEST_CREDENTIALS |
Real keychain round-trip for the SSH passphrase on macOS / Linux-with-Secret-Service; skipped when DBUS_SESSION_BUS_ADDRESS unset (same gate as existing keychain integration tests). |
E2E / BDD¶
Extend features/ (e.g. features/cli/credentials.feature or a new ssh.feature) with a scenario: generating a key with keychain available stores a reference and not the passphrase; with keychain unavailable, the config omits the keychain key. Gated by INT_TEST_E2E_CLI.
Quality gates¶
pkg/setup/githubkeeps its current coverage policy (β₯ 90% for new code inssh_keychain.go).- Race detector clean:
go test -race ./pkg/setup/github/... ./pkg/credentials/.... - Both the default build and a build that blank-imports
pkg/credentials/keychaincompile and pass. - golangci-lint: no new findings, no
//nolintbeyond the package norms.
Project Structure¶
New files¶
| File | Purpose |
|---|---|
pkg/setup/github/ssh_keychain.go |
offerSSHPassphraseKeychain, ResolveSSHPassphrase, sshPassphraseAccount. |
pkg/setup/github/ssh_keychain_test.go |
Unit + security tests above. |
Modified files¶
| File | Change |
|---|---|
pkg/setup/github/ssh.go |
generateKey calls offerSSHPassphraseKeychain; zero passphrase after. |
docs/development/security-decisions.md |
Β§ H-1: status β "Remediated β Phase 3 complete"; remove the deferral bullet; note SSH-passphrase keychain storage. |
docs/development/specs/2026-04-02-credential-storage-hardening.md |
Cross-reference this closeout spec from its Phase 3 section. |
docs/components/credentials.md / docs/components/setup.md |
Document SSH-passphrase keychain storage + github.ssh.key.keychain. |
docs/how-to/configure-credentials.md |
Add SSH-passphrase keychain section. |
CLAUDE.md |
One line under Credential Storage: SSH passphrases for generated keys may be stored in the keychain (reference recorded at github.ssh.key.keychain); never literal, never env-var. |
Generator impact¶
None structural. Scaffolded tools that enable the GitHub init feature and blank-import pkg/credentials/keychain inherit the offer automatically β no template change. Tools that omit the keychain import simply never see it (Probe false).
Migration & Compatibility¶
- Backward compatible. No existing config key changes meaning.
github.ssh.key.keychainis new and additive; absence = today's behaviour. - No migration required. Existing generated keys keep working; users who want the convenience can regenerate or set the keychain entry manually (documented in the how-to).
- API stability.
ResolveSSHPassphraseand thegithub.ssh.key.keychainkey are new (Beta tier).offerSSHPassphraseKeychainis package-private. No existing signature changes. Consistent with the pre-1.0 posture.
Security Invariants¶
- The SSH passphrase is never written to the config file in any mode (no literal, no env-var) β only a keychain reference.
- The passphrase never appears in log output at any level.
- Keychain
Store/Retrieveerrors never embed the passphrase. - The offer appears only when a keychain backend is registered and a live
Probesucceeds β never a dead option. - A
Storefailure is non-fatal: the user retains a working passphrase-protected key. ~/.sshdiscovery remains a directory listing only (unchanged from L-4); file contents are read only for the user-selected key (unchanged).- Best-effort passphrase zeroing after use, with M-4's documented limits.
Open Questions¶
- Resolver package placement.
ResolveSSHPassphraseis proposed inpkg/setup/github. If a runtime git-transport consumer needs it without importing the setup wizard, it may belong inpkg/vcs/githubor a neutralpkg/credentialshelper instead. Confirm the intended consumer before fixing the location. (Recommend resolving before implementation.) - Default of the confirm form. Proposed default is Yes (store in keychain), matching the parent spec's "first-time users want the convenience" rationale for OAuth. Confirm this is the desired nudge, or whether SSH passphrases warrant a No default (more conservative).
- Existing/selected keys. This spec deliberately offers keychain storage only for generated keys (D2). Should there be an explicit, separate opt-in to cache an existing key's passphrase (prompting for it)? Currently out of scope β confirm that is acceptable.
- ssh-agent loading. Should a follow-up load the decrypted key into
ssh-agentafter retrieval, or is keychain-retrieval-then-prompt sufficient? Out of scope here; flag for a future spec. config migrate-credentialsawareness. The migrate command operates on token/API literals. Should it learn to surface "SSH passphrase not yet in keychain" as an advisory? Likely a separate, optional enhancement β confirm it is out of scope.
Resolutions (open questions confirmed with user 2026-06-21)¶
- Resolver placement β RESOLVED: put
ResolveSSHPassphrasein a neutralpkg/credentialshelper (transport- and wizard-agnostic), notpkg/setup/github. The generic credentials package owns the SSH-passphrase resolution so both the setup wizard and any future runtime git-transport consumer use the same path. (Departs from the draft's recommendation.) - Confirm-form default β RESOLVED: default No (conservative). SSH passphrases protect long-lived private keys, so storage is opt-in β the user actively chooses Yes. Does not inherit the OAuth convenience nudge.
- Existing/selected keys β RESOLVED: out of scope. Keychain storage is offered only on the generate branch (D2), where the passphrase is already in hand. Caching an existing key's passphrase is a later, separate opt-in.
- ssh-agent loading (Q4) and
migrate-credentialsSSH advisories (Q5) β RESOLVED: both out of scope, flagged as future follow-ups; this spec stays tight.
Reminder for the approval decision: 4 of the 5 original H-1 Phase-3 items already
shipped (OAuth device flow, display-once, config migrate-credentials, BDD), so
the implementation scope of this spec is just the SSH-passphrase keychain
helper plus clearing the now-stale Phase-3 deferral note in
docs/development/security-decisions.md.
Decision-Log Alignment¶
- H-1 / security-decisions.md: Phase 3 was explicitly Deferred; this spec fulfils that intent. On completion the deferral note is cleared and the H-1 status advances to "Phase 3 complete". No standing decision is contradicted.
- Rejected secrets-provider registry (feature-decisions, 31 Mar 2026): honoured. This spec adds no provider-injection seam, no new interface, no Vault/SSM/registry abstraction β it reuses the existing
credentials.BackendStore/Retrieve/Deleteexactly as Phase 2 established. - L-4 (~/.ssh scanning): unchanged; this spec does not broaden discovery.
- M-4 (string zeroing limits): explicitly acknowledged for the passphrase local.
- Pre-1.0 API posture (project memory): new keys/helpers ship as additive minor changes; no breaking change.