From e72949d3f8e05eaf7eb42f9555e8c05fe762ea3f Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Mon, 21 Jul 2025 02:29:06 +0300 Subject: [PATCH] Initial commit --- .editorconfig | 21 +-- .github/ISSUE_TEMPLATE/bug_report.md | 17 +- .github/renovate.json | 8 +- .github/workflows/codeql.yml | 8 - .github/workflows/pr-lint.yml | 5 - .github/workflows/stale.yml | 4 - .github/workflows/sync-labels.yml | 6 - .gitignore | 150 ++++------------ .go-version | 1 + .golangci.yml | 118 ++++++++++++ .goreleaser.yml | 241 +++++++++++++++++++++++++ .mega-linter.yml | 9 +- .pre-commit-config.yaml | 43 +++-- .yamlfmt.yml | 11 ++ .yamllint.yml | 1 - Justfile | 58 ++++++ README.md | 105 +++++++++++ a.go | 100 +++++++++++ a_test.go | 259 +++++++++++++++++++++++++++ cmd/completion.go | 27 +++ cmd/config.go | 50 ++++++ cmd/config_shared.go | 143 +++++++++++++++ cmd/decrypt.go | 122 +++++++++++++ cmd/encrypt.go | 172 ++++++++++++++++++ go.mod | 19 ++ go.sum | 28 +++ revive.toml | 72 ++++++++ 27 files changed, 1608 insertions(+), 190 deletions(-) create mode 100644 .go-version create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 .yamlfmt.yml create mode 100644 Justfile create mode 100644 README.md create mode 100644 a.go create mode 100644 a_test.go create mode 100644 cmd/completion.go create mode 100644 cmd/config.go create mode 100644 cmd/config_shared.go create mode 100644 cmd/decrypt.go create mode 100644 cmd/encrypt.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 revive.toml diff --git a/.editorconfig b/.editorconfig index 8ef3473..05bf82d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,21 +1,14 @@ root = true [*] -charset = utf-8 -end_of_line = lf -indent_size = 2 indent_style = space -insert_final_newline = true -max_line_length = 160 -tab_width = 2 -trim_trailing_whitespace = true - -[{*.md}] indent_size = 2 -tab_width = 2 -max_line_length = 160 -trim_trailing_whitespace = false +insert_final_newline = true +max_line_length = 120 -[{*.mk,GNUmakefile,makefile}] -tab_width = 4 +[*.go] +indent_style = tab +indent_width = 2 + +[{Makefile,go.mod,go.sum}] indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f57b5f9..d2190be 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,6 +12,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,15 +25,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/renovate.json b/.github/renovate.json index e46316f..f02f654 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,6 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "github>ivuorinen/renovate-config" - ] + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>ivuorinen/renovate-config" + ] } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b57e3af..e1c84c3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,7 +1,6 @@ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: 'CodeQL' - on: push: branches: ['main'] @@ -10,36 +9,29 @@ on: schedule: - cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday merge_group: - permissions: actions: read contents: read - jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: security-events: write - strategy: fail-fast: false matrix: language: ['javascript'] # Add languages used in your actions - steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Initialize CodeQL uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: languages: ${{ matrix.language }} queries: security-and-quality - - name: Autobuild uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 - - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 4b189d1..d019386 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -1,19 +1,15 @@ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Lint Code Base - on: push: branches: [master, main] pull_request: branches: [master, main] - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - permissions: read-all - jobs: Linter: name: PR Lint @@ -23,7 +19,6 @@ jobs: statuses: write contents: read packages: read - steps: - name: Run PR Lint # https://github.com/ivuorinen/actions diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1a06fcc..ca606c7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,23 +1,19 @@ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Stale - on: schedule: - cron: '0 8 * * *' # Every day at 08:00 workflow_call: workflow_dispatch: - permissions: contents: read packages: read statuses: read - jobs: stale: name: 🧹 Clean up stale issues and PRs runs-on: ubuntu-latest - permissions: contents: write # only for delete-branch option issues: write diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index 9be6541..2695d2f 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -1,7 +1,6 @@ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Sync Labels - on: push: branches: @@ -15,23 +14,18 @@ on: workflow_call: workflow_dispatch: merge_group: - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - permissions: read-all - jobs: labels: name: ♻️ Sync Labels runs-on: ubuntu-latest timeout-minutes: 10 - permissions: contents: read issues: write - steps: - name: ⤵️ Checkout Repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.gitignore b/.gitignore index e2a1faa..d8f7785 100644 --- a/.gitignore +++ b/.gitignore @@ -1,134 +1,44 @@ -.php-cs-fixer.cache -.php-cs-fixer.php -composer.phar -/vendor/ -.phpunit.result.cache -.phpunit.cache -/app/phpunit.xml -/phpunit.xml -/build/ -logs +*.iws *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -pids +*.pem *.pid -*.seed -*.pid.lock -lib-cov -coverage -*.lcov -.nyc_output -.grunt -bower_components -.lock-wscript -build/Release -node_modules/ -jspm_packages/ -web_modules/ -*.tsbuildinfo -.npm -.eslintcache -.stylelintcache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ -.node_repl_history *.tgz -.yarn-integrity -.env -.env.development.local -.env.test.local -.env.production.local -.env.local -.cache -.parcel-cache -.next -out -.nuxt -dist -.cache/ -.vuepress/dist -.temp -.docusaurus -.serverless/ -.fusebox/ -.dynamodb/ -.tern-port -.vscode-test -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* -[._]*.s[a-v][a-z] -!*.svg # comment out if you don't need vector files -[._]*.sw[a-p] -[._]s[a-rt-v][a-z] -[._]ss[a-gi-z] -[._]sw[a-p] -Session.vim -Sessionx.vim -.netrwhist *~ -tags -[._]*.un~ -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf +.DS_Store +.env +.env*.local +.env.development.local +.env.local +.env.production.local +.env.test.local .idea/**/aws.xml .idea/**/contentModel.xml -.idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml +.idea/**/dataSources/ .idea/**/dbnavigator.xml +.idea/**/dictionaries +.idea/**/dynamic.xml .idea/**/gradle.xml .idea/**/libraries -cmake-build-*/ .idea/**/mongoSettings.xml -*.iws -out/ -.idea_modules/ -atlassian-ide-plugin.xml +.idea/**/shelf +.idea/**/sqlDataSources.xml +.idea/**/tasks.xml +.idea/**/uiDesigner.xml +.idea/**/usage.statistics.xml +.idea/**/workspace.xml +.idea/caches/build_file_checksums.ser +.idea/httpRequests .idea/replstate.xml .idea/sonarlint/ -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties -.idea/httpRequests -.idea/caches/build_file_checksums.ser -npm-debug.log -yarn-error.log -bootstrap/compiled.php -app/storage/ -public/storage -public/hot -public_html/storage -public_html/hot -storage/*.key -Homestead.yaml -Homestead.json -/.vagrant -/node_modules -/.pnp -.pnp.js -/coverage -/.next/ -/out/ -/build -.DS_Store -*.pem -.env*.local -.vercel -next-env.d.ts +.idea_modules/ +.netrwhist +.vscode-test +Session.vim +Sessionx.vim +[._]*.un~ +coverage* +logs +out/ +tags diff --git a/.go-version b/.go-version new file mode 100644 index 0000000..a6c2798 --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.23.0 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e49623d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,118 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/golangci-lint.json +# golangci-lint configuration for f2b project +# https://golangci-lint.run/usage/configuration/ + +version: "2" +run: + timeout: 5m + modules-download-mode: readonly + go: "1.21" + +linters: + enable: + # Essential linters + - errcheck # Error checking + - govet # Go vet + - ineffassign # Inefficient assignment checking + - staticcheck # Static code analysis + - unused # Unused variable checking + - lll # Line length checking + - gosec # Security checking + - usetesting # Unit testing + - revive # Code style checking + + # Code quality linters + - misspell # Spell checking + - unconvert # Unconvert checking + - gocyclo # Cyclomatic complexity checking + - prealloc # Preallocation checking + - bodyclose # Body close checking + - rowserrcheck # Rows error checking + - sqlclosecheck # SQL close checking + - durationcheck # Duration checking + - errorlint # Error linting + - predeclared # Predeclared identifier checking + - wastedassign # Wasted assignment checking + - containedctx # Contained context checking + - contextcheck # Context checking + - errname # Error name checking + - nilnil # Nil nil checking + - thelper # Helper function checking + - usestdlibvars # Use standard library variables + - whitespace # Whitespace checking + - godox # TODO/FIXME/etc comments + + disable: + # Disable overly strict linters for this project + - varnamelen # Variable name length checking + - tagliatelle # Struct tag format checking + - makezero # Make zero checking + - testpackage # Separate test package requirement + - paralleltest # Parallel test requirement + - forcetypeassert # Force type assertion + - ireturn # Return interface checking + - nlreturn # New line return checking + - cyclop # Cyclomatic complexity (covered by gocyclo) + - funlen # Function length checking + - gocognit # Cognitive complexity checking + - maintidx # Maintainability index + - nestif # Nested if checking + - wsl # Whitespace linter (too strict) + - gocritic # Too many style opinions + - nakedret # Naked returns + - nolintlint # Nolint directive checking + - noctx # Context checking + + settings: + errcheck: + check-type-assertions: false + check-blank: false + + govet: + enable-all: true + disable: + - fieldalignment # Can be too strict for simple structs + - shadow # Variable shadowing can be acceptable + + gocyclo: + min-complexity: 20 + + misspell: + locale: US + + prealloc: + simple: true + range-loops: true + for-loops: false + + errorlint: + errorf: false # Allow %v instead of %w for some cases + + lll: + line-length: 120 + +formatters: + enable: + - gofmt + - goimports + - golines + + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/ivuorinen/a + golines: + max-len: 120 + tab-len: 4 + shorten-comments: false + reformat-tags: true + chain-split-dots: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: true diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..a527e1d --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,241 @@ +--- +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=jcroql +# GoReleaser configuration +# Documentation: https://goreleaser.com/customization/ +version: 2 + +# Set the project name +project_name: a + +# Clean dist folder before build +before: + hooks: + - go mod tidy + - go generate ./... + +# Build configuration +builds: + - id: a + main: . + binary: a + + # Custom ldflags + ldflags: + - -s -w + - -X github.com/ivuorinen/a/cmd.version={{.Version}} + - -X github.com/ivuorinen/a/cmd.commit={{.Commit}} + - -X github.com/ivuorinen/a/cmd.date={{.Date}} + - -X github.com/ivuorinen/a/cmd.builtBy=goreleaser + + # Build for multiple platforms + goos: + - linux + - darwin + - freebsd + - openbsd + - netbsd + + goarch: + - amd64 + - arm64 + - arm + - "386" + + goarm: + - "6" + - "7" + + # Skip certain combinations + ignore: + - goos: darwin + goarch: "386" + - goos: darwin + goarch: arm + - goos: freebsd + goarch: arm + - goos: openbsd + goarch: arm + - goos: netbsd + goarch: arm + + # Set environment variables + env: + - CGO_ENABLED=0 + + # Custom build tags + tags: + - netgo + - osusergo + +# Archive configuration +archives: + - id: a + formats: ["binary", "tar.gz"] + + # Archive format + format_overrides: + - goos: windows + format: zip + + # Files to include in archive + files: + - LICENSE.md + - README.md + - CHANGELOG.md + - docs/* + +# Checksum configuration +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +# Snapshot configuration +snapshot: + name_template: "{{ incpatch .Version }}-next" + +# Release configuration +release: + github: + owner: ivuorinen + name: a + + # Release notes + header: | + ## a v{{ .Version }} ({{ .Date }}) + + A robust command-line interface (CLI) wrapper around the age encryption tool + + footer: | + ## Installation + + ### Using Go + ```bash + go install github.com/ivuorinen/a@latest + ``` + + ### Using Homebrew (macOS/Linux) + ```bash + brew tap ivuorinen/tap + brew install a + ``` + + ### Manual Download + Download the appropriate binary for your platform from the assets below. + + ## Documentation + + See the [README](https://github.com/ivuorinen/a#readme) for usage instructions. + + # Automatically generate release notes + + make_latest: true + +# Changelog configuration +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "typo" + - "Merge pull request" + - "Merge branch" + groups: + - title: "🚀 Features" + regexp: "^feat" + - title: "🐛 Bug Fixes" + regexp: "^fix" + - title: "🔒 Security" + regexp: "^security" + - title: "⚡ Performance" + regexp: "^perf" + - title: "♻️ Refactoring" + regexp: "^refactor" + - title: "Other changes" + +# Homebrew tap configuration +brews: + - name: a + repository: + owner: ivuorinen + name: homebrew-tap + branch: main + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + + commit_author: + name: goreleaserbot + email: bot@goreleaser.com + + commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" + + homepage: "https://github.com/ivuorinen/a" + description: "Modern, secure Go-based CLI tool for managing Fail2Ban jails and bans" + license: "MIT" + + dependencies: + - name: go + type: optional + + test: | + system "#{bin}/a", "version" + + install: | + bin.install "a" + +# NFPM configuration for Linux packages +nfpms: + - id: a + package_name: a + vendor: ivuorinen + homepage: https://github.com/ivuorinen/a + maintainer: ivuorinen + description: Modern, secure Go-based CLI tool for managing Fail2Ban jails and bans + license: MIT + + formats: + - deb + - rpm + - apk + + bindir: /usr/bin + + contents: + - src: ./LICENSE.md + dst: /usr/share/doc/a/LICENSE.md + - src: ./README.md + dst: /usr/share/doc/a/README.md + + scripts: + postinstall: | + #!/bin/sh + echo "a has been installed. Run 'a --help' to get started." + +# Docker configuration +dockers: + - image_templates: + - "ghcr.io/ivuorinen/a:{{ .Tag }}" + - "ghcr.io/ivuorinen/a:v{{ .Major }}" + - "ghcr.io/ivuorinen/a:v{{ .Major }}.{{ .Minor }}" + - "ghcr.io/ivuorinen/a:latest" + + dockerfile: | + FROM alpine:latest + RUN apk --no-cache add ca-certificates + COPY a /usr/local/bin/ + ENTRYPOINT ["a"] + + build_flag_templates: + - "--pull" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source={{.GitURL}}" + - "--platform=linux/amd64" + +# Announce releases +announce: + skip: false diff --git a/.mega-linter.yml b/.mega-linter.yml index 82e546d..0c4696e 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -2,7 +2,6 @@ # Configuration file for MegaLinter # See all available variables at # https://megalinter.io/configuration/ and in linters documentation - APPLY_FIXES: all SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run PARALLEL: true @@ -14,22 +13,16 @@ JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript PRINT_ALPACA: false # Print Alpaca logo in console SARIF_REPORTER: true # Generate SARIF report SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log - DISABLE_LINTERS: - REPOSITORY_DEVSKIM - ENABLE_LINTERS: - YAML_YAMLLINT - MARKDOWN_MARKDOWNLINT - YAML_PRETTIER - JSON_PRETTIER - - JAVASCRIPT_ES - - TYPESCRIPT_ES - YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json - FILTER_REGEX_EXCLUDE: > - (node_modules|\.automation/test|docs/json-schemas|\.github/workflows) + (node_modules|\.automation/test) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ccfa22d..500de4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - - id: requirements-txt-fixer - id: detect-private-key - id: trailing-whitespace args: [--markdown-linebreak-ext=md] @@ -22,19 +21,42 @@ repos: - id: pretty-format-json args: [--autofix, --no-sort-keys] + - repo: https://github.com/pre-commit/sync-pre-commit-deps + rev: v0.0.3 + hooks: + - id: sync-pre-commit-deps + + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 + hooks: + - id: go-build-mod + alias: build + - id: go-mod-tidy + alias: tidy + - id: golangci-lint-mod + alias: lint + - id: go-fmt + alias: fmt + args: [-s, -w] + + - repo: https://github.com/google/yamlfmt + rev: v0.17.2 + hooks: + - id: yamlfmt + - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.44.0 + rev: v0.45.0 hooks: - id: markdownlint args: [-c, .markdownlint.json, --fix] - repo: https://github.com/adrienverge/yamllint - rev: v1.37.0 + rev: v1.37.1 hooks: - id: yamllint - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.11.0-1 + rev: v3.12.0-2 hooks: - id: shfmt @@ -42,22 +64,17 @@ repos: rev: v0.10.0 hooks: - id: shellcheck - args: ['--severity=warning'] + args: ["--severity=warning"] - repo: https://github.com/rhysd/actionlint rev: v1.7.7 hooks: - id: actionlint - args: ['-shellcheck='] - - - repo: https://github.com/renovatebot/pre-commit-hooks - rev: 39.227.2 - hooks: - - id: renovate-config-validator + args: ["-shellcheck="] - repo: https://github.com/bridgecrewio/checkov.git - rev: '3.2.400' + rev: "3.2.451" hooks: - id: checkov args: - - '--quiet' + - "--quiet" diff --git a/.yamlfmt.yml b/.yamlfmt.yml new file mode 100644 index 0000000..f3151e0 --- /dev/null +++ b/.yamlfmt.yml @@ -0,0 +1,11 @@ +--- +# yamlfmt configuration file +# Schema: https://raw.githubusercontent.com/google/yamlfmt/main/schema.json +formatter: + type: basic + include_document_start: true + gitignore_excludes: true + retain_line_breaks_single: true + eof_newline: true + max_line_length: 120 + indent: 2 diff --git a/.yamllint.yml b/.yamllint.yml index 065bc60..c82606e 100644 --- a/.yamllint.yml +++ b/.yamllint.yml @@ -1,6 +1,5 @@ --- extends: default - rules: line-length: max: 200 diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..998d16c --- /dev/null +++ b/Justfile @@ -0,0 +1,58 @@ +# Project automation for 'a' CLI wrapper for age encryption +# Set the shell to bash for compatibility + +set shell := ["bash", "-cu"] + +# Variables + +BINARY := "a" + +# Default: show help +default: + @just --list + +# Format all code (Go, YAML, Markdown) +format: + gofmt -s -w . + goimports -w . + yamlfmt -c .yamlfmt.yml . + markdownlint -c .markdownlint.json --fix '**/*.md' + +# Lint Go code and configs +lint: + golangci-lint run + yamllint -c .yamllint.yml . + markdownlint -c .markdownlint.json '**/*.md' + +# Run all tests +test: + go test -v ./... + +# Build the binary +build: + go build -o {{ BINARY }} . + +# Run GoReleaser (dry-run by default) +release: + goreleaser release --clean --skip-publish --snapshot + +# Run GoReleaser for actual release (requires env vars) +release-publish: + goreleaser release --clean + +# Run pre-commit hooks on all files +precommit: + pre-commit run --all-files + +# Update Go modules +tidy: + go mod tidy + +# Clean build artifacts +clean: + rm -rf {{ BINARY }} dist/ coverage* *.log + +# Show help +help: + @echo "Available commands:" + @just --list diff --git a/README.md b/README.md new file mode 100644 index 0000000..864c2ad --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# A CLI Wrapper for Age Encryption + +A robust command-line interface (CLI) wrapper around the [age](https://github.com/FiloSottile/age) +encryption tool. This utility simplifies encryption and decryption using SSH keys, +with integrated support for fetching public keys from GitHub. + +## Features + +* **Secure Encryption/Decryption:** Utilize SSH and GitHub keys with `age` for strong encryption. +* **Configuration:** Easily configurable via a YAML file. +* **Structured Logging:** JSON-formatted logs with configurable paths. +* **Cross-platform:** Supports Linux, macOS, and Windows. +* **Shell Completion:** Auto-generated completion scripts for Bash, Zsh, and Fish. +* **Robust Error Handling:** Comprehensive and clear error messaging. + +## Installation + +### Prerequisites + +* Go (1.21+) +* `age` encryption tool + +### Build from source + +```bash +git clone +cd +go build -o a +``` + +### Move binary to path (optional) + +```bash +sudo mv a /usr/local/bin/ +``` + +## Usage + +### Basic usage + +```bash +a [command] [flags] +``` + +### Commands + +* `config`: Manage application settings +* `encrypt`: Encrypt files +* `decrypt`: Decrypt files +* `completion`: Generate shell completion scripts + +### Examples + +#### Configure the CLI + +```bash +a config --ssh-key ~/.ssh/id_rsa --github-user yourusername --default-recipients ~/.ssh/id_rsa.pub --cache-ttl 120 +``` + +#### Encrypt a file + +```bash +a encrypt -o encrypted_file.txt input.txt +``` + +#### Decrypt a file + +```bash +a decrypt -o decrypted_file.txt encrypted_file.txt +``` + +## Generate shell completions + +```bash +a completion bash > /etc/bash_completion.d/a +``` + +## Configuration File + +Configuration is stored at `$HOME/.config/a/config.yaml`: + +```yaml +ssh_key_path: "/home/user/.ssh/id_rsa" +github_user: "yourusername" +default_recipients: + - "/home/user/.ssh/id_rsa.pub" +cache_ttl_minutes: 120 +log_file_path: "/home/user/.state/a/cli.log" +``` + +## Logging + +Structured JSON logs are written to a configurable log file (`cli.log`). Verbosity can be adjusted with the `-v` or `--verbose` flag. + +## Testing + +Run unit tests with: + +```bash +go test ./... +``` + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/a.go b/a.go new file mode 100644 index 0000000..b061043 --- /dev/null +++ b/a.go @@ -0,0 +1,100 @@ +// a is a robust CLI wrapper for the age encryption tool using SSH/GitHub keys. +package main + +import ( + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/ivuorinen/a/cmd" +) + +const version = "v0.3.0" + +var ( + log = logrus.New() + cfg *cmd.Config + cfgFile string +) + +// initConfigPaths initializes configuration and cache directories. +func initConfigPaths() error { + paths, err := cmd.InitConfigPaths() + if err != nil { + return err + } + cfgFile = paths.ConfigFile + return nil +} + +// loadConfig loads configuration from the YAML file. +func loadConfig() (*cmd.Config, error) { + return cmd.LoadConfig(cfgFile) +} + +// saveConfig saves configuration to the YAML file. +func saveConfig(cfg *cmd.Config) error { + return cmd.SaveConfig(cfgFile, cfg) +} + +// setupLogging configures JSON logging to file and stdout. +func setupLogging(verbose bool) error { + log.SetFormatter(&logrus.JSONFormatter{}) + logFile, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("could not open log file: %w", err) + } + log.SetOutput(logFile) + if verbose { + log.SetLevel(logrus.DebugLevel) + } else { + log.SetLevel(logrus.InfoLevel) + } + return nil +} + +func main() { + var verbose bool + + rootCmd := &cobra.Command{ + Use: "a", + Short: "CLI wrapper for age encryption using SSH/GitHub keys", + Version: version, + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + if err := initConfigPaths(); err != nil { + return fmt.Errorf("error initializing paths: %w", err) + } + var err error + cfg, err = loadConfig() + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + return setupLogging(verbose) + }, + } + + rootCmd.PersistentFlags().BoolVarP( + &verbose, + "verbose", + "v", + false, + "Enable verbose output", + ) + + // Add subcommands from cmd/* + rootCmd.AddCommand( + cmd.ConfigCmd(cfg, func(c any) error { + return saveConfig(c.(*cmd.Config)) + }), + cmd.Encrypt(cfg, log), + cmd.Decrypt(cfg, log), + cmd.Completion(rootCmd), + ) + + // Execute the root command + if err := rootCmd.Execute(); err != nil { + log.WithError(err).Fatal("Command execution failed") + } +} diff --git a/a_test.go b/a_test.go new file mode 100644 index 0000000..1ecc631 --- /dev/null +++ b/a_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" + + "github.com/ivuorinen/a/cmd" +) + +func TestInitConfigPaths(t *testing.T) { + paths, err := cmd.InitConfigPaths() + assert.NoError(t, err, "initializing config paths should not produce an error") + + assert.DirExists(t, paths.ConfigDir, "config directory should exist") + assert.FileExists(t, paths.ConfigFile, "config file path should exist") + assert.DirExists(t, paths.CacheDir, "cache directory should exist") +} + +func TestLoadAndSaveConfig(t *testing.T) { + tempDir := t.TempDir() + cfgFile := filepath.Join(tempDir, "config.yaml") + + cfg := &cmd.Config{ + SSHKeyPath: "/tmp/id_rsa", + GitHubUser: "testuser", + DefaultRecipients: []string{"/tmp/key.pub"}, + CacheTTLMinutes: 60, + LogFilePath: "/tmp/test.log", + } + + err := cmd.SaveConfig(cfgFile, cfg) + assert.NoError(t, err, "saving config should not produce an error") + + loadedCfg, err := cmd.LoadConfig(cfgFile) + assert.NoError(t, err, "loading config should not produce an error") + assert.Equal(t, cfg, loadedCfg, "loaded config should match saved config") +} + +func TestDefaultLogFilePath(t *testing.T) { + tempDir := t.TempDir() + cfgFile := filepath.Join(tempDir, "config.yaml") + + cfg := &cmd.Config{ + SSHKeyPath: "/tmp/id_rsa", + GitHubUser: "testuser", + DefaultRecipients: []string{"/tmp/key.pub"}, + CacheTTLMinutes: 60, + } + + data, err := yaml.Marshal(cfg) + assert.NoError(t, err, "marshaling config should not produce an error") + assert.NoError(t, os.WriteFile(cfgFile, data, 0o600)) + + loadedCfg, err := cmd.LoadConfig(cfgFile) + assert.NoError(t, err, "loading config should not produce an error") + assert.NotEmpty(t, loadedCfg.LogFilePath, "default log file path should be set") +} + +func TestSetupLogging(t *testing.T) { + tempLogFile := filepath.Join(t.TempDir(), "cli.log") + cfg := &cmd.Config{LogFilePath: tempLogFile} + + log := logrus.New() + log.SetFormatter(&logrus.JSONFormatter{}) + logFile, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + assert.NoError(t, err, "opening log file should not produce an error") + log.SetOutput(logFile) + log.SetLevel(logrus.InfoLevel) + + log.Info("Test log entry") + assert.FileExists(t, tempLogFile, "log file should exist after setup") +} + +func TestCmdConfig(t *testing.T) { + cfg := &cmd.Config{} + cmdObj := cmd.ConfigCmd(cfg, func(_ any) error { return nil }) + assert.NotNil(t, cmdObj, "ConfigCmd should return a non-nil cobra command") + + flags := cmdObj.Flags() + sshKey, _ := flags.GetString("ssh-key") + assert.Empty(t, sshKey, "default ssh-key flag should be empty") +} + +func TestCmdEncryptPlaceholder(t *testing.T) { + cfg := &cmd.Config{} + log := logrus.New() + cmdObj := cmd.Encrypt(cfg, log) + assert.NotNil(t, cmdObj, "Encrypt should return a non-nil cobra command") +} + +func TestCmdDecryptPlaceholder(t *testing.T) { + cfg := &cmd.Config{} + log := logrus.New() + cmdObj := cmd.Decrypt(cfg, log) + assert.NotNil(t, cmdObj, "Decrypt should return a non-nil cobra command") +} + +func TestCmdCompletion(t *testing.T) { + rootCmd := &cobra.Command{Use: "a"} + cmdObj := cmd.Completion(rootCmd) + assert.NotNil(t, cmdObj, "Completion should return a non-nil cobra command") +} + +// Helper to generate a temporary SSH keypair for testing +func generateSSHKeyPair(dir string) (privKey, pubKey string, err error) { + privKey = filepath.Join(dir, "id_rsa") + pubKey = privKey + ".pub" + cmd := exec.Command("ssh-keygen", "-t", "rsa", "-b", "2048", "-N", "", "-f", privKey) + if err := cmd.Run(); err != nil { + return "", "", err + } + return privKey, pubKey, nil +} + +// Helper to write test results to a file +func writeTestResult(dir, name string, content []byte) { + _ = os.WriteFile(filepath.Join(dir, name), content, 0o600) +} + +func TestEncryptDecrypt_Success(t *testing.T) { + tempDir := t.TempDir() + plaintext := []byte("This is a secret message for encryption test.") + + // Generate SSH keypair + privKey, pubKey, err := generateSSHKeyPair(tempDir) + writeTestResult( + tempDir, + "sshkeygen_success.txt", + fmt.Appendf(nil, "priv: %s\npub: %s\nerr: %v", privKey, pubKey, err), + ) + assert.NoError(t, err, "ssh-keygen should succeed") + + // Write plaintext file + inputFile := filepath.Join(tempDir, "input.txt") + assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600)) + + // Prepare config + cfg := &cmd.Config{ + DefaultRecipients: []string{pubKey}, + LogFilePath: filepath.Join(tempDir, "cli.log"), + } + log := logrus.New() + + // Encrypt + encryptedFile := filepath.Join(tempDir, "encrypted.txt") + encryptCmd := cmd.Encrypt(cfg, log) + err = encryptCmd.Flags().Set("input", inputFile) + assert.NoError(t, err) + err = encryptCmd.Flags().Set("output", encryptedFile) + assert.NoError(t, err) + err = encryptCmd.RunE(encryptCmd, []string{}) + writeTestResult(tempDir, "encrypt_result.txt", fmt.Appendf(nil, "err: %v", err)) + assert.NoError(t, err) + assert.FileExists(t, encryptedFile, "encrypted file should exist") + + // Decrypt + decryptCfg := &cmd.Config{SSHKeyPath: privKey, LogFilePath: cfg.LogFilePath} + decryptedFile := filepath.Join(tempDir, "decrypted.txt") + decryptCmd := cmd.Decrypt(decryptCfg, log) + err = decryptCmd.Flags().Set("input", encryptedFile) + assert.NoError(t, err) + err = decryptCmd.Flags().Set("output", decryptedFile) + assert.NoError(t, err) + err = decryptCmd.RunE(decryptCmd, []string{}) + writeTestResult(tempDir, "decrypt_result.txt", fmt.Appendf(nil, "err: %v", err)) + assert.NoError(t, err) + assert.FileExists(t, decryptedFile, "decrypted file should exist") + + // Compare output (decryptedFile is generated by the test and not user-controlled) + // Ensure decryptedFile exists and is in tempDir before reading (gosec G304 mitigation) + info, statErr := os.Stat(decryptedFile) + assert.NoError(t, statErr, "decrypted file should exist before reading") + assert.True(t, strings.HasPrefix(decryptedFile, tempDir), "decrypted file must be in tempDir") + assert.Equal(t, info.Mode().Perm(), os.FileMode(0o600), "decrypted file must have 0600 permissions") + + // #nosec G304 -- decryptedFile is generated in tempDir and not user-controlled + decrypted, err := os.ReadFile(decryptedFile) + writeTestResult(tempDir, "decrypted.txt", decrypted) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted, "decrypted output should match original plaintext") +} + +func TestEncryptDecrypt_WrongKey(t *testing.T) { + tempDir := t.TempDir() + plaintext := []byte("Secret message for wrong key test.") + + // Generate two SSH keypairs + _, pubKey1, err := generateSSHKeyPair(tempDir) + assert.NoError(t, err) + privKey2, _, err := generateSSHKeyPair(tempDir) + assert.NoError(t, err) + + // Write plaintext file + inputFile := filepath.Join(tempDir, "input.txt") + assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600)) + + // Encrypt with pubKey1 + cfg := &cmd.Config{ + DefaultRecipients: []string{pubKey1}, + LogFilePath: filepath.Join(tempDir, "cli.log"), + } + log := logrus.New() + encryptedFile := filepath.Join(tempDir, "encrypted.txt") + encryptCmd := cmd.Encrypt(cfg, log) + err = encryptCmd.Flags().Set("input", inputFile) + assert.NoError(t, err) + err = encryptCmd.Flags().Set("output", encryptedFile) + assert.NoError(t, err) + err = encryptCmd.RunE(encryptCmd, []string{}) + writeTestResult(tempDir, "encrypt_wrongkey_result.txt", fmt.Appendf(nil, "err: %v", err)) + assert.NoError(t, err) + assert.FileExists(t, encryptedFile, "encrypted file should exist") + + // Try to decrypt with privKey2 (should fail) + decryptCfg := &cmd.Config{SSHKeyPath: privKey2, LogFilePath: cfg.LogFilePath} + decryptedFile := filepath.Join(tempDir, "decrypted_wrongkey.txt") + decryptCmd := cmd.Decrypt(decryptCfg, log) + err = decryptCmd.Flags().Set("input", encryptedFile) + assert.NoError(t, err) + err = decryptCmd.Flags().Set("output", decryptedFile) + assert.NoError(t, err) + err = decryptCmd.RunE(decryptCmd, []string{}) + writeTestResult(tempDir, "decrypt_wrongkey_result.txt", fmt.Appendf(nil, "err: %v", err)) + assert.Error(t, err, "decryption should fail with wrong key") +} + +func TestEncryptDecrypt_MissingRecipient(t *testing.T) { + tempDir := t.TempDir() + plaintext := []byte("Secret message for missing recipient test.") + + // Write plaintext file + inputFile := filepath.Join(tempDir, "input.txt") + assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600)) + + // Encrypt with no recipient + cfg := &cmd.Config{ + DefaultRecipients: []string{}, + LogFilePath: filepath.Join(tempDir, "cli.log"), + } + log := logrus.New() + encryptedFile := filepath.Join(tempDir, "encrypted.txt") + encryptCmd := cmd.Encrypt(cfg, log) + err := encryptCmd.Flags().Set("input", inputFile) + assert.NoError(t, err) + err = encryptCmd.Flags().Set("output", encryptedFile) + assert.NoError(t, err) + err = encryptCmd.RunE(encryptCmd, []string{}) + writeTestResult(tempDir, "encrypt_missingrecipient_result.txt", fmt.Appendf(nil, "err: %v", err)) + assert.Error(t, err, "encryption should fail with no recipient") +} diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..921379b --- /dev/null +++ b/cmd/completion.go @@ -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) + } + }, + } +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..260e229 --- /dev/null +++ b/cmd/config.go @@ -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 +} diff --git a/cmd/config_shared.go b/cmd/config_shared.go new file mode 100644 index 0000000..64e8a8e --- /dev/null +++ b/cmd/config_shared.go @@ -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 +} diff --git a/cmd/decrypt.go b/cmd/decrypt.go new file mode 100644 index 0000000..268954c --- /dev/null +++ b/cmd/decrypt.go @@ -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 +} diff --git a/cmd/encrypt.go b/cmd/encrypt.go new file mode 100644 index 0000000..69af16e --- /dev/null +++ b/cmd/encrypt.go @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fa4bb55 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/ivuorinen/a + +go 1.24 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b909b3e --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/revive.toml b/revive.toml new file mode 100644 index 0000000..c5b7cc0 --- /dev/null +++ b/revive.toml @@ -0,0 +1,72 @@ +# Revive configuration for "a" project +# https://revive.run/ + +ignoreGeneratedHeader = false +severity = "warning" +confidence = 0.8 +errorCode = 0 +warningCode = 0 + +# Core rules that align with golangci-lint settings +[rule.blank-imports] +[rule.context-as-argument] +[rule.context-keys-type] +[rule.dot-imports] +[rule.error-return] +[rule.error-strings] +[rule.error-naming] +[rule.exported] +[rule.if-return] +[rule.increment-decrement] +[rule.var-naming] +[rule.var-declaration] +[rule.range] +[rule.receiver-naming] +[rule.time-naming] +[rule.unexported-return] +[rule.indent-error-flow] +[rule.errorf] +[rule.empty-block] +[rule.superfluous-else] +[rule.unreachable-code] +[rule.redefines-builtin-id] + +# Rules that complement golangci-lint but don't conflict +[rule.atomic] +[rule.bool-literal-in-expr] +[rule.constant-logical-expr] +[rule.defer] +[rule.early-return] +[rule.empty-lines] +[rule.get-return] +[rule.identical-branches] +[rule.imports-blacklist] +[rule.modifies-parameter] +[rule.modifies-value-receiver] +[rule.optimize-operands-order] +[rule.string-of-int] +[rule.struct-tag] +# [rule.switch-default] # Rule not available in current version +[rule.unconditional-recursion] +[rule.unnecessary-stmt] +[rule.useless-break] +[rule.waitgroup-by-value] + +# Project-specific rules +[rule.package-comments] # Require package comments +[rule.confusing-naming] # Catch confusing variable/function names + +# Disable rules that conflict with golangci-lint or project preferences +# [rule.unused-parameter] # Disabled - conflicts with golangci-lint unused linter +# [rule.line-length-limit] # Disabled - conflicts with golangci-lint lll (which is also disabled) +# [rule.function-length] # Disabled - conflicts with golangci-lint funlen (which is disabled) +# [rule.cyclomatic] # Disabled - conflicts with golangci-lint gocyclo +# [rule.cognitive-complexity] # Disabled - conflicts with golangci-lint gocognit (which is disabled) +# [rule.max-public-structs] # Disabled - too restrictive for this project +# [rule.flag-parameter] # Disabled - can be too strict for CLI applications +# [rule.deep-exit] # Disabled - acceptable in CLI applications +# [rule.file-header] # Disabled - no specific file header requirement +# [rule.add-constant] # Disabled - can be too strict for configuration values +# [rule.argument-limit] # Disabled - can be too restrictive for some functions +# [rule.function-result-limit] # Disabled - Go allows multiple returns +# [rule.unhandled-error] # Disabled - conflicts with golangci-lint errcheck