feat: encryption with age
This commit is contained in:
parent
ba93931c33
commit
9bdc9c30c6
25 changed files with 733 additions and 64 deletions
71
README.md
71
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)
|
||||
|
||||
<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`.
|
||||
|
|
|
|||
13
cmd/del.go
13
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
|
||||
}
|
||||
|
|
|
|||
10
cmd/get.go
10
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 {
|
||||
|
|
|
|||
76
cmd/identity.go
Normal file
76
cmd/identity.go
Normal 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)
|
||||
}
|
||||
21
cmd/list.go
21
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,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 {
|
||||
|
|
|
|||
21
cmd/mv.go
21
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
103
cmd/secret.go
Normal 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
232
cmd/secret_test.go
Normal 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()
|
||||
}
|
||||
36
cmd/set.go
36
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
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
9
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
|
||||
)
|
||||
|
|
|
|||
20
go.sum
20
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=
|
||||
|
|
|
|||
16
main_test.go
16
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)
|
||||
|
|
|
|||
7
testdata/cp__encrypt__ok.ct
vendored
Normal file
7
testdata/cp__encrypt__ok.ct
vendored
Normal 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
|
||||
2
testdata/help__ok.ct
vendored
2
testdata/help__ok.ct
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
testdata/help__set__ok.ct
vendored
8
testdata/help__set__ok.ct
vendored
|
|
@ -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
7
testdata/mv__encrypt__ok.ct
vendored
Normal 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
|
||||
2
testdata/remove__dedupe__ok.ct
vendored
2
testdata/remove__dedupe__ok.ct
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
testdata/root__ok.ct
vendored
1
testdata/root__ok.ct
vendored
|
|
@ -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
4
testdata/set__encrypt__ok.ct
vendored
Normal 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
|
||||
4
testdata/set__encrypt__ok__with__ttl.ct
vendored
Normal file
4
testdata/set__encrypt__ok__with__ttl.ct
vendored
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue