diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1a60d..edc4c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 ` 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 @@ -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 `_`. - 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 diff --git a/README.md b/README.md index 1f11063..f5f2b09 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -268,7 +287,7 @@ export VARS_SSH_KEY=~/.ssh/id_work vars # first run: create the store vars set [value] # add/update a key (prompts if omitted; "vars set KEY -" reads stdin) vars get # 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 # rename a key (-f to skip the prompt) vars rm ... # delete keys (-f to skip the prompt) @@ -279,6 +298,7 @@ vars init # scaffold .vars.yaml in the current directory vars resolve [flags] # resolve manifest keys as shell exports vars git # run git in the store directory vars sync # pull + push the store to its remote +vars clone # clone the store from a remote repo ``` `resolve` flags: `-f/--file`, `-p/--profile`, `--dotenv`, `--fish`, `--partial`, diff --git a/cmd/02_set.go b/cmd/02_set.go index 58c4c24..a97439c 100644 --- a/cmd/02_set.go +++ b/cmd/02_set.go @@ -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 ( @@ -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") diff --git a/cmd/05_ls.go b/cmd/05_ls.go index be5e79c..4384864 100644 --- a/cmd/05_ls.go +++ b/cmd/05_ls.go @@ -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() @@ -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 } diff --git a/cmd/11_clone.go b/cmd/11_clone.go new file mode 100644 index 0000000..6ce1956 --- /dev/null +++ b/cmd/11_clone.go @@ -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 ", + 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 + }, +} diff --git a/cmd/store.go b/cmd/store.go index d110738..8c8c400 100644 --- a/cmd/store.go +++ b/cmd/store.go @@ -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 { diff --git a/internal/git/git.go b/internal/git/git.go index 16a8728..ff74c81 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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 ` 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. diff --git a/internal/session/session.go b/internal/session/session.go index c196843..e0bac4a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -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). @@ -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. @@ -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 # 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 `.age`:\n\n" + - "1. Parse the age header; find the `-> vars-ssh-v1 ` stanza. `` 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" diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 8b15f82..c84c20f 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -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) diff --git a/internal/vault/lock_other.go b/internal/vault/lock_other.go new file mode 100644 index 0000000..dce8e9c --- /dev/null +++ b/internal/vault/lock_other.go @@ -0,0 +1,8 @@ +//go:build !unix + +package vault + +// lockStore is a no-op where flock is unavailable. vars targets Unix +// (Linux/macOS/WSL); native Windows is out of scope, so mutations there are not +// serialized across processes. +func lockStore(dir string) (func(), error) { return func() {}, nil } diff --git a/internal/vault/lock_unix.go b/internal/vault/lock_unix.go new file mode 100644 index 0000000..f5a2e26 --- /dev/null +++ b/internal/vault/lock_unix.go @@ -0,0 +1,29 @@ +//go:build unix + +package vault + +import ( + "os" + "path/filepath" + "syscall" +) + +// lockStore takes an exclusive advisory lock on the store so concurrent vars +// mutations serialize instead of racing the git index or each other's renames. +// It returns a function that releases the lock. The lock is advisory (only other +// vars processes honor it), held on the gitignored .vars.lock file, and released +// by the kernel if the process dies (so there is no stale lock to clean up). +func lockStore(dir string) (func(), error) { + f, err := os.OpenFile(filepath.Join(dir, lockFile), os.O_CREATE|os.O_RDWR, filePerm) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, err + } + return func() { + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + f.Close() + }, nil +} diff --git a/internal/vault/lock_unix_test.go b/internal/vault/lock_unix_test.go new file mode 100644 index 0000000..c6c24c5 --- /dev/null +++ b/internal/vault/lock_unix_test.go @@ -0,0 +1,43 @@ +//go:build unix + +package vault + +import ( + "testing" + "time" +) + +// A second lock attempt must block until the first is released, so concurrent +// mutations serialize instead of racing. +func TestLockStore_Serializes(t *testing.T) { + dir := t.TempDir() + unlock1, err := lockStore(dir) + if err != nil { + t.Fatalf("first lock: %v", err) + } + + acquired := make(chan struct{}) + go func() { + unlock2, err := lockStore(dir) // must block while unlock1 is held + if err != nil { + t.Errorf("second lock: %v", err) + return + } + close(acquired) + unlock2() + }() + + select { + case <-acquired: + t.Fatal("second lock was acquired while the first was still held") + case <-time.After(100 * time.Millisecond): + // still blocked, as expected + } + + unlock1() + select { + case <-acquired: // now it can proceed + case <-time.After(2 * time.Second): + t.Fatal("second lock never acquired after the first was released") + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index d05d795..24f2c3a 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -1,9 +1,10 @@ // Package vault is the vars store: one age-encrypted file per secret, scopes as // directories, rooted at a single directory that is usually a git repo. // -// It does encrypted file CRUD only. Versioning is delegated to an optional -// Committer (implemented by the git package), so the vault knows nothing about -// git and stays trivially testable. +// It does encrypted file CRUD plus the store's on-disk scaffolding (the +// store.json descriptor and the static README/.gitignore/.gitattributes). +// Versioning is delegated to an optional Committer (implemented by the git +// package), so the vault knows nothing about git and stays trivially testable. package vault import ( @@ -24,6 +25,10 @@ const ( DescriptorFile = "store.json" ageExt = ".age" + // lockFile is the advisory mutation lock (gitignored by the default-deny + // allowlist, so it is never committed). + lockFile = ".vars.lock" + dirPerm = 0o700 filePerm = 0o600 ) @@ -171,6 +176,11 @@ type Item struct { // Set encrypts value and writes it to .age, then commits. func (v *Vault) Set(key string, value []byte) error { + unlock, err := lockStore(v.dir) + if err != nil { + return err + } + defer unlock() if err := v.writeKey(key, value); err != nil { return err } @@ -183,6 +193,11 @@ func (v *Vault) Set(key string, value []byte) error { // residual non-atomicity is a disk error partway through the final write loop, // which no single-rename scheme can avoid. func (v *Vault) SetMany(items []Item, message string) error { + unlock, err := lockStore(v.dir) + if err != nil { + return err + } + defer unlock() type blob struct { path string data []byte @@ -199,6 +214,9 @@ func (v *Vault) SetMany(items []Item, message string) error { } blobs = append(blobs, blob{path, ciphertext}) } + if err := WriteScaffold(v.dir); err != nil { + return err + } for _, b := range blobs { if err := os.MkdirAll(filepath.Dir(b.path), dirPerm); err != nil { return fmt.Errorf("creating scope directory: %w", err) @@ -216,6 +234,9 @@ func (v *Vault) writeKey(key string, value []byte) error { if err != nil { return err } + if err := WriteScaffold(v.dir); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { return fmt.Errorf("creating scope directory: %w", err) } @@ -228,6 +249,11 @@ func (v *Vault) writeKey(key string, value []byte) error { // Delete removes key (and prunes now-empty scope directories), then commits. func (v *Vault) Delete(key string) error { + unlock, err := lockStore(v.dir) + if err != nil { + return err + } + defer unlock() if err := v.removeKey(key); err != nil { return err } @@ -236,6 +262,11 @@ func (v *Vault) Delete(key string) error { // DeleteMany removes several keys and commits once with the given message. func (v *Vault) DeleteMany(keys []string, message string) error { + unlock, err := lockStore(v.dir) + if err != nil { + return err + } + defer unlock() for _, key := range keys { if err := v.removeKey(key); err != nil { return err @@ -263,6 +294,11 @@ func (v *Vault) removeKey(key string) error { // Rename moves a key. No re-encryption: the wrapping key derives from the // in-file salt, not the path. Errors if dst exists or src is missing. func (v *Vault) Rename(from, to string) error { + unlock, err := lockStore(v.dir) + if err != nil { + return err + } + defer unlock() src, err := v.pathFor(from) if err != nil { return err @@ -370,15 +406,51 @@ func validateKey(key string) error { if strings.HasPrefix(key, "/") || strings.HasSuffix(key, "/") { return fmt.Errorf("invalid key %q: must not start or end with %q", key, "/") } - if strings.ContainsAny(key, "\x00\\") { - return fmt.Errorf("invalid key %q: contains an illegal character", key) - } if strings.ContainsRune(key, '~') { return fmt.Errorf("invalid key %q: '~' is reserved for version references (KEY~N)", key) } + // Keys are file paths and env-var-ish names, so each '/'-separated segment is + // restricted to [A-Za-z0-9_-]: portable across machines and filesystems, no + // Unicode-normalization collisions, no path traversal, no surprises. for _, seg := range strings.Split(key, "/") { - if seg == "" || seg == "." || seg == ".." { - return fmt.Errorf("invalid key %q: empty or relative path segment", key) + if seg == "" { + return fmt.Errorf("invalid key %q: empty scope segment", key) + } + for _, r := range seg { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') { + return fmt.Errorf("invalid key %q: only letters, digits, '_', '-', and '/' separators are allowed", key) + } + } + } + return nil +} + +// scaffoldFiles are the unencrypted, static files every store carries besides +// store.json: the break-glass README and the git allowlist/attributes. +var scaffoldFiles = []struct{ name, content string }{ + {"README.md", readmeContent}, + {".gitignore", gitignoreContent}, + {".gitattributes", gitattributesContent}, +} + +// WriteScaffold writes any missing static store file (README, .gitignore, +// .gitattributes), so creating a store and writing to one share a single code +// path, and a write self-heals a store whose scaffolding was deleted or arrived +// incomplete (e.g. a restored .gitignore re-arms the default-deny allowlist +// before the next commit). It never overwrites an existing file, so a +// customized README is preserved. store.json is not written here: it carries the +// key fingerprint and is created when the store is opened (see session.Create). +func WriteScaffold(dir string) error { + for _, f := range scaffoldFiles { + p := filepath.Join(dir, f.name) + switch _, err := os.Stat(p); { + case err == nil: + continue // present, leave it be + case !os.IsNotExist(err): + return err + } + if err := os.WriteFile(p, []byte(f.content), filePerm); err != nil { + return fmt.Errorf("writing %s: %w", f.name, err) } } return nil @@ -430,3 +502,47 @@ func atomicWrite(path string, data []byte, perm os.FileMode) error { success = true return nil } + +// gitignoreContent 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 gitignoreContent = `# vars store: commit only encrypted secrets, the descriptor, and this README. +* +!*/ +!*.age +!/store.json +!/README.md +!/.gitignore +!/.gitattributes +` + +// gitattributesContent 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 gitattributesContent = "*.age binary\n" + +// readmeContent 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 readmeContent = "# 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 # 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 `.age`:\n\n" + + "1. Parse the age header; find the `-> vars-ssh-v1 ` stanza. `` 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" diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 712a888..8e34ccd 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -62,6 +62,67 @@ func TestVault_SetGet(t *testing.T) { } } +// Each '/'-separated key segment is restricted to [A-Za-z0-9_-]: portable, no +// Unicode-normalization collisions, no path traversal, no surprising characters. +func TestValidateKey(t *testing.T) { + valid := []string{"RPC_URL", "prod/PRIVATE_KEY", "a/b/c", "API-KEY", "v2-key_3"} + for _, k := range valid { + if err := validateKey(k); err != nil { + t.Errorf("validateKey(%q) = %v, want nil", k, err) + } + } + invalid := []string{ + "café", // accented (non-ASCII) + "clé_privée", // accented + "API.KEY", // dot + "a b", // space + "a@b", // punctuation + "a\tb", // control character + "a\\b", // backslash + "/leading", // leading slash + "trailing/", // trailing slash + "a//b", // empty segment + "a/../b", // relative segment (dot rejected) + "K~1", // reserved version marker + "", // empty + } + for _, k := range invalid { + if err := validateKey(k); err == nil { + t.Errorf("validateKey(%q) = nil, want an error", k) + } + } +} + +// A write self-heals missing static scaffolding (e.g. a deleted .gitignore that +// would otherwise leave the default-deny allowlist disarmed). +func TestSet_HealsMissingScaffold(t *testing.T) { + v := newVault(t, nil) + // A fresh Init writes only store.json; the static files appear on first write. + if err := v.Set("K", []byte("v")); err != nil { + t.Fatalf("set: %v", err) + } + for _, f := range []string{"README.md", ".gitignore", ".gitattributes"} { + if _, err := os.Stat(filepath.Join(v.dir, f)); err != nil { + t.Fatalf("write did not heal %s: %v", f, err) + } + } +} + +// WriteScaffold restores only what's missing; it never clobbers a customized file. +func TestWriteScaffold_PreservesExisting(t *testing.T) { + v := newVault(t, nil) + readme := filepath.Join(v.dir, "README.md") + if err := os.WriteFile(readme, []byte("custom"), filePerm); err != nil { + t.Fatal(err) + } + if err := WriteScaffold(v.dir); err != nil { + t.Fatalf("scaffold: %v", err) + } + if b, _ := os.ReadFile(readme); string(b) != "custom" { + t.Fatalf("clobbered a customized README: %q", b) + } +} + // A missing key must be distinguishable (errors.Is ErrNotFound) from a real // decrypt/IO failure, so resolve's scope fallback and import don't mask the latter. func TestVault_GetMissingIsErrNotFound(t *testing.T) { diff --git a/test/e2e/integration_test.go b/test/e2e/integration_test.go index 6567cc2..e24a05f 100644 --- a/test/e2e/integration_test.go +++ b/test/e2e/integration_test.go @@ -697,3 +697,111 @@ func TestFirstRunBackupTip(t *testing.T) { has(t, se, "versioned with git") // message reflects that git is active has(t, se, "vars git remote add origin") // one-time backup nudge } + +// seedSourceStore builds a store in a separate dir sharing r's key, with one +// secret, and returns its path. Skips the test if git isn't functional (clone +// needs a real repo). +func seedSourceStore(t *testing.T, r *runner) string { + t.Helper() + src := t.TempDir() + env := append([]string{}, r.env...) + for i := range env { + if strings.HasPrefix(env[i], "VARS_STORE_DIR=") { + env[i] = "VARS_STORE_DIR=" + src + } + } + cmd := exec.Command(binary, "set", "RPC_URL", "https://rpc") + cmd.Dir, cmd.Env = r.workDir, env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("seed source store: %v\n%s", err, out) + } + if _, e := os.Stat(filepath.Join(src, ".git")); e != nil { + t.Skip("git unavailable in this environment; clone needs a real repo") + } + return src +} + +func TestClone(t *testing.T) { + r := newRunner(t) // r.storeDir is the clone target (no store yet) + src := seedSourceStore(t, r) + + _, se, err := r.run("clone", src) + if err != nil { + t.Fatalf("clone: %v\n%s", err, se) + } + has(t, se, "Cloned into") + // The clone is a usable store with the same key: secrets read back. + if got := r.mustRun("get", "RPC_URL"); got != "https://rpc" { + t.Fatalf("get after clone = %q", got) + } + // origin is set (so `vars sync` works); a store with secrets refuses re-clone. + if out := r.mustRun("git", "remote"); !strings.Contains(out, "origin") { + t.Fatalf("expected origin remote, got %q", out) + } + if _, _, err := r.run("clone", src); err == nil { + t.Fatal("clone over a store with secrets should fail") + } +} + +func TestCloneReplacesEmptyStore(t *testing.T) { + r := newRunner(t) + src := seedSourceStore(t, r) + r.mustRun("ls") // first-run creates an empty store at the target (no secrets) + + _, se, err := r.run("clone", src) + if err != nil { + t.Fatalf("clone should replace an empty store: %v\n%s", err, se) + } + has(t, se, "Replacing the empty store") + if got := r.mustRun("get", "RPC_URL"); got != "https://rpc" { + t.Fatalf("get after clone = %q", got) + } +} + +// The 0700 store root is the access boundary; clone must lock it down regardless +// of the umask git cloned under. +func TestCloneLocksStoreDir(t *testing.T) { + r := newRunner(t) + src := seedSourceStore(t, r) + if _, se, err := r.run("clone", src); err != nil { + t.Fatalf("clone: %v\n%s", err, se) + } + fi, err := os.Stat(r.storeDir) + if err != nil { + t.Fatal(err) + } + if m := fi.Mode().Perm(); m != 0o700 { + t.Fatalf("clone store dir = %o, want 700", m) + } +} + +// set must base "exists" on the file, not on a successful decrypt: --skip over an +// existing-but-unreadable key must error, never silently overwrite it. +func TestSetSkipDoesNotOverwriteUnreadable(t *testing.T) { + r := newRunner(t) + r.mustRun("set", "K", "original") + age := filepath.Join(r.storeDir, "K.age") + if err := os.WriteFile(age, []byte("garbage"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := r.run("set", "--skip", "K", "new"); err == nil { + t.Fatal("set --skip over an unreadable existing key should error, not overwrite") + } + if b, _ := os.ReadFile(age); string(b) != "garbage" { + t.Fatalf("K.age was modified: %q", b) + } +} + +// ls lists scopes; a key or unknown name is a usage error with a helpful hint. +func TestLsRejectsKeyAndUnknownScope(t *testing.T) { + r := newRunner(t) + r.mustRun("set", "proj/DB", "url") + r.mustRun("set", "TOP", "v") + r.mustRun("ls", "proj") // a scope: fine + if _, se := r.mustFail("ls", "TOP"); !strings.Contains(se, "is not a scope") { + t.Fatalf("ls of a key should error that it's not a scope; got %q", se) + } + if _, se := r.mustFail("ls", "nope"); !strings.Contains(se, "no such scope") { + t.Fatalf("ls of an unknown scope should error; got %q", se) + } +}