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

@ -25,6 +25,7 @@
- plaintext exports in multiple formats,
- support for [binary data](https://github.com/Llywelwyn/pda#binary),
- [time-to-live](https://github.com/Llywelwyn/pda#ttl) support,
- [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age),
and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb).
@ -54,6 +55,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr
- [Globs](https://github.com/Llywelwyn/pda#globs)
- [TTL](https://github.com/Llywelwyn/pda#ttl)
- [Binary](https://github.com/Llywelwyn/pda#binary)
- [Encryption](https://github.com/Llywelwyn/pda#encryption)
- [Environment](https://github.com/Llywelwyn/pda#environment)
<p align="center"></p><!-- spacer -->
@ -76,6 +78,7 @@ Usage:
Key commands:
copy Make a copy of a key
get Get the value of a key
identity Show or create the age encryption identity
list List the contents of a store
move Move a key
remove Delete one or more keys
@ -581,6 +584,74 @@ pda export
<p align="center"></p><!-- spacer -->
### Encryption
`pda set --encrypt` encrypts values at rest using [age](https://github.com/FiloSottile/age). Values are stored on disk as age ciphertext and decrypted automatically by commands like `get` and `list` when the correct identity file is present. An X25519 identity is generated on first use and saved at `~/.config/pda/identity.txt`.
```bash
pda set --encrypt api-key "sk-live-abc123"
# ok created identity at ~/.config/pda/identity.txt
pda set --encrypt token "ghp_xxxx"
```
<p align="center"></p><!-- spacer -->
`get` decrypts automatically.
```bash
pda get api-key
# sk-live-abc123
```
<p align="center"></p><!-- spacer -->
The on-disk value is ciphertext, so encrypted entries are safe to commit and push with Git.
```bash
pda export
# {"key":"api-key","value":"YWdlLWVuY3J5cHRpb24u...","encoding":"secret"}
```
<p align="center"></p><!-- spacer -->
`mv`, `cp`, and `import` all preserve encryption. Overwriting an encrypted key without `--encrypt` will warn you.
```bash
pda cp api-key api-key-backup
# still encrypted
pda set api-key "oops"
# WARN overwriting encrypted key 'api-key' as plaintext
# hint pass --encrypt to keep it encrypted
```
<p align="center"></p><!-- spacer -->
If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes.
```bash
pda ls
# api-key locked (identity file missing)
pda get api-key
# FAIL cannot get 'api-key': secret is locked (identity file missing)
```
<p align="center"></p><!-- spacer -->
`pda identity` to see your public key and identity file path.
```bash
pda identity
# ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# ok identity ~/.config/pda/identity.txt
# Just the path.
pda identity --path
# ~/.config/pda/identity.txt
# Generate a new identity. Errors if one already exists.
pda identity --new
```
<p align="center"></p><!-- spacer -->
### Environment
Config is stored in your user config directory in `pda/config.toml`.

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,8 +191,12 @@ func list(cmd *cobra.Command, args []string) error {
for _, e := range filtered {
var valueStr string
if showValues {
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 {
switch col {

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
@ -106,6 +136,7 @@ func set(cmd *cobra.Command, args []string) error {
entry := Entry{
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
}

9
go.mod
View file

@ -3,6 +3,7 @@ module github.com/llywelwyn/pda
go 1.25.3
require (
filippo.io/age v1.3.1
github.com/BurntSushi/toml v1.6.0
github.com/agnivade/levenshtein v1.2.1
github.com/gobwas/glob v0.2.3
@ -10,10 +11,11 @@ require (
github.com/jedib0t/go-pretty/v6 v6.7.0
github.com/muesli/go-app-paths v0.2.2
github.com/spf13/cobra v1.10.1
golang.org/x/term v0.36.0
golang.org/x/term v0.37.0
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/renameio v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@ -21,6 +23,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
)

20
go.sum
View file

@ -1,3 +1,9 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
@ -40,12 +46,14 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -24,10 +24,12 @@ package main
import (
"flag"
"os"
"os/exec"
"path/filepath"
"testing"
"filippo.io/age"
cmdtest "github.com/google/go-cmdtest"
)
@ -35,7 +37,19 @@ var update = flag.Bool("update", false, "update test files with results")
func TestMain(t *testing.T) {
t.Setenv("PDA_DATA", t.TempDir())
t.Setenv("PDA_CONFIG", t.TempDir())
configDir := t.TempDir()
t.Setenv("PDA_CONFIG", configDir)
// Pre-create an age identity so encryption tests don't print a
// creation message with a non-deterministic path.
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("generate identity: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "identity.txt"), []byte(id.String()+"\n"), 0o600); err != nil {
t.Fatalf("write identity: %v", err)
}
ts, err := cmdtest.Read("testdata")
if err != nil {
t.Fatalf("read testdata: %v", err)

7
testdata/cp__encrypt__ok.ct vendored Normal file
View file

@ -0,0 +1,7 @@
# Copy an encrypted key; both keys should decrypt.
$ pda set --encrypt secret-key hidden-value
$ pda cp secret-key copied-key
$ pda get secret-key
hidden-value
$ pda get copied-key
hidden-value

View file

@ -15,6 +15,7 @@ Usage:
Key commands:
copy Make a copy of a key
get Get the value of a key
identity Show or create the age encryption identity
list List the contents of a store
move Move a key
remove Delete one or more keys
@ -56,6 +57,7 @@ Usage:
Key commands:
copy Make a copy of a key
get Get the value of a key
identity Show or create the age encryption identity
list List the contents of a store
move Move a key
remove Delete one or more keys

View file

@ -2,6 +2,9 @@ $ pda help set
$ pda set --help
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:
@ -18,11 +21,15 @@ Aliases:
set, s
Flags:
-e, --encrypt Encrypt the value at rest using age
-h, --help help for set
-i, --interactive Prompt before overwriting an existing key
-t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m)
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:
@ -39,6 +46,7 @@ Aliases:
set, s
Flags:
-e, --encrypt Encrypt the value at rest using age
-h, --help help for set
-i, --interactive Prompt before overwriting an existing key
-t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m)

7
testdata/mv__encrypt__ok.ct vendored Normal file
View file

@ -0,0 +1,7 @@
# Move an encrypted key; the new key should still decrypt.
$ pda set --encrypt secret-key hidden-value
$ pda mv secret-key moved-key
$ pda get moved-key
hidden-value
$ pda get secret-key --> FAIL
FAIL cannot get 'secret-key': no such key

View file

@ -7,7 +7,9 @@ $ pda ls
a2 2
b1 3
bar 2
copied-key hidden-value
foo 1
moved-key hidden-value
$ pda rm foo --glob "*"
$ pda get bar --> FAIL
FAIL cannot get 'bar': no such key

View file

@ -14,6 +14,7 @@ Usage:
Key commands:
copy Make a copy of a key
get Get the value of a key
identity Show or create the age encryption identity
list List the contents of a store
move Move a key
remove Delete one or more keys

4
testdata/set__encrypt__ok.ct vendored Normal file
View file

@ -0,0 +1,4 @@
# Set an encrypted key, then retrieve it (transparent decryption).
$ pda set --encrypt api-key sk-test-123
$ pda get api-key
sk-test-123

View file

@ -0,0 +1,4 @@
# Set an encrypted key with TTL, then retrieve it.
$ pda set --encrypt --ttl 1h api-key sk-ttl-test
$ pda get api-key
sk-ttl-test