Przejdź do treści

🔐 SOPS and Age Encryption

SOPS (Secrets OPerationS) combined with Age encryption provides a powerful, Git-friendly approach to managing encrypted configuration files and secrets.


🎯 SOPS and Age Overview

What is SOPS?

SOPS is an editor of encrypted files that supports YAML, JSON, ENV, INI and binary formats. It can encrypt specific keys/values rather than entire files.

What is Age?

Age is a simple, modern, and secure encryption tool with small explicit keys, no config options, and UNIX-style composability.

Why SOPS + Age?

  • Git-friendly: Encrypted files can be committed to version control
  • Format-preserving: Original file structure is maintained
  • Selective encryption: Only sensitive fields are encrypted
  • Easy key management: Age keys are simple strings
  • No vendor lock-in: Works offline, no cloud dependencies

🔧 Setup and Configuration

Installing SOPS and Age

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Install SOPS
# macOS
brew install sops

# Ubuntu/Debian
sudo apt install sops

# Install Age
# macOS
brew install age

# Ubuntu/Debian
sudo apt install age

# Or download binaries
curl -L https://github.com/mozilla/sops/releases/download/v3.7.3/sops-v3.7.3.linux.amd64 -o sops
chmod +x sops
sudo mv sops /usr/local/bin/

curl -L https://github.com/FiloSottile/age/releases/download/v1.1.1/age-v1.1.1-linux-amd64.tar.gz | tar xz
sudo mv age/age* /usr/local/bin/

Generating Age Keys

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Generate a new age key pair
age-keygen -o age-key.txt

# Extract public key
age-keygen -y age-key.txt

# Set environment variable for SOPS
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
mkdir -p "$(dirname "$SOPS_AGE_KEY_FILE")"
cp age-key.txt "$SOPS_AGE_KEY_FILE"

# For multiple recipients
export SOPS_AGE_RECIPIENTS="age1publickey1,age1publickey2"

🔐 Encryption Workflows

Encrypting Configuration Files

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Create a sample configuration file
cat > config.yaml << EOF
database:
  host: prod-db.example.com
  port: 5432
  username: dbuser
  password: ENC[AES256_GCM,data:...,tag:...,iv:...]  # Will be encrypted

api:
  endpoint: https://api.example.com
  key: ENC[AES256_GCM,data:...,tag:...,iv:...]      # Will be encrypted

logging:
  level: info
  file: /var/log/app.log
EOF

# Encrypt specific fields
sops --encrypt --age "$(age-keygen -y $SOPS_AGE_KEY_FILE)" config.yaml > config.enc.yaml

# Or let SOPS encrypt automatically marked fields
# Add 'ENC[]' markers to fields you want encrypted

Automatic Field Encryption

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Create a configuration with automatic encryption markers
cat > app-config.yaml << EOF
# SOPS will automatically encrypt fields ending with _key, _secret, or _password
app:
  name: myapp
  version: 1.0.0

database:
  host: prod-db.example.com
  port: 5432
  username: dbuser
  password: supersecretpassword  # Will be encrypted automatically

api:
  endpoint: https://api.example.com
  api_key: abc123xyz            # Will be encrypted automatically

secrets:
  jwt_secret: jwtsecret123      # Will be encrypted automatically
  encryption_key: enc123        # Will be encrypted automatically

public:
  docs_url: https://docs.example.com
  support_email: support@example.com
EOF

# Encrypt with SOPS rules
cat > .sops.yaml << EOF
creation_rules:
  - path_regex: \.yaml$
    age: >-
      $(age-keygen -y $SOPS_AGE_KEY_FILE)
    encrypted_suffix: _key
    encrypted_regex: ^(password|secret|token)$
EOF

# Encrypt the file
sops --encrypt app-config.yaml > app-config.enc.yaml

Environment File Encryption

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Create environment file
cat > .env << EOF
# Public variables
APP_NAME=myapp
DEBUG=false
PORT=8080

# Sensitive variables (will be encrypted)
DATABASE_URL=postgresql://user:password@localhost:5432/db
API_KEY=sk-abc123xyz
JWT_SECRET=jwtsecret123
SMTP_PASSWORD=smtpsecret123
REDIS_URL=redis://:redispass@localhost:6379/0
EOF

# Encrypt environment file
sops --encrypt --age "$(age-keygen -y $SOPS_AGE_KEY_FILE)" .env > .env.enc

# Create SOPS config for automatic encryption
cat > .sops.yaml << EOF
creation_rules:
  - path_regex: \.env$
    age: >-
      $(age-keygen -y $SOPS_AGE_KEY_FILE)
    encrypted_suffix: _(URL|KEY|SECRET|PASSWORD|TOKEN)
EOF

sops --encrypt .env > .env.enc

🛡️ Secure Usage Patterns

Safe Decryption in Scripts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# Secure decryption function
decrypt_sops_file() {
    local encrypted_file="$1"
    local decrypted_file="$2"

    if [ -z "$encrypted_file" ] || [ -z "$decrypted_file" ]; then
        echo "Error: Encrypted and decrypted file paths required" >&2
        return 1
    fi

    if [ ! -f "$encrypted_file" ]; then
        echo "Error: Encrypted file not found: $encrypted_file" >&2
        return 1
    fi

    # Create secure temporary file
    local temp_file
    temp_file=$(mktemp "${TMPDIR:-/tmp}/sops_decrypt_XXXXXX") || {
        echo "Error: Failed to create temporary file" >&2
        return 1
    }

    chmod 600 "$temp_file"

    # Decrypt to temporary file
    if SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --decrypt "$encrypted_file" > "$temp_file"; then
        # Move to final location
        mv "$temp_file" "$decrypted_file"
        chmod 600 "$decrypted_file"
        echo "File decrypted to $decrypted_file"
        return 0
    else
        rm -f "$temp_file"
        echo "Error: Failed to decrypt $encrypted_file" >&2
        return 1
    fi
}

# Usage in deployment scripts
DECRYPTED_CONFIG=$(mktemp)
trap 'rm -f "$DECRYPTED_CONFIG"' EXIT

if decrypt_sops_file "config.enc.yaml" "$DECRYPTED_CONFIG"; then
    # Use decrypted config
    myapp --config "$DECRYPTED_CONFIG"
else
    echo "Failed to decrypt configuration" >&2
    exit 1
fi

Environment Variable Injection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Load encrypted environment variables
load_sops_env() {
    local encrypted_env_file="$1"

    if [ -z "$encrypted_env_file" ]; then
        echo "Error: Encrypted environment file required" >&2
        return 1
    fi

    # Decrypt and source environment variables
    local decrypted_content
    decrypted_content=$(SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --decrypt "$encrypted_env_file" 2>/dev/null)

    if [ $? -ne 0 ]; then
        echo "Error: Failed to decrypt environment file $encrypted_env_file" >&2
        return 1
    fi

    # Export variables (be careful with sensitive ones)
    echo "$decrypted_content" | while IFS= read -r line; do
        # Skip comments and empty lines
        if [[ ! "$line" =~ ^[[:space:]]*# ]] && [[ -n "$line" ]]; then
            # Export the variable
            export "$line"
        fi
    done

    echo "Environment variables loaded from $encrypted_env_file"
}

# Usage
load_sops_env ".env.enc"

Git Integration Hooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Pre-commit hook to prevent committing unencrypted secrets
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash

# Check for unencrypted sensitive files
if git diff --cached --name-only | grep -E '\.(env|yaml|json)$'; then
    echo "Checking for unencrypted secrets..."

    # Check if any staged files contain sensitive patterns
    for file in $(git diff --cached --name-only | grep -E '\.(env|yaml|json)$'); do
        if [ -f "$file" ] && grep -qE '(password|secret|key|token).*[[:alnum:]]{8,}' "$file"; then
            # Check if it's SOPS encrypted
            if ! grep -q 'sops:' "$file" 2>/dev/null; then
                echo "Error: Unencrypted sensitive data found in $file"
                echo "Please encrypt with SOPS before committing"
                exit 1
            fi
        fi
    done
fi

echo "Pre-commit check passed"
EOF

chmod +x .git/hooks/pre-commit

🔄 Key Management and Rotation

Managing Multiple Recipients

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Create SOPS config for multiple recipients
cat > .sops.yaml << EOF
creation_rules:
  - path_regex: \.(yaml|json|env)$
    age: >-
      age1recipient1publickey,
      age1recipient2publickey,
      age1recipient3publickey
    encrypted_regex: ^(password|secret|key|token)$
EOF

# Rotate keys for existing files
rotate_sops_keys() {
    local file_pattern="${1:-*.enc.*}"

    find . -name "$file_pattern" -type f | while read -r file; do
        echo "Rotating keys for $file"

        # Decrypt and re-encrypt with new keys
        local temp_decrypted
        temp_decrypted=$(mktemp)

        if SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --decrypt "$file" > "$temp_decrypted"; then
            # Re-encrypt with current configuration
            SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --encrypt "$temp_decrypted" > "${file}.new"
            mv "${file}.new" "$file"
            echo "Keys rotated for $file"
        else
            echo "Failed to rotate keys for $file" >&2
        fi

        rm -f "$temp_decrypted"
    done
}

# Usage
rotate_sops_keys "*.enc.yaml"

Key Revocation and Removal

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Remove a recipient from encrypted files
remove_sops_recipient() {
    local old_recipient="$1"
    local file_pattern="${2:-*.enc.*}"

    if [ -z "$old_recipient" ]; then
        echo "Error: Recipient public key required" >&2
        return 1
    fi

    # Update SOPS configuration to remove recipient
    # This requires manual editing of .sops.yaml

    # Re-encrypt all files without the old recipient
    find . -name "$file_pattern" -type f | while read -r file; do
        echo "Updating $file to remove recipient"

        local temp_decrypted
        temp_decrypted=$(mktemp)

        # Decrypt with old keys
        SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --decrypt "$file" > "$temp_decrypted"

        # Re-encrypt with new configuration (without old recipient)
        SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --encrypt "$temp_decrypted" > "${file}.new"
        mv "${file}.new" "$file"

        rm -f "$temp_decrypted"
    done

    echo "Recipient $old_recipient removed from all files"
}

🧪 Testing and Validation

SOPS File Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Validate SOPS encrypted files
validate_sops_file() {
    local file="$1"

    if [ -z "$file" ]; then
        echo "Error: File path required" >&2
        return 1
    fi

    if [ ! -f "$file" ]; then
        echo "Error: File not found: $file" >&2
        return 1
    fi

    # Check if file is SOPS encrypted
    if ! grep -q 'sops:' "$file" 2>/dev/null; then
        echo "Warning: File $file is not SOPS encrypted" >&2
        return 1
    fi

    # Try to decrypt (dry run)
    if SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" sops --decrypt "$file" >/dev/null 2>&1; then
        echo "File $file is valid and can be decrypted"
        return 0
    else
        echo "Error: File $file cannot be decrypted" >&2
        return 1
    fi
}

# Validate all encrypted files
validate_all_sops_files() {
    find . -name "*.enc.*" -type f | while read -r file; do
        validate_sops_file "$file"
    done
}

Mock SOPS for Testing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# Create mock SOPS for local testing
mock_sops() {
    local mock_dir="${1:-/tmp/mock_sops}"

    mkdir -p "$mock_dir"

    # Create mock SOPS script
    cat > "$mock_dir/sops" << 'EOF'
#!/bin/bash

# Simple mock SOPS for testing
ACTION=""
INPUT_FILE=""
OUTPUT_FILE=""

# Parse arguments
while [[ $# -gt 0 ]]; do
    case $1 in
        --encrypt)
            ACTION="encrypt"
            shift
            ;;
        --decrypt)
            ACTION="decrypt"
            shift
            ;;
        --age)
            # Skip age key
            shift 2
            ;;
        -*)
            # Skip other flags
            shift 2
            ;;
        *)
            INPUT_FILE="$1"
            shift
            ;;
    esac
done

case "$ACTION" in
    encrypt)
        if [[ -n "$INPUT_FILE" && -f "$INPUT_FILE" ]]; then
            # Simple encryption simulation
            echo "# This is a mock encrypted file" > "${INPUT_FILE}.mock"
            echo "sops:" >> "${INPUT_FILE}.mock"
            echo "  mock_encrypted: true" >> "${INPUT_FILE}.mock"
            cat "$INPUT_FILE" | sed 's/^/# /' >> "${INPUT_FILE}.mock"
            echo "Mock encrypted: $INPUT_FILE -> ${INPUT_FILE}.mock"
        else
            echo "Mock SOPS: No input file for encryption" >&2
            exit 1
        fi
        ;;
    decrypt)
        if [[ -n "$INPUT_FILE" && -f "$INPUT_FILE" ]]; then
            # Simple decryption simulation
            grep -v '^#' "$INPUT_FILE" | grep -v '^sops:' | sed 's/^# //'
        else
            echo "Mock SOPS: No input file for decryption" >&2
            exit 1
        fi
        ;;
    *)
        echo "Mock SOPS: Unknown action $ACTION" >&2
        exit 1
        ;;
esac
EOF

    chmod +x "$mock_dir/sops"

    echo "Mock SOPS created in $mock_dir"
    echo "Use: export PATH=\"$mock_dir:\$PATH\" for testing"
}

🧾 Summary

Git-friendly encryption that preserves file structure ✅ Selective field encryption for fine-grained control ✅ Simple key management with Age encryption ✅ Secure usage patterns for scripts and CI/CD ✅ Key rotation and revocation capabilities ✅ Comprehensive validation and testing support


🧾 See Also