Repository navigation
Keep keys in 1Password; batch rotation and key regen-pub - #48
Merged
Merged
Conversation
A profile's private keys live on disk (key_store omitted, or "file")
or as SSH Key items in a 1Password vault ("1password", which requires
op_vault). Any other value is rejected, so a typo cannot fall back to
looking for keys on disk. defaults.onepassword_agent optionally
overrides the platform's 1Password agent socket. Existing manifests
round-trip unchanged: both fields are omitted when unset.
opstore drives the op CLI: create an SSH Key item (the private key is generated inside 1Password and never read back), get an item by title, and retitle or archive it by ID so an edit cannot land on another item that shares a title. Items are only ever archived, never deleted. secrets.Resolve reads op://vault/item/field references with op read, beside the existing cmd: form, so a provider token can live in 1Password without spelling out a command.
A verbatim move with no change in behaviour, so a second rollback path can share it.
A host whose profile keeps its keys in 1Password renders IdentityFile as the key's .pub plus IdentityAgent pointing at the 1Password agent, quoted because the macOS socket path contains a space. A public key in IdentityFile with IdentitiesOnly is how ssh picks one key out of an agent, and is 1Password's documented fix for the six-key limit. An IdentityAgent the host sets itself wins. ssh -G is run on the output to pin that the quoted path parses as one value. The helpers sit on Profile and Defaults rather than Manifest, which is at its method ceiling.
Only the .pub of a 1Password key is ever on disk, so key listing, audit, diff and show no longer report it as half a pair. validate checks the vault item exists and matches the .pub, and fails a 1Password profile that still has a private key file on disk: the .pub makes that key look present while ssh asks the 1Password agent, which does not hold it. Agent load skips 1Password keys, and deleting one leaves its vault item in place and says so.
A deploy adds the key to whichever account token_env's token belongs to, so a personal token left in a machine user's variable added it to the wrong account and still passed the key-is-listed check. When the API login and the ssh greeting are both available they must now agree. Verify logins also pass IdentityAgent for a key held by an agent.
️✅ There are no secrets present in this pull request anymore.If these secrets were true positive and are still valid, we highly recommend you to revoke them. 🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request. |
reconcile creates a 1Password profile's key as an SSH Key item titled with the key name and writes only its public half; an existing item of that title is reused, never duplicated. rotate stages the new key as <key>.staged in the vault, deploys and verifies it through the 1Password agent, then retitles it into place, the outgoing item becoming <key>.old and any earlier predecessor going to the vault's Archive. A failed step puts back what had moved. When the outgoing key is still a key file, the same rotation moves the profile into 1Password. rollback restores <key>.old and archives the rotated-in item. Items are archived, never deleted.
When any profile keeps its keys in 1Password, doctor fails if op is not on PATH or the agent socket is missing, naming the 1Password setting that turns the agent on.
Key stores in the configuration reference, the 1Password flow and the machine-user check in rotate, validate and doctor, the rationale in architecture, and two recipes: keeping keys in 1Password, and giving an organization one rotatable bot key through a machine user (a deploy key cannot be shared across repositories).
imanimanyara
force-pushed
the
feat/onepassword-key-store
branch
from
October 19, 2026 11:21
479cffb to
c1d715c
Compare
rotate takes any number of keys, profiles or host aliases, or --category <name> or --all, through one selection shared with other bulk commands: a selector that matches nothing is an error, and --all cannot be combined with others. A batch asks once, writes one backup and one snapshot, then rotates each key on its own, saving the inventory after every committed key; a key that fails is left as it was while the rest continue. Keys no host uses are skipped. --dry-run lists the batch. A single key rotates exactly as before.
key regen-pub rebuilds each selected key's .pub from the private key (ssh-keygen -y) or, for a 1Password profile, from the vault item. It reports each key as unchanged, created or rewritten, keeps the old .pub's comment, and moves the inventory record to the regenerated fingerprint. A .pub that was a different key is called out with a pointer to redeploy. A passphrase-protected key or a missing private key is skipped with the reason. It never changes a private key.
A batch section in the rotate reference, key regen-pub beside the other key integrity checks in validate, the feature table, a recipe for rotating or repairing many keys at once, and the changelog.
The agent offers only the Personal, Private and Employee vaults until agent.toml exists, and then only what that file lists, even if it is empty. A key in any other vault is never offered to ssh, which shows up as a bare "Permission denied (publickey)". doctor now checks every vault a profile names and prints the [[ssh-keys]] entry to add. It asks for a whole-vault entry because an item-only one would not serve a rotation's staged key. Documented in the configuration reference and the 1Password recipe.
A vault item is titled with the bare key name, and key names are only unique within a profile, so two 1Password profiles sharing a vault and a key name would have found each other's item: two identities on one key. 1Password also generates Ed25519 only, so any other key_type on a 1Password profile was silently ignored. Both are now load-time errors.
The not-found check matched any error containing "not found" or "no item", so a mistyped vault or a missing account read as a missing item and reconcile tried to create it. Only op's own wording for a missing item counts now; everything else surfaces as the error it is.
Commit archives the earlier <key>.old before retitling. A later failure puts everything else back, but not that archive, and the error said nothing of it. Every failure after that point now says the predecessor was moved to the vault's Archive and can be restored from there.
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.
What & why
A profile's private keys can now live in 1Password instead of on disk, and rotation is a single
sshmgr rotatefor both stores. GitHub verify also checks which account the key authenticates as, which a GitHub machine user (one bot key with read access to every org repo) depends on.Design notes, with the 1Password facts checked against 1password.dev:
key_storeis per profile, because a key is shared by its profile's hosts. A key's name, inventory record and identity path don't change with its store, so moving a profile into 1Password is a rotation, not a state migration.key_store/op_vaultop://op item create/get/edit/delete --archive; items found by title, edited by ID;op://tokens read withop readfinishRollbackIdentityFile <key>.pub+ quotedIdentityAgent; checked with realssh -G.pub-only key; validate checks the vault item matches and fails a stray private key file<key>.staged, keeps one<key>.old, and archives older ones; rollback; switching a file profile to 1Password and rotating moves it into the vaultopon PATH and the agent socket when a profile uses 1PasswordInvariant checklist
~/.ssh/config.opargv never asks for one.Checks
go build ./...,go vet ./...(also withGOOS=windowsandGOOS=linux).go test ./...: the only failures are the same 10 that fail on unmodifiedmainin the sandbox it ran in (local port binding denied).Manifeststays at its 24-method ceiling (TestNoTypeHasBecomeTheFacadeAgain); the new helpers sit onProfile,Defaultsand a free function.golangci-lintnot run locally: the installed build is Go 1.26 and panics on this Go 1.27 toolchain. CI's lint step is the check.op, which generates real Ed25519 keys. Not yet run against a real 1Password account:opisn't installed on the machine that ran the tests.CHANGELOG.md,README.mdand docs updated.Added in this PR after review
rotatetakes several keys/profiles/aliases,--categoryor--all, plus--dry-run. One confirmation, backup and snapshot; each key rotates on its own, and a failure leaves only that key as it was. Unwired keys are skipped. A single key behaves exactly as before.key regen-pub.pubfiles from the private key (ssh-keygen -y) or the 1Password item. Reports unchanged / created / rewritten / skipped / failed; keeps the old comment and re-keys the inventory record. Never touches a private key.Both share one selector (
keysvc.Select): a selector that matches nothing is an error, and--allcan't be combined with others. Each new flag is authorised ingoOnly. Tested with CLI end-to-end tests (batch rotate with a skipped unwired key, dry run, regen-pub) and service tests (created, rewritten, unchanged, passphrase skip, 1Password source), and the comparison check was shown to fail when disabled.