Skip to content

Keep keys in 1Password; batch rotation and key regen-pub - #48

Merged
imanimanyara merged 17 commits into
mainfrom
feat/onepassword-key-store
Oct 19, 2026
Merged

imanimanyara merged 17 commits into
mainfrom
feat/onepassword-key-store

Conversation

@imanimanyara

@imanimanyara imanimanyara commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

What & why

A profile's private keys can now live in 1Password instead of on disk, and rotation is a single sshmgr rotate for 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_store is 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.

Commit What
key_store / op_vault manifest fields; unknown store or missing vault rejected; existing manifests round-trip unchanged
1Password client + op:// op item create/get/edit/delete --archive; items found by title, edited by ID; op:// tokens read with op read
finishRollback verbatim move so a second rollback path can share it
render 1Password hosts get IdentityFile <key>.pub + quoted IdentityAgent; checked with real ssh -G
presence key list, audit, diff and show accept a .pub-only key; validate checks the vault item matches and fails a stray private key file
GitHub verify the ssh greeting must match the token's login when both are readable
mint + rotate items are minted in the vault; rotate stages <key>.staged, keeps one <key>.old, and archives older ones; rollback; switching a file profile to 1Password and rotating moves it into the vault
doctor checks for op on PATH and the agent socket when a profile uses 1Password
docs configuration, rotate, validate, doctor, architecture, and two recipes

Invariant checklist

  • Manifest stays the source of truth; no hand-edited ~/.ssh/config.
  • Config changes go through the single renderer.
  • State I/O is atomic and under the advisory lock; no secrets added to git. sshmgr never reads a private key out of 1Password; a test asserts the op argv never asks for one.
  • Perms (700/600/644) preserved; converging commands stay idempotent. A second reconcile reuses the vault item instead of creating a duplicate (tested).

Checks

  • gofmt, go build ./..., go vet ./... (also with GOOS=windows and GOOS=linux).
  • Every commit builds and vets on its own (checked one by one).
  • go test ./...: the only failures are the same 10 that fail on unmodified main in the sandbox it ran in (local port binding denied).
  • Manifest stays at its 24-method ceiling (TestNoTypeHasBecomeTheFacadeAgain); the new helpers sit on Profile, Defaults and a free function.
  • golangci-lint not run locally: the installed build is Go 1.26 and panics on this Go 1.27 toolchain. CI's lint step is the check.
  • The new guards were each shown to fail with their check disabled: one-predecessor archiving, minting, the GitHub account match, and the hand-written config protection.
  • Tested against a stateful fake op, which generates real Ed25519 keys. Not yet run against a real 1Password account: op isn't installed on the machine that ran the tests.
  • CHANGELOG.md, README.md and docs updated.

Added in this PR after review

Commit What
batch rotation rotate takes several keys/profiles/aliases, --category or --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 Rebuilds .pub files 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.
docs rotate batch section, regen-pub in validate, feature table, a recipe, changelog

Both share one selector (keysvc.Select): a selector that matches nothing is an error, and --all can't be combined with others. Each new flag is authorised in goOnly. 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.

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.
Copilot AI balanced review requested due to automatic review settings October 19, 2026 11:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gitguardian

gitguardian Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

️✅ 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.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 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
imanimanyara force-pushed the feat/onepassword-key-store branch from 479cffb to c1d715c Compare October 19, 2026 11:21
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.
@imanimanyara imanimanyara changed the title Keep keys in 1Password; verify GitHub machine-user identity Keep keys in 1Password; batch rotation and key regen-pub Oct 19, 2026
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.
@imanimanyara
imanimanyara merged commit 3d51263 into main Oct 19, 2026
9 checks passed
@imanimanyara imanimanyara mentioned this pull request Oct 19, 2026
3 tasks done
@imanimanyara
imanimanyara deleted the feat/onepassword-key-store branch October 19, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants