#!/usr/bin/env bash # A script for encrypting and decrypting files or directories with age and SSH keys set -euo pipefail VERSION="1.1.0" # Default ENV values KEYS_FILE="${AGE_KEYSFILE:-$HOME/.ssh/keys.txt}" KEYS_SOURCE="${AGE_KEYSSOURCE:-https://github.com/ivuorinen.keys}" LOG_FILE="${AGE_LOGFILE:-$HOME/.cache/a.log}" VERBOSE=false DELETE_ORIGINAL=false FORCE=false # Check for required dependencies check_dependencies() { if ! command -v age &> /dev/null; then echo "Error: 'age' is not installed. Please install it first." >&2 echo " brew install age # macOS" >&2 echo " apt install age # Debian/Ubuntu" >&2 echo " dnf install age # Fedora" >&2 exit 1 fi if ! command -v curl &> /dev/null; then echo "Error: 'curl' is not installed." >&2 exit 1 fi } # Parse flags parse_flags() { local args=() for arg in "$@"; do case "$arg" in -v | --verbose) VERBOSE=true ;; --delete) DELETE_ORIGINAL=true ;; -f | --force) FORCE=true ;; *) args+=("$arg") ;; esac done # Return remaining arguments printf '%s\n' "${args[@]}" } # Ensure log directory and file exist with correct permissions prepare_log_file() { local log_dir log_dir=$(dirname "$LOG_FILE") # Create log directory if it does not exist if [[ ! -d "$log_dir" ]]; then mkdir -p "$log_dir" fi # Create log file if it does not exist if [[ ! -f "$LOG_FILE" ]]; then touch "$LOG_FILE" fi # Set permissions to 0600 chmod 0600 "$LOG_FILE" } # Logging function log_message() { local message="$1" echo "$(date +'%Y-%m-%d %H:%M:%S') - $message" >> "$LOG_FILE" # Print to user if verbose flag is set if [[ "$VERBOSE" == true ]]; then echo "$message" fi } # Function to print usage print_help() { cat << EOF Usage: a [options] [command] [file_or_directory] Commands: e, enc, encrypt Encrypt the specified file or directory d, dec, decrypt Decrypt the specified file or directory help, --help Show this help message version, --version Show version information Options: -v, --verbose Print log messages to console --delete Delete original files after successful encryption -f, --force Overwrite existing output files without prompting Environment Variables: AGE_KEYSFILE Path to the SSH keys file (default: \$HOME/.ssh/keys.txt) AGE_KEYSSOURCE URL to fetch SSH keys if keys file does not exist AGE_LOGFILE Path to the log file (default: \$HOME/.cache/a.log) Examples: Encrypt a file: a e file.txt Encrypt a directory: a e /path/to/directory Encrypt and delete originals: a --delete e file.txt Decrypt a file: a d file.txt.age Force overwrite existing files: a -f e file.txt Specify a custom keys file: AGE_KEYSFILE=/path/to/keys.txt a e file.txt Requirements: - age (encryption tool): https://github.com/FiloSottile/age - curl (for fetching keys) EOF } # Function to print version print_version() { echo "a version $VERSION" echo "Created by Ismo Vuorinen " } # Function to fetch keys if missing fetch_keys_if_missing() { if [[ ! -f "$KEYS_FILE" ]]; then log_message "Keys file '$KEYS_FILE' not found. Attempting to fetch from $KEYS_SOURCE..." mkdir -p "$(dirname "$KEYS_FILE")" if ! curl -s "$KEYS_SOURCE" -o "$KEYS_FILE" || [[ ! -s "$KEYS_FILE" ]]; then rm -f "$KEYS_FILE" 2> /dev/null || true log_message "Error: Failed to fetch keys from $KEYS_SOURCE" exit 1 fi chmod 0400 "$KEYS_FILE" log_message "Keys file fetched and permissions set to 0400." fi } # Function to encrypt a single file encrypt_single_file() { local file="$1" # Skip already encrypted files if [[ "$file" == *.age ]]; then log_message "Skipping already encrypted file: $file" return 0 fi local output_file="${file}.age" # Check if output file exists if [[ -f "$output_file" && "$FORCE" != true ]]; then log_message "Error: Output file '$output_file' already exists. Use --force to overwrite." return 1 fi fetch_keys_if_missing local temp_file temp_file="$(mktemp -p "$(dirname "$file")")" if age -R "$KEYS_FILE" "$file" > "$temp_file" && mv "$temp_file" "$output_file"; then log_message "File encrypted successfully: $output_file" if [[ "$DELETE_ORIGINAL" == true ]]; then rm -f "$file" log_message "Original file deleted: $file" fi else rm -f "$temp_file" log_message "Error: Failed to encrypt file '$file'." return 1 fi } # Function to encrypt files or directories encrypt_file_or_directory() { local file="$1" if [[ -d "$file" ]]; then # Enable dotglob to include hidden files shopt -s dotglob nullglob local files=("$file"/*) shopt -u dotglob nullglob if [[ ${#files[@]} -eq 0 ]]; then log_message "Warning: Directory '$file' is empty." return 0 fi for f in "${files[@]}"; do encrypt_file_or_directory "$f" done elif [[ -f "$file" ]]; then encrypt_single_file "$file" else log_message "Warning: '$file' is not a file or directory, skipping." fi } # Function to decrypt a single file decrypt_single_file() { local file="$1" if [[ ! "$file" == *.age ]]; then log_message "Skipping non-.age file: $file" return 0 fi local output_file="${file%.age}" # Check if output file exists if [[ -f "$output_file" && "$FORCE" != true ]]; then log_message "Error: Output file '$output_file' already exists. Use --force to overwrite." return 1 fi fetch_keys_if_missing local temp_file temp_file="$(mktemp -p "$(dirname "$file")")" if age -d -i "$KEYS_FILE" "$file" > "$temp_file" && mv "$temp_file" "$output_file"; then log_message "File decrypted successfully: $output_file" if [[ "$DELETE_ORIGINAL" == true ]]; then rm -f "$file" log_message "Encrypted file deleted: $file" fi else rm -f "$temp_file" log_message "Error: Failed to decrypt file '$file'." return 1 fi } # Function to decrypt files or directories decrypt_file_or_directory() { local file="$1" if [[ -d "$file" ]]; then # Enable nullglob to handle no matches gracefully shopt -s nullglob local files=("$file"/*.age) shopt -u nullglob if [[ ${#files[@]} -eq 0 ]]; then log_message "Warning: No .age files found in directory '$file'." return 0 fi for f in "${files[@]}"; do decrypt_single_file "$f" done elif [[ -f "$file" ]]; then decrypt_single_file "$file" else log_message "Warning: '$file' is not a file or directory, skipping." fi } # Main entry point main() { check_dependencies # Parse flags and get remaining arguments mapfile -t ARGS < <(parse_flags "$@") prepare_log_file local command="${ARGS[0]:-}" local target="${ARGS[1]:-}" case "$command" in e | enc | encrypt) if [[ -z "$target" ]]; then log_message "Error: No file or directory specified for encryption." print_help exit 1 fi encrypt_file_or_directory "$target" ;; d | dec | decrypt) if [[ -z "$target" ]]; then log_message "Error: No file or directory specified for decryption." print_help exit 1 fi decrypt_file_or_directory "$target" ;; help | --help | -h) print_help ;; version | --version) print_version ;; "") print_help exit 1 ;; *) log_message "Error: Unknown command '$command'" print_help exit 1 ;; esac } main "$@" # vim: ft=bash:syn=sh:ts=2:sw=2:et:ai:nowrap