pda/cmd/secret.go

241 lines
5.9 KiB
Go

package cmd
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"filippo.io/age"
gap "github.com/muesli/go-app-paths"
)
// identityPath returns the path to the age identity file,
// respecting PDA_DATA the same way Store.path() does.
func identityPath() (string, error) {
if override := os.Getenv("PDA_DATA"); override != "" {
return filepath.Join(override, "identity.txt"), nil
}
scope := gap.NewScope(gap.User, "pda")
dir, err := scope.DataPath("")
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
}
// recipientsPath returns the path to the additional recipients file,
// respecting PDA_DATA the same way identityPath does.
func recipientsPath() (string, error) {
if override := os.Getenv("PDA_DATA"); override != "" {
return filepath.Join(override, "recipients.txt"), nil
}
scope := gap.NewScope(gap.User, "pda")
dir, err := scope.DataPath("")
if err != nil {
return "", err
}
return filepath.Join(dir, "recipients.txt"), nil
}
// loadRecipients loads additional age recipients from disk.
// Returns (nil, nil) if the recipients file does not exist.
func loadRecipients() ([]*age.X25519Recipient, error) {
path, err := recipientsPath()
if err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
var recipients []*age.X25519Recipient
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
r, err := age.ParseX25519Recipient(line)
if err != nil {
return nil, fmt.Errorf("parse recipient %q: %w", line, err)
}
recipients = append(recipients, r)
}
return recipients, scanner.Err()
}
// saveRecipients writes the recipients file. If the list is empty,
// the file is deleted.
func saveRecipients(recipients []*age.X25519Recipient) error {
path, err := recipientsPath()
if err != nil {
return err
}
if len(recipients) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
var buf bytes.Buffer
for _, r := range recipients {
fmt.Fprintln(&buf, r.String())
}
return os.WriteFile(path, buf.Bytes(), 0o600)
}
// allRecipients combines the identity's own recipient with any additional
// recipients from the recipients file into a single []age.Recipient slice.
// Returns nil if identity is nil and no recipients file exists.
func allRecipients(identity *age.X25519Identity) ([]age.Recipient, error) {
extra, err := loadRecipients()
if err != nil {
return nil, err
}
if identity == nil && len(extra) == 0 {
return nil, nil
}
var recipients []age.Recipient
if identity != nil {
recipients = append(recipients, identity.Recipient())
}
for _, r := range extra {
recipients = append(recipients, r)
}
return recipients, nil
}
// encrypt encrypts plaintext for the given recipients using age.
func encrypt(plaintext []byte, recipients ...age.Recipient) ([]byte, error) {
var buf bytes.Buffer
w, err := age.Encrypt(&buf, recipients...)
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
}
// reencryptAllStores decrypts all secrets across all stores with the
// given identity, then re-encrypts them for the new recipient list.
// Returns the count of re-encrypted secrets.
func reencryptAllStores(identity *age.X25519Identity, recipients []age.Recipient) (int, error) {
store := &Store{}
storeNames, err := store.AllStores()
if err != nil {
return 0, err
}
count := 0
for _, name := range storeNames {
p, err := store.storePath(name)
if err != nil {
return 0, err
}
entries, err := readStoreFile(p, identity)
if err != nil {
return 0, err
}
hasSecrets := false
for _, e := range entries {
if e.Secret {
if e.Locked {
return 0, fmt.Errorf("cannot re-encrypt: secret '%s@%s' is locked (identity cannot decrypt it)", e.Key, name)
}
hasSecrets = true
}
}
if !hasSecrets {
continue
}
if err := writeStoreFile(p, entries, recipients); err != nil {
return 0, err
}
for _, e := range entries {
if e.Secret {
spec := KeySpec{Key: e.Key, DB: name}
okf("re-encrypted %s", spec.Display())
count++
}
}
}
return count, 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)
}