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,
|
- 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`.
|
||||||
|
|
|
||||||
13
cmd/del.go
13
cmd/del.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
cmd/get.go
10
cmd/get.go
|
|
@ -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
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)
|
||||||
|
}
|
||||||
23
cmd/list.go
23
cmd/list.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
21
cmd/mv.go
21
cmd/mv.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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()
|
||||||
|
}
|
||||||
40
cmd/set.go
40
cmd/set.go
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
9
go.mod
|
|
@ -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
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 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=
|
||||||
|
|
|
||||||
16
main_test.go
16
main_test.go
|
|
@ -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
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:
|
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
|
||||||
|
|
|
||||||
8
testdata/help__set__ok.ct
vendored
8
testdata/help__set__ok.ct
vendored
|
|
@ -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
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
|
||||||
14
testdata/remove__dedupe__ok.ct
vendored
14
testdata/remove__dedupe__ok.ct
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
1
testdata/root__ok.ct
vendored
1
testdata/root__ok.ct
vendored
|
|
@ -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
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