feat: decrypt Jasypt values in .env and Spring files (#80)

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #80.
This commit is contained in:
keb authored and Karl-Einar Bergstrøm committed 2026-10-15 13:31:58 +02:00
1 parent 96395cb6a2
commit 17da9eaa32
13 files changed
+381 -29

No files matched your search

+22 -5
View File
@@ -104,11 +104,12 @@ they are.
## Decrypting
Listing never decrypts: SOPS encrypts values only, so keys and metadata
(encryption, last modified) are read straight from the files. Revealing,
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
KMS, PGP). When sops can't decrypt, its whole error message is shown.
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
metadata (encryption, last modified) are read straight from the files.
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
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
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
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
`e` opens the selected file, or on a secret, its file at that key's line:
+67
View File
@@ -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
}
+37
View File
@@ -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)
}
}
}
+1
View File
@@ -133,6 +133,7 @@ type Object struct {
Type ObjectType
ID string
Name string
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
Version string
Enabled *bool
Updated time.Time
+30 -3
View File
@@ -2,6 +2,8 @@ package repo
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -22,8 +24,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
}
var out []provider.Attr
meta, isSops := readMeta(c.ID)
// A plain file's values are at risk in git; a Spring file's only when
// one of its secrets is in plain text.
// A plain file's values are at risk in git, unless they're all Jasypt;
// a Spring file's only when one of its secrets is in plain text.
plain := !isSops
if !isSops && IsSpring(c.ID) {
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")})
}
} else {
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
out, plain = envDetails(c.ID)
}
git := func(args ...string) (string, bool) {
@@ -65,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
}
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)
}
+74
View File
@@ -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])
}
+102
View File
@@ -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)
}
}
}
}
+17 -6
View File
@@ -32,6 +32,7 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"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,
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)"})
case !isSops && k.jasypt:
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
}
out = append(out, o)
}
@@ -396,17 +400,23 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
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) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file = filePath(o.Container)
}
if _, isSops := readMeta(file); !isSops {
read := plainValue
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 {
var err error
@@ -1045,7 +1055,8 @@ type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"]
encrypted bool
line int // 1-based
jasypt bool // a plain env file's ENC(...) value
line int // 1-based
}
// parseKeys lists a sops file's keys without decrypting anything.
@@ -1070,7 +1081,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue
}
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
case "ini":
+7 -10
View File
@@ -15,6 +15,7 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -137,7 +138,7 @@ func (k springKey) reference() 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.
@@ -346,15 +347,15 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj
if !filter.secret(k) {
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 {
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():
state = provider.Attr{Label: "encrypted", Value: "jasypt"}
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
}
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},
})
}
@@ -403,12 +404,8 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p
jasypt++
}
}
encryption := "none: plain text"
if jasypt > 0 {
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys))
}
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)},
}, plain
}
+5 -4
View File
@@ -141,7 +141,7 @@ jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD}
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",
"applicationset/applications.yaml": "kind: ApplicationSet\n",
@@ -180,12 +180,13 @@ app:
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
var names []string
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)
}
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)
}
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
+3
View File
@@ -137,6 +137,9 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
line = sty.Muted.Render(o.Name + " (disabled)")
}
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 {
case expiry.Expired:
line += " " + sty.Error.Render("✗ expired")
+15
View File
@@ -38,11 +38,13 @@ func (m *Model) storeEntries() (favs, recent []option) {
return nil
}
var out []entry
detail := m.objectDetail(key)
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := ""
if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
}
account = strings.Trim(detail+" · "+account, " ·")
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{
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
}
// 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
// icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string {
+1 -1
View File
@@ -90,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
if err != nil {
continue
}
suffix := accountOf(src)
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")