Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d557a9c
Add key_store and op_vault to manifest profiles
imanimanyara Oct 19, 2026
a2c3684
Add a 1Password client and op:// secret references
imanimanyara Oct 19, 2026
da6d6bc
Move rollback's redeploy-and-revoke tail into finishRollback
imanimanyara Oct 19, 2026
830aaa8
Render 1Password hosts with their public key and the agent
imanimanyara Oct 19, 2026
9d5cd9e
Treat a 1Password key as present when its public half is
imanimanyara Oct 19, 2026
65a7157
Verify a GitHub key authenticates as the token's account
imanimanyara Oct 19, 2026
88650ef
Mint and rotate keys inside 1Password
imanimanyara Oct 19, 2026
67f44cb
Check the op CLI and the 1Password agent in doctor
imanimanyara Oct 19, 2026
c1d715c
Document 1Password key stores and GitHub machine users
imanimanyara Oct 19, 2026
c98829b
Rotate a batch of keys: several, a profile, a category, or all
imanimanyara Oct 19, 2026
a4536c8
Add key regen-pub: rebuild public keys from their source
imanimanyara Oct 19, 2026
df7232a
Document batch rotation and key regen-pub
imanimanyara Oct 19, 2026
ffdf17f
Check that the 1Password agent serves each profile's vault
imanimanyara Oct 19, 2026
9c099a3
Reject 1Password key setups that would share or misstate a key
imanimanyara Oct 19, 2026
6bdc164
Treat only op's "isn't an item" as a missing 1Password item
imanimanyara Oct 19, 2026
233bcd4
Name the archived predecessor when a 1Password rotation fails
imanimanyara Oct 19, 2026
96f4c4f
Note the 1Password vault and key rules in the changelog
imanimanyara Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Keys can live in 1Password.** A profile with `"key_store": "1password"` and
an `op_vault` keeps its private keys as SSH Key items in that vault, created
with the `op` CLI; only the public halves are written to disk. Its hosts render
`IdentityFile <key>.pub` and `IdentityAgent` pointing at the 1Password agent.
`rotate` stages the replacement in the vault, verifies it through the agent,
and keeps one `<key>.old` predecessor; `rollback` restores it. Items are
archived, never deleted. Rotating a key after switching its profile to
`1password` moves it into the vault. `validate` checks the item matches the
`.pub`, and `doctor` checks for `op`, the agent socket, and that the agent
serves each vault (`agent.toml`). Two 1Password profiles may not keep the same
key name in one vault, and 1Password keys must be `ed25519`.
- **Batch rotation.** `rotate` accepts several keys, profiles or host aliases,
`--category <name>`, or `--all`, with `--dry-run` to list the batch. One
confirmation, backup and snapshot; each key rotates on its own, so one failure
leaves only that key as it was.
- **`key regen-pub`** rebuilds `.pub` files from the private key, or from the
vault item for a 1Password profile, for the same selections. It reports each
key as unchanged, created or rewritten, and never touches a private key.
- **`op://` secret references.** A provider token whose value starts `op://` is
read with `op read`, beside the existing `cmd:` form.
- **Profiles can be grouped into categories.** A profile with `"category":
"clients"` keeps its keys under `profiles/clients/<profile>/` and renders its
`Host` blocks into `profiles/clients/config`, which the root config Includes
Expand Down Expand Up @@ -60,6 +80,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Changed

- **GitHub verify checks the account.** When a deploy's API token and an ssh
login are both available, GitHub must greet the key as the account the token
belongs to. A token for the wrong account (a personal token left in a machine
user's variable) used to add the key there and pass verification.
- **`reconcile` refuses to mint a key that only changed directory.** A declared
key found in the other layout (flat when the manifest says categorized, or the
reverse) used to be planned as a fresh key, leaving two keys for one identity.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@ else is in the [documentation index](#documentation).
### Recipes

- [Two GitHub accounts on one machine](docs/recipes/two-github-accounts.md) — one hostname, two identities, neither able to act as the other.
- [Keep keys in 1Password](docs/recipes/keep-keys-in-1password.md) — private keys generated and rotated inside a vault; only the `.pub` on disk.
- [Use a GitHub machine user](docs/recipes/github-machine-user.md) — one bot key with read access to every org repo, rotated in one command.
- [Group profiles into categories](docs/recipes/group-profiles-into-categories.md) — `internal`, `clients`, `others`: one directory and one `config` file each.
- [Onboard an existing `~/.ssh`](docs/recipes/onboard-an-existing-ssh-dir.md) — bring a hand-built setup under management without regenerating anything.
- [Rotate a key with no downtime](docs/recipes/rotate-a-key.md) — stage, verify, commit; and how to step back.
- [Rotate or repair many keys at once](docs/recipes/rotate-many-keys.md) — a category or everything in one batch, and rebuilding `.pub` files.
- [Work against a sandbox](docs/recipes/dev-mode.md) — exercise every verb without touching your real `~/.ssh`.
- [Back up, and restore onto a new machine](docs/recipes/back-up-and-restore.md) — the only path that recovers the same keys.
- [Recover from a locked-out server](docs/recipes/recover-from-lockout.md) — break-glass with no working SSH.
Expand Down
15 changes: 15 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ layering test fails any `filepath.Join` that builds `profiles/<computed>` by han
That is the defect categories expose: a hand-built path is right for a flat
profile and silently one directory too high for a categorized one.

### Why can keys live in 1Password, and why per profile?

A key file on disk is only as safe as the disk, and every copy of `~/.ssh` (a
backup, a bundle, a laptop) carries it. With `key_store: 1password` the private
key is generated inside 1Password and never read back: sshmgr writes only the
public half, and ssh reaches the key through the 1Password agent, selected by
that `.pub` with `IdentitiesOnly`. Rotation, the part people skip, stays one
command, and the vault keeps every predecessor in its Archive.

The store is a profile setting, not a host one, because a key is shared by its
profile's hosts: a per-host field could have two hosts disagree about where one
key lives. Nothing else about a key changes with its store: its name, its
inventory record and its `IdentityFile` identity are the same, so moving a
profile between stores is a rotation, not a migration of state.

### Why is there no single service object?

v1 had a `SshManagerService` facade: 1356 lines, 54
Expand Down
58 changes: 57 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ guarantees each alias offers only its own key and trusts only its own host keys.
"profiles": {
"work": {
"category": "others", // optional: profiles/others/work/ + profiles/others/config
"key_store": "file", // or "1password" (then op_vault is required)
"op_vault": null, // the 1Password vault holding this profile's keys
"key_scope": "per_service", // or "shared"
"key_name": null, // set only when key_scope == "shared"
"hosts": [
Expand Down Expand Up @@ -131,6 +133,7 @@ guarantees each alias offers only its own key and trusts only its own host keys.
| `expiry_check.debounce_hours` | `24` | min gap between inline banners |
| `expiry_check.desktop_notify` | `true` | allow the scheduled desktop notification |
| `global_options` | `{}` | ssh options emitted under `Host *` (e.g. `AddKeysToAgent`, macOS `UseKeychain`) |
| `onepassword_agent` | platform default | the 1Password SSH agent socket for `key_store: 1password` profiles (see [Key stores](#key-stores)) |

### `provider` / `token_env`

Expand Down Expand Up @@ -176,6 +179,55 @@ Set it with `sshmgr profile add <name> --category <c>` or
`sshmgr profile edit <name> --category <c>`. The edit moves the key directory and
updates the inventory. See [Group profiles into categories](recipes/group-profiles-into-categories.md).

## Key stores

A profile's private keys live in one of two places, set by `key_store`:

| `key_store` | Private key | On disk | Rendered host |
|---|---|---|---|
| `file` (or omitted) | `profiles/…/<key>` | private key + `.pub` | `IdentityFile <key>` |
| `1password` | an SSH Key item titled `<key>` in `op_vault` | `.pub` only | `IdentityFile <key>.pub` + `IdentityAgent "<socket>"` |

With `1password`, `reconcile` creates the key inside 1Password with the
[`op` CLI](https://developer.1password.com/docs/cli/) and writes only its public
half. ssh then asks the 1Password agent for the key matching that `.pub`, so the
private key never touches disk. `rotate` generates replacements in the vault too.
Needs `op` (2.20 or later) signed in, and the agent turned on in the 1Password
app (Settings > Developer > Use the SSH agent); `doctor` checks both.

**The agent must serve the vault.** With no `agent.toml`, the 1Password SSH
agent offers only keys in your Personal, Private or Employee vault. Once
`~/.config/1Password/ssh/agent.toml` exists, it offers only what the file lists
(even an empty file overrides the default). For any other vault, or whenever the
file exists, list the vault as a whole:

```toml
[[ssh-keys]]
vault = "Simtabi"
```

A whole-vault entry, not one per item: a rotation's `<key>.staged` item has to be
served to be verified. `doctor` checks this for every vault a profile names.

The agent socket defaults to the platform's path (macOS
`~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock`, Linux
`~/.1password/agent.sock`); set `defaults.onepassword_agent` to override it. On
Windows no `IdentityAgent` is rendered, because Microsoft OpenSSH uses one fixed
pipe for every host.

Two rules are enforced when the manifest loads:

- **Key names are unique within a vault.** The item is titled with the key name,
so two 1Password profiles sharing a vault and a key name would share one key.
- **Keys are `ed25519`.** 1Password generates nothing else, so another
`key_type` on a 1Password profile is rejected rather than silently ignored.

The store is per profile, not per host, so the hosts sharing a key cannot
disagree about where it lives. To move a profile into 1Password, set
`key_store` and run `sshmgr rotate` on each key: it generates the new key in the
vault and archives the old key file to `old/`. See
[Keep keys in 1Password](recipes/keep-keys-in-1password.md).

## Key scope

- `per_service` *(default)* - one key per host: smallest blast radius.
Expand Down Expand Up @@ -222,10 +274,14 @@ runs at use-time and its trimmed stdout is the token (so it integrates with any
secret manager and the secret never touches disk):

```sh
GH_TOKEN=cmd:op read op://Private/GitHub/token # 1Password CLI
GH_TOKEN=op://Private/GitHub/token # 1Password secret reference
GH_TOKEN=cmd:op read op://Private/GitHub/token # the same, spelled out
GH_TOKEN=cmd:keyring get ssh-manager gh_token # OS keyring
```

A value starting `op://` is read with `op read`, the whole reference as one
argument, so item and field names may contain spaces.

The command runs once per process (memoized) via the argv-only subprocess
chokepoint - no shell, no injection. A failed/empty/timed-out command yields no
token (the provider falls back to the manual paste path). Note: a literal token
Expand Down
4 changes: 3 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ project-local mode. See [installation.md](installation.md) for the full layout.
| Command | What it does | Tests |
|---|---|---|
| `key list [selector]` | Every key with its fingerprint, expiry, the hosts using it, and its state — including keys nothing references. | U |
| `key regen-pub [selectors...] [--category] [--all] [--dry-run]` | Rebuild `.pub` files from the private key or the 1Password item; reports unchanged / created / rewritten / skipped. Never changes a private key. | U |
| `key add <profile> <name> [--type] [--rotate-after-days] [--host] [--passphrase]` | Declare a key on a profile and mint it. Without `--host` the key is minted **unwired**: it exists and nothing uses it, which `doctor` then reports. | U |
| `key delete <profile/key> [--purge] [--revoke] [--yes] [--no-key-backup]` | Remove the declaration and the inventory record. **Refuses while any host still resolves to the key** — a `Host` block pointing at a missing `IdentityFile` fails as a bare "Permission denied (publickey)" with nothing naming the cause. `--purge` also deletes the files. | U |
| `clean [--dry-run] [--adopt]` | Prune stale `known_hosts` pins, stale inventory records and write residue. Only removes pins tagged `sshmgr`; `--adopt` opts an untagged pin that matches a manifest host into management, since an untagged pin is presumed to be yours. | U |
Expand All @@ -72,6 +73,7 @@ project-local mode. See [installation.md](installation.md) for the full layout.
|---|---|---|
| `deploy <key> [target]` | Install the public key on its target via the host's provider (GitHub/GitLab CLI, cloud-VPS REST, `generic-ssh` `ssh-copy-id`, or web-panel/manual) and record it. **Exits ≠0 if a target is unreachable.** | U·E·F |
| `rotate <key> [--yes] [--allow-unverified] [--passphrase]` | Zero-downtime staged rotation: stage → deploy → verify → archive (≤1 predecessor). Aborts cleanly (pulling the staged key back) if a target can't be verified or is unreachable. | U·E·F |
| `rotate [selectors...] [--category] [--all] [--dry-run]` | Rotate a batch: several keys, profiles or aliases, a category, or every key a host uses. One confirmation, backup and snapshot; each key rotates on its own and a failure leaves only that key untouched. | U |
| `rollback <key> [--yes]` | Restore the single `/old/` predecessor (re-deploy is best-effort; skips unreachable hosts). | U·E·F |
| `load <profile>` | Add the profile's keys to the agent (Keychain on macOS). | U·F |

Expand All @@ -95,7 +97,7 @@ bounded SSH-level reachability probe first, so a down or VPN-gated host (includi

| Command | What it does | Tests |
|---|---|---|
| `profile add <name> [--shared] [--key-name] [--category]` | Add a profile, optionally filed under `profiles/<category>/`. | U·F |
| `profile add <name> [--shared] [--key-name] [--category]` | Add a profile, optionally filed under `profiles/<category>/`. A profile keeps its keys on disk, or in 1Password with `key_store: 1password` (see [Key stores](configuration.md#key-stores)). | U·F |
| `profile edit <name> [--key-scope] [--key-name] [--category]` | Change a profile in place. `--category` moves its key directory under `profiles/<category>/` (or back, with `""`) and re-points its inventory records. | U |
| `profile delete <name> [--yes] [--revoke] [--purge] [--no-key-backup]` | Remove a profile and its hosts; `--revoke` pulls its keys off their targets. | U·F |
| `host add <profile> <alias> [-H] [-u] [-p] [--provider] [--token-env] [--key-name] [--tag]` | Add a host to a profile (alias is **positional**). | U·F |
Expand Down
61 changes: 61 additions & 0 deletions docs/recipes/github-machine-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Use a GitHub machine user

Give an organization one SSH key that can read every repository, and rotate it
in one command.

A GitHub organization cannot hold an SSH key, and a deploy key belongs to
exactly one repository: GitHub refuses the same public key on a second one. A
machine user is an ordinary GitHub account used only by automation. Put it in a
team with **Read** on the repositories it needs, and its single key reaches all
of them.

## Set it up

1. Create the account (for example `simtabi-bot`) with an inbox you control,
and turn on two-factor authentication.
2. In the organization, create a team (for example `bots-read`) with **Read**
access to the repositories, and add the account to it.
3. Create a token for the machine user that can manage its SSH keys: a
fine-grained token with the account permission *Git SSH keys: read and
write*, or a classic token with `admin:public_key`.
4. Store the token where sshmgr can read it, in `~/.config/ssh-manager/.env`:

```sh
GH_TOKEN_SIMTABI=op://Simtabi/simtabi-bot/token
```

5. Wire a host to it in the manifest, with `provider` and `token_env`:

```jsonc
{"alias": "github-simtabi", "hostname": "github.com", "user": "git",
"provider": "github", "token_env": "GH_TOKEN_SIMTABI",
"key_name": "simtabibot_github-ed25519"}
```

6. Add the key to the account and check it:

```sh
sshmgr reconcile
sshmgr deploy simtabibot_github-ed25519
ssh -T git@github-simtabi # Hi simtabi-bot! ...
```

Use the alias in remotes: `git@github-simtabi:simtabi/ssh-manager.git`.

## Rotate it

```sh
sshmgr rotate simtabibot_github-ed25519
```

The new key is added to the account, verified, and the old one removed through
the API. Verify also checks that GitHub greets the new key as the account the
token belongs to, so a token for the wrong account fails the rotation instead of
committing a key the alias cannot use.

To keep the private key out of `~/.ssh` as well, give the profile
`key_store: 1password`; see [Keep keys in 1Password](keep-keys-in-1password.md).

---

[← Docs index](../../README.md#documentation)
72 changes: 72 additions & 0 deletions docs/recipes/keep-keys-in-1password.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Keep keys in 1Password

Generate and rotate a profile's private keys inside a 1Password vault, so only
the public halves are ever on disk.

You need the [1Password CLI](https://developer.1password.com/docs/cli/get-started/)
(`op` 2.20 or later), signed in, and the SSH agent turned on in the 1Password
app (Settings > Developer > Use the SSH agent).

If the vault is not your Personal, Private or Employee vault, or you already
have a `~/.config/1Password/ssh/agent.toml`, tell the agent to serve it:

```toml
# ~/.config/1Password/ssh/agent.toml
[[ssh-keys]]
vault = "Simtabi"
```

In the manifest, give the profile a store and a vault:

```jsonc
"simtabibot": {
"category": "orgs",
"key_store": "1password",
"op_vault": "Simtabi",
"hosts": [
{"alias": "github-simtabi", "hostname": "github.com", "user": "git",
"provider": "github", "token_env": "GH_TOKEN_SIMTABI",
"key_name": "simtabibot_github-ed25519"}
]
}
```

```sh
sshmgr doctor # op found, agent socket present, vault served
sshmgr reconcile # creates the item, writes simtabibot_github-ed25519.pub
sshmgr validate # the item exists and matches the .pub
```

The rendered host points ssh at the public key and the 1Password agent:

```
Host github-simtabi
HostName github.com
User git
IdentityFile ~/.ssh/profiles/orgs/simtabibot/simtabibot_github-ed25519.pub
IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
IdentitiesOnly yes
```

Rotate the same way as any key:

```sh
sshmgr rotate simtabibot_github-ed25519
```

The new key is created in the vault as `<key>.staged`, deployed and verified,
then takes the live title, with the old item kept as `<key>.old`. Items are
archived, never deleted.

## Moving an existing profile into 1Password

Set `key_store` and `op_vault` on the profile, then rotate each of its keys. The
rotation generates the replacement in the vault and archives the old key file
pair to `old/`. Until you rotate, `validate` fails the key, because ssh would ask
the 1Password agent for a key it does not hold.

Full reference: [Key stores](../configuration.md#key-stores).

---

[← Docs index](../../README.md#documentation)
39 changes: 39 additions & 0 deletions docs/recipes/rotate-many-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Rotate or repair many keys at once

Rotate a whole category, a profile or every key in one command, and rebuild
public keys that went missing or wrong.

See what a batch would do before it does it:

```sh
sshmgr rotate --all --dry-run
```

Rotate by category, profile, host alias or key, in any mix:

```sh
sshmgr rotate --category clients
sshmgr rotate simtabi github-imani
```

The batch asks once, writes one backup, then rotates each key with the usual
stage, deploy, verify and commit. A key that cannot be verified is left exactly
as it was and named at the end; the others still rotate.

To rebuild `.pub` files from the keys they belong to (from the private key, or
from the vault item for a 1Password profile):

```sh
sshmgr key regen-pub --all --dry-run
sshmgr key regen-pub --all
```

A `.pub` reported as `rewritten` was a different key, so redeploy anything you
set up from it: `sshmgr deploy <key>`.

Full reference: [`rotate`](../tools/rotate.md#rotating-a-batch) ·
[`key regen-pub`](../tools/validate.md).

---

[← Docs index](../../README.md#documentation)
1 change: 1 addition & 0 deletions docs/tools/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ sshmgr doctor --strict # every dangling-key state is fatal (CI)
| `home` / `ssh` | the resolved config home and `~/.ssh`, so an override is visible |
| `providers` | whether the catalog is the shipped default or a user file |
| `agent` | whether `ssh-agent` is running and how many keys it holds |
| `1Password` | when a profile uses `key_store: 1password`: that `op` is on PATH, the agent socket exists, and the agent serves the profile's vault (`agent.toml`) |
| `known_hosts` | whether the trust store exists, and which hosts are unpinned |
| `config drift` | whether the rendered block still matches the manifest |
| `perms` | every managed path whose mode is not what it should be |
Expand Down
Loading
Loading