shh

command module
v0.7.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 1 Imported by: 0

README ΒΆ

shh

Warning: Experimental β€” not yet ready for production use.

🀫 Commit your secrets. Yes, really.

  • Encrypted secrets live in your repo, safe to push, easy to share.
  • Add teammates by GitHub username β€” shh users add alice fetches their SSH key automatically.
  • No GitHub? Pass an age key directly.
  • One binary, one dependency (gh). Private keys stay in your OS keyring.

Install

brew install stefanpenner/tap/shh

Or from source:

go install github.com/stefanpenner/shh@latest

Quick Start

shh init                              # one-time setup (requires gh auth login)
shh set DATABASE_URL postgres://localhost/mydb
shh set API_KEY sk-secret123
shh shell                             # launch a shell with secrets loaded

No plaintext .env file ever touches disk.

Commands

shh set KEY value                     # add or update a secret
shh get KEY                           # print a single secret value
shh rm KEY                            # remove a secret
shh edit                              # edit all secrets in $EDITOR
shh list                              # list secret names
shh env --stdout                      # print export statements (requires --stdout)
shh shell                             # open a shell with secrets loaded
shh run -- <cmd> [args...]            # run a command with secrets injected
shh template <file>                   # render a template with secrets substituted
shh doctor                            # check your setup for common issues
shh whoami                            # show your key and identity

All commands default to .env.enc. Use -e to pick an environment:

shh shell -e staging                  # opens shell with staging.env.enc
shh run -e production -- node app.js  # runs with production secrets

Or pass a filename directly:

shh set KEY value staging.env.enc

Already have a .env file? Encrypt it:

shh encrypt .env                      # creates .env.enc (then delete .env)

Team Workflow

# You (project owner)
shh init
shh set SECRET supersecret
git add .env.enc && git push

# Add a teammate
shh users add alice
git add .env.enc && git push

# Alice (joining the project)
shh login                             # auto-detects SSH key via GitHub
shh shell                             # works immediately
shh users list                        # show who has access
shh users add <username-or-key>       # add by GitHub username or age key
shh users remove <user|#>            # revoke access (rotates data key)

Recovery root (paper / 1Password / QR)

Keep at least two ways to open .env.enc (daily Mac key + recovery).
Generate a recovery recipient with a scannable QR:

# In a repo that has .env.enc and your daily key in the keyring / SHH_AGE_KEY:
shh users add --name recovery --qr --qr-out ~/Desktop/shh-recovery.png
# β†’ prints AGE-SECRET-KEY-1… once (paste into 1Password)
# β†’ terminal QR on stderr + PNG at --qr-out (print, then delete the file)
git add .env.enc && git commit -m "shh: add recovery recipient"

Restore on a new machine from the QR image:

shh login --qr-file ~/path/to/shh-recovery.png
shh doctor
shh env --stdout | head   # proves decrypt

Or without QR: export SHH_AGE_KEY='AGE-SECRET-KEY-1…' then shh env --stdout.

Formal model of this flow: specs/RecoveryQR.tla (tlc specs/RecoveryQR.tla).

CI / Production

Create a deploy key for environments that don't have a GitHub identity:

shh users add --name production-deploy
# Prints a secret key β€” store it as SHH_AGE_KEY in your CI/deploy platform
# Optional: --qr --qr-out ./deploy.png for a one-time cold copy
git add .env.enc && git push

Then in your CI pipeline or Dockerfile:

shh run -- node app.js            # secrets injected, SHH_AGE_KEY auto-filtered
# or
eval $(shh env --stdout)          # export secrets into the current shell

You manage one platform secret (SHH_AGE_KEY); everything else lives in .env.enc.

If you already have an age public key, pass it directly:

shh users add --name staging --key age1xyzt...

Hardware keys (YubiKey & Secure Enclave)

shh supports age plugin recipients, so a key can be backed by hardware whose private key can never be extracted β€” a YubiKey or Apple's Secure Enclave β€” instead of a copyable secret string.

# YubiKey (private key lives on the device; decrypt needs a touch)
brew install age-plugin-yubikey
age-plugin-yubikey --generate                       # prints age1yubikey1… + an identity file
shh users add --name stef-yubikey --key age1yubikey1…
shh login --identity ~/age-yubikey-identity.txt     # store the identity stub in your keyring

# Apple Secure Enclave (built into the Mac; decrypt needs Touch ID)
brew install age-plugin-se
age-plugin-se keygen -o se-key.txt                  # recipient is in the file header
shh users add --name stef-laptop --key "$(age-plugin-se recipients -i se-key.txt)"
shh login --identity se-key.txt

Adding or re-wrapping for a plugin recipient needs the plugin binary installed (encryption is public-key only β€” no hardware required). Decrypting needs the hardware itself present (touch / PIN / biometric).

Passphrase ("brain") key

A key derived from a passphrase β€” nothing to store, nothing to lose. The only recovery path that survives losing every device.

shh users add --name failsafe --passphrase   # prompts; adds a derived recipient
shh login --passphrase                         # re-derive to unlock
  • It's a normal age key (argon2id β†’ X25519), so it shows as [extractable].
  • .env.enc is committed, so it can be brute-forced offline β†’ use a generated 8-word passphrase, and keep it complementary to a hardware key, not your only failsafe.
  • Details + threat model: docs/passphrase-security.md.
Extractable key, or non-extractable?
Extractable (X25519 β€” the default) Non-extractable (YubiKey / Secure Enclave)
The private key is… a copyable string sealed in hardware, never leaves it
Back up / sync / recover βœ… yes ❌ no β€” can't be copied
Use as a CI SHH_AGE_KEY βœ… yes ❌ no (needs the device)
If the store/laptop is compromised the key leaks the key is safe
Lost device recover from your backup locked out unless you enrolled a second key
  • CI / deploy keys β†’ extractable. No human, no hardware; the key must live as a platform secret.
  • Your everyday key β†’ non-extractable. Secure Enclave: free, and a stolen laptop or leaked backup never exposes it.
  • An offline root β†’ a YubiKey in a safe. Enroll two (each its own recipient) β€” there's no recovery for hardware keys.
  • Sync a key only if it's extractable, and only in an end-to-end-encrypted store. Syncing means accepting it's copyable β€” right for a recovery key, wrong for one whose job is to be un-copyable.

Retiring or losing an extractable key requires rotating your secrets. shh users remove re-wraps the data key for the remaining recipients, but the removed key already saw every secret value and can still decrypt the old .env.enc in git history. So whenever an extractable key is exposed or rotated out, treat its secrets as compromised and change the values (shh users remove …, then re-generate + shh set each affected secret). Hardware keys never leak a copyable secret, so retiring one is just shh users remove.

The recipient's key encodes its type (age1… extractable, age1yubikey1… / age1se1… hardware), and shh users list tags each one ([extractable], [yubikey], [secure-enclave]) so you can see which keys are in scope for the rotation rule above.

How It Works

shh uses envelope encryption: a random 32-byte AES-256 data key encrypts all secrets, and that data key is wrapped (age-encrypted) individually to each recipient.

  • Adding a user β€” re-wraps the existing data key for the new recipient. Secrets don't change.
  • Removing a user β€” generates a new data key, re-encrypts all secrets, wraps only for remaining recipients. The removed key can't read new versions β€” but it already saw the current values and can still decrypt the old .env.enc in git history, so if it was an extractable key, rotate the secret values too.

Private keys stay in your OS keyring (macOS Keychain, GNOME/KDE Secret Service, Windows Credential Manager). Set SHH_AGE_KEY to override for CI/Docker. Set SHH_PLAINTEXT to point at a plain .env file to skip decryption entirely.

File Format

.env.enc is TOML:

version = 2
mac = "hmac-sha256-hex"

[recipients]
"https://github.com/alice" = "age1..."
"https://github.com/bob" = "age1..."

[wrapped_keys]
"https://github.com/alice" = "base64-data-key-wrapped-to-alice"
"https://github.com/bob" = "base64-data-key-wrapped-to-bob"

[secrets]
DATABASE_URL = "base64-aes-256-gcm-ciphertext"
API_KEY = "base64-aes-256-gcm-ciphertext"
Section Purpose
recipients Maps identities to age public keys
wrapped_keys The data key, individually wrapped to each recipient β€” one entry per recipient so branch additions don't conflict
secrets Values encrypted with AES-256-GCM using the data key; key name is authenticated data
mac HMAC-SHA256 over all fields, verified on every decrypt

Git Merge Support

Per-recipient wrapped keys mean adding teammates on different branches won't conflict. If a merge conflict does occur, any shh command auto-detects it, performs a semantic 3-way merge, and stages the result. True conflicts (same key modified both sides) are reported for manual resolution.

For proactive conflict prevention:

# .gitattributes (commit this)
*.env.enc merge=shh

# ~/.gitconfig (each developer)
[merge "shh"]
    name = shh encrypted env merge
    driver = shh merge %O %A %B

Claude Code Integration

If your team uses Claude Code, copy CLAUDE.md.example into your project's CLAUDE.md (or append it to an existing one). This teaches Claude how to manage secrets with shh β€” it will use the right commands, avoid leaking values, and follow best practices automatically.

# In your project directory:
curl -sL https://raw.githubusercontent.com/stefanpenner/shh/main/CLAUDE.md.example >> CLAUDE.md

License

MIT

Documentation ΒΆ

The Go Gopher

There is no documentation for this package.

Directories ΒΆ

Path Synopsis
internal
cli
qr
Package qr encodes and decodes age recovery identities as QR codes for paper / 1Password cold backup (Ring 0).
Package qr encodes and decodes age recovery identities as QR codes for paper / 1Password cold backup (Ring 0).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL