login: XDG config paths and non-clobbering multi-identity key files - #612
Open
sini wants to merge 5 commits into
Open
login: XDG config paths and non-clobbering multi-identity key files#612sini wants to merge 5 commits into
sini wants to merge 5 commits into
Conversation
sini
force-pushed
the
feat/storage-layout
branch
3 times, most recently
from
August 21, 2026 02:06
1bdb72c to
4bd8391
Compare
sini
force-pushed
the
feat/storage-layout
branch
from
September 2, 2026 19:32
a3dd481 to
1f6634c
Compare
Two storage-layout changes, predicates for upcoming refresh-daemon work: Client config resolution now follows the XDG Base Directory spec: the first existing of $XDG_CONFIG_HOME/opk/config.yml (when set and absolute; ~/.config/opk/config.yml otherwise, %AppData%\opk\config.yml on Windows) and the legacy ~/.opk/config.yml. An existing legacy config keeps working untouched and always wins when it is the only one; fresh --create-config writes land at the XDG location with 0700/0600 (the config can carry client_secret values). Login logs which config file is in use at -v. The old --config-path help text claiming %APPDATA%\.opk on Windows was wrong (the code always used ~/.opk everywhere) and is fixed. Key files no longer clobber across identities: the default ~/.ssh slot rule compares the (iss, aud, sub) identity parsed from the existing certificate's embedded PK token, failing closed, instead of trusting the "openpubkey" comment alone — a certificate whose PK token no longer parses but whose comment is exactly "openpubkey" remains reusable, so every legacy file behaves exactly as before. When all default slots belong to other identities, keys are written to the opkssh identity directory (~/.ssh/opkssh, created on demand with its IdentityFile fragment — inert without the --configure Include) named <issuer>-<client_id> or, when a different account at the same provider collides, suffixed with an 8-hex sha256(iss|aud|sub) tag; a foreign occupant at the tagged path is an error, never an overwrite. A warning naming the written path fires only when the fallback was used and no ssh-agent ended up holding the key. Intentional behavior changes, called out explicitly: a login whose default slots are all taken now succeeds into the identity directory instead of failing with "no default ssh key file free"; a parseable certificate belonging to a different identity is never overwritten (previously any opkssh cert could clobber any other); a missing opkssh directory or config fragment is created on demand rather than erroring. Single-identity users keep byte-identical behavior throughout.
Post-review cleanup of the storage-layout change; behavior-preserving: - One slot classifier (classifySlot) now answers "who holds this key-file slot" for every tier. The default tier and the opkssh identity directory keep their distinct write policies as explicit mappings over the classified states; previously two similar loops re-implemented the same pipeline and their one behavioral divergence (an absent private key makes a default slot writable even over an orphaned certificate, while the identity directory fails closed) was undocumented. - keyIdentity now carries its own validity, and equality is only reachable through sameAs, which requires both sides valid — the fail-closed rule is enforced by construction instead of at each comparison site. The identity is extracted once per login and passed down. - The IdentityFile fragment format is one contract shared by login (writer) and logout (remover) via identityFileLine/fragmentLines; the two inline copies could drift, and the writer's matcher did not tolerate CRLF while the remover did. Re-logins no longer rewrite an unchanged fragment. - ResolveClientConfigPath reports whether a config was found, removing the caller's redundant re-stat; the --config-path help text is a shared constant across both commands that carry the flag; candidate enumeration is unexported to keep the module's public API flat. - The fallback landing is returned through the write-path call chain instead of being smuggled through a LoginCmd field. - Tests share fixtures (opkDirFixture, startTestAgent, mustReadFile) and gain found-flag expectations; 13 docs/cli files whose only change was the generated date stamp are dropped from the change.
OpenSSH mis-parses an unquoted ssh_config path with a space (common with Windows home directories). identityFileLine now double-quotes a path containing blanks, per ssh_config token rules; since login's writer and logout's remover both render lines through this one function, matching and removal stay consistent by construction. Paths without blanks stay unquoted so fragment entries written by earlier versions keep matching exactly.
t.Setenv(HOME) does not drive os.UserHomeDir on Windows (it reads USERPROFILE), so the round-trip's blank-bearing home premise cannot hold there; the identityFileLine rendering assertions still run on every platform. Same guard pattern as TestResolveClientConfigPath.
sini
force-pushed
the
feat/storage-layout
branch
from
September 2, 2026 20:26
1f6634c to
87d6bd8
Compare
Comments are cut to the constraint each one carries, and restricted to ascii. Every shorthand term is defined where it lives: the config fragment is named as ~/.ssh/opkssh/config with its Include relationship at identityFileLine, key-file slots are defined at certState, and the two key-writing destinations are named rather than called tiers. Subjectless and provenance sentences are rewritten to name their objects.
sini
force-pushed
the
feat/storage-layout
branch
from
September 2, 2026 20:59
87d6bd8 to
a6a9758
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two storage-layout changes, predicates for the refresh-daemon work discussed in #606:
1. XDG config-path support
The client config now resolves through the first existing of:
$XDG_CONFIG_HOME/opk/config.ymlwhen the variable is set and absolute (the XDG Base Directory spec requires relative values to be ignored), otherwise~/.config/opk/config.ymlon Linux/macOS or%AppData%\opk\config.ymlon Windows. These are replacement semantics per the spec, matching git/gh.~/.opk/config.yml(all platforms).An existing
~/.opk/config.ymlkeeps working untouched and always wins when it is the only config. Fresh--create-configruns write the XDG location with 0700/0600 (the config can carryclient_secretvalues).opkssh login -vnames the config file in use. The old--config-pathhelp text claiming%APPDATA%\.opkon Windows was incorrect (the code always used~/.opkon every platform) and is fixed on both commands that carry the flag.2. Non-clobbering multi-identity key files
Today a second identity's login either overwrites the first's
~/.ssh/id_ecdsa(any opkssh cert may clobber any other, because the check is theopenpubkeycomment alone) or fails with "no default ssh key file free". Now:openpubkey(so every legacy file behaves exactly as before).~/.ssh/opkssh/, created on demand together with itsIdentityFileconfig fragment, inert without the explicit--configureInclude, which this PR never touches), named<issuer>-<client_id>or, when a different account at the same provider collides, suffixed with an 8-hexsha256(iss|aud|sub)tag (hashed because some OPs use emails as subjects, which don't belong in file names). A foreign occupant at the tagged path is an error, never an overwrite.Single-identity users keep byte-identical behavior throughout, as the fallback machinery only engages when a second identity actually appears.
Intentional behavior changes
~/.ssh/opkssh/directory or config fragment is created on demand rather than being an error.addCertToAgentandmaybeAddCertToAgentnow report whether the key reached an agent, which is what lets the fallback warning stay silent when an agent is holding the key.Notes
IdentityFilepaths containing blanks are now double-quoted per ssh_config token rules (an unquoted path with a space is mis-parsed by OpenSSH, common with Windows home directories). Login's writer and logout's remover render lines through one shared function, so matching and removal cannot drift; blank-free paths stay unquoted so existing fragment entries keep matching exactly.docs/cli/is regenerated (opkssh gendocs) for the changed help text.Design spec (with the review history behind these decisions): https://gist.github.com/sini/c32d1b73adbb56f1bdbbe620b10dc80f