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, - plaintext exports in multiple formats,
- support for [binary data](https://github.com/Llywelwyn/pda#binary), - support for [binary data](https://github.com/Llywelwyn/pda#binary),
- [time-to-live](https://github.com/Llywelwyn/pda#ttl) support, - [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). 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) - [Globs](https://github.com/Llywelwyn/pda#globs)
- [TTL](https://github.com/Llywelwyn/pda#ttl) - [TTL](https://github.com/Llywelwyn/pda#ttl)
- [Binary](https://github.com/Llywelwyn/pda#binary) - [Binary](https://github.com/Llywelwyn/pda#binary)
- [Encryption](https://github.com/Llywelwyn/pda#encryption)
- [Environment](https://github.com/Llywelwyn/pda#environment) - [Environment](https://github.com/Llywelwyn/pda#environment)
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -76,6 +78,7 @@ Usage:
Key commands: Key commands:
copy Make a copy of a key copy Make a copy of a key
get Get the value 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 list List the contents of a store
move Move a key move Move a key
remove Delete one or more keys remove Delete one or more keys
@ -581,6 +584,74 @@ pda export
<p align="center"></p><!-- spacer --> <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 ### Environment
Config is stored in your user config directory in `pda/config.toml`. Config is stored in your user config directory in `pda/config.toml`.

View file

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

View file

@ -72,6 +72,8 @@ For example:
func get(cmd *cobra.Command, args []string) error { func get(cmd *cobra.Command, args []string) error {
store := &Store{} store := &Store{}
identity, _ := loadIdentity()
spec, err := store.parseKey(args[0], true) spec, err := store.parseKey(args[0], true)
if err != nil { if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err) 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 { if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err) return fmt.Errorf("cannot get '%s': %v", args[0], err)
} }
entries, err := readStoreFile(p) entries, err := readStoreFile(p, identity)
if err != nil { if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err) 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)) 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") binary, err := cmd.Flags().GetBool("include-binary")
if err != nil { 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" "os"
"strconv" "strconv"
"filippo.io/age"
"github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text" "github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra" "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) 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 '@' dbName := targetDB[1:] // strip leading '@'
p, err := store.storePath(dbName) p, err := store.storePath(dbName)
if err != nil { if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
} }
entries, err := readStoreFile(p) entries, err := readStoreFile(p, identity)
if err != nil { if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
} }
@ -150,10 +157,14 @@ func list(cmd *cobra.Command, args []string) error {
output := cmd.OutOrStdout() output := cmd.OutOrStdout()
// NDJSON format: emit JSON lines directly // NDJSON format: emit JSON lines directly (encrypted form for secrets)
if listFormat.String() == "ndjson" { if listFormat.String() == "ndjson" {
for _, e := range filtered { 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 { if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) 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 { for _, e := range filtered {
var valueStr string var valueStr string
if showValues { 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)) row := make(table.Row, 0, len(columns))
for _, col := range columns { for _, col := range columns {

View file

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

View file

@ -32,6 +32,8 @@ import (
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"filippo.io/age"
) )
// Entry is the in-memory representation of a stored key-value pair. // Entry is the in-memory representation of a stored key-value pair.
@ -39,6 +41,8 @@ type Entry struct {
Key string Key string
Value []byte Value []byte
ExpiresAt uint64 // Unix timestamp; 0 = never expires 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. // jsonEntry is the NDJSON on-disk format.
@ -51,7 +55,8 @@ type jsonEntry struct {
// readStoreFile reads all non-expired entries from an NDJSON file. // readStoreFile reads all non-expired entries from an NDJSON file.
// Returns empty slice (not error) if file does not exist. // 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) f, err := os.Open(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -76,7 +81,7 @@ func readStoreFile(path string) ([]Entry, error) {
if err := json.Unmarshal(line, &je); err != nil { if err := json.Unmarshal(line, &je); err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err) return nil, fmt.Errorf("line %d: %w", lineNo, err)
} }
entry, err := decodeJsonEntry(je) entry, err := decodeJsonEntry(je, identity)
if err != nil { if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err) 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. // writeStoreFile atomically writes entries to an NDJSON file, sorted by key.
// Expired entries are excluded. Empty entry list writes an empty file. // 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 // Sort by key for deterministic output
slices.SortFunc(entries, func(a, b Entry) int { slices.SortFunc(entries, func(a, b Entry) int {
return strings.Compare(a.Key, b.Key) 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 { if e.ExpiresAt > 0 && e.ExpiresAt <= now {
continue 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) data, err := json.Marshal(je)
if err != nil { if err != nil {
return fmt.Errorf("key '%s': %w", e.Key, err) 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) 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 var value []byte
switch je.Encoding { switch je.Encoding {
case "", "text": case "", "text":
@ -147,15 +177,35 @@ func decodeJsonEntry(je jsonEntry) (Entry, error) {
default: default:
return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key) 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 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} 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) { if utf8.Valid(e.Value) {
je.Value = string(e.Value) je.Value = string(e.Value)
je.Encoding = "text" je.Encoding = "text"
@ -163,11 +213,7 @@ func encodeJsonEntry(e Entry) jsonEntry {
je.Value = base64.StdEncoding.EncodeToString(e.Value) je.Value = base64.StdEncoding.EncodeToString(e.Value)
je.Encoding = "base64" je.Encoding = "base64"
} }
if e.ExpiresAt > 0 { return je, nil
ts := int64(e.ExpiresAt)
je.ExpiresAt = &ts
}
return je
} }
// findEntry returns the index of the entry with the given key, or -1. // 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 {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) t.Fatal(err)
} }
got, err := readStoreFile(path) got, err := readStoreFile(path, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -69,11 +69,11 @@ func TestReadStoreFileSkipsExpired(t *testing.T) {
{Key: "dead", Value: []byte("no"), ExpiresAt: 1}, // expired long ago {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) t.Fatal(err)
} }
got, err := readStoreFile(path) got, err := readStoreFile(path, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -84,7 +84,7 @@ func TestReadStoreFileSkipsExpired(t *testing.T) {
} }
func TestReadStoreFileNotExist(t *testing.T) { func TestReadStoreFileNotExist(t *testing.T) {
got, err := readStoreFile("/nonexistent/path.ndjson") got, err := readStoreFile("/nonexistent/path.ndjson", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -103,11 +103,11 @@ func TestWriteStoreFileSortsKeys(t *testing.T) {
{Key: "bravo", Value: []byte("2")}, {Key: "bravo", Value: []byte("2")},
} }
if err := writeStoreFile(path, entries); err != nil { if err := writeStoreFile(path, entries, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, err := readStoreFile(path) got, err := readStoreFile(path, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -122,12 +122,12 @@ func TestWriteStoreFileAtomic(t *testing.T) {
path := filepath.Join(dir, "test.ndjson") path := filepath.Join(dir, "test.ndjson")
// Write initial data // 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) t.Fatal(err)
} }
// Overwrite — should not leave .tmp files // 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) t.Fatal(err)
} }

View file

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

View file

@ -59,6 +59,7 @@ func init() {
cpCmd.GroupID = "keys" cpCmd.GroupID = "keys"
delCmd.GroupID = "keys" delCmd.GroupID = "keys"
listCmd.GroupID = "keys" listCmd.GroupID = "keys"
identityCmd.GroupID = "keys"
rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"}) 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" "strings"
"time" "time"
"filippo.io/age"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -37,6 +38,9 @@ var setCmd = &cobra.Command{
Short: "Set a key to a given value", Short: "Set a key to a given value",
Long: `Set a key to a given value or stdin. Optionally specify a store. 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 {{ }}. PDA supports parsing Go templates. Actions are delimited with {{ }}.
For example: For example:
@ -60,6 +64,11 @@ func set(cmd *cobra.Command, args []string) error {
} }
promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
secret, err := cmd.Flags().GetBool("encrypt")
if err != nil {
return err
}
spec, err := store.parseKey(args[0], true) spec, err := store.parseKey(args[0], true)
if err != nil { if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err) 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) 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) p, err := store.storePath(spec.DB)
if err != nil { if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err) return fmt.Errorf("cannot set '%s': %v", args[0], err)
} }
entries, err := readStoreFile(p) entries, err := readStoreFile(p, identity)
if err != nil { if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err) return fmt.Errorf("cannot set '%s': %v", args[0], err)
} }
idx := findEntry(entries, spec.Key) 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 { if promptOverwrite && idx >= 0 {
promptf("overwrite '%s'? (y/n)", spec.Display()) promptf("overwrite '%s'? (y/n)", spec.Display())
var confirm string var confirm string
@ -104,8 +134,9 @@ func set(cmd *cobra.Command, args []string) error {
} }
entry := Entry{ entry := Entry{
Key: spec.Key, Key: spec.Key,
Value: value, Value: value,
Secret: secret,
} }
if ttl != 0 { if ttl != 0 {
entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix()) entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix())
@ -117,7 +148,7 @@ func set(cmd *cobra.Command, args []string) error {
entries = append(entries, entry) 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) return fmt.Errorf("cannot set '%s': %v", args[0], err)
} }
@ -128,4 +159,5 @@ func init() {
rootCmd.AddCommand(setCmd) rootCmd.AddCommand(setCmd)
setCmd.Flags().DurationP("ttl", "t", 0, "Expire the key after the provided duration (e.g. 24h, 30m)") 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("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 { if err != nil {
return nil, err return nil, err
} }
entries, err := readStoreFile(p) entries, err := readStoreFile(p, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

9
go.mod
View file

@ -3,6 +3,7 @@ module github.com/llywelwyn/pda
go 1.25.3 go 1.25.3
require ( require (
filippo.io/age v1.3.1
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/agnivade/levenshtein v1.2.1 github.com/agnivade/levenshtein v1.2.1
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v0.2.3
@ -10,10 +11,11 @@ require (
github.com/jedib0t/go-pretty/v6 v6.7.0 github.com/jedib0t/go-pretty/v6 v6.7.0
github.com/muesli/go-app-paths v0.2.2 github.com/muesli/go-app-paths v0.2.2
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
golang.org/x/term v0.36.0 golang.org/x/term v0.37.0
) )
require ( require (
filippo.io/hpke v0.4.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/renameio v0.1.0 // indirect github.com/google/renameio v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.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/mitchellh/go-homedir v1.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/text v0.26.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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= 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/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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -24,10 +24,12 @@ package main
import ( import (
"flag" "flag"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
"filippo.io/age"
cmdtest "github.com/google/go-cmdtest" 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) { func TestMain(t *testing.T) {
t.Setenv("PDA_DATA", t.TempDir()) 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") ts, err := cmdtest.Read("testdata")
if err != nil { if err != nil {
t.Fatalf("read testdata: %v", err) 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: Key commands:
copy Make a copy of a key copy Make a copy of a key
get Get the value 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 list List the contents of a store
move Move a key move Move a key
remove Delete one or more keys remove Delete one or more keys
@ -56,6 +57,7 @@ Usage:
Key commands: Key commands:
copy Make a copy of a key copy Make a copy of a key
get Get the value 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 list List the contents of a store
move Move a key move Move a key
remove Delete one or more keys remove Delete one or more keys

View file

@ -2,6 +2,9 @@ $ pda help set
$ pda set --help $ pda set --help
Set a key to a given value or stdin. Optionally specify a store. 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 {{ }}. PDA supports parsing Go templates. Actions are delimited with {{ }}.
For example: For example:
@ -18,11 +21,15 @@ Aliases:
set, s set, s
Flags: Flags:
-e, --encrypt Encrypt the value at rest using age
-h, --help help for set -h, --help help for set
-i, --interactive Prompt before overwriting an existing key -i, --interactive Prompt before overwriting an existing key
-t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m) -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. 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 {{ }}. PDA supports parsing Go templates. Actions are delimited with {{ }}.
For example: For example:
@ -39,6 +46,7 @@ Aliases:
set, s set, s
Flags: Flags:
-e, --encrypt Encrypt the value at rest using age
-h, --help help for set -h, --help help for set
-i, --interactive Prompt before overwriting an existing key -i, --interactive Prompt before overwriting an existing key
-t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m) -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

@ -1,13 +1,15 @@
$ pda set foo 1 $ pda set foo 1
$ pda set bar 2 $ pda set bar 2
$ pda ls $ pda ls
a echo hello a echo hello
a1 1 a1 1
a2 2 a2 2
b1 3 b1 3
bar 2 bar 2
foo 1 copied-key hidden-value
foo 1
moved-key hidden-value
$ pda rm foo --glob "*" $ pda rm foo --glob "*"
$ pda get bar --> FAIL $ pda get bar --> FAIL
FAIL cannot get 'bar': no such key FAIL cannot get 'bar': no such key

View file

@ -14,6 +14,7 @@ Usage:
Key commands: Key commands:
copy Make a copy of a key copy Make a copy of a key
get Get the value 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 list List the contents of a store
move Move a key move Move a key
remove Delete one or more keys 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