logo hsb.horse
← ブログ一覧に戻る

ブログ

age-tarでディレクトリ単位の暗号化バックアップを自動化する

ageとtarを組み合わせて、サブディレクトリごとに暗号化アーカイブを作るシェルスクリプト。日本語ディレクトリ名を含む運用でもCLI互換性を保てる。

公開日: 更新日:

ローカルのファイルをクラウドストレージへバックアップするとき、平文のままアップロードする運用は避けたい。
そこで、agetar を組み合わせて「ディレクトリ単位で暗号化アーカイブを作る」age-tar スクリプトを作った。

何を解決したいか

バックアップ運用で、次の2点がボトルネックになりやすい。

  1. ディレクトリごとに tar して age で暗号化する手順が手作業だと面倒
  2. 日本語やスペースを含むディレクトリ名がCLIで扱いづらい

age-tar はこの2点をまとめて処理する。

このスクリプトでできること

  • 指定ディレクトリ直下のサブディレクトリを、それぞれ tar + age で暗号化
  • 出力ファイル名は base64URL エンコードしてCLI互換性を確保
  • 復号時に元のディレクトリ名へ戻して展開
  • --dryrun で実行内容だけ確認

前提

ageのインストール

Terminal window
# macOS
brew install age
# Ubuntu/Debian
apt install age
# または公式リリースから
# https://github.com/FiloSottile/age/releases

鍵の生成

Terminal window
# 秘密鍵を作成
age-keygen -o ~/.age/key.txt
# 公開鍵を抽出
age-keygen -y ~/.age/key.txt > ~/.age/key.pub

使い方

暗号化

Terminal window
age-tar -R ~/.age/key.pub -i /path/to/backup/target

/path/to/backup/target 直下の各ディレクトリが .tar.age になる。

target/
├── 日本語フォルダ/
├── my documents/
└── projects/

暗号化後(元ディレクトリはそのまま残る):

target/
├── 5pel5pys6Kqe44OV44Kp44Or44OA.tar.age
├── bXkgZG9jdW1lbnRz.tar.age
├── cHJvamVjdHM.tar.age
├── 日本語フォルダ/
├── my documents/
└── projects/

復号

Terminal window
age-tar -d -I ~/.age/key.txt -i /path/to/encrypted/target

.tar.age ファイルを復号して、元のディレクトリ名で展開する。

ドライラン

Terminal window
# 暗号化
age-tar -R ~/.age/key.pub -i /path/to/target --dryrun
# 復号
age-tar -d -I ~/.age/key.txt -i /path/to/target --dryrun

実行されるコマンドだけ表示し、実ファイル操作は行わない。

運用例

Terminal window
# 1. ローカルで暗号化
age-tar -R ~/.age/key.pub -i ~/important-data
# 2. 暗号化ファイルをアップロード
rclone copy ~/important-data/*.tar.age remote:backup/
# 3. 復元
rclone copy remote:backup/ ~/restore/
age-tar -d -I ~/.age/key.txt -i ~/restore

スクリプト全文

#!/bin/bash
set -euo pipefail
usage() {
cat <<EOF
Usage:
Encrypt: $(basename "$0") -R <public_key_path> -i <target_directory> [--dryrun]
Decrypt: $(basename "$0") -d -I <identity_path> -i <target_directory> [--dryrun]
Options:
-R <path> Path to the age public key file (for encryption)
-I <path> Path to the age identity/secret key file (for decryption)
-i <path> Path to the target directory (required)
-d Decrypt mode
--dryrun Simulation mode - show commands without executing
Description:
Encrypt mode:
Archives each subdirectory under the target directory into a tar file,
then encrypts it using age with the specified public key.
The output filename is the directory name encoded in base64URL format.
Decrypt mode:
Decrypts each .tar.age file in the target directory,
extracts the tar archive, and restores the original directory name
from the base64URL encoded filename.
EOF
exit 1
}
# Encode string to base64URL format
to_base64url() {
local str="$1"
# base64URL: replace + with -, / with _, remove = padding
echo -n "$str" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '='
}
# Decode base64URL format to string
from_base64url() {
local str="$1"
# Restore standard base64: replace - with +, _ with /
local base64_str
base64_str=$(echo -n "$str" | tr '_' '/' | tr '\-' '+')
# Add padding if necessary
local padding=$((4 - ${#base64_str} % 4))
if [[ $padding -ne 4 ]]; then
base64_str="${base64_str}$(printf '=%.0s' $(seq 1 $padding))"
fi
echo -n "$base64_str" | base64 -d
}
# Parse arguments
PUBLIC_KEY=""
IDENTITY=""
TARGET_DIR=""
DRYRUN=false
DECRYPT_MODE=false
while [[ $# -gt 0 ]]; do
case "$1" in
-R)
if [[ -z "${2:-}" ]]; then
echo "Error: -R requires a path argument" >&2
exit 1
fi
PUBLIC_KEY="$2"
shift 2
;;
-I)
if [[ -z "${2:-}" ]]; then
echo "Error: -I requires a path argument" >&2
exit 1
fi
IDENTITY="$2"
shift 2
;;
-i)
if [[ -z "${2:-}" ]]; then
echo "Error: -i requires a path argument" >&2
exit 1
fi
TARGET_DIR="$2"
shift 2
;;
-d)
DECRYPT_MODE=true
shift
;;
--dryrun)
DRYRUN=true
shift
;;
-h|--help)
usage
;;
*)
echo "Error: Unknown option: $1" >&2
usage
;;
esac
done
# Validate required arguments
if [[ -z "$TARGET_DIR" ]]; then
echo "Error: -i <target_directory> is required" >&2
usage
fi
if $DECRYPT_MODE; then
if [[ -z "$IDENTITY" ]]; then
echo "Error: -I <identity_path> is required for decryption" >&2
usage
fi
if [[ ! -f "$IDENTITY" ]]; then
echo "Error: Identity file not found: $IDENTITY" >&2
exit 1
fi
else
if [[ -z "$PUBLIC_KEY" ]]; then
echo "Error: -R <public_key_path> is required for encryption" >&2
usage
fi
if [[ ! -f "$PUBLIC_KEY" ]]; then
echo "Error: Public key file not found: $PUBLIC_KEY" >&2
exit 1
fi
fi
if [[ ! -d "$TARGET_DIR" ]]; then
echo "Error: Target directory not found: $TARGET_DIR" >&2
exit 1
fi
# Check if age command exists
if ! command -v age &> /dev/null; then
echo "Error: 'age' command not found. Please install age first." >&2
exit 1
fi
TARGET_DIR="${TARGET_DIR%/}" # Remove trailing slash if present
# Decrypt mode
if $DECRYPT_MODE; then
found_files=false
for encrypted_file in "$TARGET_DIR"/*.tar.age; do
# Skip if no files found (glob didn't match)
[[ -f "$encrypted_file" ]] || continue
found_files=true
filename=$(basename "$encrypted_file")
encoded_name="${filename%.tar.age}"
original_dirname=$(from_base64url "$encoded_name")
tarfile="${TARGET_DIR}/${encoded_name}.tar"
echo "Processing: $filename"
echo " Decoded name: $original_dirname"
if $DRYRUN; then
echo " [dryrun] age -d -i \"$IDENTITY\" -o \"$tarfile\" \"$encrypted_file\""
echo " [dryrun] tar -xf \"$tarfile\" -C \"$TARGET_DIR\""
echo " [dryrun] rm \"$tarfile\""
else
# Decrypt with age
echo " Decrypting: $tarfile"
age -d -i "$IDENTITY" -o "$tarfile" "$encrypted_file"
# Extract tar archive
echo " Extracting: $original_dirname"
tar -xf "$tarfile" -C "$TARGET_DIR"
# Remove the tar file
echo " Removing tar: $tarfile"
rm "$tarfile"
fi
echo " Done: $original_dirname"
echo
done
if ! $found_files; then
echo "Warning: No .tar.age files found in $TARGET_DIR" >&2
exit 0
fi
echo "All files decrypted successfully."
exit 0
fi
# Encrypt mode
found_dirs=false
for dir in "$TARGET_DIR"/*/; do
# Skip if no directories found (glob didn't match)
[[ -d "$dir" ]] || continue
found_dirs=true
dir="${dir%/}" # Remove trailing slash
dirname=$(basename "$dir")
encoded_name=$(to_base64url "$dirname")
tarfile="${TARGET_DIR}/${encoded_name}.tar"
encrypted_file="${TARGET_DIR}/${encoded_name}.tar.age"
echo "Processing: $dirname"
echo " Encoded name: $encoded_name"
if $DRYRUN; then
echo " [dryrun] tar -cf \"$tarfile\" -C \"$TARGET_DIR\" \"$dirname\""
echo " [dryrun] age --armor -R \"$PUBLIC_KEY\" -o \"$encrypted_file\" \"$tarfile\""
echo " [dryrun] rm \"$tarfile\""
else
# Create tar archive
echo " Creating tar archive: $tarfile"
tar -cf "$tarfile" -C "$TARGET_DIR" "$dirname"
# Encrypt with age
echo " Encrypting: $encrypted_file"
age --armor -R "$PUBLIC_KEY" -o "$encrypted_file" "$tarfile"
# Remove the unencrypted tar file
echo " Removing unencrypted tar: $tarfile"
rm "$tarfile"
fi
echo " Done: $encrypted_file"
echo
done
if ! $found_dirs; then
echo "Warning: No subdirectories found in $TARGET_DIR" >&2
exit 0
fi
echo "All directories processed successfully."

インストール

Terminal window
curl -o ~/.local/bin/age-tar <YOUR_GIST_URL>
chmod +x ~/.local/bin/age-tar
# ~/.bashrc or ~/.zshrc
export PATH="$HOME/.local/bin:$PATH"

補足

  • この実装は対象ディレクトリの「直下のサブディレクトリ」を処理対象にする
  • 暗号化後も元ディレクトリは残るため、必要なら別途削除方針を決める
  • 復号時は .tar.age から一時的な .tar を作り、展開後に削除する

まとめ

age-tar を使うと、ディレクトリ単位の暗号化バックアップを手順化できる。
age の暗号化と base64URL によるファイル名正規化を組み合わせることで、日本語名を含むデータでもCLIで扱いやすい運用にできる。