feat: encryption with age
This commit is contained in:
parent
ba93931c33
commit
9bdc9c30c6
25 changed files with 733 additions and 64 deletions
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)
|
||||
}
|
||||
23
cmd/list.go
23
cmd/list.go
|
|
@ -30,6 +30,7 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -126,12 +127,18 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
|
||||
}
|
||||
|
||||
identity, _ := loadIdentity()
|
||||
var recipient *age.X25519Recipient
|
||||
if identity != nil {
|
||||
recipient = identity.Recipient()
|
||||
}
|
||||
|
||||
dbName := targetDB[1:] // strip leading '@'
|
||||
p, err := store.storePath(dbName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
|
||||
}
|
||||
entries, err := readStoreFile(p)
|
||||
entries, err := readStoreFile(p, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
|
||||
}
|
||||
|
|
@ -150,10 +157,14 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
|
||||
output := cmd.OutOrStdout()
|
||||
|
||||
// NDJSON format: emit JSON lines directly
|
||||
// NDJSON format: emit JSON lines directly (encrypted form for secrets)
|
||||
if listFormat.String() == "ndjson" {
|
||||
for _, e := range filtered {
|
||||
data, err := json.Marshal(encodeJsonEntry(e))
|
||||
je, err := encodeJsonEntry(e, recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
|
||||
}
|
||||
data, err := json.Marshal(je)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
|
||||
}
|
||||
|
|
@ -180,7 +191,11 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
for _, e := range filtered {
|
||||
var valueStr string
|
||||
if showValues {
|
||||
valueStr = store.FormatBytes(listBinary, e.Value)
|
||||
if e.Locked {
|
||||
valueStr = "locked (identity file missing)"
|
||||
} else {
|
||||
valueStr = store.FormatBytes(listBinary, e.Value)
|
||||
}
|
||||
}
|
||||
row := make(table.Row, 0, len(columns))
|
||||
for _, col := range columns {
|
||||
|
|
|
|||
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()
|
||||
}
|
||||
40
cmd/set.go
40
cmd/set.go
|
|
@ -28,6 +28,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -37,6 +38,9 @@ var setCmd = &cobra.Command{
|
|||
Short: "Set a key to a given value",
|
||||
Long: `Set a key to a given value or stdin. Optionally specify a store.
|
||||
|
||||
Pass --encrypt to encrypt the value at rest using age. An identity file
|
||||
is generated automatically on first use.
|
||||
|
||||
PDA supports parsing Go templates. Actions are delimited with {{ }}.
|
||||
|
||||
For example:
|
||||
|
|
@ -60,6 +64,11 @@ func set(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
|
||||
|
||||
secret, err := cmd.Flags().GetBool("encrypt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec, err := store.parseKey(args[0], true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
|
|
@ -81,17 +90,38 @@ func set(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
}
|
||||
|
||||
// Load or create identity depending on --encrypt flag
|
||||
var identity *age.X25519Identity
|
||||
if secret {
|
||||
identity, err = ensureIdentity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
}
|
||||
} else {
|
||||
identity, _ = loadIdentity()
|
||||
}
|
||||
var recipient *age.X25519Recipient
|
||||
if identity != nil {
|
||||
recipient = identity.Recipient()
|
||||
}
|
||||
|
||||
p, err := store.storePath(spec.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
}
|
||||
entries, err := readStoreFile(p)
|
||||
entries, err := readStoreFile(p, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
}
|
||||
|
||||
idx := findEntry(entries, spec.Key)
|
||||
|
||||
// Warn if overwriting an encrypted key without --encrypt
|
||||
if idx >= 0 && entries[idx].Secret && !secret {
|
||||
warnf("overwriting encrypted key '%s' as plaintext", spec.Display())
|
||||
printHint("pass --encrypt to keep it encrypted")
|
||||
}
|
||||
|
||||
if promptOverwrite && idx >= 0 {
|
||||
promptf("overwrite '%s'? (y/n)", spec.Display())
|
||||
var confirm string
|
||||
|
|
@ -104,8 +134,9 @@ func set(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
entry := Entry{
|
||||
Key: spec.Key,
|
||||
Value: value,
|
||||
Key: spec.Key,
|
||||
Value: value,
|
||||
Secret: secret,
|
||||
}
|
||||
if ttl != 0 {
|
||||
entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix())
|
||||
|
|
@ -117,7 +148,7 @@ func set(cmd *cobra.Command, args []string) error {
|
|||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
if err := writeStoreFile(p, entries); err != nil {
|
||||
if err := writeStoreFile(p, entries, recipient); err != nil {
|
||||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
}
|
||||
|
||||
|
|
@ -128,4 +159,5 @@ func init() {
|
|||
rootCmd.AddCommand(setCmd)
|
||||
setCmd.Flags().DurationP("ttl", "t", 0, "Expire the key after the provided duration (e.g. 24h, 30m)")
|
||||
setCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting an existing key")
|
||||
setCmd.Flags().BoolP("encrypt", "e", false, "Encrypt the value at rest using age")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue