mirror of
https://github.com/ivuorinen/a.git
synced 2026-01-26 11:34:07 +00:00
Initial commit
This commit is contained in:
27
cmd/completion.go
Normal file
27
cmd/completion.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Package cmd provides CLI command constructors for the age wrapper.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Completion returns a cobra.Command that generates shell completions.
|
||||
func Completion(rootCmd *cobra.Command) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = rootCmd.GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
50
cmd/config.go
Normal file
50
cmd/config.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ConfigCmd returns a cobra.Command for configuring SSH keys, GitHub settings, and logging.
|
||||
//
|
||||
// The saveConfig callback is called with the updated config.
|
||||
func ConfigCmd(cfg any, saveConfig func(cfg any) error) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Configure SSH keys, GitHub settings, and logging",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// Type assertion for expected config struct
|
||||
config, ok := cfg.(*Config)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sshKey, _ := cmd.Flags().GetString("ssh-key")
|
||||
ghUser, _ := cmd.Flags().GetString("github-user")
|
||||
logPath, _ := cmd.Flags().GetString("log-file-path")
|
||||
recipients, _ := cmd.Flags().GetStringSlice("default-recipients")
|
||||
ttl, _ := cmd.Flags().GetInt("cache-ttl")
|
||||
config.SSHKeyPath = sshKey
|
||||
config.GitHubUser = ghUser
|
||||
config.DefaultRecipients = recipients
|
||||
config.CacheTTLMinutes = ttl
|
||||
config.LogFilePath = logPath
|
||||
return saveConfig(config)
|
||||
},
|
||||
}
|
||||
|
||||
// These flag defaults assume cfg is already loaded
|
||||
if config, ok := cfg.(*Config); ok {
|
||||
cmd.Flags().String("ssh-key", "", "Path to private SSH key")
|
||||
cmd.Flags().String("github-user", "", "GitHub username for public keys")
|
||||
cmd.Flags().String("log-file-path", config.LogFilePath, "Path for the log file")
|
||||
cmd.Flags().StringSlice("default-recipients", []string{}, "Public key file paths")
|
||||
cmd.Flags().Int("cache-ttl", 120, "Cache TTL in minutes")
|
||||
} else {
|
||||
cmd.Flags().String("ssh-key", "", "Path to private SSH key")
|
||||
cmd.Flags().String("github-user", "", "GitHub username for public keys")
|
||||
cmd.Flags().String("log-file-path", "", "Path for the log file")
|
||||
cmd.Flags().StringSlice("default-recipients", []string{}, "Public key file paths")
|
||||
cmd.Flags().Int("cache-ttl", 120, "Cache TTL in minutes")
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
143
cmd/config_shared.go
Normal file
143
cmd/config_shared.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// Config represents the application's YAML configuration.
|
||||
type Config struct {
|
||||
SSHKeyPath string `yaml:"ssh_key_path"`
|
||||
GitHubUser string `yaml:"github_user"`
|
||||
DefaultRecipients []string `yaml:"default_recipients"`
|
||||
CacheTTLMinutes int `yaml:"cache_ttl_minutes"`
|
||||
LogFilePath string `yaml:"log_file_path"`
|
||||
}
|
||||
|
||||
// ConfigPaths holds config and cache file paths.
|
||||
type ConfigPaths struct {
|
||||
ConfigDir string
|
||||
ConfigFile string
|
||||
CacheDir string
|
||||
}
|
||||
|
||||
// InitConfigPaths initializes configuration and cache directories and returns their paths.
|
||||
func InitConfigPaths() (ConfigPaths, error) {
|
||||
var configDir string
|
||||
var err error
|
||||
|
||||
// Personal preference, I don't like the "$HOME/Library/Application Support/" path
|
||||
if runtime.GOOS == "darwin" {
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
} else {
|
||||
configDir, err = os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
}
|
||||
|
||||
cfgDir := filepath.Join(configDir, "a")
|
||||
cfgFile := filepath.Join(cfgDir, "config.yaml")
|
||||
if err := os.MkdirAll(cfgDir, 0o700); err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
|
||||
cacheBase, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
cacheDir := filepath.Join(cacheBase, "a")
|
||||
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return ConfigPaths{}, err
|
||||
}
|
||||
|
||||
return ConfigPaths{
|
||||
ConfigDir: cfgDir,
|
||||
ConfigFile: cfgFile,
|
||||
CacheDir: cacheDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from the YAML file.
|
||||
// gosec G304: cfgFile is always set by InitConfigPaths and not user-controlled.
|
||||
func LoadConfig(cfgFile string) (*Config, error) {
|
||||
// gosec G304 mitigation: Ensure cfgFile is within the expected config directory
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expectedDir := filepath.Join(configDir, "a")
|
||||
absCfgFile, err := filepath.Abs(cfgFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasPrefix(absCfgFile, expectedDir) {
|
||||
return nil, fmt.Errorf(
|
||||
"config file path %s is not within expected config directory %s",
|
||||
absCfgFile,
|
||||
expectedDir,
|
||||
)
|
||||
}
|
||||
if _, err := os.Stat(cfgFile); err != nil {
|
||||
return nil, fmt.Errorf("config file does not exist: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(cfgFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config file does not exist: %w", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return nil, fmt.Errorf("config file must have 0600 permissions, got %o", info.Mode().Perm())
|
||||
}
|
||||
// #nosec G304 -- cfgFile is validated to be within the config directory
|
||||
data, err := os.ReadFile(cfgFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.LogFilePath == "" {
|
||||
stateDir := filepath.Join(os.Getenv("HOME"), ".state", "a")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.LogFilePath = filepath.Join(stateDir, "cli.log")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig saves configuration to the YAML file.
|
||||
func SaveConfig(cfgFile string, cfg *Config) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfgFile, data, 0o600)
|
||||
}
|
||||
|
||||
// ScanSSHPrivateKeys scans ~/.ssh for private keys matching id_* (excluding .pub).
|
||||
func ScanSSHPrivateKeys() ([]string, error) {
|
||||
sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
|
||||
files, err := os.ReadDir(sshDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []string
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := f.Name()
|
||||
if strings.HasPrefix(name, "id_") && !strings.HasSuffix(name, ".pub") {
|
||||
keys = append(keys, filepath.Join(sshDir, name))
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
122
cmd/decrypt.go
Normal file
122
cmd/decrypt.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// tryDecrypt attempts to decrypt using the given key and output/input files.
|
||||
func tryDecrypt(keyPath, output, input string) error {
|
||||
ageBin := "age"
|
||||
if ageBin != "age" {
|
||||
return fmt.Errorf("invalid binary for decryption: %s", ageBin)
|
||||
}
|
||||
ageArgs := []string{"-d", "-i", keyPath, "-o", output, input}
|
||||
expectedFlags := map[string]bool{"-d": true, "-i": true, "-o": true}
|
||||
for i, arg := range ageArgs {
|
||||
if i == 0 || i == 2 || i == 4 {
|
||||
if !expectedFlags[arg] && i != 0 {
|
||||
return fmt.Errorf("unexpected flag in age arguments: %s", arg)
|
||||
}
|
||||
} else if arg == "" {
|
||||
return fmt.Errorf("invalid argument for decryption: empty string")
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(keyPath, "id_rsa") && !strings.HasSuffix(keyPath, "id_ed25519") {
|
||||
return fmt.Errorf("invalid key file for decryption: %s", keyPath)
|
||||
}
|
||||
if !strings.HasSuffix(output, ".txt") && !strings.HasSuffix(output, ".out") {
|
||||
return fmt.Errorf("invalid output file for decryption: %s", output)
|
||||
}
|
||||
// #nosec G204 -- ageBin and ageArgs are validated above
|
||||
return exec.Command(ageBin, ageArgs...).Run()
|
||||
}
|
||||
|
||||
// selectSSHKey determines which SSH key to use based on flags and config.
|
||||
func selectSSHKey(sshKeyFlag string, cfg *Config) string {
|
||||
if sshKeyFlag != "" {
|
||||
return sshKeyFlag
|
||||
}
|
||||
return cfg.SSHKeyPath
|
||||
}
|
||||
|
||||
// tryAllKeys attempts decryption with all provided keys, returns true on success.
|
||||
func tryAllKeys(keys []string, input, output string, log *logrus.Logger, triedKeys *[]string) bool {
|
||||
for _, keyPath := range keys {
|
||||
*triedKeys = append(*triedKeys, keyPath)
|
||||
log.WithFields(logrus.Fields{
|
||||
"input": input,
|
||||
"output": output,
|
||||
"sshKey": keyPath,
|
||||
}).Info("Trying decryption with SSH key")
|
||||
err := tryDecrypt(keyPath, output, input)
|
||||
if err == nil {
|
||||
log.Info("Decryption successful")
|
||||
return true
|
||||
}
|
||||
log.WithError(err).Warnf("Decryption failed with key %s", keyPath)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Decrypt returns a cobra.Command that decrypts files using age, scanning local SSH keys if needed.
|
||||
func Decrypt(cfg *Config, log *logrus.Logger) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "decrypt",
|
||||
Short: "Decrypt a file",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
input, _ := cmd.Flags().GetString("input")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
sshKeyFlag, _ := cmd.Flags().GetString("ssh-key")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
sshKey := selectSSHKey(sshKeyFlag, cfg)
|
||||
var triedKeys []string
|
||||
var success bool
|
||||
|
||||
if sshKey != "" {
|
||||
triedKeys = append(triedKeys, sshKey)
|
||||
log.WithFields(logrus.Fields{
|
||||
"input": input,
|
||||
"output": output,
|
||||
"sshKey": sshKey,
|
||||
}).Info("Trying decryption with provided SSH key")
|
||||
if err := tryDecrypt(sshKey, output, input); err == nil {
|
||||
log.Info("Decryption successful")
|
||||
success = true
|
||||
} else {
|
||||
log.WithError(err).Warn("Decryption failed with provided SSH key")
|
||||
}
|
||||
} else {
|
||||
keys, err := ScanSSHPrivateKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not scan ~/.ssh for private keys: %w", err)
|
||||
}
|
||||
success = tryAllKeys(keys, input, output, log, &triedKeys)
|
||||
}
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("decryption failed: none of the tried SSH keys matched\nTried keys: %v", triedKeys)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringP("input", "i", "", "Input file to decrypt")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file for decrypted data")
|
||||
cmd.Flags().String("ssh-key", "", "SSH private key to use for decryption")
|
||||
return cmd
|
||||
}
|
||||
172
cmd/encrypt.go
Normal file
172
cmd/encrypt.go
Normal file
@@ -0,0 +1,172 @@
|
||||
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.
|
||||
Reference in New Issue
Block a user