feat: encryption with age

This commit is contained in:
Lewis Wynne 2026-02-11 12:36:42 +00:00
parent ba93931c33
commit 9bdc9c30c6
25 changed files with 733 additions and 64 deletions

View file

@ -26,6 +26,7 @@ import (
"fmt"
"strings"
"filippo.io/age"
"github.com/gobwas/glob"
"github.com/spf13/cobra"
)
@ -97,13 +98,19 @@ func del(cmd *cobra.Command, args []string) error {
return nil
}
identity, _ := loadIdentity()
var recipient *age.X25519Recipient
if identity != nil {
recipient = identity.Recipient()
}
for _, dbName := range storeOrder {
st := byStore[dbName]
p, err := store.storePath(dbName)
if err != nil {
return err
}
entries, err := readStoreFile(p)
entries, err := readStoreFile(p, identity)
if err != nil {
return err
}
@ -114,7 +121,7 @@ func del(cmd *cobra.Command, args []string) error {
}
entries = append(entries[:idx], entries[idx+1:]...)
}
if err := writeStoreFile(p, entries); err != nil {
if err := writeStoreFile(p, entries, recipient); err != nil {
return err
}
}
@ -145,7 +152,7 @@ func keyExists(store *Store, arg string) (bool, error) {
if err != nil {
return false, err
}
entries, err := readStoreFile(p)
entries, err := readStoreFile(p, nil)
if err != nil {
return false, err
}

View file

@ -72,6 +72,8 @@ For example:
func get(cmd *cobra.Command, args []string) error {
store := &Store{}
identity, _ := loadIdentity()
spec, err := store.parseKey(args[0], true)
if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
@ -80,7 +82,7 @@ func get(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
}
entries, err := readStoreFile(p)
entries, err := readStoreFile(p, identity)
if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
}
@ -92,7 +94,11 @@ func get(cmd *cobra.Command, args []string) error {
}
return fmt.Errorf("cannot get '%s': %w", args[0], suggestKey(spec.Key, keys))
}
v := entries[idx].Value
entry := entries[idx]
if entry.Locked {
return fmt.Errorf("cannot get '%s': secret is locked (identity file missing)", spec.Display())
}
v := entry.Value
binary, err := cmd.Flags().GetBool("include-binary")
if err != nil {

76
cmd/identity.go Normal file
View file

@ -0,0 +1,76 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var identityCmd = &cobra.Command{
Use: "identity",
Short: "Show or create the age encryption identity",
Args: cobra.NoArgs,
RunE: identityRun,
SilenceUsage: true,
}
func identityRun(cmd *cobra.Command, args []string) error {
showPath, err := cmd.Flags().GetBool("path")
if err != nil {
return err
}
createNew, err := cmd.Flags().GetBool("new")
if err != nil {
return err
}
if createNew {
existing, err := loadIdentity()
if err != nil {
return fmt.Errorf("cannot create identity: %v", err)
}
if existing != nil {
path, _ := identityPath()
return withHint(
fmt.Errorf("identity already exists at %s", path),
"delete the file manually before creating a new one",
)
}
id, err := ensureIdentity()
if err != nil {
return fmt.Errorf("cannot create identity: %v", err)
}
okf("pubkey %s", id.Recipient())
return nil
}
if showPath {
path, err := identityPath()
if err != nil {
return err
}
fmt.Println(path)
return nil
}
// Default: show identity info
id, err := loadIdentity()
if err != nil {
return fmt.Errorf("cannot load identity: %v", err)
}
if id == nil {
printHint("no identity found — use 'pda identity --new' or 'pda set --encrypt' to create one")
return nil
}
path, _ := identityPath()
okf("pubkey %s", id.Recipient())
okf("identity %s", path)
return nil
}
func init() {
identityCmd.Flags().Bool("new", false, "Generate a new identity (errors if one already exists)")
identityCmd.Flags().Bool("path", false, "Print only the identity file path")
identityCmd.MarkFlagsMutuallyExclusive("new", "path")
rootCmd.AddCommand(identityCmd)
}

View file

@ -30,6 +30,7 @@ import (
"os"
"strconv"
"filippo.io/age"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra"
@ -126,12 +127,18 @@ func list(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
identity, _ := loadIdentity()
var recipient *age.X25519Recipient
if identity != nil {
recipient = identity.Recipient()
}
dbName := targetDB[1:] // strip leading '@'
p, err := store.storePath(dbName)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
entries, err := readStoreFile(p)
entries, err := readStoreFile(p, identity)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
@ -150,10 +157,14 @@ func list(cmd *cobra.Command, args []string) error {
output := cmd.OutOrStdout()
// NDJSON format: emit JSON lines directly
// NDJSON format: emit JSON lines directly (encrypted form for secrets)
if listFormat.String() == "ndjson" {
for _, e := range filtered {
data, err := json.Marshal(encodeJsonEntry(e))
je, err := encodeJsonEntry(e, recipient)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
data, err := json.Marshal(je)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
@ -180,7 +191,11 @@ func list(cmd *cobra.Command, args []string) error {
for _, e := range filtered {
var valueStr string
if showValues {
valueStr = store.FormatBytes(listBinary, e.Value)
if e.Locked {
valueStr = "locked (identity file missing)"
} else {
valueStr = store.FormatBytes(listBinary, e.Value)
}
}
row := make(table.Row, 0, len(columns))
for _, col := range columns {

View file

@ -26,6 +26,7 @@ import (
"fmt"
"strings"
"filippo.io/age"
"github.com/spf13/cobra"
)
@ -65,6 +66,12 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
}
promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
identity, _ := loadIdentity()
var recipient *age.X25519Recipient
if identity != nil {
recipient = identity.Recipient()
}
fromSpec, err := store.parseKey(args[0], true)
if err != nil {
return err
@ -79,7 +86,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
if err != nil {
return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
}
srcEntries, err := readStoreFile(srcPath)
srcEntries, err := readStoreFile(srcPath, identity)
if err != nil {
return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
}
@ -99,7 +106,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
if err != nil {
return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
}
dstEntries, err = readStoreFile(dstPath)
dstEntries, err = readStoreFile(dstPath, identity)
if err != nil {
return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
}
@ -118,11 +125,13 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
}
}
// Write destination entry
// Write destination entry — preserve secret status
newEntry := Entry{
Key: toSpec.Key,
Value: srcEntry.Value,
ExpiresAt: srcEntry.ExpiresAt,
Secret: srcEntry.Secret,
Locked: srcEntry.Locked,
}
if sameStore {
@ -139,7 +148,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
dstEntries = append(dstEntries[:idx], dstEntries[idx+1:]...)
}
}
if err := writeStoreFile(dstPath, dstEntries); err != nil {
if err := writeStoreFile(dstPath, dstEntries, recipient); err != nil {
return err
}
} else {
@ -149,12 +158,12 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
} else {
dstEntries = append(dstEntries, newEntry)
}
if err := writeStoreFile(dstPath, dstEntries); err != nil {
if err := writeStoreFile(dstPath, dstEntries, recipient); err != nil {
return err
}
if !keepSource {
srcEntries = append(srcEntries[:srcIdx], srcEntries[srcIdx+1:]...)
if err := writeStoreFile(srcPath, srcEntries); err != nil {
if err := writeStoreFile(srcPath, srcEntries, recipient); err != nil {
return err
}
}

View file

@ -32,6 +32,8 @@ import (
"strings"
"time"
"unicode/utf8"
"filippo.io/age"
)
// Entry is the in-memory representation of a stored key-value pair.
@ -39,6 +41,8 @@ type Entry struct {
Key string
Value []byte
ExpiresAt uint64 // Unix timestamp; 0 = never expires
Secret bool // encrypted on disk
Locked bool // secret but no identity available to decrypt
}
// jsonEntry is the NDJSON on-disk format.
@ -51,7 +55,8 @@ type jsonEntry struct {
// readStoreFile reads all non-expired entries from an NDJSON file.
// Returns empty slice (not error) if file does not exist.
func readStoreFile(path string) ([]Entry, error) {
// If identity is nil, secret entries are returned as locked.
func readStoreFile(path string, identity *age.X25519Identity) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
@ -76,7 +81,7 @@ func readStoreFile(path string) ([]Entry, error) {
if err := json.Unmarshal(line, &je); err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err)
}
entry, err := decodeJsonEntry(je)
entry, err := decodeJsonEntry(je, identity)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err)
}
@ -91,7 +96,8 @@ func readStoreFile(path string) ([]Entry, error) {
// writeStoreFile atomically writes entries to an NDJSON file, sorted by key.
// Expired entries are excluded. Empty entry list writes an empty file.
func writeStoreFile(path string, entries []Entry) error {
// If recipient is nil, secret entries are written as-is (locked passthrough).
func writeStoreFile(path string, entries []Entry, recipient *age.X25519Recipient) error {
// Sort by key for deterministic output
slices.SortFunc(entries, func(a, b Entry) int {
return strings.Compare(a.Key, b.Key)
@ -113,7 +119,10 @@ func writeStoreFile(path string, entries []Entry) error {
if e.ExpiresAt > 0 && e.ExpiresAt <= now {
continue
}
je := encodeJsonEntry(e)
je, err := encodeJsonEntry(e, recipient)
if err != nil {
return fmt.Errorf("key '%s': %w", e.Key, err)
}
data, err := json.Marshal(je)
if err != nil {
return fmt.Errorf("key '%s': %w", e.Key, err)
@ -133,7 +142,28 @@ func writeStoreFile(path string, entries []Entry) error {
return os.Rename(tmp, path)
}
func decodeJsonEntry(je jsonEntry) (Entry, error) {
func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) {
var expiresAt uint64
if je.ExpiresAt != nil {
expiresAt = uint64(*je.ExpiresAt)
}
if je.Encoding == "secret" {
ciphertext, err := base64.StdEncoding.DecodeString(je.Value)
if err != nil {
return Entry{}, fmt.Errorf("decode secret for '%s': %w", je.Key, err)
}
if identity == nil {
return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil
}
plaintext, err := decrypt(ciphertext, identity)
if err != nil {
warnf("cannot decrypt '%s': %v", je.Key, err)
return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil
}
return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true}, nil
}
var value []byte
switch je.Encoding {
case "", "text":
@ -147,15 +177,35 @@ func decodeJsonEntry(je jsonEntry) (Entry, error) {
default:
return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key)
}
var expiresAt uint64
if je.ExpiresAt != nil {
expiresAt = uint64(*je.ExpiresAt)
}
return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt}, nil
}
func encodeJsonEntry(e Entry) jsonEntry {
func encodeJsonEntry(e Entry, recipient *age.X25519Recipient) (jsonEntry, error) {
je := jsonEntry{Key: e.Key}
if e.ExpiresAt > 0 {
ts := int64(e.ExpiresAt)
je.ExpiresAt = &ts
}
if e.Secret && e.Locked {
// Passthrough: Value holds raw ciphertext, re-encode as-is
je.Value = base64.StdEncoding.EncodeToString(e.Value)
je.Encoding = "secret"
return je, nil
}
if e.Secret {
if recipient == nil {
return je, fmt.Errorf("no recipient available to encrypt")
}
ciphertext, err := encrypt(e.Value, recipient)
if err != nil {
return je, fmt.Errorf("encrypt: %w", err)
}
je.Value = base64.StdEncoding.EncodeToString(ciphertext)
je.Encoding = "secret"
return je, nil
}
if utf8.Valid(e.Value) {
je.Value = string(e.Value)
je.Encoding = "text"
@ -163,11 +213,7 @@ func encodeJsonEntry(e Entry) jsonEntry {
je.Value = base64.StdEncoding.EncodeToString(e.Value)
je.Encoding = "base64"
}
if e.ExpiresAt > 0 {
ts := int64(e.ExpiresAt)
je.ExpiresAt = &ts
}
return je
return je, nil
}
// findEntry returns the index of the entry with the given key, or -1.

View file

@ -38,11 +38,11 @@ func TestReadWriteRoundtrip(t *testing.T) {
{Key: "gamma", Value: []byte{0xff, 0xfe}}, // binary
}
if err := writeStoreFile(path, entries); err != nil {
if err := writeStoreFile(path, entries, nil); err != nil {
t.Fatal(err)
}
got, err := readStoreFile(path)
got, err := readStoreFile(path, nil)
if err != nil {
t.Fatal(err)
}
@ -69,11 +69,11 @@ func TestReadStoreFileSkipsExpired(t *testing.T) {
{Key: "dead", Value: []byte("no"), ExpiresAt: 1}, // expired long ago
}
if err := writeStoreFile(path, entries); err != nil {
if err := writeStoreFile(path, entries, nil); err != nil {
t.Fatal(err)
}
got, err := readStoreFile(path)
got, err := readStoreFile(path, nil)
if err != nil {
t.Fatal(err)
}
@ -84,7 +84,7 @@ func TestReadStoreFileSkipsExpired(t *testing.T) {
}
func TestReadStoreFileNotExist(t *testing.T) {
got, err := readStoreFile("/nonexistent/path.ndjson")
got, err := readStoreFile("/nonexistent/path.ndjson", nil)
if err != nil {
t.Fatal(err)
}
@ -103,11 +103,11 @@ func TestWriteStoreFileSortsKeys(t *testing.T) {
{Key: "bravo", Value: []byte("2")},
}
if err := writeStoreFile(path, entries); err != nil {
if err := writeStoreFile(path, entries, nil); err != nil {
t.Fatal(err)
}
got, err := readStoreFile(path)
got, err := readStoreFile(path, nil)
if err != nil {
t.Fatal(err)
}
@ -122,12 +122,12 @@ func TestWriteStoreFileAtomic(t *testing.T) {
path := filepath.Join(dir, "test.ndjson")
// Write initial data
if err := writeStoreFile(path, []Entry{{Key: "a", Value: []byte("1")}}); err != nil {
if err := writeStoreFile(path, []Entry{{Key: "a", Value: []byte("1")}}, nil); err != nil {
t.Fatal(err)
}
// Overwrite — should not leave .tmp files
if err := writeStoreFile(path, []Entry{{Key: "b", Value: []byte("2")}}); err != nil {
if err := writeStoreFile(path, []Entry{{Key: "b", Value: []byte("2")}}, nil); err != nil {
t.Fatal(err)
}

View file

@ -30,6 +30,7 @@ import (
"os"
"strings"
"filippo.io/age"
"github.com/gobwas/glob"
"github.com/spf13/cobra"
)
@ -94,10 +95,18 @@ func restore(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
identity, _ := loadIdentity()
var recipient *age.X25519Recipient
if identity != nil {
recipient = identity.Recipient()
}
restored, err := restoreEntries(decoder, p, restoreOpts{
matchers: matchers,
promptOverwrite: promptOverwrite,
drop: drop,
identity: identity,
recipient: recipient,
})
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
@ -130,13 +139,15 @@ type restoreOpts struct {
matchers []glob.Glob
promptOverwrite bool
drop bool
identity *age.X25519Identity
recipient *age.X25519Recipient
}
func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (int, error) {
var existing []Entry
if !opts.drop {
var err error
existing, err = readStoreFile(storePath)
existing, err = readStoreFile(storePath, opts.identity)
if err != nil {
return 0, err
}
@ -161,7 +172,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (
continue
}
entry, err := decodeJsonEntry(je)
entry, err := decodeJsonEntry(je, opts.identity)
if err != nil {
return 0, fmt.Errorf("entry %d: %w", entryNo, err)
}
@ -188,7 +199,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (
}
if restored > 0 || opts.drop {
if err := writeStoreFile(storePath, existing); err != nil {
if err := writeStoreFile(storePath, existing, opts.recipient); err != nil {
return 0, err
}
}

View file

@ -59,6 +59,7 @@ func init() {
cpCmd.GroupID = "keys"
delCmd.GroupID = "keys"
listCmd.GroupID = "keys"
identityCmd.GroupID = "keys"
rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"})

103
cmd/secret.go Normal file
View file

@ -0,0 +1,103 @@
package cmd
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"filippo.io/age"
gap "github.com/muesli/go-app-paths"
)
// identityPath returns the path to the age identity file,
// respecting PDA_CONFIG the same way configPath() does.
func identityPath() (string, error) {
if override := os.Getenv("PDA_CONFIG"); override != "" {
return filepath.Join(override, "identity.txt"), nil
}
scope := gap.NewScope(gap.User, "pda")
dir, err := scope.ConfigPath("")
if err != nil {
return "", err
}
return filepath.Join(dir, "identity.txt"), nil
}
// loadIdentity loads the age identity from disk.
// Returns (nil, nil) if the identity file does not exist.
func loadIdentity() (*age.X25519Identity, error) {
path, err := identityPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
identity, err := age.ParseX25519Identity(string(bytes.TrimSpace(data)))
if err != nil {
return nil, fmt.Errorf("parse identity %s: %w", path, err)
}
return identity, nil
}
// ensureIdentity loads an existing identity or generates a new one.
// On first creation prints an ok message with the file path.
func ensureIdentity() (*age.X25519Identity, error) {
id, err := loadIdentity()
if err != nil {
return nil, err
}
if id != nil {
return id, nil
}
id, err = age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("generate identity: %w", err)
}
path, err := identityPath()
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(id.String()+"\n"), 0o600); err != nil {
return nil, err
}
okf("created identity at %s", path)
return id, nil
}
// encrypt encrypts plaintext for the given recipient using age.
func encrypt(plaintext []byte, recipient *age.X25519Recipient) ([]byte, error) {
var buf bytes.Buffer
w, err := age.Encrypt(&buf, recipient)
if err != nil {
return nil, err
}
if _, err := w.Write(plaintext); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// decrypt decrypts age ciphertext with the given identity.
func decrypt(ciphertext []byte, identity *age.X25519Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
if err != nil {
return nil, err
}
return io.ReadAll(r)
}

232
cmd/secret_test.go Normal file
View file

@ -0,0 +1,232 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"filippo.io/age"
)
func TestEncryptDecryptRoundtrip(t *testing.T) {
id, err := generateTestIdentity(t)
if err != nil {
t.Fatal(err)
}
recipient := id.Recipient()
tests := []struct {
name string
plaintext []byte
}{
{"simple text", []byte("hello world")},
{"empty", []byte("")},
{"binary", []byte{0x00, 0xff, 0xfe, 0xfd}},
{"large", make([]byte, 64*1024)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ciphertext, err := encrypt(tt.plaintext, recipient)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if len(ciphertext) == 0 && len(tt.plaintext) > 0 {
t.Fatal("ciphertext is empty for non-empty plaintext")
}
got, err := decrypt(ciphertext, id)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if string(got) != string(tt.plaintext) {
t.Errorf("roundtrip mismatch: got %q, want %q", got, tt.plaintext)
}
})
}
}
func TestLoadIdentityMissing(t *testing.T) {
t.Setenv("PDA_CONFIG", t.TempDir())
id, err := loadIdentity()
if err != nil {
t.Fatal(err)
}
if id != nil {
t.Fatal("expected nil identity for missing file")
}
}
func TestEnsureIdentityCreatesFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("PDA_CONFIG", dir)
id, err := ensureIdentity()
if err != nil {
t.Fatal(err)
}
if id == nil {
t.Fatal("expected non-nil identity")
}
path := filepath.Join(dir, "identity.txt")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("identity file not created: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("identity file permissions = %o, want 0600", perm)
}
// Second call should return same identity
id2, err := ensureIdentity()
if err != nil {
t.Fatal(err)
}
if id2.Recipient().String() != id.Recipient().String() {
t.Error("second ensureIdentity returned different identity")
}
}
func TestEnsureIdentityIdempotent(t *testing.T) {
dir := t.TempDir()
t.Setenv("PDA_CONFIG", dir)
id1, err := ensureIdentity()
if err != nil {
t.Fatal(err)
}
id2, err := ensureIdentity()
if err != nil {
t.Fatal(err)
}
if id1.String() != id2.String() {
t.Error("ensureIdentity is not idempotent")
}
}
func TestSecretEntryRoundtrip(t *testing.T) {
id, err := generateTestIdentity(t)
if err != nil {
t.Fatal(err)
}
recipient := id.Recipient()
dir := t.TempDir()
path := filepath.Join(dir, "test.ndjson")
entries := []Entry{
{Key: "plain", Value: []byte("hello")},
{Key: "encrypted", Value: []byte("secret-value"), Secret: true},
}
if err := writeStoreFile(path, entries, recipient); err != nil {
t.Fatal(err)
}
// Read with identity — should decrypt
got, err := readStoreFile(path, id)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d entries, want 2", len(got))
}
plain := got[findEntry(got, "plain")]
if string(plain.Value) != "hello" || plain.Secret || plain.Locked {
t.Errorf("plain entry unexpected: %+v", plain)
}
secret := got[findEntry(got, "encrypted")]
if string(secret.Value) != "secret-value" {
t.Errorf("secret value = %q, want %q", secret.Value, "secret-value")
}
if !secret.Secret {
t.Error("secret entry should have Secret=true")
}
if secret.Locked {
t.Error("secret entry should not be locked when identity available")
}
}
func TestSecretEntryLockedWithoutIdentity(t *testing.T) {
id, err := generateTestIdentity(t)
if err != nil {
t.Fatal(err)
}
recipient := id.Recipient()
dir := t.TempDir()
path := filepath.Join(dir, "test.ndjson")
entries := []Entry{
{Key: "encrypted", Value: []byte("secret-value"), Secret: true},
}
if err := writeStoreFile(path, entries, recipient); err != nil {
t.Fatal(err)
}
// Read without identity — should be locked
got, err := readStoreFile(path, nil)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %d entries, want 1", len(got))
}
if !got[0].Secret || !got[0].Locked {
t.Errorf("expected Secret=true, Locked=true, got Secret=%v, Locked=%v", got[0].Secret, got[0].Locked)
}
if string(got[0].Value) == "secret-value" {
t.Error("locked entry should not contain plaintext")
}
}
func TestLockedPassthrough(t *testing.T) {
id, err := generateTestIdentity(t)
if err != nil {
t.Fatal(err)
}
recipient := id.Recipient()
dir := t.TempDir()
path := filepath.Join(dir, "test.ndjson")
// Write with encryption
entries := []Entry{
{Key: "encrypted", Value: []byte("secret-value"), Secret: true},
}
if err := writeStoreFile(path, entries, recipient); err != nil {
t.Fatal(err)
}
// Read without identity (locked)
locked, err := readStoreFile(path, nil)
if err != nil {
t.Fatal(err)
}
// Write back without identity (passthrough)
if err := writeStoreFile(path, locked, nil); err != nil {
t.Fatal(err)
}
// Read with identity — should still decrypt
got, err := readStoreFile(path, id)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %d entries, want 1", len(got))
}
if string(got[0].Value) != "secret-value" {
t.Errorf("after passthrough: value = %q, want %q", got[0].Value, "secret-value")
}
if !got[0].Secret || got[0].Locked {
t.Error("entry should be Secret=true, Locked=false after decryption")
}
}
func generateTestIdentity(t *testing.T) (*age.X25519Identity, error) {
t.Helper()
dir := t.TempDir()
t.Setenv("PDA_CONFIG", dir)
return ensureIdentity()
}

View file

@ -28,6 +28,7 @@ import (
"strings"
"time"
"filippo.io/age"
"github.com/spf13/cobra"
)
@ -37,6 +38,9 @@ var setCmd = &cobra.Command{
Short: "Set a key to a given value",
Long: `Set a key to a given value or stdin. Optionally specify a store.
Pass --encrypt to encrypt the value at rest using age. An identity file
is generated automatically on first use.
PDA supports parsing Go templates. Actions are delimited with {{ }}.
For example:
@ -60,6 +64,11 @@ func set(cmd *cobra.Command, args []string) error {
}
promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
secret, err := cmd.Flags().GetBool("encrypt")
if err != nil {
return err
}
spec, err := store.parseKey(args[0], true)
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
@ -81,17 +90,38 @@ func set(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
// Load or create identity depending on --encrypt flag
var identity *age.X25519Identity
if secret {
identity, err = ensureIdentity()
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
} else {
identity, _ = loadIdentity()
}
var recipient *age.X25519Recipient
if identity != nil {
recipient = identity.Recipient()
}
p, err := store.storePath(spec.DB)
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
entries, err := readStoreFile(p)
entries, err := readStoreFile(p, identity)
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
idx := findEntry(entries, spec.Key)
// Warn if overwriting an encrypted key without --encrypt
if idx >= 0 && entries[idx].Secret && !secret {
warnf("overwriting encrypted key '%s' as plaintext", spec.Display())
printHint("pass --encrypt to keep it encrypted")
}
if promptOverwrite && idx >= 0 {
promptf("overwrite '%s'? (y/n)", spec.Display())
var confirm string
@ -104,8 +134,9 @@ func set(cmd *cobra.Command, args []string) error {
}
entry := Entry{
Key: spec.Key,
Value: value,
Key: spec.Key,
Value: value,
Secret: secret,
}
if ttl != 0 {
entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix())
@ -117,7 +148,7 @@ func set(cmd *cobra.Command, args []string) error {
entries = append(entries, entry)
}
if err := writeStoreFile(p, entries); err != nil {
if err := writeStoreFile(p, entries, recipient); err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
@ -128,4 +159,5 @@ func init() {
rootCmd.AddCommand(setCmd)
setCmd.Flags().DurationP("ttl", "t", 0, "Expire the key after the provided duration (e.g. 24h, 30m)")
setCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting an existing key")
setCmd.Flags().BoolP("encrypt", "e", false, "Encrypt the value at rest using age")
}

View file

@ -252,7 +252,7 @@ func (s *Store) Keys(dbName string) ([]string, error) {
if err != nil {
return nil, err
}
entries, err := readStoreFile(p)
entries, err := readStoreFile(p, nil)
if err != nil {
return nil, err
}