Files
a/cmd/encrypt.go
2025-07-31 11:40:39 +03:00

173 lines
5.1 KiB
Go

package cmd
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// Encrypt returns a cobra.Command that encrypts files using age, supporting GitHub key fetching.
func Encrypt(cfg *Config, log *logrus.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "encrypt",
Short: "Encrypt a file",
RunE: func(cmd *cobra.Command, _ []string) error {
input, _ := cmd.Flags().GetString("input")
output, _ := cmd.Flags().GetString("output")
recipients, _ := cmd.Flags().GetStringSlice("recipient")
ghUserFlag, _ := cmd.Flags().GetString("github-user")
if input == "" {
return fmt.Errorf("input file is required")
}
if output == "" {
return fmt.Errorf("output file is required")
}
if _, err := os.Stat(input); err != nil {
return fmt.Errorf("input file does not exist: %w", err)
}
allRecipients, ghUser, err := collectRecipients(cfg, recipients, ghUserFlag, log)
if err != nil {
return err
}
if len(allRecipients) == 0 {
return fmt.Errorf("at least one recipient is required")
}
ageArgs, err := buildAgeArgs(output, input, allRecipients)
if err != nil {
return err
}
log.WithFields(logrus.Fields{
"input": input,
"output": output,
"recipients": allRecipients,
"githubUser": ghUser,
}).Info("Encrypting file")
if err := runAgeEncrypt(ageArgs, log); err != nil {
return err
}
log.Info("Encryption successful")
return nil
},
}
cmd.Flags().StringP("input", "i", "", "Input file to encrypt")
cmd.Flags().StringP("output", "o", "", "Output file for encrypted data")
cmd.Flags().StringSliceP("recipient", "r", []string{}, "Recipient public key file or string")
cmd.Flags().String("github-user", "", "GitHub username to fetch public keys for encryption")
return cmd
}
// Helper to collect recipients including GitHub keys
func collectRecipients(
cfg *Config,
recipients []string,
ghUserFlag string,
log *logrus.Logger,
) ([]string, string, error) {
allRecipients := append([]string{}, cfg.DefaultRecipients...)
allRecipients = append(allRecipients, recipients...)
ghUser := ghUserFlag
if ghUser == "" && cfg.GitHubUser != "" {
ghUser = cfg.GitHubUser
}
if ghUser != "" {
validUser := regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$`)
if !validUser.MatchString(ghUser) {
log.Warnf("Invalid GitHub username: %s", ghUser)
} else {
url := fmt.Sprintf("https://github.com/%s.keys", ghUser)
if !strings.HasPrefix(url, "https://github.com/") || !strings.HasSuffix(url, ".keys") {
log.Warnf("Refusing to fetch keys from non-GitHub URL: %s", url)
} else {
// #nosec G107 -- url is validated to be a GitHub keys endpoint above
resp, err := http.Get(url)
if err != nil {
log.WithError(err).Warnf("Failed to fetch GitHub keys for user %s", ghUser)
} else {
var githubKeys []string
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if err == nil && closeErr == nil {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if line != "" {
githubKeys = append(githubKeys, line)
}
}
} else {
if err != nil {
log.WithError(err).Warn("Failed to read GitHub keys response body")
}
if closeErr != nil {
log.WithError(closeErr).Warn("Failed to close GitHub keys response body")
}
}
} else {
_ = resp.Body.Close()
log.Warnf("GitHub returned status %d for user %s", resp.StatusCode, ghUser)
}
allRecipients = append(allRecipients, githubKeys...)
}
}
}
}
return allRecipients, ghUser, nil
}
// Helper to build and validate age arguments
func buildAgeArgs(output, input string, recipients []string) ([]string, error) {
ageArgs := []string{"-o", output}
for _, r := range recipients {
ageArgs = append(ageArgs, "-r", r)
}
ageArgs = append(ageArgs, input)
// Only allow expected flags for age and restrict file extensions
expectedFlags := map[string]bool{"-o": true, "-r": true}
for i, arg := range ageArgs {
if i%2 == 0 && i < len(ageArgs)-2 { // flags before last two args
if !expectedFlags[arg] {
return nil, fmt.Errorf("unexpected flag in age arguments: %s", arg)
}
} else if arg == "" {
return nil, fmt.Errorf("invalid argument for encryption: empty string")
}
}
// Restrict output to expected file extensions
if !strings.HasSuffix(output, ".txt") && !strings.HasSuffix(output, ".out") {
return nil, fmt.Errorf("invalid output file for encryption: %s", output)
}
return ageArgs, nil
}
// Helper to run age encryption command
func runAgeEncrypt(ageArgs []string, log *logrus.Logger) error {
ageBin := "age"
if ageBin != "age" {
return fmt.Errorf("invalid binary for encryption: %s", ageBin)
}
cmdAge := exec.Command(ageBin, ageArgs...)
if err := cmdAge.Run(); err != nil {
log.WithError(err).Error("Encryption failed")
return fmt.Errorf("age encryption failed: %w", err)
}
return nil
}
// Config struct should be imported from the main package or shared as needed.