feat: share env files as encrypted links
S → f on a repo, an env file or a key in one copies a lazysecrets://share link holding the env files, encrypted (age) with a secret's value: whoever can read that secret can open it. The key is share.key in the repo's .lazysecrets.yaml, else in the config, else picked when sharing; on Azure the link pins its version. Opening one says what it holds, decrypts when asked, and saves the files in the clone of the repo (or a folder chosen), replacing existing ones only when asked. lazysecrets share prints a link from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
26 files changed
+1541
-98
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
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(), "doesn't decrypt") {
|
||||
t.Errorf("wrong key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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, "at least 16"},
|
||||
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "secret's link"},
|
||||
"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); 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 TestReadGarbled(t *testing.T) {
|
||||
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}})
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user