Skip to content

Instantly share code, notes, and snippets.

@felipealfonsog
Created July 5, 2026 04:35
Show Gist options
  • Select an option

  • Save felipealfonsog/2762179ac42d090e6535bf9a1e2b6dc9 to your computer and use it in GitHub Desktop.

Select an option

Save felipealfonsog/2762179ac42d090e6535bf9a1e2b6dc9 to your computer and use it in GitHub Desktop.
crypttar - tar + age/gpg, no FUSE
#!/usr/bin/env bash
#
# crypttar v1.1.0 — portable encrypted archives using tar + age/gpg.
# macOS/Linux. No FUSE, no mounts, no APFS/LUKS dependency.
#
# Core flows:
# folder -> tar stream (+ embedded .MANIFEST) -> age/gpg -> encrypted archive
# tarball -> age/gpg -> encrypted archive
#
set -euo pipefail
VERSION="v1.1.0"
SCRIPT_NAME="$(basename "$0")"
umask 077
# Keep tar/perl/macOS locale warnings out of the flow when the user's locale is unsupported.
# This does not alter the user's shell config; it only affects this process and its children.
export LC_ALL=C
export LANG=C
# -----------------------------------------------------------------------------
# Utilities
# -----------------------------------------------------------------------------
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
have() {
command -v "$1" >/dev/null 2>&1
}
require_tool() {
case "$1" in
age)
have age || die "age is not installed. macOS: brew install age | Arch: sudo pacman -S age"
;;
gpg)
have gpg || die "gpg is not installed. macOS: brew install gnupg | Arch: sudo pacman -S gnupg"
;;
tar)
have tar || die "tar is required but was not found."
;;
shasum)
have shasum || die "shasum is required but was not found."
;;
*)
have "$1" || die "$1 is required but was not found."
;;
esac
}
confirm() {
local prompt="$1"
local ans
while true; do
printf '%s [y/N]: ' "$prompt" >&2
IFS= read -r ans
case "$ans" in
[yY]|[yY][eE][sS]) return 0 ;;
""|[nN]|[nN][oO]) return 1 ;;
*) printf 'Please answer y or n.\n' >&2 ;;
esac
done
}
choose_crypto_tool() {
# Prompts go to stderr. Selected value only goes to stdout.
local ans
while true; do
printf 'Encryption tool: 1) age 2) gpg [1]: ' >&2
IFS= read -r ans
case "${ans:-1}" in
1|age|AGE)
require_tool age
printf 'age\n'
return 0
;;
2|gpg|GPG)
require_tool gpg
printf 'gpg\n'
return 0
;;
*)
printf 'Invalid option. Choose 1/age or 2/gpg.\n' >&2
;;
esac
done
}
choose_compression() {
# Prompts go to stderr. Selected value only goes to stdout.
local ans
while true; do
printf 'Compression: 1) none 2) gzip [2]: ' >&2
IFS= read -r ans
case "${ans:-2}" in
1|none|NONE|no|NO)
printf 'none\n'
return 0
;;
2|gzip|GZIP|gz|GZ|yes|YES)
printf 'gzip\n'
return 0
;;
*)
printf 'Invalid option. Choose 1/none or 2/gzip.\n' >&2
;;
esac
done
}
sha256_file() {
local file="$1"
require_tool shasum
shasum -a 256 "$file" > "${file}.sha256"
}
verify_sha256_file() {
local file="$1"
local sumfile="${file}.sha256"
[ -f "$file" ] || die "Not a file: $file"
[ -f "$sumfile" ] || die "Missing checksum file: $sumfile"
require_tool shasum
shasum -a 256 -c "$sumfile"
}
infer_crypto_tool() {
case "$1" in
*.age) printf 'age\n' ;;
*.gpg) printf 'gpg\n' ;;
*) die "Cannot infer encryption tool from extension. Expected .age or .gpg" ;;
esac
}
strip_crypto_ext() {
case "$1" in
*.age) printf '%s\n' "${1%.age}" ;;
*.gpg) printf '%s\n' "${1%.gpg}" ;;
*) printf '%s\n' "$1" ;;
esac
}
infer_tar_mode_from_plain_name() {
case "$1" in
*.tar.gz|*.tgz) printf 'gzip\n' ;;
*.tar) printf 'plain\n' ;;
*) die "After removing .age/.gpg, filename must end in .tar, .tar.gz, or .tgz" ;;
esac
}
is_plain_tarball() {
case "$1" in
*.tar|*.tar.gz|*.tgz) return 0 ;;
*) return 1 ;;
esac
}
file_size_bytes() {
# Cross-platform macOS/BSD + GNU stat.
if stat -f '%z' "$1" >/dev/null 2>&1; then
stat -f '%z' "$1"
else
stat -c '%s' "$1"
fi
}
mtime_epoch() {
if stat -f '%m' "$1" >/dev/null 2>&1; then
stat -f '%m' "$1"
else
stat -c '%Y' "$1"
fi
}
mode_octal() {
if stat -f '%Lp' "$1" >/dev/null 2>&1; then
stat -f '%Lp' "$1"
else
stat -c '%a' "$1"
fi
}
maybe_pv() {
if have pv; then
pv
else
cat
fi
}
# -----------------------------------------------------------------------------
# Manifest
# -----------------------------------------------------------------------------
make_folder_manifest() {
# make_folder_manifest <src_dir> <compression> <tool> <outfile> <manifest_file>
local src="$1"
local compression="$2"
local tool="$3"
local outfile="$4"
local manifest="$5"
local abs_src host os uname_s tar_v crypto_v pv_v total_bytes total_entries
abs_src="$(cd "$(dirname "$src")" && pwd -P)/$(basename "$src")"
host="$(hostname 2>/dev/null || printf 'unknown')"
os="$(uname -s 2>/dev/null || printf 'unknown')"
uname_s="$(uname -a 2>/dev/null || printf 'unknown')"
tar_v="$(tar --version 2>/dev/null | head -n 1 || tar --help 2>/dev/null | head -n 1 || printf 'tar version unavailable')"
case "$tool" in
age) crypto_v="$(age --version 2>/dev/null || printf 'age version unavailable')" ;;
gpg) crypto_v="$(gpg --version 2>/dev/null | head -n 1 || printf 'gpg version unavailable')" ;;
*) crypto_v="unknown" ;;
esac
if have pv; then
pv_v="$(pv --version 2>/dev/null | head -n 1 || printf 'pv available')"
else
pv_v="not installed"
fi
total_entries="$(find "$src" | wc -l | tr -d ' ')"
total_bytes="$(du -sk "$src" 2>/dev/null | awk '{print $1 * 1024}' || printf 'unknown')"
{
printf 'crypttar_manifest_version=1\n'
printf 'crypttar_version=%s\n' "$VERSION"
printf 'created_at_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf 'host=%s\n' "$host"
printf 'os=%s\n' "$os"
printf 'uname=%s\n' "$uname_s"
printf 'source_type=directory\n'
printf 'source_basename=%s\n' "$(basename "$src")"
printf 'source_path_at_creation=%s\n' "$abs_src"
printf 'compression=%s\n' "$compression"
printf 'encryption_tool=%s\n' "$tool"
printf 'output_file=%s\n' "$outfile"
printf 'tar_version=%s\n' "$tar_v"
printf 'crypto_version=%s\n' "$crypto_v"
printf 'pv=%s\n' "$pv_v"
printf 'total_entries=%s\n' "$total_entries"
printf 'approx_total_bytes=%s\n' "$total_bytes"
printf '\n'
printf '[inventory]\n'
printf '# type<TAB>mode<TAB>mtime_epoch<TAB>size_bytes<TAB>relative_path\n'
# Use relative paths. Avoid checksumming every file by default; the encrypted archive itself gets SHA256.
(
cd "$(dirname "$src")"
find "$(basename "$src")" -print | sort | while IFS= read -r p; do
if [ -d "$p" ]; then
printf 'dir\t%s\t%s\t-\t%s\n' "$(mode_octal "$p")" "$(mtime_epoch "$p")" "$p"
elif [ -f "$p" ]; then
printf 'file\t%s\t%s\t%s\t%s\n' "$(mode_octal "$p")" "$(mtime_epoch "$p")" "$(file_size_bytes "$p")" "$p"
elif [ -L "$p" ]; then
printf 'symlink\t%s\t%s\t-\t%s -> %s\n' "$(mode_octal "$p")" "$(mtime_epoch "$p")" "$p" "$(readlink "$p" 2>/dev/null || printf '?')"
else
printf 'other\t%s\t%s\t-\t%s\n' "$(mode_octal "$p" 2>/dev/null || printf '?')" "$(mtime_epoch "$p" 2>/dev/null || printf '?')" "$p"
fi
done
)
} > "$manifest"
}
# -----------------------------------------------------------------------------
# Current-directory pickers
# -----------------------------------------------------------------------------
pick_from_current_dir() {
# pick_from_current_dir dir|tarball|encrypted
# Menu/prompt go to stderr. The selected path only goes to stdout.
local kind="$1"
local title prompt item ans idx
local items=()
case "$kind" in
dir)
title="Directories in current directory"
prompt="Directory to encrypt"
while IFS= read -r item; do
[ -n "$item" ] && items+=("$item")
done < <(find . -maxdepth 1 -type d ! -name . -print | sed 's#^\./##' | sort)
;;
tarball)
title="Plain tarballs in current directory"
prompt="Tarball to encrypt"
while IFS= read -r item; do
[ -n "$item" ] && items+=("$item")
done < <(find . -maxdepth 1 -type f \( -name '*.tar' -o -name '*.tar.gz' -o -name '*.tgz' \) -print | sed 's#^\./##' | sort)
;;
encrypted)
title="Encrypted archives in current directory"
prompt="Encrypted archive"
while IFS= read -r item; do
[ -n "$item" ] && items+=("$item")
done < <(find . -maxdepth 1 -type f \( -name '*.tar.age' -o -name '*.tar.gpg' -o -name '*.tar.gz.age' -o -name '*.tar.gz.gpg' -o -name '*.tgz.age' -o -name '*.tgz.gpg' \) -print | sed 's#^\./##' | sort)
;;
*) die "Internal error: invalid picker kind '$kind'" ;;
esac
printf '\n%s:\n' "$title" >&2
if [ "${#items[@]}" -gt 0 ]; then
idx=1
for item in "${items[@]}"; do
printf ' %d) %s\n' "$idx" "$item" >&2
idx=$((idx + 1))
done
else
printf ' (none found)\n' >&2
fi
printf '\n%s — type number or path: ' "$prompt" >&2
IFS= read -r ans
[ -n "$ans" ] || die "No input provided."
case "$ans" in
*[!0-9]*|'')
printf '%s\n' "$ans"
;;
*)
[ "$ans" -ge 1 ] 2>/dev/null || die "Invalid selection."
[ "$ans" -le "${#items[@]}" ] || die "Invalid selection."
printf '%s\n' "${items[$((ans - 1))]}"
;;
esac
}
# -----------------------------------------------------------------------------
# Encryption
# -----------------------------------------------------------------------------
encrypt_stdin_to_file() {
local tool="$1"
local outfile="$2"
case "$tool" in
age)
require_tool age
age -p -o "$outfile"
;;
gpg)
require_tool gpg
gpg --symmetric --cipher-algo AES256 \
--s2k-mode 3 --s2k-count 65000000 --s2k-digest-algo SHA512 \
-o "$outfile"
;;
*) die "Internal error: invalid encryption tool '$tool'" ;;
esac
}
output_or_fail() {
local outfile="$1"
if [ -e "$outfile" ]; then
confirm "Overwrite '$outfile'?" || die "Cancelled."
rm -f -- "$outfile" "${outfile}.sha256"
fi
}
encrypt_folder() {
local src="$1"
[ -d "$src" ] || die "Not a directory: $src"
src="${src%/}"
local base parent compression tar_ext tool crypto_ext outfile tmpdir manifest
base="$(basename "$src")"
parent="$(cd "$(dirname "$src")" && pwd -P)"
require_tool tar
compression="$(choose_compression)"
tool="$(choose_crypto_tool)"
if [ "$compression" = "gzip" ]; then
tar_ext=".tar.gz"
else
tar_ext=".tar"
fi
if [ "$tool" = "age" ]; then
crypto_ext=".age"
else
crypto_ext=".gpg"
fi
outfile="${PWD}/${base}${tar_ext}${crypto_ext}"
output_or_fail "$outfile"
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/crypttar.XXXXXX")"
CRYPTTAR_TMPDIR="$tmpdir"
trap 'rm -rf -- "$CRYPTTAR_TMPDIR"' EXIT
manifest="${tmpdir}/.MANIFEST"
make_folder_manifest "$src" "$compression" "$tool" "$outfile" "$manifest"
printf '\nEncrypting folder\n' >&2
printf ' Source: %s\n' "$src" >&2
printf ' Compression: %s\n' "$compression" >&2
printf ' Tool: %s\n' "$tool" >&2
printf ' Manifest: embedded as .MANIFEST\n' >&2
printf ' Output: %s\n\n' "$outfile" >&2
if [ "$compression" = "gzip" ]; then
tar -czf - -C "$tmpdir" .MANIFEST -C "$parent" "$base" | maybe_pv | encrypt_stdin_to_file "$tool" "$outfile"
else
tar -cf - -C "$tmpdir" .MANIFEST -C "$parent" "$base" | maybe_pv | encrypt_stdin_to_file "$tool" "$outfile"
fi
rm -rf -- "$tmpdir"
trap - EXIT
sha256_file "$outfile"
printf '\nDone: %s\nSHA256: %s.sha256\n' "$outfile" "$outfile" >&2
printf 'Verifying SHA256...\n' >&2
verify_sha256_file "$outfile" >&2
if confirm "Delete original plaintext directory '$src'?"; then
printf 'Warning: secure deletion is not guaranteed on flash/SSD media.\n' >&2
confirm "Confirm deletion?" && rm -rf -- "$src"
fi
}
encrypt_tarball() {
local src="$1"
[ -f "$src" ] || die "Not a file: $src"
is_plain_tarball "$src" || die "Expected a plain tarball ending in .tar, .tar.gz, or .tgz"
local tool crypto_ext outfile
tool="$(choose_crypto_tool)"
if [ "$tool" = "age" ]; then
crypto_ext=".age"
else
crypto_ext=".gpg"
fi
outfile="${PWD}/$(basename "$src")${crypto_ext}"
output_or_fail "$outfile"
printf '\nEncrypting existing tarball\n' >&2
printf ' Source: %s\n' "$src" >&2
printf ' Tool: %s\n' "$tool" >&2
printf ' Manifest: not embedded; existing tarball is encrypted as-is\n' >&2
printf ' Output: %s\n\n' "$outfile" >&2
case "$tool" in
age)
require_tool age
age -p -o "$outfile" "$src"
;;
gpg)
require_tool gpg
gpg --symmetric --cipher-algo AES256 \
--s2k-mode 3 --s2k-count 65000000 --s2k-digest-algo SHA512 \
-o "$outfile" "$src"
;;
esac
sha256_file "$outfile"
printf '\nDone: %s\nSHA256: %s.sha256\n' "$outfile" "$outfile" >&2
printf 'Verifying SHA256...\n' >&2
verify_sha256_file "$outfile" >&2
if confirm "Delete original plaintext tarball '$src'?"; then
printf 'Warning: secure deletion is not guaranteed on flash/SSD media.\n' >&2
confirm "Confirm deletion?" && rm -f -- "$src"
fi
}
# -----------------------------------------------------------------------------
# Decryption / listing / verification
# -----------------------------------------------------------------------------
decrypt_to_stdout() {
local tool="$1"
local src="$2"
case "$tool" in
age)
require_tool age
age -d "$src"
;;
gpg)
require_tool gpg
gpg --decrypt "$src"
;;
*) die "Internal error: invalid decrypt tool '$tool'" ;;
esac
}
decrypt_archive() {
local src="$1"
[ -f "$src" ] || die "Not a file: $src"
local tool plain_name tar_mode default_dest destdir
tool="$(infer_crypto_tool "$src")"
require_tool tar
plain_name="$(strip_crypto_ext "$src")"
tar_mode="$(infer_tar_mode_from_plain_name "$plain_name")"
default_dest="$(dirname "$src")"
printf 'Extract destination [%s]: ' "$default_dest" >&2
IFS= read -r destdir
destdir="${destdir:-$default_dest}"
mkdir -p "$destdir"
printf '\nDecrypting and extracting\n' >&2
printf ' Source: %s\n' "$src" >&2
printf ' Tool: %s\n' "$tool" >&2
printf ' Archive: %s\n' "$tar_mode" >&2
printf ' Destination: %s\n\n' "$destdir" >&2
if [ "$tar_mode" = "gzip" ]; then
decrypt_to_stdout "$tool" "$src" | tar -xzf - -C "$destdir"
else
decrypt_to_stdout "$tool" "$src" | tar -xf - -C "$destdir"
fi
printf '\nDone. Extracted into: %s\n' "$destdir" >&2
}
list_archive() {
local src="$1"
[ -f "$src" ] || die "Not a file: $src"
local tool plain_name tar_mode
tool="$(infer_crypto_tool "$src")"
require_tool tar
plain_name="$(strip_crypto_ext "$src")"
tar_mode="$(infer_tar_mode_from_plain_name "$plain_name")"
if [ "$tar_mode" = "gzip" ]; then
decrypt_to_stdout "$tool" "$src" | tar -tzf -
else
decrypt_to_stdout "$tool" "$src" | tar -tf -
fi
}
verify_sha256() {
verify_sha256_file "$1"
}
# -----------------------------------------------------------------------------
# Help / menu
# -----------------------------------------------------------------------------
show_help() {
cat <<EOF_HELP
$SCRIPT_NAME $VERSION — portable encrypted archives using tar + age/gpg
USAGE
$SCRIPT_NAME
$SCRIPT_NAME encrypt-folder <directory>
$SCRIPT_NAME encrypt-tarball <file.tar|file.tar.gz|file.tgz>
$SCRIPT_NAME decrypt <file.tar[.gz].age|file.tar[.gz].gpg>
$SCRIPT_NAME list <file.tar[.gz].age|file.tar[.gz].gpg>
$SCRIPT_NAME verify <encrypted-file>
$SCRIPT_NAME version
$SCRIPT_NAME help
INTERACTIVE FLOW
1) Encrypt folder from current directory
- Choose a directory by number or path.
- Choose compression: none or gzip.
- Choose encryption: age or gpg.
- Output is automatic: folder.tar.age, folder.tar.gz.age, folder.tar.gpg, etc.
- A .MANIFEST file is embedded at archive root.
- A .sha256 file is generated and immediately verified.
2) Encrypt existing tarball from current directory
- Choose an existing .tar, .tar.gz, or .tgz by number or path.
- Choose encryption: age or gpg.
- The tarball is encrypted as-is; no repacking.
- A .sha256 file is generated and immediately verified.
DESIGN
- No FUSE.
- No mounted encrypted volume.
- No LUKS/APFS dependency.
- Folder encryption streams tar directly into age/gpg.
- No plaintext .tar temporary file is created for folder encryption.
- Existing tarballs are encrypted directly.
- Decryption infers age/gpg and gzip/plain tar from the filename.
- Locale is forced to C inside the script to avoid macOS tar/perl locale warnings.
REQUIREMENTS
macOS:
brew install age gnupg
Arch Linux:
sudo pacman -S age gnupg
Optional progress meter:
brew install pv
sudo pacman -S pv
EOF_HELP
}
show_version() {
printf '%s %s\n' "$SCRIPT_NAME" "$VERSION"
}
interactive_menu() {
local opt path
printf '========================================\n'
printf ' crypttar %s - tar + age/gpg, no FUSE\n' "$VERSION"
printf ' Current directory: %s\n' "$PWD"
printf '========================================\n\n'
printf '1) Encrypt a folder from current directory\n'
printf '2) Encrypt an existing tarball from current directory\n'
printf '3) Decrypt/extract encrypted archive\n'
printf '4) List encrypted archive contents\n'
printf '5) Verify encrypted archive SHA256\n'
printf '6) Help\n'
printf '7) Exit\n\n'
printf 'Choose [1-7]: '
IFS= read -r opt
case "$opt" in
1)
path="$(pick_from_current_dir dir)"
encrypt_folder "$path"
;;
2)
path="$(pick_from_current_dir tarball)"
encrypt_tarball "$path"
;;
3)
path="$(pick_from_current_dir encrypted)"
decrypt_archive "$path"
;;
4)
path="$(pick_from_current_dir encrypted)"
list_archive "$path"
;;
5)
path="$(pick_from_current_dir encrypted)"
verify_sha256 "$path"
;;
6)
show_help
;;
7)
printf 'Bye.\n'
;;
*)
die "Invalid option."
;;
esac
}
# -----------------------------------------------------------------------------
# Entry point
# -----------------------------------------------------------------------------
case "${1:-}" in
encrypt-folder)
[ "$#" -eq 2 ] || die "Usage: $SCRIPT_NAME encrypt-folder <directory>"
encrypt_folder "$2"
;;
encrypt-tarball)
[ "$#" -eq 2 ] || die "Usage: $SCRIPT_NAME encrypt-tarball <file.tar|file.tar.gz|file.tgz>"
encrypt_tarball "$2"
;;
decrypt)
[ "$#" -eq 2 ] || die "Usage: $SCRIPT_NAME decrypt <encrypted-archive>"
decrypt_archive "$2"
;;
list)
[ "$#" -eq 2 ] || die "Usage: $SCRIPT_NAME list <encrypted-archive>"
list_archive "$2"
;;
verify)
[ "$#" -eq 2 ] || die "Usage: $SCRIPT_NAME verify <encrypted-file>"
verify_sha256 "$2"
;;
version|--version|-V)
show_version
;;
help|-h|--help)
show_help
;;
"")
interactive_menu
;;
*)
die "Unknown command: $1. Use '$SCRIPT_NAME help'."
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment