feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link. **Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file). - **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference. - **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels. - **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening. - **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them. - **The command line:** `lazysecrets share [--key <secret>] <env files>`. **Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`. 1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`. 2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore. 3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written. 4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged. **Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable. **Safety:** - A link can only write env files, inside the folder: never in `.git`, never through a symlink. - New files are created with mode 0600. - Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB. **Along the way:** - Opening a link to a secret ends on its details, as `enter` would. - `o` on an Azure secret opens its portal page, and in the versions view, that version's page. - The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address. - `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning. - `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there. - New dependency: `filippo.io/age`. Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
1 parent
2044341b20
commit
4377eb98bf
40 files changed
+2449
-138
No files matched your search
@@ -0,0 +1,69 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Rules are what a key's value must be to share with it: random enough
|
||||
// that guessing it is out of reach, since anyone with a link can try.
|
||||
type Rules struct {
|
||||
MinLength int // characters
|
||||
MinClasses int // kinds of character: lowercase, uppercase, digits, symbols
|
||||
}
|
||||
|
||||
// DefaultRules take a random base64 or hex value (openssl rand -base64
|
||||
// 32, or -hex 32) and refuse passwords made up to be remembered.
|
||||
var DefaultRules = Rules{MinLength: 32, MinClasses: 2}
|
||||
|
||||
// Check reports what key lacks, without showing it.
|
||||
func (r Rules) Check(key []byte) error {
|
||||
r = r.withDefaults()
|
||||
s := string(key)
|
||||
var lower, upper, digit, symbol bool
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case unicode.IsLower(c):
|
||||
lower = true
|
||||
case unicode.IsUpper(c):
|
||||
upper = true
|
||||
case unicode.IsDigit(c):
|
||||
digit = true
|
||||
default:
|
||||
symbol = true
|
||||
}
|
||||
}
|
||||
kinds := 0
|
||||
for _, has := range []bool{lower, upper, digit, symbol} {
|
||||
if has {
|
||||
kinds++
|
||||
}
|
||||
}
|
||||
n := len([]rune(s))
|
||||
var has, needs []string
|
||||
if n < r.MinLength {
|
||||
has = append(has, fmt.Sprintf("%d characters", n))
|
||||
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
|
||||
}
|
||||
if kinds < r.MinClasses {
|
||||
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
|
||||
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
|
||||
}
|
||||
if len(has) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("its value has %s; share.keyRules needs %s",
|
||||
strings.Join(has, " and "), strings.Join(needs, " and "))
|
||||
}
|
||||
|
||||
func (r Rules) withDefaults() Rules {
|
||||
if r.MinLength == 0 {
|
||||
r.MinLength = DefaultRules.MinLength
|
||||
}
|
||||
if r.MinClasses == 0 {
|
||||
r.MinClasses = DefaultRules.MinClasses
|
||||
}
|
||||
r.MinLength = max(r.MinLength, MinKey)
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Package share seals env files into a lazysecrets://share link, so they
|
||||
// can be passed on in chat or a ticket. They're encrypted with the value of
|
||||
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
|
||||
// read that secret can open the link, nobody else.
|
||||
//
|
||||
// A link says in the clear which secret it's encrypted with, the repo the
|
||||
// files are from and how many there are, which is what's needed to open
|
||||
// it. The files' paths and contents are encrypted, with age
|
||||
// (https://age-encryption.org), the secret's value as its passphrase.
|
||||
//
|
||||
// The link's payload is unpadded base64url of:
|
||||
//
|
||||
// version (1) | uvarint length of the header | header (JSON) | age file
|
||||
//
|
||||
// The age file holds the files as deflated JSON.
|
||||
//
|
||||
// A change to the format gets the next version, and Read keeps reading
|
||||
// every earlier one: links stay in chat and tickets for years. A
|
||||
// version it doesn't know is from a newer lazysecrets.
|
||||
package share
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
const (
|
||||
// version is the format's, written first; see the package doc.
|
||||
version = 1
|
||||
// MaxLink keeps a link short enough for chat and tickets (Teams takes
|
||||
// about 28 KB) and for passing it to a terminal window.
|
||||
MaxLink = 16 << 10
|
||||
// MinKey is the shortest key the rules can ask for.
|
||||
MinKey = 16
|
||||
// maxUnpacked caps the bytes opening a link unpacks.
|
||||
maxUnpacked = 1 << 20
|
||||
)
|
||||
|
||||
// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms
|
||||
// each way, instead of age's 2^18 (a third of a second). scrypt makes
|
||||
// guessing a weak passphrase slow; a key keeping to the rules is random
|
||||
// and long enough not to need that. maxWorkFactor is the most opening
|
||||
// accepts, so a link can't make it work for minutes.
|
||||
var workFactor, maxWorkFactor = 15, 20
|
||||
|
||||
// File is a shared file.
|
||||
type File struct {
|
||||
Path string // relative to the repo's root, with slashes
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Header is what a link says in the clear.
|
||||
type Header struct {
|
||||
// Key is the secret whose value the files are encrypted with.
|
||||
Key ref.Ref
|
||||
// Repo is the repo the files are from: its remote without scheme or
|
||||
// .git (a repo link's host).
|
||||
Repo string
|
||||
// Files is how many files the link holds.
|
||||
Files int
|
||||
}
|
||||
|
||||
// Sealed is a share link read, its files still encrypted.
|
||||
type Sealed struct {
|
||||
Header
|
||||
box []byte
|
||||
}
|
||||
|
||||
type header struct {
|
||||
Key string `json:"key"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Path string `json:"path"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Seal encrypts files with key, the value of h.Key, into a share link,
|
||||
// if key keeps to rules (*KeyError if not).
|
||||
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
|
||||
if _, err := ParseKey(h.Key.String()); err != nil {
|
||||
return ref.Ref{}, fmt.Errorf("the key: %w", err)
|
||||
}
|
||||
if err := rules.Check(key); err != nil {
|
||||
return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return ref.Ref{}, errors.New("no files to share")
|
||||
}
|
||||
plain := make([]file, len(files))
|
||||
for i, f := range files {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if !utf8.Valid(f.Data) {
|
||||
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
|
||||
}
|
||||
plain[i] = file{Path: f.Path, Data: string(f.Data)}
|
||||
}
|
||||
b, err := json.Marshal(plain)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
|
||||
rcpt, err := age.NewScryptRecipient(string(key))
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
rcpt.SetWorkFactor(workFactor)
|
||||
var box bytes.Buffer
|
||||
enc, err := age.Encrypt(&box, rcpt)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
z, _ := flate.NewWriter(enc, flate.BestCompression)
|
||||
if _, err := z.Write(b); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if err := z.Close(); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
|
||||
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
|
||||
payload = append(append(payload, hb...), box.Bytes()...)
|
||||
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
|
||||
if n := len(r.String()); n > MaxLink {
|
||||
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// KeyError is a key that can't be used: it can't be read, or doesn't
|
||||
// keep to the rules.
|
||||
type KeyError struct {
|
||||
Name string // the secret's
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
|
||||
func (e *KeyError) Unwrap() error { return e.Err }
|
||||
|
||||
// Read reads a share link's header.
|
||||
func Read(r ref.Ref) (Sealed, error) {
|
||||
bad := errors.New("share link is cut short or garbled: copy all of it")
|
||||
b, err := base64.RawURLEncoding.DecodeString(r.Share)
|
||||
if err != nil || len(b) < 2 {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
if b[0] != version {
|
||||
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
||||
}
|
||||
n, size := binary.Uvarint(b[1:])
|
||||
if size <= 0 || n > uint64(len(b)) {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
rest := b[1+size:]
|
||||
if n > uint64(len(rest)) {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
var h header
|
||||
if err := json.Unmarshal(rest[:n], &h); err != nil {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
key, err := ParseKey(h.Key)
|
||||
if err != nil {
|
||||
return Sealed{}, fmt.Errorf("share link's key: %w", err)
|
||||
}
|
||||
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
|
||||
}
|
||||
|
||||
// Open decrypts the files with key, the value of s.Key.
|
||||
func (s Sealed) Open(key []byte) ([]File, error) {
|
||||
id, err := age.NewScryptIdentity(string(key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id.SetMaxWorkFactor(maxWorkFactor)
|
||||
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
|
||||
if err != nil {
|
||||
var wrong *age.NoIdentityMatchError
|
||||
if errors.As(err, &wrong) {
|
||||
return nil, s.wrongKey()
|
||||
}
|
||||
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||
}
|
||||
if len(b) > maxUnpacked {
|
||||
return nil, errors.New("the link holds more than lazysecrets unpacks")
|
||||
}
|
||||
var plain []file
|
||||
if err := json.Unmarshal(b, &plain); err != nil {
|
||||
return nil, fmt.Errorf("can't read the link's files: %w", err)
|
||||
}
|
||||
out := make([]File, len(plain))
|
||||
for i, f := range plain {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// wrongKey says why the key, read fine, doesn't decrypt the link: its
|
||||
// value isn't the one the link was made with.
|
||||
func (s Sealed) wrongKey() error {
|
||||
name := s.Key.Name
|
||||
switch {
|
||||
case s.Key.Kind == provider.KindAzure && s.Key.Version != "":
|
||||
// A version's value never changes.
|
||||
return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version)
|
||||
case s.Key.Kind == provider.KindAzure:
|
||||
return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name)
|
||||
}
|
||||
// Databricks secrets and SOPS values are changed in place.
|
||||
return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name)
|
||||
}
|
||||
|
||||
// CheckPath accepts a path relative to a repo's root, with slashes, of an
|
||||
// env file (.env, .env.local, prod.env) outside .git: what a link may
|
||||
// write.
|
||||
func CheckPath(p string) error {
|
||||
switch {
|
||||
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
|
||||
p == ".." || strings.HasPrefix(p, "../"):
|
||||
return fmt.Errorf("%q isn't a path inside a repo", p)
|
||||
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
|
||||
return fmt.Errorf("%q is inside .git", p)
|
||||
case !repo.IsEnv(p):
|
||||
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultKey is the key for a repo's files: share.key in the repo's
|
||||
// .lazysecrets.yaml, else general (share.key in the config). ok is false
|
||||
// when neither names one.
|
||||
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
||||
link, err := repo.ShareKey(root)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, err
|
||||
}
|
||||
where := root + "/.lazysecrets.yaml"
|
||||
if link == "" {
|
||||
link, where = general, "the config"
|
||||
}
|
||||
if link == "" {
|
||||
return ref.Ref{}, false, nil
|
||||
}
|
||||
key, err = ParseKey(link)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err)
|
||||
}
|
||||
return key, true, nil
|
||||
}
|
||||
|
||||
// ParseKey reads which secret is the key: its lazysecrets:// link, Key
|
||||
// Vault URL or @Microsoft.KeyVault(...) reference.
|
||||
func ParseKey(s string) (ref.Ref, error) {
|
||||
key, err := ref.Parse(s)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
switch {
|
||||
case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
|
||||
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
|
||||
case key.Kind == provider.KindDatabricks && key.Host == "":
|
||||
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Pin is key at version, the version its value was read at, for Azure,
|
||||
// whose old versions stay readable: a link keeps working when the key
|
||||
// is rotated, until that version is disabled.
|
||||
func Pin(key ref.Ref, version string) ref.Ref {
|
||||
if key.Kind == provider.KindAzure && key.Version == "" {
|
||||
key.Version = version
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
|
||||
|
||||
var (
|
||||
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
|
||||
pass = []byte("0123456789abcdef0123456789abcdef")
|
||||
)
|
||||
|
||||
func TestSealOpen(t *testing.T) {
|
||||
files := []File{
|
||||
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
|
||||
}
|
||||
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
|
||||
t.Errorf("link shows what it shares: %s", link)
|
||||
}
|
||||
// It goes through ref.Parse like any link.
|
||||
r, err := ref.Parse(link.String())
|
||||
if err != nil || !r.IsShare() {
|
||||
t.Fatalf("parse: %v, %+v", err, r)
|
||||
}
|
||||
s, err := Read(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
|
||||
t.Errorf("header %+v", s.Header)
|
||||
}
|
||||
got, err := s.Open(pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
|
||||
t.Errorf("opened %+v", got)
|
||||
}
|
||||
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") {
|
||||
t.Errorf("wrong key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeySaysWhy(t *testing.T) {
|
||||
files := []File{{Path: ".env", Data: []byte("A=1\n")}}
|
||||
for link, want := range map[string]string{
|
||||
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made",
|
||||
"lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made",
|
||||
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with",
|
||||
} {
|
||||
k, err := ParseKey(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Seal(Header{Key: k}, pass, files, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _ := Read(r)
|
||||
if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", link, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealRefuses(t *testing.T) {
|
||||
ok := []File{{Path: ".env", Data: []byte("A=1")}}
|
||||
for name, c := range map[string]struct {
|
||||
h Header
|
||||
pass []byte
|
||||
files []File
|
||||
want string
|
||||
}{
|
||||
"short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"},
|
||||
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
|
||||
"no files": {Header{Key: key}, pass, nil, "no files"},
|
||||
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
|
||||
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
|
||||
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
|
||||
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
|
||||
} {
|
||||
if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// random is text that doesn't compress.
|
||||
func random(n int) string {
|
||||
var b strings.Builder
|
||||
x := uint32(1)
|
||||
for b.Len() < n {
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
r Rules
|
||||
key string
|
||||
pass bool
|
||||
}{
|
||||
{Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32
|
||||
{Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex
|
||||
{Rules{}, "correct-horse-battery-staple", false}, // 28: short
|
||||
{Rules{}, strings.Repeat("a", 40), false}, // one kind
|
||||
{Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true},
|
||||
{Rules{MinLength: 4}, "abc123", false}, // never under MinKey
|
||||
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false},
|
||||
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true},
|
||||
} {
|
||||
if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass {
|
||||
t.Errorf("%+v %q: %v", c.r, c.key, err)
|
||||
} else if err != nil && strings.Contains(err.Error(), c.key) {
|
||||
t.Errorf("the error shows the key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadGarbled(t *testing.T) {
|
||||
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
|
||||
s, err := Read(cut)
|
||||
if err == nil {
|
||||
_, err = s.Open(pass)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("a link cut in half opened")
|
||||
}
|
||||
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
|
||||
t.Errorf("version 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPath(t *testing.T) {
|
||||
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
|
||||
if err := CheckPath(p); err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
|
||||
if err := CheckPath(p); err == nil {
|
||||
t.Errorf("%q accepted", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
|
||||
t.Skip("git:", string(out))
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
|
||||
targets, err := Plan(dir, []File{
|
||||
{Path: ".env", Data: []byte("NEW=1\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !targets[0].Exists || targets[0].Exposed {
|
||||
t.Errorf(".env: %+v", targets[0])
|
||||
}
|
||||
if targets[1].Exists || !targets[1].Exposed {
|
||||
t.Errorf("apps/api/.env.local: %+v", targets[1])
|
||||
}
|
||||
for _, tg := range targets {
|
||||
if err := tg.Write(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
|
||||
t.Errorf(".env is %q", b)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("new file: %v, %v", info.Mode(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSymlinks(t *testing.T) {
|
||||
dir, outside := t.TempDir(), t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
|
||||
t.Errorf("through a linked folder: %v", err)
|
||||
}
|
||||
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
|
||||
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("onto a symlink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKey(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
|
||||
"https://kv-team.vault.azure.net/secrets/env-share",
|
||||
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
|
||||
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
|
||||
} {
|
||||
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
|
||||
t.Errorf("%s: %+v, %v", s, k, err)
|
||||
}
|
||||
}
|
||||
for s, want := range map[string]string{
|
||||
"{{secrets/team/env-share}}": "names no workspace",
|
||||
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
|
||||
"https://kv-team.vault.azure.net/": "isn't a secret",
|
||||
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
|
||||
} {
|
||||
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", s, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
|
||||
k, ok, err := DefaultKey(dir, general)
|
||||
if err != nil || !ok || k.Name != "env-share" {
|
||||
t.Errorf("general: %+v %v %v", k, ok, err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
|
||||
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
|
||||
t.Errorf("the repo's should win: %+v", k)
|
||||
}
|
||||
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
|
||||
t.Error("a key with none set")
|
||||
}
|
||||
unpinned := key
|
||||
unpinned.Version = ""
|
||||
if p := Pin(unpinned, "abc"); p.Version != "abc" {
|
||||
t.Errorf("pin: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Target is where an opened file goes.
|
||||
type Target struct {
|
||||
File
|
||||
Abs string
|
||||
Exists bool
|
||||
// Exposed is set when dir is a git repo that doesn't ignore the file:
|
||||
// its values could end up in a commit.
|
||||
Exposed bool
|
||||
}
|
||||
|
||||
// Plan says where files go under dir, without writing anything. A path
|
||||
// that leads out of dir through a symlink, or is one, is an error.
|
||||
func Plan(dir string, files []File) ([]Target, error) {
|
||||
root, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Target, len(files))
|
||||
for i, f := range files {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))}
|
||||
if err := inside(root, filepath.Dir(t.Abs)); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
switch info, err := os.Lstat(t.Abs); {
|
||||
case err == nil && info.Mode()&fs.ModeSymlink != 0:
|
||||
return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path)
|
||||
case err == nil && !info.Mode().IsRegular():
|
||||
return nil, fmt.Errorf("%s isn't a file", f.Path)
|
||||
case err == nil:
|
||||
t.Exists = true
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return nil, err
|
||||
}
|
||||
t.Exposed = exposed(root, f.Path)
|
||||
out[i] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inside checks that dir, or the part of it that exists, is under root
|
||||
// once symlinks are followed.
|
||||
func inside(root, dir string) error {
|
||||
for {
|
||||
real, err := filepath.EvalSymlinks(dir)
|
||||
if errors.Is(err, fs.ErrNotExist) && dir != root {
|
||||
dir = filepath.Dir(dir) // created when writing
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
|
||||
return errors.New("leads out of the folder through a symlink")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// exposed reports whether root is in a git repo that doesn't ignore p.
|
||||
func exposed(root, p string) bool {
|
||||
err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run()
|
||||
var exit *exec.ExitError
|
||||
return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo
|
||||
}
|
||||
|
||||
// Write writes the file, readable by the user only when it's new.
|
||||
func (t Target) Write() error {
|
||||
if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(t.Abs, t.Data, 0o600)
|
||||
}
|
||||
Reference in new issue
Block a user