Initial commit

This commit is contained in:
2025-07-21 02:29:06 +03:00
parent d4be866383
commit e72949d3f8
27 changed files with 1608 additions and 190 deletions

27
cmd/completion.go Normal file
View 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
View 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
View 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
View 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
View 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.