feat: decrypt Jasypt values in .env and Spring files #80
No files matched your search
+22
-5
@@ -104,11 +104,12 @@ they are.
|
|||||||
|
|
||||||
## Decrypting
|
## Decrypting
|
||||||
|
|
||||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
|
||||||
(encryption, last modified) are read straight from the files. Revealing,
|
metadata (encryption, last modified) are read straight from the files.
|
||||||
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
|
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
|
||||||
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
|
||||||
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error
|
||||||
|
message is shown.
|
||||||
|
|
||||||
On a file, the right pane shows how it's encrypted and the keys it's
|
On a file, the right pane shows how it's encrypted and the keys it's
|
||||||
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
||||||
@@ -117,6 +118,22 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that
|
|||||||
git tracks, or that isn't ignored, is marked in red: its values are in the
|
git tracks, or that isn't ignored, is marked in red: its values are in the
|
||||||
repo's history, or easily could be.
|
repo's history, or easily could be.
|
||||||
|
|
||||||
|
### Jasypt
|
||||||
|
|
||||||
|
A value like `ENC(...)` in a plain `.env` or Spring file is
|
||||||
|
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
|
||||||
|
with jasypt-spring-boot's default algorithm (3.0+:
|
||||||
|
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
|
||||||
|
|
||||||
|
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
|
||||||
|
2. the file's own: `jasypt.encryptor.password` in a Spring file,
|
||||||
|
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` (or
|
||||||
|
`${VAR:default}`) in it is read from the environment.
|
||||||
|
|
||||||
|
Environment variables are lazysecrets' own: a link opened from the browser
|
||||||
|
starts it without your shell's. A `.env` whose values are all Jasypt's
|
||||||
|
isn't marked in red.
|
||||||
|
|
||||||
## Opening files
|
## Opening files
|
||||||
|
|
||||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Package jasypt decrypts values encrypted with Jasypt the way
|
||||||
|
// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the
|
||||||
|
// base64 of a random salt, a random IV and the ciphertext, with
|
||||||
|
// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations,
|
||||||
|
// AES-256-CBC).
|
||||||
|
package jasypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/pbkdf2"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const iterations = 1000
|
||||||
|
|
||||||
|
// ErrDecrypt is a value that doesn't decrypt with the password: a wrong
|
||||||
|
// password, or one encrypted with another algorithm.
|
||||||
|
var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)")
|
||||||
|
|
||||||
|
// Is reports whether v is a Jasypt value, ENC(...).
|
||||||
|
func Is(v string) bool {
|
||||||
|
return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts an ENC(...) value.
|
||||||
|
func Decrypt(v, password string) (string, error) {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")"))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("not Jasypt: ENC(...) isn't base64")
|
||||||
|
}
|
||||||
|
const size = aes.BlockSize // the salt's and the IV's too
|
||||||
|
if len(b) < 3*size || len(b)%size != 0 {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
salt, iv, data := b[:size], b[size:2*size], b[2*size:]
|
||||||
|
key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
plain := make([]byte, len(data))
|
||||||
|
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
|
||||||
|
// A wrong password decrypts to noise: its padding is almost never
|
||||||
|
// valid, and the rest rarely UTF-8.
|
||||||
|
n := int(plain[len(plain)-1])
|
||||||
|
if n == 0 || n > size {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
for _, c := range plain[len(plain)-n:] {
|
||||||
|
if int(c) != n {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plain = plain[:len(plain)-n]
|
||||||
|
if !utf8.Valid(plain) {
|
||||||
|
return "", ErrDecrypt
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package jasypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default
|
||||||
|
// encryptor, with the password "test-password".
|
||||||
|
var vectors = map[string]string{
|
||||||
|
"ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2",
|
||||||
|
"ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrypt(t *testing.T) {
|
||||||
|
for enc, want := range vectors {
|
||||||
|
if !Is(enc) {
|
||||||
|
t.Errorf("%s: not Jasypt", enc)
|
||||||
|
}
|
||||||
|
if got, err := Decrypt(enc, "test-password"); err != nil || got != want {
|
||||||
|
t.Errorf("%s: %q %v, want %q", enc, got, err, want)
|
||||||
|
}
|
||||||
|
if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) {
|
||||||
|
t.Errorf("%s with a wrong password: %v", enc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} {
|
||||||
|
if _, err := Decrypt(v, "test-password"); err == nil {
|
||||||
|
t.Errorf("%s: no error", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} {
|
||||||
|
if Is(v) {
|
||||||
|
t.Errorf("%s: Jasypt", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ type Object struct {
|
|||||||
Type ObjectType
|
Type ObjectType
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
|
||||||
Version string
|
Version string
|
||||||
Enabled *bool
|
Enabled *bool
|
||||||
Updated time.Time
|
Updated time.Time
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,8 +24,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
}
|
}
|
||||||
var out []provider.Attr
|
var out []provider.Attr
|
||||||
meta, isSops := readMeta(c.ID)
|
meta, isSops := readMeta(c.ID)
|
||||||
// A plain file's values are at risk in git; a Spring file's only when
|
// A plain file's values are at risk in git, unless they're all Jasypt;
|
||||||
// one of its secrets is in plain text.
|
// a Spring file's only when one of its secrets is in plain text.
|
||||||
plain := !isSops
|
plain := !isSops
|
||||||
if !isSops && IsSpring(c.ID) {
|
if !isSops && IsSpring(c.ID) {
|
||||||
out, plain = p.springDetails(c)
|
out, plain = p.springDetails(c)
|
||||||
@@ -40,7 +42,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
out, plain = envDetails(c.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
git := func(args ...string) (string, bool) {
|
git := func(args ...string) (string, bool) {
|
||||||
@@ -65,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envDetails says how a plain env file's values are encrypted, and whether
|
||||||
|
// any is in plain text.
|
||||||
|
func envDetails(file string) (attrs []provider.Attr, plain bool) {
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true
|
||||||
|
}
|
||||||
|
keys, _ := parseKeys(file, b)
|
||||||
|
n := 0
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.jasypt {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jasyptShare is how many of a plain file's values are Jasypt's.
|
||||||
|
func jasyptShare(n, of int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "none: plain text"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("jasypt: %d of %d values", n, of)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jasyptEnv is where Spring looks for the password first, before the
|
||||||
|
// config's jasypt.encryptor.password, and so does lazysecrets.
|
||||||
|
const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
|
||||||
|
|
||||||
|
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
|
||||||
|
// file; other values are as they are.
|
||||||
|
func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, error) {
|
||||||
|
s := v.Reveal()
|
||||||
|
if !jasypt.Is(s) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
password, from, err := jasyptPassword(file)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, err
|
||||||
|
}
|
||||||
|
plain, err := jasypt.Decrypt(s, password)
|
||||||
|
if errors.Is(err, jasypt.ErrDecrypt) {
|
||||||
|
return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err)
|
||||||
|
}
|
||||||
|
return provider.NewSecretValue([]byte(plain)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jasyptPassword is the password for a file's Jasypt values, and where it's
|
||||||
|
// from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's own setting
|
||||||
|
// (jasypt.encryptor.password in a Spring file, JASYPT_ENCRYPTOR_PASSWORD
|
||||||
|
// in an env file), with a ${VAR} in it read from the environment.
|
||||||
|
func jasyptPassword(file string) (password, from string, err error) {
|
||||||
|
if v := os.Getenv(jasyptEnv); v != "" {
|
||||||
|
return v, "$" + jasyptEnv, nil
|
||||||
|
}
|
||||||
|
setting, read := jasyptEnv, plainValue
|
||||||
|
if IsSpring(file) {
|
||||||
|
setting, read = "jasypt.encryptor.password", springValue
|
||||||
|
}
|
||||||
|
v, err := read(file, setting)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
|
||||||
|
}
|
||||||
|
return resolvePlaceholder(v.Reveal(), setting+" in "+filepath.Base(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholder is Spring's ${VAR} or ${VAR:default}.
|
||||||
|
var placeholder = regexp.MustCompile(`^\$\{([^:}]+)(:[^}]*)?\}$`)
|
||||||
|
|
||||||
|
// resolvePlaceholder reads a ${VAR} value from the environment, falling
|
||||||
|
// back to its default; other values are as they are.
|
||||||
|
func resolvePlaceholder(v, from string) (string, string, error) {
|
||||||
|
m := placeholder.FindStringSubmatch(v)
|
||||||
|
switch {
|
||||||
|
case m == nil:
|
||||||
|
return v, from, nil
|
||||||
|
case os.Getenv(m[1]) != "":
|
||||||
|
return os.Getenv(m[1]), "$" + m[1] + " (" + from + ")", nil
|
||||||
|
case m[2] != "":
|
||||||
|
return m[2][1:], "the default in " + from, nil
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v, m[1])
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hunter2, encrypted by Jasypt with the password "test-password".
|
||||||
|
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
|
||||||
|
|
||||||
|
func TestJasyptPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
value := func(name, body string) (string, error) {
|
||||||
|
file := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := "app.token"
|
||||||
|
if !IsSpring(name) {
|
||||||
|
key = "TOKEN"
|
||||||
|
}
|
||||||
|
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
|
||||||
|
return v.Reveal(), err
|
||||||
|
}
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||||
|
t.Setenv("MY_PASSWORD", "")
|
||||||
|
spring := func(password string) string {
|
||||||
|
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct{ name, file, body, env, want string }{
|
||||||
|
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
|
||||||
|
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
|
||||||
|
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
|
||||||
|
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||||
|
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||||
|
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
|
||||||
|
} {
|
||||||
|
t.Setenv("MY_PASSWORD", c.env)
|
||||||
|
if got, err := value(c.file, c.body); err != nil || got != c.want {
|
||||||
|
t.Errorf("%s: %q %v", c.name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||||
|
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
|
||||||
|
t.Errorf("environment first: %q %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
|
||||||
|
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
|
||||||
|
t.Errorf("a wrong one says where it's from: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||||
|
t.Setenv("MY_PASSWORD", "")
|
||||||
|
for body, want := range map[string]string{
|
||||||
|
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
|
||||||
|
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
|
||||||
|
} {
|
||||||
|
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("no password: %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJasyptEnvFile(t *testing.T) {
|
||||||
|
root := gitRepo(t, map[string]string{
|
||||||
|
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
|
||||||
|
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
p := New(Settings{Dir: root})
|
||||||
|
srcs, _ := p.Sources(ctx)
|
||||||
|
cs, _ := p.Containers(ctx, srcs[0])
|
||||||
|
for _, c := range cs {
|
||||||
|
attrs, _ := p.Details(ctx, c)
|
||||||
|
got := map[string]provider.Attr{}
|
||||||
|
for _, a := range attrs {
|
||||||
|
got[a.Label] = a
|
||||||
|
}
|
||||||
|
switch c.Name {
|
||||||
|
case "jasypt.env":
|
||||||
|
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
|
||||||
|
t.Errorf("all jasypt: %+v", got)
|
||||||
|
}
|
||||||
|
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||||
|
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
|
||||||
|
t.Errorf("keys: %+v", keys)
|
||||||
|
}
|
||||||
|
case "mixed.env":
|
||||||
|
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
|
||||||
|
t.Errorf("mixed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
|
|
||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,8 +381,11 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
|||||||
Container: c, Type: provider.TypeSecret,
|
Container: c, Type: provider.TypeSecret,
|
||||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
||||||
}
|
}
|
||||||
if isSops && !k.encrypted {
|
switch {
|
||||||
|
case isSops && !k.encrypted:
|
||||||
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||||
|
case !isSops && k.jasypt:
|
||||||
|
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
|
||||||
}
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
@@ -396,17 +400,23 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
|||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value decrypts one key with sops.
|
// Value reads one key: from a plain file, decrypting a Jasypt value; from
|
||||||
|
// a SOPS file, with sops.
|
||||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||||
file = filePath(o.Container)
|
file = filePath(o.Container)
|
||||||
}
|
}
|
||||||
if _, isSops := readMeta(file); !isSops {
|
if _, isSops := readMeta(file); !isSops {
|
||||||
|
read := plainValue
|
||||||
if IsSpring(file) {
|
if IsSpring(file) {
|
||||||
return springValue(file, o.Name)
|
read = springValue
|
||||||
}
|
}
|
||||||
return plainValue(file, o.Name)
|
v, err := read(file, o.Name)
|
||||||
|
if err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
return jasyptValue(file, v)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
var err error
|
var err error
|
||||||
@@ -1045,6 +1055,7 @@ type key struct {
|
|||||||
name string // db.password, list[0], with . [ ] \ in keys escaped
|
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||||
extract string // sops --extract: ["db"]["password"]
|
extract string // sops --extract: ["db"]["password"]
|
||||||
encrypted bool
|
encrypted bool
|
||||||
|
jasypt bool // a plain env file's ENC(...) value
|
||||||
line int // 1-based
|
line int // 1-based
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1081,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
|
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
|
||||||
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
case "ini":
|
case "ini":
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ func (k springKey) reference() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k springKey) jasypt() bool {
|
func (k springKey) jasypt() bool {
|
||||||
return strings.HasPrefix(k.value, "ENC(") && strings.HasSuffix(k.value, ")")
|
return jasypt.Is(k.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// plain reports whether the file holds the value itself, readable.
|
// plain reports whether the file holds the value itself, readable.
|
||||||
@@ -346,15 +347,15 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj
|
|||||||
if !filter.secret(k) {
|
if !filter.secret(k) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
state := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}
|
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
|
||||||
switch {
|
switch {
|
||||||
case k.reference():
|
case k.reference():
|
||||||
state = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
|
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
|
||||||
case k.jasypt():
|
case k.jasypt():
|
||||||
state = provider.Attr{Label: "encrypted", Value: "jasypt"}
|
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
|
||||||
}
|
}
|
||||||
out = append(out, provider.Object{
|
out = append(out, provider.Object{
|
||||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name,
|
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
|
||||||
Attrs: []provider.Attr{format, state},
|
Attrs: []provider.Attr{format, state},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -403,12 +404,8 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p
|
|||||||
jasypt++
|
jasypt++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
encryption := "none: plain text"
|
|
||||||
if jasypt > 0 {
|
|
||||||
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys))
|
|
||||||
}
|
|
||||||
return []provider.Attr{
|
return []provider.Attr{
|
||||||
{Label: "encryption", Value: encryption},
|
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
|
||||||
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
||||||
}, plain
|
}, plain
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ jasypt:
|
|||||||
encryptor:
|
encryptor:
|
||||||
password: ${JASYPT_ENCRYPTOR_PASSWORD}
|
password: ${JASYPT_ENCRYPTOR_PASSWORD}
|
||||||
app:
|
app:
|
||||||
api-key: ENC(abc==)
|
api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
|
||||||
`,
|
`,
|
||||||
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
|
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
|
||||||
"applicationset/applications.yaml": "kind: ApplicationSet\n",
|
"applicationset/applications.yaml": "kind: ApplicationSet\n",
|
||||||
@@ -180,12 +180,13 @@ app:
|
|||||||
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
|
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
|
||||||
var names []string
|
var names []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
names = append(names, k.Name)
|
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
|
||||||
}
|
}
|
||||||
if want := []string{"spring.datasource.password", "jasypt.encryptor.password", "app.api-key"}; !slices.Equal(names, want) {
|
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
|
||||||
t.Errorf("keys %v, want %v", names, want)
|
t.Errorf("keys %v, want %v", names, want)
|
||||||
}
|
}
|
||||||
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "ENC(abc==)" {
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||||
|
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
|
||||||
t.Errorf("value: %q %v", v.Reveal(), err)
|
t.Errorf("value: %q %v", v.Reveal(), err)
|
||||||
}
|
}
|
||||||
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
|
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
|||||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||||
}
|
}
|
||||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||||
|
if o.Detail != "" {
|
||||||
|
line += sty.Muted.Render(" " + o.Detail)
|
||||||
|
}
|
||||||
switch state, left := m.expiry(o); state {
|
switch state, left := m.expiry(o); state {
|
||||||
case expiry.Expired:
|
case expiry.Expired:
|
||||||
line += " " + sty.Error.Render("✗ expired")
|
line += " " + sty.Error.Render("✗ expired")
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var out []entry
|
var out []entry
|
||||||
|
detail := m.objectDetail(key)
|
||||||
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
||||||
account := ""
|
account := ""
|
||||||
if p, ok := m.byID[acct]; ok {
|
if p, ok := m.byID[acct]; ok {
|
||||||
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
||||||
}
|
}
|
||||||
|
account = strings.Trim(detail+" · "+account, " ·")
|
||||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||||
out = append(out, entry{depth, sort, option{
|
out = append(out, entry{depth, sort, option{
|
||||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||||
@@ -157,6 +159,19 @@ func (m Model) inPlainEnv(r ref.Ref) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// objectDetail is the Detail of the secret with the store key key, as far
|
||||||
|
// as the lists loaded tell.
|
||||||
|
func (m Model) objectDetail(key string) string {
|
||||||
|
for _, e := range m.cache.objects {
|
||||||
|
for _, o := range e.value {
|
||||||
|
if o.Detail != "" && objectKey(o) == key {
|
||||||
|
return o.Detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
||||||
// icon and colour, "app / apps/api/.env".
|
// icon and colour, "app / apps/api/.env".
|
||||||
func (m Model) repoLabel(repo string, file ...string) string {
|
func (m Model) repoLabel(repo string, file ...string) string {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
suffix := accountOf(src)
|
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
|
||||||
if t != provider.TypeSecret {
|
if t != provider.TypeSecret {
|
||||||
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
||||||
|
|||||||
Reference in new issue
Block a user