Add SSH keys and config to credential sharing (#139)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

* Add SSH keys and config to credential sharing

Private keys have lived as plaintext-on-disk files guarded only by a
passphrase. This puts them in the same end-to-end-encrypted vaults as
.env secrets, and adds an agent path so a machine can use a key without
ever writing one to its disk.

- `ssh` provider: ~/.ssh as a value bag. Files are picked by sniffing
  contents (PRIVATE KEY blocks, ssh-*/ecdsa-*/sk-* public keys) plus
  config, config.d/* and allowed_signers. known_hosts and
  authorized_keys are host-specific and access-granting, so they need
  an explicit --include.
- Each file is one secret carrying a JSON envelope of path, mode and
  body. The engine only hands write() the secrets that CHANGED, so a
  separate manifest secret would be absent whenever a key's contents
  change but the file list doesn't — self-describing values keep every
  restore total.
- `logicsrc secrets ssh push|pull|list|agent`, addressed by PERSON not
  project: the vault is ssh--<username>, which teams vaults reads as
  project ssh, env <username>. One teammate's keys never land in
  another's restore; sharing stays a deliberate teams grant.
- Both directions hold back anything that would overwrite a file that
  already differs, and say what they skipped. --force opts in. A
  restore onto a machine with its own keys is otherwise a way to lose
  them.
- Restores chmod each file back to its recorded mode; writeFileSync's
  mode applies only on create, so an existing world-readable key would
  otherwise stay world-readable. The adapter declares delete:false.
- push warns about passphrase-less private keys before they go up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add worked examples to secrets and secrets ssh help

Commander's usage line shows only the first alias, so `logicsrc secrets`
— the spelling people actually type — was invisible in its own help.
The examples carry it, alongside the flows worth copying: link/up/down,
the ssh backup round trip, and a plan → dry-run → approve sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Advertise the ssh provider on the marketing page

The marketing-drift contract failed the build because `ssh` shipped in the
provider registry with no entry in MARKETING_PROOF -- which is the test
working: it exists so a provider cannot ship while the pages people
actually land on still describe the tool without it.

The proof regex is `/~\/\.ssh|SSH key/` rather than a bare `/SSH/` on
purpose. The provider grid renders every registry `name`, and this one is
"Local SSH directory", so `/SSH/` would already be satisfied by the
generated grid and the provider could ship with no copy written about it
at all -- passing the test while failing its intent. Requiring the path or
the phrase means a human wrote a sentence.

That sentence is the new block in the credential-sharing band: ~/.ssh is a
directory of files whose permission bits are load-bearing, not a set of
KEY=VALUE lines, which is the part that makes this provider different from
the other six. README already named ~/.ssh keys, so it needed no change.

apps/logicsrc-web: 75/75 contract tests pass (was 74 passed, 1 failed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-08-13 17:03:00 -07:00 committed by GitHub
parent 1cfec322ac
commit b1805d08e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 939 additions and 10 deletions

View file

@ -57,6 +57,7 @@ doppler
railway
github-secrets
sh1pt
ssh
```
- `.env`: read, diff, redact, and write local environment files.
@ -65,6 +66,8 @@ sh1pt
- GitHub Secrets: sync repository, organization, and environment secrets.
- sh1pt: sync the distribution credential vault — App Store Connect keys, Play
service accounts, npm and Docker tokens, Cloudflare tokens.
- ssh: read and restore a local `~/.ssh` — key pairs, `config`,
`allowed_signers` — with permission bits preserved.
`sh1pt` is the one adapter driven through a **CLI** rather than an HTTP API,
because sh1pt publishes `sh1pt secret set|get|list|rm` as the interface to its
@ -81,6 +84,57 @@ stating:
false`), exactly like `github-secrets`: it can be a sync target but never a
source, and it supports no value-restoring rollback.
## SSH Keys
`logicsrc secrets ssh` pairs the `ssh` adapter with a team vault, so private
keys live encrypted in a vault instead of as plaintext-on-disk files guarded
only by a passphrase — the same trade Proton Pass makes with its SSH agent.
```bash
# Back up ~/.ssh (key pairs + config) into the vault for your username
logicsrc secrets ssh push profullstack # → vault ssh--anthony
logicsrc secrets ssh push --dry-run # show what would go up
logicsrc secrets ssh push --include authorized_keys
# See what a vault holds — paths, kinds and modes, never key bodies
logicsrc secrets ssh list profullstack
# Restore onto a new machine, permissions and all
logicsrc secrets ssh pull profullstack
# Or use the keys without ever writing them to that machine's disk
logicsrc secrets ssh agent profullstack --lifetime 3600
```
Key material is addressed by **person, not project**: the vault is
`ssh--<username>`, which `teams vaults` lists as project `ssh`, env
`<username>`. One teammate's keys therefore never land in another's restore,
and sharing a key stays a deliberate `teams grant`.
Implementation notes:
- Each file becomes one secret whose value is a JSON envelope carrying the
relative path, permission bits, and body. The envelope exists because the
engine only hands `write()` the secrets that CHANGED — a separate manifest
secret would be missing from that set whenever a key's contents change but
the file list doesn't, leaving nowhere to look up the destination path.
- Files are selected by sniffing contents, not by filename: anything holding a
`PRIVATE KEY` block or an `ssh-*`/`ecdsa-*`/`sk-*` public key line, plus
`config`, `config.d/*` and `allowed_signers`. `known_hosts` and
`authorized_keys` are host-specific and access-granting, so they are only
included when named with `--include`.
- Both directions hold back anything that would **overwrite a file that already
differs**, and say what they skipped; `--force` opts into the overwrite. A
restore onto a machine with its own keys is otherwise a way to lose them.
- Restores recreate the directory `0700` and chmod each file back to its
recorded mode — `writeFileSync`'s mode applies only on create, so an existing
world-readable key would otherwise stay world-readable.
- The adapter declares `delete: false`. Removing a local key you still need is
unrecoverable from here, so deletions are reported and refused, never applied.
- `push` warns when a private key has **no passphrase**. It stays end-to-end
encrypted in the vault, but everyone granted that vault gets a ready-to-use
key.
## Core Objects
```txt