diff --git a/README.md b/README.md index 1a733da..679f0ae 100644 --- a/README.md +++ b/README.md @@ -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)

@@ -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

+### 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" +``` + +

+ +`get` decrypts automatically. +```bash +pda get api-key +# sk-live-abc123 +``` + +

+ +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"} +``` + +

+ +`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 +``` + +

+ +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) +``` + +

+ +`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 +``` + +

+ ### Environment Config is stored in your user config directory in `pda/config.toml`. diff --git a/cmd/del.go b/cmd/del.go index c5dd22c..1f4a20d 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -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 } diff --git a/cmd/get.go b/cmd/get.go index 036ac4d..eeead61 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -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 { diff --git a/cmd/identity.go b/cmd/identity.go new file mode 100644 index 0000000..a7a4ccf --- /dev/null +++ b/cmd/identity.go @@ -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) +} diff --git a/cmd/list.go b/cmd/list.go index 08c83a4..7166a80 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -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 { diff --git a/cmd/mv.go b/cmd/mv.go index b097dcc..d9d5069 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -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 } } diff --git a/cmd/ndjson.go b/cmd/ndjson.go index 9e737bb..09e35d2 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -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. diff --git a/cmd/ndjson_test.go b/cmd/ndjson_test.go index bacc3aa..a1acabe 100644 --- a/cmd/ndjson_test.go +++ b/cmd/ndjson_test.go @@ -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) } diff --git a/cmd/restore.go b/cmd/restore.go index 5440796..d8ae2c2 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -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 } } diff --git a/cmd/root.go b/cmd/root.go index 358ec06..b7a03bf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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:"}) diff --git a/cmd/secret.go b/cmd/secret.go new file mode 100644 index 0000000..b71f272 --- /dev/null +++ b/cmd/secret.go @@ -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) +} diff --git a/cmd/secret_test.go b/cmd/secret_test.go new file mode 100644 index 0000000..6db1bb1 --- /dev/null +++ b/cmd/secret_test.go @@ -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() +} diff --git a/cmd/set.go b/cmd/set.go index 9f1a123..0684328 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -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") } diff --git a/cmd/shared.go b/cmd/shared.go index 511b673..3b72ad2 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -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 } diff --git a/go.mod b/go.mod index 5a28a71..d2043ff 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 125c9e8..d6e8549 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main_test.go b/main_test.go index 822623e..72c9d4e 100644 --- a/main_test.go +++ b/main_test.go @@ -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) diff --git a/testdata/cp__encrypt__ok.ct b/testdata/cp__encrypt__ok.ct new file mode 100644 index 0000000..b172e6c --- /dev/null +++ b/testdata/cp__encrypt__ok.ct @@ -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 diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index 4acbf58..86a7c34 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -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 diff --git a/testdata/help__set__ok.ct b/testdata/help__set__ok.ct index d1e2f57..34cd7d6 100644 --- a/testdata/help__set__ok.ct +++ b/testdata/help__set__ok.ct @@ -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) diff --git a/testdata/mv__encrypt__ok.ct b/testdata/mv__encrypt__ok.ct new file mode 100644 index 0000000..a0b641f --- /dev/null +++ b/testdata/mv__encrypt__ok.ct @@ -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 diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index 9324b0c..54c5ba8 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,13 +1,15 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls - a echo hello - - a1 1 - a2 2 - b1 3 - bar 2 - foo 1 + a echo hello + + a1 1 + 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 diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index e5c16d7..29c2cc7 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -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 diff --git a/testdata/set__encrypt__ok.ct b/testdata/set__encrypt__ok.ct new file mode 100644 index 0000000..1796daf --- /dev/null +++ b/testdata/set__encrypt__ok.ct @@ -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 diff --git a/testdata/set__encrypt__ok__with__ttl.ct b/testdata/set__encrypt__ok__with__ttl.ct new file mode 100644 index 0000000..c1af7f1 --- /dev/null +++ b/testdata/set__encrypt__ok__with__ttl.ct @@ -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