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
16 changes: 15 additions & 1 deletion 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.8.0]

### Added
- `vars clone <remote>` clones an existing store from a git remote into your local store directory (e.g. to set up from a store you already pushed), instead of creating a fresh, divergent one. It replaces an empty local store but refuses to overwrite one that holds secrets, locks the store directory to `0700`, and reports whether the SSH key the store is encrypted to is available (that key may differ from the one that authenticated the clone). `git clone` sets `origin`, so `vars sync` works immediately.

### Changed
- Writing to a store self-heals its static scaffolding: a missing `README.md`, `.gitignore`, or `.gitattributes` is recreated on the next write (a deleted `.gitignore` re-arms the default-deny allowlist before secrets are committed). Existing files are never overwritten. The scaffold now has a single source of truth in the `vault` package, shared by store creation and writes.
- `vars ls <arg>` accepts only a scope.
- Concurrent mutations are serialized by an advisory file lock (`flock` on a gitignored `.vars.lock`), so two simultaneous writes no longer race the git index, and a rename can't clobber a concurrently created key. The lock auto-releases if the process dies; it's a no-op on platforms without `flock` (vars targets Unix).
- Key names are restricted to `[A-Za-z0-9_-]` segments separated by `/`. This rejects accents and other non-ASCII (which collide across machines under Unicode normalization), control characters, and path-traversal, keeping keys portable and predictable.

### Fixed
- `vars set` treated an existing key whose value can't be decrypted (corrupt or foreign file, or the wrong key loaded) as a brand-new key: `--skip` would overwrite it and a plain `set` replaced it silently. It now surfaces the read failure instead, matching `vars import`.

## [0.7.0]

### Changed
Expand All @@ -12,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- The `[n]ew name` conflict option in `set`/`import` asks for a full key (scopes allowed, e.g. `prod/K`) instead of appending a `_<suffix>`.
- Key-free commands (`ls`, `scope`, `mv`, `rm`) no longer require the SSH key; it's resolved lazily, only when a command actually encrypts or decrypts.

## [0.6.0] (unreleased)
## [0.6.0]

Complete re-architecture: from a single scrypt-encrypted blob + gRPC agent to a
per-file, SSH-encrypted, git-tracked store. **Breaking: there is no in-place
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ vars dump # print everything (debugging / migration)
The store lives at `~/.local/share/vars/store/` by default (override with
`VARS_STORE_DIR`). It's just a directory of encrypted `.age` files with optional versioning.

Key names use letters, digits, `_` and `-`, with `/` for scopes (e.g. `prod/DB_URL`).
Other characters are rejected, so keys stay portable across machines and filesystems.

---

## Scopes
Expand Down Expand Up @@ -216,6 +219,22 @@ vars sync # pull --rebase, then push

After a change, vars reminds you to `vars sync` when a remote is configured.

### On a new machine

Once your store has a remote, get it onto a new machine by **cloning the remote
into your local store**, not by creating a fresh store (which would diverge):

```sh
vars clone git@github.com:me/store.git # clones into ~/.local/share/vars/store
vars get RPC_URL # ready, if that SSH key is loaded
```

`vars clone` sets `origin`, so `vars sync` works right away. If an empty local store exists,
clone replaces it (it refuses only if the local store holds secrets).
The key that authenticates the clone (your SSH key)
may differ from the key the store is encrypted with; clone tells you to `ssh-add`
the latter if it isn't loaded.

---

## How it works
Expand Down Expand Up @@ -255,7 +274,7 @@ export VARS_SSH_KEY=~/.ssh/id_work
- **Only encrypted files are committed**
- **Break-glass:** every store ships a `README.md` showing how to unlock with
`ssh-keygen` plus the decryption details, so you're never locked into the `vars` binary.
- **Permissions:** store dir `0700`, files `0600`; atomic writes.
- **Permissions:** the store directory is `0700`, the access boundary.
- **Quantum:** the file cipher is symmetric (safe). The SSH keypair is the
Shor-vulnerable link, as in every mainstream tool today; rotate long-lived
secrets and don't treat the store as an eternal archive.
Expand All @@ -268,7 +287,7 @@ export VARS_SSH_KEY=~/.ssh/id_work
vars # first run: create the store
vars set <key> [value] # add/update a key (prompts if omitted; "vars set KEY -" reads stdin)
vars get <key> # print a value (KEY~N for N versions ago)
vars ls [scope] # list keys as a tree (optionally a subtree)
vars ls [scope] # list keys as a tree (optional arg must be a scope)
vars scope ls # list scope prefixes
vars mv <old> <new> # rename a key (-f to skip the prompt)
vars rm <key>... # delete keys (-f to skip the prompt)
Expand All @@ -279,6 +298,7 @@ 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
```

`resolve` flags: `-f/--file`, `-p/--profile`, `--dotenv`, `--fish`, `--partial`,
Expand Down
11 changes: 10 additions & 1 deletion cmd/02_set.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package cmd

import (
"errors"
"fmt"
"io"
"os"

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

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

var (
Expand Down Expand Up @@ -72,7 +75,13 @@ shell history).
for {
existing, getErr := v.Get(key)
if getErr != nil {
break // new key — no conflict
if !errors.Is(getErr, vault.ErrNotFound) {
// The key exists but its current value can't be read (corrupt
// or foreign file, or the wrong key loaded). Don't silently
// overwrite it or treat --skip as "new" — surface it, as import does.
return UserError(getErr.Error())
}
break // truly new key — no conflict
}
if string(existing) == value {
fmt.Fprintln(os.Stderr, "Already set")
Expand Down
21 changes: 16 additions & 5 deletions cmd/05_ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ func init() {
var lsCmd = &cobra.Command{
Use: "ls [scope]",
Short: "List keys in the store as a tree",
Long: `Print the store's keys as a tree (scopes are directories).

With a scope argument, show only that subtree.`,
Long: `Print the store's keys as a tree (key scopes are a directory).
If a scope if given, only that subtree's keys are shown.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
v, err := openVault()
Expand All @@ -30,12 +29,24 @@ With a scope argument, show only that subtree.`,
return InternalError(err.Error())
}
if len(args) == 1 {
prefix := strings.TrimSuffix(args[0], "/") + "/"
scope := strings.TrimSuffix(args[0], "/")
prefix := scope + "/"
var sub []string
isKey := false
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
switch {
case strings.HasPrefix(k, prefix):
sub = append(sub, strings.TrimPrefix(k, prefix))
case k == scope:
isKey = true
}
}
if len(sub) == 0 {
// Only scopes are listable. Tell a key apart from a typo.
if isKey {
return UserError(fmt.Sprintf("%q is not a scope", scope))
}
return UserError(fmt.Sprintf("no such scope %q", scope))
}
keys = sub
}
Expand Down
79 changes: 79 additions & 0 deletions cmd/11_clone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"fmt"
"os"

"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(cloneCmd)
}

var cloneCmd = &cobra.Command{
Use: "clone <remote>",
Short: "Clone an existing store from a git remote",
Long: `Clone an existing store into your local store directory by cloning its git
remote (e.g. to set vars up from an encrypted remote store). Use this instead of
creating a fresh store, which would diverge from the remote. git clone sets
'origin', so 'vars sync' works right away.

If a store already exists locally, clone replaces it only when it holds no secrets.
The SSH key that authenticates the clone may differ from the key the store is encrypted
to; this reports whether the latter is available to read secrets.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
remote, dir := args[0], storeDir()
if vault.Exists(dir) {
// Replace an empty store (no secrets to lose); never clobber one with secrets.
v, err := session.Open(dir)
if err != nil {
return UserError(err.Error())
}
keys, err := v.List()
if err != nil {
return InternalError(err.Error())
}
if len(keys) > 0 {
return UserError(fmt.Sprintf("a store with %d secret(s) already exists at %s; clone won't overwrite it (move it aside, or set VARS_STORE_DIR elsewhere)", len(keys), dir))
}
fmt.Fprintf(os.Stderr, "Replacing the empty store at %s\n", dir)
if err := os.RemoveAll(dir); err != nil {
return InternalError(fmt.Sprintf("removing the empty store: %v", err))
}
}
if !git.Available() {
return UserError("git is not installed; it's needed to clone a store")
}
if err := git.Clone(remote, dir); err != nil {
return &ExitError{Code: 1} // git already wrote its own error
}
// The 0700 store root is the access boundary (git can't record file modes,
// and a later pull would reset them anyway, so we don't chase per-file modes).
if err := os.Chmod(dir, 0o700); err != nil {
fmt.Fprintf(os.Stderr, "vars: warning: could not set %s to 0700: %v\n", dir, err)
}
if !vault.Exists(dir) {
return UserError(fmt.Sprintf("cloned into %s, but there's no store.json — is this a vars store?", dir))
}
fmt.Fprintf(os.Stderr, "Cloned into %s\n", dir)

meta, err := vault.ReadMeta(dir)
if err != nil {
return UserError(err.Error())
}
switch {
case meta.Scheme != session.Scheme:
fmt.Fprintf(os.Stderr, "Note: store scheme %q is not supported by this vars (%q); upgrade to read it.\n", meta.Scheme, session.Scheme)
case !session.KeyAvailable(meta.KeyFingerprint):
fmt.Fprintf(os.Stderr, "This store is encrypted to SSH key %s.\n", meta.KeyFingerprint)
fmt.Fprintln(os.Stderr, "Load that key (`ssh-add`, or point VARS_SSH_KEY at it) to read secrets.")
}
return nil
},
}
5 changes: 3 additions & 2 deletions cmd/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ func openVault() (*vault.Vault, error) {

// firstRun creates the store, selecting which SSH key to bind it to.
func firstRun(dir string) error {
fmt.Fprintf(os.Stderr, "No store found, creating one at:\n %s\n\n", dir)

// Resolve a usable key before announcing creation, so a keyless run errors
// cleanly instead of claiming to create a store it then can't.
signers, err := session.UsableInitSigners()
if err != nil {
return UserError(err.Error())
}
fmt.Fprintf(os.Stderr, "No store found, creating one at:\n %s\n\n", dir)

signer := signers[0]
if len(signers) > 1 {
Expand Down
9 changes: 9 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ func IsRepo(dir string) bool {
return exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree").Run() == nil
}

// Clone runs `git clone <remote> <dir>` with the process's own stdio, so the
// user's credentials, host-key prompts, and progress all apply. It also sets
// `origin`, so `vars sync` works immediately afterward.
func Clone(remote, dir string) error {
cmd := exec.Command("git", "clone", remote, dir)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}

// Init initializes a git repo in dir (which must already exist) and ensures a
// commit identity, so auto-commits never fail for lack of one. An existing
// global/local identity is left untouched.
Expand Down
63 changes: 11 additions & 52 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ func signerForFingerprint(fp string) (*sshderive.Signer, error) {
return nil, fmt.Errorf("could not find the SSH key this store needs (%s); load it with `ssh-add`, or point VARS_SSH_KEY at its file", fp)
}

// KeyAvailable reports whether the SSH key matching fingerprint can be resolved
// right now (VARS_SSH_KEY / ssh-agent / default file). Used by `vars clone` to
// tell the user if they're ready to decrypt; this key may differ from the one
// that authenticated the git clone.
func KeyAvailable(fingerprint string) bool {
_, err := signerForFingerprint(fingerprint)
return err == nil
}

// UsableInitSigners returns candidate keys for creating a new store: the
// VARS_SSH_KEY file if set, otherwise every usable key in ssh-agent, otherwise
// the default key file. Callers pick one (auto when there is exactly one).
Expand Down Expand Up @@ -170,14 +179,8 @@ func Create(dir string, signer *sshderive.Signer) error {
if err := vault.Init(dir, vault.Meta{Scheme: Scheme, KeyFingerprint: signer.Fingerprint()}); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(storeReadme), 0o644); err != nil {
return fmt.Errorf("writing README.md: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(storeGitignore), 0o644); err != nil {
return fmt.Errorf("writing .gitignore: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte(storeGitattributes), 0o644); err != nil {
return fmt.Errorf("writing .gitattributes: %w", err)
if err := vault.WriteScaffold(dir); err != nil {
return err
}
// git is a soft dependency: if it's missing or fails, the store is still
// created and fully usable, just without versioning/sync.
Expand Down Expand Up @@ -206,47 +209,3 @@ func defaultKeyPath() string {
}
return ""
}

// storeGitignore is a default-deny allowlist: the store's git repo tracks only
// encrypted secrets, the descriptor, and the README — never stray plaintext,
// editor junk, or the atomic-write temp files. Users can `git add -f` to override.
const storeGitignore = `# vars store: commit only encrypted secrets, the descriptor, and this README.
*
!*/
!*.age
!/store.json
!/README.md
!/.gitignore
!/.gitattributes
`

// storeGitattributes marks encrypted files as binary so git never text-merges
// them (which would inject conflict markers into ciphertext) and never applies
// line-ending conversion (which would corrupt the bytes under core.autocrlf). A
// conflict on a key then resolves cleanly as a whole-file "pick a side".
const storeGitattributes = "*.age binary\n"

// storeReadme is written as README.md at the store root, so the directory
// explains itself and documents how to recover secrets without the vars binary.
const storeReadme = "# vars store\n\n" +
"This directory is an encrypted [vars](https://github.com/vars-cli/vars) store: one\n" +
"[age](https://age-encryption.org)-encrypted file per secret, each file's key derived\n" +
"from an SSH key (scheme `ssh-v1`; the key's fingerprint is in `store.json`).\n\n" +
"## Reading these secrets\n\n" +
"Use vars, it's open-source and a single static Go binary, so rebuild it if needed:\n\n" +
" go install github.com/vars-cli/vars@latest # if you don't have the binary\n" +
" vars dump # print every key and value\n" +
" vars get <KEY> # one value\n\n" +
"## If vars is unavailable: the format\n\n" +
"You need the SSH private key whose fingerprint is in `store.json`. Decryption uses\n" +
"standard primitives, so it can be reimplemented in any language with a crypto library\n" +
"(it is NOT a sequence of shell commands). For each `<key>.age`:\n\n" +
"1. Parse the age header; find the `-> vars-ssh-v1 <salt>` stanza. `<salt>` is base64\n" +
" (raw std). Its body is `nonce (12 bytes) || ChaCha20-Poly1305-sealed file-key`.\n" +
"2. Sign the decoded salt with SSHSIG, namespace `vars.store.v1` (hash sha512):\n\n" +
" ssh-keygen -Y sign -n vars.store.v1 -f ~/.ssh/id_ed25519 salt-file\n\n" +
" Use the inner signature bytes (`string(format) || string(blob)`) from the `.sig`.\n" +
"3. `wrapKey = HKDF-SHA256(secret = signature bytes, salt = decoded salt, info = \"vars.store.v1/fileKey\")` (32 bytes).\n" +
"4. `file-key = ChaCha20-Poly1305-Open(wrapKey, nonce, sealed)`.\n" +
"5. Decrypt the age payload with that file-key (age's injected-file-key identity).\n\n" +
"Reference implementation: `internal/crypto/sshderive` in the vars source.\n"
35 changes: 35 additions & 0 deletions internal/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ func writeKey(t *testing.T) (path, fingerprint string) {
return path, ssh.FingerprintSHA256(ss.PublicKey())
}

// noKeyEnv neutralizes every SSH key source (env override, agent, default file)
// so the resolver finds nothing: the "no key available" edge.
func noKeyEnv(t *testing.T) {
t.Helper()
t.Setenv("VARS_SSH_KEY", "")
t.Setenv("SSH_AUTH_SOCK", "")
t.Setenv("HOME", t.TempDir()) // a home with no ~/.ssh keys
}

// With no key anywhere, creating a store (first run) errors with the actionable
// ssh-keygen hint instead of half-creating something it can't encrypt to.
func TestUsableInitSigners_NoKey(t *testing.T) {
noKeyEnv(t)
_, err := UsableInitSigners()
if err == nil {
t.Fatal("expected an error when no SSH key is available")
}
if !strings.Contains(err.Error(), "ssh-keygen") {
t.Fatalf("error should hint at ssh-keygen, got %q", err)
}
}

// With no key anywhere, resolving the store's key (read/write path) errors with
// the actionable ssh-add hint.
func TestSignerForFingerprint_NoKey(t *testing.T) {
noKeyEnv(t)
_, err := signerForFingerprint("SHA256:irrelevant")
if err == nil {
t.Fatal("expected an error when no SSH key is available")
}
if !strings.Contains(err.Error(), "ssh-add") {
t.Fatalf("error should hint at ssh-add, got %q", err)
}
}

func TestSignerForFingerprint_ViaEnvKey(t *testing.T) {
keyPath, fp := writeKey(t)
t.Setenv("VARS_SSH_KEY", keyPath)
Expand Down
Loading
Loading