241 lines
5.9 KiB
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)
|
|
}
|