Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [0.9.0]

### Added
- `vars info` prints a read-only summary of the store: its location, the SSH key it's encrypted to and whether that key is available right now (and from where, agent, a `~/.ssh` file, or `VARS_SSH_KEY`), the secret/scope counts, and local git state. It needs no key and touches no network, so it's the command to run when you can't decrypt and want to know why.

### Changed
- The SSH key is found by **fingerprint** across `~/.ssh`, not just the conventional `id_ed25519`/`id_rsa` names. So a dedicated decryption key under any filename (e.g. `~/.ssh/id_vars`) is picked up automatically, no `VARS_SSH_KEY` needed. When the matching key is found but can't be loaded (passphrase-protected and not in the agent), the error names the exact file: `load it with ssh-add <path>`.
- When a command needs the key and it isn't loaded, vars runs `ssh-add` on **that specific key** (prompting for its passphrase) and proceeds in one go, but only when there's a terminal to prompt on and an agent to load into. Non-interactive runs get a clean error instead of hanging, and an explicit `VARS_SSH_KEY` is left strict (not auto-loaded). Use `ssh -t host vars …` to get a prompt over SSH.
- `vars dump` confirms before printing every secret in plaintext (the deliberate exception to the store's purpose). `--force`/`-f` skips the prompt and is required for non-interactive use, so a stray script can't mass-export every secret. `vars resolve` remains the command for feeding secrets into a process/pipe.
- `vars sync` reports the remote it synced with (`Store synced with <remote>`); `clone` and `sync` sit together in `vars help`.

### Fixed
- `vars dump` fails once with a single message when the store's SSH key isn't available, instead of warning per key. The per-key skip+warn remains for individual unreadable files.

## [0.8.0]

### Added
Expand Down
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,41 @@ fingerprint in `store.json`. To force a specific key (a non-standard path or dis
export VARS_SSH_KEY=~/.ssh/id_work
```

### Using a dedicated decryption key

You don't have to reuse your everyday login key. A common setup: a **separate key
just for vars**, kept alongside your others.

```sh
ssh-keygen -t ed25519 -f ~/.ssh/id_vars # a key only for decrypting the store
```

- **Discovery is automatic.** vars scans `~/.ssh` and uses whichever key matches
the store's fingerprint, `~/.ssh/id_vars` (or any name) is found on its own.
- **It won't interfere with logging into servers.** A non-default filename like
`id_vars` is *not* offered by `ssh` or loaded by a bare `ssh-add`, so your login
key stays the one used for servers.
- **Keep it passphrase-protected.** When you run a command that needs it in a
terminal, vars runs `ssh-add ~/.ssh/id_vars` for you (prompting once) and proceeds;
after that it's cached in the agent for the session.

Once `id_vars` is in the agent alongside your login key, it can be offered to
servers. To keep logins on your real key(s) only, set `IdentitiesOnly` globally and
**list each login key** (the `IdentityFile` lines accumulate into an allowlist;
anything not listed, including `id_vars`, is never offered):

```
# ~/.ssh/config
Host *
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519 # list every key you log in with
IdentityFile ~/.ssh/id_rsa

Host github.com
IdentitiesOnly yes
IdentityFile ~/.ssh/id_github
```

---

## Security
Expand Down Expand Up @@ -293,16 +328,17 @@ vars mv <old> <new> # rename a key (-f to skip the prompt)
vars rm <key>... # delete keys (-f to skip the prompt)
vars log <key> # a key's change history (newest first)
vars import [scope] <file> # import key=value pairs from a .env file
vars dump # print all keys and values
vars dump # print all keys and values (-f to skip the confirm)
vars init # scaffold .vars.yaml in the current directory
vars resolve [flags] # resolve manifest keys as shell exports
vars git <args> # run git in the store directory
vars sync # pull + push the store to its remote
vars clone <remote> # clone the store from a remote repo
vars info # store location, key + readiness, counts, git (read-only)
```

`resolve` flags: `-f/--file`, `-p/--profile`, `--dotenv`, `--fish`, `--partial`,
`--origin`. `set`/`import` take `--replace`/`--skip`; `mv`/`rm` take `-f/--force`.
`--origin`. `set`/`import` take `--replace`/`--skip`; `mv`/`rm`/`dump` take `-f/--force`.

---

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
69 changes: 0 additions & 69 deletions cmd/09_dump.go

This file was deleted.

7 changes: 4 additions & 3 deletions cmd/12_git.go → cmd/10_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
)

func init() {
rootCmd.AddCommand(gitCmd)
rootCmd.AddCommand(syncCmd)
rootCmd.AddCommand(gitCmd)
}

var gitCmd = &cobra.Command{
Expand Down Expand Up @@ -52,10 +52,11 @@ var syncCmd = &cobra.Command{
if !git.Available() || !git.IsRepo(dir) {
return UserError("the store is not a git repo; nothing to sync")
}
if err := git.New(dir).Sync(); err != nil {
repo := git.New(dir)
if err := repo.Sync(); err != nil {
return UserError(err.Error())
}
fmt.Fprintln(os.Stderr, "Synced")
fmt.Fprintf(os.Stderr, "Store synced with %s\n", repo.Remote())
return nil
},
}
File renamed without changes.
103 changes: 103 additions & 0 deletions cmd/12_dump.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cmd

import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/vars-cli/vars/internal/format"
"github.com/vars-cli/vars/internal/session"
"github.com/vars-cli/vars/internal/vault"
)

var (
dumpFish bool
dumpDotenv bool
dumpForce bool
)

func init() {
dumpCmd.Flags().BoolVar(&dumpDotenv, "dotenv", false, "Output as KEY=value (for docker --env-file etc.)")
dumpCmd.Flags().BoolVar(&dumpFish, "fish", false, "Output in fish shell format (set -x KEY value)")
dumpCmd.Flags().BoolVarP(&dumpForce, "force", "f", false, "Skip the confirmation prompt (for non-interactive use)")
rootCmd.AddCommand(dumpCmd)
}

var dumpCmd = &cobra.Command{
Use: "dump",
Short: "Dump all variables from the store",
Long: `Print every key and value from the store, in plaintext. No manifest involved.
Use it for migrating or debugging. For loading secrets into a process, use 'vars resolve' instead.

Prompts for confirmation unless --force is given.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
formatter := format.Posix
if dumpFish {
formatter = format.Fish
} else if dumpDotenv {
formatter = format.Dotenv
}

v, err := openVault()
if err != nil {
return err
}
keys, err := v.List() // no key needed; gives the count for the confirmation
if err != nil {
return InternalError(err.Error())
}

// dump prints every secret in plaintext — the deliberate exception to the
// store's whole purpose. Confirm first (before unlocking, so a cancel is
// free), unless --force; refuse non-interactively so a stray script can't
// mass-export every secret without explicit intent.
if !dumpForce && len(keys) > 0 {
fmt.Fprintf(os.Stderr, "This prints all %d secret(s) in plaintext.\n", len(keys))
if !term.IsTerminal(int(os.Stdin.Fd())) {
return UserError("dumping all secrets in plaintext requires confirmation; use --force for non-interactive use")
}
answer, err := stdinPrompter().Line("Continue? [y/N] ")
if err != nil {
return UserError(err.Error())
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") {
fmt.Fprintln(os.Stderr, "Aborted")
return nil
}
}

// Unlock the key (auto ssh-add when there's a terminal). Fail once here if
// it can't be resolved, rather than warning per key; the per-file skip
// below is only for individual unreadable files.
if meta, merr := vault.ReadMeta(storeDir()); merr == nil {
if err := session.EnsureKey(meta.KeyFingerprint); err != nil {
return UserError(err.Error())
}
}
// Recovery/migration path: dump everything we can, warn on what we can't,
// and exit non-zero if any key failed — one bad file must not hide the rest.
failed := false
for _, key := range keys {
val, err := v.Get(key)
if err != nil {
fmt.Fprintf(os.Stderr, "vars: warning: skipping %q: %v\n", key, err)
failed = true
continue
}
if dumpDotenv && format.HasNewline(string(val)) {
fmt.Fprintf(os.Stderr, "vars: warning: skipping %q: value has a newline, not representable in --dotenv\n", key)
failed = true
continue
}
fmt.Fprintln(os.Stdout, formatter(key, string(val)))
}
if failed {
return InternalError("some keys could not be dumped (see warnings above)")
}
return nil
},
}
73 changes: 73 additions & 0 deletions cmd/13_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/vars-cli/vars/internal/git"
"github.com/vars-cli/vars/internal/session"
"github.com/vars-cli/vars/internal/vault"
)

func init() {
rootCmd.AddCommand(infoCmd)
}

var infoCmd = &cobra.Command{
Use: "info",
Short: "Show store location, key, readiness, and git state",
Long: `Print a summary of the store: where it lives, which SSH key it's
encrypted to and whether that key is available right now (and from where), how
many secrets and scopes it holds, and its local git state.

Needs no key and touches no network, so it's the command to run when you can't
decrypt and want to know why.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
dir := storeDir()
if !vault.Exists(dir) {
return UserError(fmt.Sprintf("no vars store at %s (run `vars` to create one)", dir))
}
meta, err := vault.ReadMeta(dir)
if err != nil {
return UserError(err.Error())
}

scheme := meta.Scheme
if scheme != session.Scheme {
scheme += " (unsupported by this build)"
}
fmt.Printf("Store: %s\n", dir)
fmt.Printf("Scheme: %s\n", scheme)
fmt.Printf("Key: %s %s\n", meta.KeyFingerprint, session.KeyStatus(meta.KeyFingerprint))

// Counts: lazy backend, so no key is needed to list the files.
if v, oerr := session.Open(dir); oerr == nil {
keys, _ := v.List()
scopes, _ := v.Scopes()
scopeWord := "scopes"
if len(scopes) == 1 {
scopeWord = "scope"
}
fmt.Printf("Secrets: %d (%d %s)\n", len(keys), len(scopes), scopeWord)
}

// Git: local state only (no fetch, so it stays offline/instant).
switch {
case !git.Available() || !git.IsRepo(dir):
fmt.Println("Git: not versioned")
default:
repo := git.New(dir)
line := "repo, no remote"
if url := repo.RemoteURL(); url != "" {
line = "repo, remote " + url
}
if repo.HasUncommittedChanges() {
line += " (uncommitted changes; run `vars sync`)"
}
fmt.Printf("Git: %s\n", line)
}
return nil
},
}
15 changes: 15 additions & 0 deletions internal/crypto/sshderive/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ func sshString(b []byte) []byte {
return out
}

// FingerprintOfPubFile returns the SHA256 fingerprint of an OpenSSH public-key
// file (e.g. ~/.ssh/id_vars.pub). It needs no passphrase, so it's usable to find
// which key file matches a store, even when that key is passphrase-protected.
func FingerprintOfPubFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
pub, _, _, _, err := ssh.ParseAuthorizedKey(data)
if err != nil {
return "", err
}
return ssh.FingerprintSHA256(pub), nil
}

// supportedKeyType returns nil for deterministic key types and a clear,
// actionable error otherwise.
func supportedKeyType(pub ssh.PublicKey) error {
Expand Down
Loading
Loading