logo hsb.horse
← Retour au blog

Blog

Automatiser des sauvegardes chiffrées par répertoire avec age et tar

Un script shell qui combine age et tar pour créer une archive chiffrée pour chaque sous-répertoire. Il conserve des noms de fichiers compatibles CLI, même avec des dossiers en japonais.

Publié: Mis à jour:

Quand je sauvegarde des fichiers locaux vers un stockage cloud, je préfère éviter de les téléverser en clair.

J’ai donc écrit un script age-tar qui combine age et tar pour créer des archives chiffrées répertoire par répertoire.

Ce que je voulais résoudre

Deux points deviennent vite pénibles dans un flux de sauvegarde :

  1. Exécuter manuellement tar puis age pour chaque répertoire est fastidieux
  2. Les noms de répertoires contenant du japonais ou des espaces sont peu pratiques à manipuler en CLI

age-tar traite ces deux problèmes en même temps.

Ce que fait ce script

  • Chiffre chaque sous-répertoire situé directement sous un dossier cible avec tar + age
  • Encode les noms de fichiers de sortie en base64URL pour garder des noms compatibles CLI
  • Restaure le nom de répertoire d’origine pendant le déchiffrement
  • Permet de vérifier uniquement les commandes via --dryrun

Pré-requis

Installer age

Terminal window
# macOS
brew install age
# Ubuntu/Debian
apt install age
# Ou utiliser les releases officielles
# https://github.com/FiloSottile/age/releases

Générer les clés

Terminal window
# Créer une clé secrète
age-keygen -o ~/.age/key.txt
# Extraire la clé publique
age-keygen -y ~/.age/key.txt > ~/.age/key.pub

Utilisation

Chiffrer

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

Chaque dossier situé directement sous /path/to/backup/target devient un fichier .tar.age.

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

Après chiffrement, les répertoires d’origine restent en place :

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

Déchiffrer

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

Le script déchiffre les fichiers .tar.age puis les extrait avec le nom de répertoire d’origine.

Dry run

Terminal window
# Encrypt
age-tar -R ~/.age/key.pub -i /path/to/target --dryrun
# Decrypt
age-tar -d -I ~/.age/key.txt -i /path/to/target --dryrun

Seules les commandes qui seraient exécutées sont affichées, sans opération réelle sur les fichiers.

Exemple de workflow

Terminal window
# 1. Chiffrer en local
age-tar -R ~/.age/key.pub -i ~/important-data
# 2. Téléverser les fichiers chiffrés
rclone copy ~/important-data/*.tar.age remote:backup/
# 3. Restaurer
rclone copy remote:backup/ ~/restore/
age-tar -d -I ~/.age/key.txt -i ~/restore

Script complet

#!/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."

Installation

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"

Remarques

  • Cette implémentation traite uniquement les sous-répertoires immédiats du dossier cible
  • Les répertoires d’origine restent présents après chiffrement, il faut donc décider séparément d’une politique de suppression
  • Au déchiffrement, un fichier .tar temporaire est créé à partir de .tar.age, extrait, puis supprimé

Résumé

age-tar permet de transformer une sauvegarde chiffrée par répertoire en procédure répétable.

En combinant le chiffrement avec age et une normalisation des noms de fichiers en base64URL, même des données avec des noms de dossiers japonais deviennent plus simples à manipuler en CLI.