diff options
Diffstat (limited to 'secrets')
| -rw-r--r-- | secrets/.gitattributes | 1 | ||||
| -rw-r--r-- | secrets/.gitignore | 2 | ||||
| -rw-r--r-- | secrets/README.md | 163 | ||||
| -rw-r--r-- | secrets/common.sh | 307 | ||||
| -rwxr-xr-x | secrets/create-local.sh | 75 | ||||
| -rwxr-xr-x | secrets/ensure.sh | 32 | ||||
| -rwxr-xr-x | secrets/get-local.sh | 96 | ||||
| -rwxr-xr-x | secrets/get.sh | 46 | ||||
| -rwxr-xr-x | secrets/list.sh | 36 | ||||
| -rwxr-xr-x | secrets/main.sh | 69 | ||||
| -rwxr-xr-x | secrets/put-local.sh | 71 | ||||
| -rwxr-xr-x | secrets/put.sh | 40 | ||||
| -rwxr-xr-x | secrets/ready.sh | 31 | ||||
| -rwxr-xr-x | secrets/rotate.sh | 119 | ||||
| -rw-r--r-- | secrets/test/README.md | 148 | ||||
| -rwxr-xr-x | secrets/test/common.sh | 108 | ||||
| -rwxr-xr-x | secrets/test/create-local.sh | 122 | ||||
| -rwxr-xr-x | secrets/test/edge-cases.sh | 100 | ||||
| -rwxr-xr-x | secrets/test/ensure.sh | 29 | ||||
| -rwxr-xr-x | secrets/test/get.sh | 44 | ||||
| -rwxr-xr-x | secrets/test/list.sh | 55 | ||||
| -rwxr-xr-x | secrets/test/local.sh | 132 | ||||
| -rwxr-xr-x | secrets/test/main.sh | 57 | ||||
| -rwxr-xr-x | secrets/test/paths.sh | 119 | ||||
| -rwxr-xr-x | secrets/test/put.sh | 34 | ||||
| -rwxr-xr-x | secrets/test/ready.sh | 27 | ||||
| -rwxr-xr-x | secrets/test/rotate.sh | 69 | ||||
| -rwxr-xr-x | secrets/test/run.sh | 82 |
28 files changed, 2214 insertions, 0 deletions
diff --git a/secrets/.gitattributes b/secrets/.gitattributes new file mode 100644 index 0000000..ad48a77 --- /dev/null +++ b/secrets/.gitattributes @@ -0,0 +1 @@ +*.age binary diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100644 index 0000000..b47e1b5 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1,2 @@ +.id +.bin diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..293b747 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,163 @@ +# szecret + +Secret management using [age](https://github.com/FiloSottile/age) encryption. + +## Quick Start + +```bash +# Store a secret +echo "my-api-key" | szecret put api-key + +# Retrieve a secret +szecret get api-key + +# List all secrets +szecret list +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `get <name>` | Retrieve and decrypt a secret | +| `put <name>` | Store a secret (reads from stdin) | +| `list` | List all secrets in the store | +| `ready` | Check if private key is configured | +| `ensure` | Ensure private key is configured (interactive) | +| `rotate` | Generate a new key and re-encrypt the global store | +| `create-local <name> <dir>` | Create a localized secret store | +| `put-local <file>` | Encrypt to a local store | +| `get-local <file>` | Decrypt from a local store | + +## Local Stores + +Local stores allow you to keep encrypted secrets outside the global store, useful for project-specific secrets that live alongside the code. + +### Creating a Local Store + +```bash +# Initialize a local store in a directory +szecret create-local myproject /path/to/project/secrets/ + +# This creates: +# - A key in the global store (local-key-myproject) +# - A metadata file at /path/to/project/secrets/.szecret containing: +# store=myproject +# pubkey=age1... +``` + +The metadata includes the public key for verification, preventing accidental use of the wrong store. + +### Using a Local Store + +```bash +# Encrypt a secret +echo "db-password" | szecret put-local /path/to/project/secrets/db.age + +# Decrypt a secret +szecret get-local /path/to/project/secrets/db.age +``` + +The commands automatically read the store name from the `.szecret` metadata file in the directory. + +## Setup + +### Where the store lives + +These scripts carry no secrets. The global store is a directory of `.age` +files plus `id.pub`, the public key they are encrypted to, kept in a checkout +of its own. szecret looks for it in this order: + +1. `$SZECRET_STORE` and `$SZECRET_PUBLIC_KEY`, if set. +2. `~/.local/share/szecret/store` and `~/.local/share/szecret/id.pub`, beside + the private keys. Normally symlinks into the checkout that holds the store. + +With neither, every command stops with setup instructions, and `ready` +fails silently. + +### Initial Setup + +```bash +# Generate a new keypair (file must be named after its public key) +age-keygen -o private +pubkey="$(age-keygen -y private)" +mkdir -p ~/.local/share/szecret/store +echo "${pubkey}" > ~/.local/share/szecret/id.pub +mv private "${HOME}/.local/share/szecret/${pubkey}" +``` + +### On a New Machine + +```bash +# The ensure command will prompt for your private key +szecret ensure +``` + +### Rotating the Global Key + +```bash +# Re-encrypt all secrets with a new age key +szecret rotate +``` + +This updates `id.pub` and every file in the store. When complete, commit +those changes in the checkout that holds the store, and update the private key +backup in 1Password. + +## Migration Guide + +### Migrating Local Stores (pre-2026) + +If you created local stores before the metadata system was introduced, run +`create-local` again with the directory - it will detect the existing key +and just add the metadata: + +```bash +szecret create-local myapp /path/to/secrets/ +# Output: Added metadata for existing store 'myapp' +``` + +**Before (old syntax):** +```bash +szecret create-local myapp +echo "secret" | szecret put-local myapp /path/to/secrets/password.age +szecret get-local myapp /path/to/secrets/password.age +``` + +**After (new syntax):** +```bash +# One-time migration +szecret create-local myapp /path/to/secrets/ + +# Now use simplified commands +echo "secret" | szecret put-local /path/to/secrets/password.age +szecret get-local /path/to/secrets/password.age +``` + +**For automated scripts:** The old `get-local <store-name> <file>` syntax still works but emits a deprecation warning suggesting the migration command. + +## Environment Variables + +For testing or custom setups, these environment variables override default paths: + +| Variable | Description | Default | +|----------|-------------|---------| +| `SZECRET_STORE` | Global store directory | `~/.local/share/szecret/store` | +| `SZECRET_PUBLIC_KEY` | Public key path | `~/.local/share/szecret/id.pub` | +| `SZECRET_PRIVATE_KEY` | Single private key file (full override) | (none) | +| `SZECRET_PRIVATE_KEY_DIR` | Directory containing private keys (named by their public key) | `~/.local/share/szecret` | + +* Private keys are stored in `~/.local/share/szecret/` (or `$SZECRET_PRIVATE_KEY_DIR`), with each file named after its public key (e.g. `age1...`). +* `id.pub` indicates which public key is current. + +## Testing + +```bash +# Run all tests +./test/run.sh + +# Run specific tests +./test/run.sh local create-local +``` + +See [test/README.md](test/README.md) for more details. diff --git a/secrets/common.sh b/secrets/common.sh new file mode 100644 index 0000000..ef1b7b5 --- /dev/null +++ b/secrets/common.sh @@ -0,0 +1,307 @@ +this_dir="$(cd "$(dirname "$0")" && pwd)" + +exists() { command -v "$1" >/dev/null 2>&1; } # Check if command exists (is in path). + +if [[ "$(whoami)" == "root" ]]; then + # find out who the normal user is based on $this_dir, + # since $this_dir will be somewhere within /Users/<user>/... or /home/<user>/... + # and then switch to that user, using exec to avoid forking + + # Cross-platform user detection + user="" + + # Use uname to detect OS and choose appropriate stat command + if command -v stat >/dev/null 2>&1; then + if [[ "$(uname)" == "Darwin" ]]; then + user="$(stat -f '%Su' "${this_dir}" 2>/dev/null || true)" + elif [[ "$(uname)" == "Linux" ]]; then + user="$(stat -c '%U' "${this_dir}" 2>/dev/null || true)" + else + >&2 echo "ERROR: Unsupported OS: $(uname)" + exit 1 + fi + fi + + # Fallback method: Parse user from directory path + if [[ -z "${user}" ]]; then + # Extract username from path like /Users/username/... or /home/username/... + if [[ "$(uname)" == "Darwin" ]] && [[ "${this_dir}" =~ ^/Users/([^/]+) ]]; then + user="${BASH_REMATCH[1]}" + elif [[ "$(uname)" == "Linux" ]] && [[ "${this_dir}" =~ ^/home/([^/]+) ]]; then + user="${BASH_REMATCH[1]}" + fi + fi + + if [[ -n "${user}" ]]; then + if id "${user}" >/dev/null 2>&1; then + # exec to avoid forking, + # and make sure to use a login shell + # propagate all arguments using "$@" + # The command structure: su - user -c "command" -- args + # - su - user: switch to user with login shell + # - -c "command": execute the quoted command + # - -- args: separator, everything after -- goes to the command + # - "$@": all original script arguments + # - \"\$@\": escaped version for the inner command string + exec su - "${user}" -c "exec bash -l $0 \"\$@\"" -- "$@" + >&2 echo "ERROR: Failed to switch to user ${user}" + else + >&2 echo "ERROR: Detected user ${user} does not exist" + fi + else + >&2 echo "WARN: Failed to determine user from szecret dir: ${this_dir}" + fi + >&2 echo "ERROR: Refusing to run szecret as root" + exit 1 +fi + +if ! exists age ; then + >&2 cat <<EOF +ERROR: age tool not found + +Install from: https://github.com/FiloSottile/age +EOF + exit 1 +fi + +XDG_DATA_HOME=${XDG_DATA_HOME:-"$HOME/.local/share"} + +# Private key directory. Files must be named after their public key (age1...). +# SZECRET_PRIVATE_KEY can still be used as a full single-file override (mainly for tests). +private_key_dir="${SZECRET_PRIVATE_KEY_DIR:-${XDG_DATA_HOME}/szecret}" + +# The global store and the public key its secrets are encrypted to. This code +# carries neither: a machine with only these scripts holds no secrets. Looked +# for in order: +# +# 1. SZECRET_STORE / SZECRET_PUBLIC_KEY, if set (tests, or a separate store). +# 2. store/ and id.pub in ~/.local/share/szecret, beside the private keys. +# Normally links into whichever checkout holds the store. +# +# With none of these, every command fails with the instructions below. +szecret_data_dir="${XDG_DATA_HOME}/szecret" + +if [[ -n "${SZECRET_STORE:-}" ]]; then + store="${SZECRET_STORE}" + mkdir -p "${store}" +elif [[ -d "${szecret_data_dir}/store" ]]; then + store="${szecret_data_dir}/store" +else + store="" +fi + +if [[ -n "${SZECRET_PUBLIC_KEY:-}" ]]; then + public_key="${SZECRET_PUBLIC_KEY}" +elif [[ -r "${szecret_data_dir}/id.pub" ]]; then + public_key="${szecret_data_dir}/id.pub" +else + public_key="${szecret_data_dir}/id.pub" +fi + +if [[ -z "${store}" || ! -r "${public_key}" ]]; then + # `ready` is asked by every interactive shell; a machine with no store is + # simply not ready, which needs no explanation each time. + if [[ -n "${szecret_quiet:-}" ]]; then + exit 1 + fi + >&2 cat <<EOF +ERROR: no szecret store on this machine. + +Looked for ${szecret_data_dir}/store and ${szecret_data_dir}/id.pub. +Link them to an existing store, point SZECRET_STORE and SZECRET_PUBLIC_KEY at +one, or start a new one: + + age-keygen -o private + pubkey="\$(age-keygen -y private)" + mkdir -p ${szecret_data_dir}/store + echo "\${pubkey}" > ${szecret_data_dir}/id.pub + mv private "${private_key_dir}/\${pubkey}" + +EOF + exit 1 +fi + +key_id="$(cat "${public_key}" 2>/dev/null || true)" +if [[ -z "$key_id" ]]; then + >&2 echo "WARN: Invalid public key: ${public_key}" + exit 1 +fi + +private_key="${SZECRET_PRIVATE_KEY:-${private_key_dir}/${key_id}}" + +file_for() { + local name="$1" + echo "${store}/${name}.age" +} + +local_key_name() { + local local_store_name="$1" + echo "local-key-${local_store_name}" +} + +ensure_local_store() { + local local_store_name="$1" + local local_key_name + local local_key_file + local_key_name="$(local_key_name "${local_store_name}")" + local_key_file="$(file_for "${local_key_name}")" + + if ! [[ -r "${local_key_file}" ]]; then + >&2 echo "ERROR: Local store '${local_store_name}' does not exist (key: ${local_key_name})" + exit 1 + fi +} + +get_local_private_key() { + local local_store_name="$1" + local local_key_name + local_key_name="$(local_key_name "${local_store_name}")" + "${this_dir}/get.sh" "${local_key_name}" +} + +exists() { command -v "$1" >/dev/null 2>&1; } # Check if command exists (is in path). +pathadd() { [[ -d "$1" ]] && PATH="$1"$(echo ":$PATH:" | sed "s|:$1:|:|" | sed 's|:$||'); } + +ensure_age() { + if exists age ; then + return + fi + local bin="${this_dir}/.bin" + pathadd "${bin}" + if [[ -x "${bin}/age" ]]; then + return + fi + local os + local arch + os="$(uname)" + os=${os,,} + arch="$(uname -m)" + if [[ "$arch" == "aarch64" ]]; then + arch="arm64" + elif [[ "$arch" == "x86_64" ]]; then + arch="amd64" + fi + tar="$bin/age.tar.gz" + curl -LSsfo "$tar" "https://dl.filippo.io/age/latest?for=${os}/${arch}" + tar xf "$tar" -C "$bin" --strip-components=1 +} + +private_key_is_setup() { + check_legacy_private_key_migration + if ! [[ -n "${private_key}" && -r "${private_key}" ]]; then + >&2 echo "WARN: Missing age key: ${private_key}" + fi + [[ -r "${private_key}" ]] +} + +ensure_private_key() { + mkdir -p "${private_key_dir}" + if private_key_is_setup; then + return + fi + + if [ ! -t 0 ]; then + >&2 echo "ERROR: szecret private key not installed: ${public_key}" + exit 1 + fi + + ensure_age + echo -n "Enter age private key ${public_key}: " + read -s private_key_value + echo + + local derived + derived="$(age-keygen -y <<<"${private_key_value}" 2>/dev/null || true)" + if [[ "$derived" != "$(cat "${public_key}")" ]]; then + >&2 echo "ERROR: Invalid private key (does not match ${public_key})" + exit 1 + fi + + mkdir -p "${private_key_dir}" + local target="${private_key_dir}/${derived}" + echo "${private_key_value}" > "${target}" + chmod 600 "${target}" 2>/dev/null || true +} + +check_legacy_private_key_migration() { + local f="${private_key_dir}/.id" + if [[ ! -r "${f}" ]]; then + return + fi + + local pub + pub="$(age-keygen -y "$f" 2>/dev/null || true)" + if [[ -z "$pub" || "$pub" != age1* ]]; then + >&2 echo "WARN: stray file in ${private_key_dir} that does not belong there: $f" + return + fi + + local target="${private_key_dir}/${pub}" + mv "$f" "$target" +} + +age_encrypt() { + local pubkeyfile="$1" + local outputfile="$2" + cat | age --encrypt --recipients-file "${pubkeyfile}" --output "${outputfile}" +} + +age_decrypt() { + local privatekeyfile="$1" + local inputfile="$2" + age --decrypt --identity "${privatekeyfile}" "${inputfile}" +} + +# Local store metadata helpers + +write_local_metadata() { + local metadata_file="$1" + local store_name="$2" + local pubkey="$3" + cat > "${metadata_file}" <<EOF +store=${store_name} +pubkey=${pubkey} +EOF +} + +# Read local metadata file +# Sets global variables: _metadata_store and _metadata_pubkey +read_local_metadata() { + local metadata_file="$1" + _metadata_store="" + _metadata_pubkey="" + + if [[ ! -r "${metadata_file}" ]]; then + return 1 + fi + + _metadata_store="$(grep '^store=' "${metadata_file}" | cut -d= -f2)" + _metadata_pubkey="$(grep '^pubkey=' "${metadata_file}" | cut -d= -f2)" + + if [[ -z "${_metadata_store}" || -z "${_metadata_pubkey}" ]]; then + >&2 echo "ERROR: Invalid metadata format in ${metadata_file}" + >&2 echo "Run 'szecret create-local <store-name> <directory>' to recreate" + return 1 + fi +} + +get_local_public_key() { + local local_store_name="$1" + local local_private_key + local_private_key="$(get_local_private_key "${local_store_name}")" + age-keygen -y <<<"${local_private_key}" +} + +verify_local_pubkey() { + local expected_pubkey="$1" + local actual_pubkey="$2" + local store_name="$3" + + if [[ "${expected_pubkey}" != "${actual_pubkey}" ]]; then + >&2 echo "ERROR: Public key mismatch for store '${store_name}'" + >&2 echo " Expected: ${expected_pubkey}" + >&2 echo " Actual: ${actual_pubkey}" + return 1 + fi +} + diff --git a/secrets/create-local.sh b/secrets/create-local.sh new file mode 100755 index 0000000..ffebd9c --- /dev/null +++ b/secrets/create-local.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +create-local: Create a new localized secret store + +Creates a new age private key for a localized secret store and initializes +a directory with metadata for automatic store discovery. + +If the store key already exists but the directory lacks metadata, this command +will add the metadata file (useful for migrating existing stores). + +Usage: + create-local <store-name> <directory> + create-local -h|--help + +Arguments: + store-name Name of the local store to create + directory Directory to initialize with store metadata + +The command will: +1. Check if the directory already has a .szecret metadata file +2. If the store key doesn't exist, generate a new age private key +3. Write the store name to <directory>/.szecret + +Note: This command will refuse to overwrite existing metadata. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 2 ]]; then + >&2 echo "ERROR: Need local store name and directory" + exit 1 +fi + +local_store_name="$1" +target_dir="$2" +metadata_file="${target_dir}/.szecret" +local_key_name="$(local_key_name "${local_store_name}")" +local_key_file="$(file_for "${local_key_name}")" + +key_exists=false +metadata_exists=false + +[[ -r "${local_key_file}" ]] && key_exists=true +[[ -e "${metadata_file}" ]] && metadata_exists=true + +# Check if metadata already exists +if [[ "${metadata_exists}" == "true" ]]; then + >&2 echo "ERROR: Directory already has .szecret metadata" + exit 1 +fi + +# Ensure target directory exists +mkdir -p "${target_dir}" + +ensure_age + +if [[ "${key_exists}" == "false" ]]; then + # New store: create key first + age-keygen 2>/dev/null | "${this_dir}/put.sh" "${local_key_name}" +fi + +# Get public key and write metadata +local_pubkey="$(get_local_public_key "${local_store_name}")" +write_local_metadata "${metadata_file}" "${local_store_name}" "${local_pubkey}" + +if [[ "${key_exists}" == "true" ]]; then + >&2 echo "Added metadata for existing store '${local_store_name}'" +fi diff --git a/secrets/ensure.sh b/secrets/ensure.sh new file mode 100755 index 0000000..e11212f --- /dev/null +++ b/secrets/ensure.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +ensure: Ensure the private key is configured and available + +Verifies that the age private key is properly set up for secret operations. +If not configured, prompts the user to enter it. + +Usage: + ensure + ensure -h|--help + +The command will: +1. Check if the private key exists and is readable +2. If not found, prompt for manual entry +3. Validate the entered key against the public key +4. Store the private key in the appropriate location + +Note: Interactive command that may prompt for user input. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +root_dir="$(cd "${this_dir}" && cd .. && pwd)" + +source "${this_dir}/common.sh" + +ensure_private_key diff --git a/secrets/get-local.sh b/secrets/get-local.sh new file mode 100755 index 0000000..719a914 --- /dev/null +++ b/secrets/get-local.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +get-local: Retrieve and decrypt a secret from a localized store + +Decrypts and outputs a secret value from a localized store. The store name +is read from the .szecret metadata file in the file's directory. + +Usage: + get-local <file-path> + get-local -h|--help + +Arguments: + file-path Path to the encrypted file to decrypt + +The command will: +1. Read the store name from <directory>/.szecret +2. Retrieve the local store's private key from global store +3. Check if the encrypted file exists and is readable +4. Decrypt the file using age with the local store's private key +5. Output the decrypted content to stdout + +Legacy usage (deprecated): + get-local <local-store-name> <file-path> + +Note: Exits with error if file doesn't exist or decryption fails. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need file path" + exit 1 +fi + +# Determine if using legacy or new syntax +if [[ "$#" -ge 2 ]]; then + # Legacy syntax: get-local <store-name> <file-path> + local_store_name="$1" + file_path="$2" + target_dir="$(dirname "${file_path}")" + metadata_file="${target_dir}/.szecret" + + >&2 echo "WARN: Using deprecated syntax. Use: szecret get-local ${file_path}" + if [[ ! -r "${metadata_file}" ]]; then + >&2 echo " To migrate, run: szecret create-local ${local_store_name} ${target_dir}" + fi + + ensure_local_store "${local_store_name}" + ensure_age + # No pubkey verification for legacy syntax +else + # New syntax: get-local <file-path> + file_path="$1" + target_dir="$(dirname "${file_path}")" + metadata_file="${target_dir}/.szecret" + + if ! read_local_metadata "${metadata_file}"; then + >&2 echo "ERROR: No .szecret metadata in ${target_dir}" + >&2 echo "Run 'szecret create-local <store-name> ${target_dir}' first" + exit 1 + fi + + local_store_name="${_metadata_store}" + expected_pubkey="${_metadata_pubkey}" + + ensure_local_store "${local_store_name}" + ensure_age + + # Verify pubkey matches + actual_pubkey="$(get_local_public_key "${local_store_name}")" + verify_local_pubkey "${expected_pubkey}" "${actual_pubkey}" "${local_store_name}" +fi + +# Check if the encrypted file exists +if ! [[ -r "${file_path}" ]]; then + >&2 echo "ERROR: File '${file_path}' not found or not readable" + exit 1 +fi + +# Get the local private key from the global store +local_private_key="$(get_local_private_key "${local_store_name}")" + +# Create a temporary file for the private key +temp_private_key="$(mktemp)" +trap 'rm -f "${temp_private_key}"' EXIT +echo "${local_private_key}" > "${temp_private_key}" + +# Decrypt the file using the local private key +age_decrypt "${temp_private_key}" "${file_path}" diff --git a/secrets/get.sh b/secrets/get.sh new file mode 100755 index 0000000..d88b696 --- /dev/null +++ b/secrets/get.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +get: Retrieve and decrypt a secret from the store + +Decrypts and outputs a secret value from the global secret store. + +Usage: + get <secret-name> + get -h|--help + +Arguments: + secret-name Name of the secret to retrieve + +The command will: +1. Check if the secret exists in the store +2. Ensure the private key is available +3. Decrypt the secret using age and output to stdout + +Note: Exits with error if secret doesn't exist or decryption fails. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need secret name" + exit 1 +fi +name="$1" + +file="$(file_for "${name}")" +if ! [[ -r "${file}" ]]; then + >&2 echo "ERROR: Secret '$name' not found" + exit 1 +fi + +ensure_private_key + +age_decrypt "${private_key}" "${file}" + diff --git a/secrets/list.sh b/secrets/list.sh new file mode 100755 index 0000000..bf087e3 --- /dev/null +++ b/secrets/list.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +list: List all available secrets in the store + +Displays all secret names in the global store in a columnar format. + +Usage: + list + list -h|--help + +The command will: +1. List all .age files in the store directory +2. Remove the .age extension from filenames +3. Display the secret names in columns + +Note: Only shows secret names, not their values. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$(realpath "$0")")" && pwd)" +source "${this_dir}/common.sh" + +maybe_column() { + if [ -t 1 ]; then + column + else + cat + fi +} + +ls "${store}" | sed 's/\.age$//' | sort | maybe_column diff --git a/secrets/main.sh b/secrets/main.sh new file mode 100755 index 0000000..89bbea3 --- /dev/null +++ b/secrets/main.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +set -euo pipefail + +this_dir="$(cd "$(dirname "$(realpath "$0")")" && pwd)" + +# list all commands, name only +cmds() { + find "${this_dir}" -maxdepth 1 -type f -name '*.sh' -print0 \ + | xargs -0 -n 1 basename \ + | sed 's/\.sh$//' \ + | grep -Ev '^(main|common)$' \ + | sort +} + +# list all commands, name and description +cmd_descriptions() { + for cmd in $(cmds); do + local cmd_sh="${this_dir}/${cmd}.sh" + if [[ -x "${cmd_sh}" ]]; then + local description + description="$(bash "${cmd_sh}" -h 2>/dev/null | head -1 | sed 's/^[^:]*: *//')" + if [[ -n "${description}" ]]; then + printf "%-15s %s\n" "${cmd}" "${description}" + else + printf "%-15s\n" "${cmd}" + fi + fi + done +} + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +szecret: Secret management using age encryption + +Usage: + szecret -h|--help + szecret <command> -h|--help + szecret <command> [args...] + +Available commands: + +$(cmd_descriptions | sed 's/^/ /') +EOF + exit 0 +fi + +# check number of arguments > 1, error and exit if not +# extract first argument, call script with same name using remaining arguments +if [ $# -lt 1 ]; then + >&2 cat <<EOF +ERROR: no command specified. Available: + +$(cmd_descriptions | sed 's/^/ /') +EOF + exit 1 +fi + +cmd="$1" +cmd_sh="${this_dir}/${cmd}.sh" +shift + +if [[ ! -e "${cmd_sh}" ]]; then + >&2 echo "ERROR: no command named ${cmd}. Available: $(cmds | paste -sd ',' -)" + exit 1 +fi + +exec bash "${cmd_sh}" "$@" + diff --git a/secrets/put-local.sh b/secrets/put-local.sh new file mode 100755 index 0000000..cba71c6 --- /dev/null +++ b/secrets/put-local.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +put-local: Store a secret in a localized store + +Encrypts data from stdin and stores it in a localized store. The store name +is read from the .szecret metadata file in the file's directory. + +Usage: + put-local <file-path> + echo "secret-value" | put-local <file-path> + put-local -h|--help + +Arguments: + file-path Path where to store the encrypted file + +The command will: +1. Read the store name from <directory>/.szecret +2. Retrieve the local store's private key from global store +3. Generate the corresponding public key from the private key +4. Read data from stdin +5. Encrypt the data using age with the local store's public key +6. Store the encrypted data at the specified file path + +Note: Will overwrite existing files at the target path. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need file path" + exit 1 +fi + +file_path="$1" +target_dir="$(dirname "${file_path}")" +metadata_file="${target_dir}/.szecret" + +# Read store name and pubkey from metadata +if ! read_local_metadata "${metadata_file}"; then + >&2 echo "ERROR: No .szecret metadata in ${target_dir}" + >&2 echo "Run 'szecret create-local <store-name> ${target_dir}' first" + exit 1 +fi + +local_store_name="${_metadata_store}" +expected_pubkey="${_metadata_pubkey}" + +ensure_local_store "${local_store_name}" +ensure_age + +# Verify pubkey matches +actual_pubkey="$(get_local_public_key "${local_store_name}")" +verify_local_pubkey "${expected_pubkey}" "${actual_pubkey}" "${local_store_name}" + +# Get the local private key from the global store +local_private_key="$(get_local_private_key "${local_store_name}")" + +# Create a temporary file for the public key (needed because age_encrypt uses pipes) +temp_public_key="$(mktemp)" +trap 'rm -f "${temp_public_key}"' EXIT +age-keygen -y <<<"${local_private_key}" > "${temp_public_key}" + +# Encrypt data from stdin to the specified file path using the local store's public key +age_encrypt "${temp_public_key}" "${file_path}" diff --git a/secrets/put.sh b/secrets/put.sh new file mode 100755 index 0000000..2675066 --- /dev/null +++ b/secrets/put.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +put: Store a secret in the encrypted store + +Encrypts data from stdin and stores it as a named secret in the global store. + +Usage: + put <secret-name> + echo "secret-value" | put <secret-name> + put -h|--help + +Arguments: + secret-name Name to store the secret under + +The command will: +1. Read data from stdin +2. Encrypt the data using age with the public key +3. Store the encrypted data in the store as <secret-name>.age + +Note: Will overwrite existing secrets with the same name. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need secret name" + exit 1 +fi +name="$1" + +ensure_age +age_encrypt "${public_key}" "$(file_for "${name}")" + diff --git a/secrets/ready.sh b/secrets/ready.sh new file mode 100755 index 0000000..dee2f86 --- /dev/null +++ b/secrets/ready.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +ready: Check if the secret management system is ready for use + +Tests whether the age private key is properly configured and available +for secret operations. Exits with status 0 if ready, non-zero if not. + +Usage: + ready + ready -h|--help + +The command will: +1. Check if the private key exists and is readable +2. Perform any necessary legacy key migration +3. Exit with appropriate status code + +Note: Silent command, check exit status for result. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$(realpath "$0")")" && pwd)" +szecret_quiet=1 +source "${this_dir}/common.sh" + +private_key_is_setup + diff --git a/secrets/rotate.sh b/secrets/rotate.sh new file mode 100755 index 0000000..4286f4c --- /dev/null +++ b/secrets/rotate.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +rotate: Generate a new age key and re-encrypt the global secret store + +Creates a new age keypair, re-encrypts every secret in the global store with +the new public key, updates the public key file, and stores the new +private key in the key directory. + +Usage: + rotate + rotate --yes + rotate -h|--help + +Options: + --yes Skip interactive confirmation + +The command will: +1. Confirm the operation (unless --yes) +2. Decrypt each secret in the store using the current private key +3. Generate a new age keypair +4. Re-encrypt all secrets with the new public key +5. Update the public key file and install the new private key + +Note: Interactive command. Commit the updated store files and public key + wherever the store is tracked, + and update the private key backup in 1Password when complete. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +assume_yes=false +if [[ "${1:-}" == "--yes" ]]; then + assume_yes=true +elif [[ -n "${1:-}" ]]; then + >&2 echo "ERROR: Unknown argument: $1" + exit 1 +fi + +ensure_age +ensure_private_key + +old_private_key="${private_key}" +old_public_key="${key_id}" + +shopt -s nullglob +store_files=("${store}"/*.age) +shopt -u nullglob +secret_count="${#store_files[@]}" + +if [[ "${assume_yes}" != "true" ]]; then + if [[ ! -t 1 ]]; then + >&2 echo "ERROR: rotate requires interactive confirmation (use --yes to skip)" + exit 1 + fi + >&2 echo "This will generate a new age key and re-encrypt ${secret_count} secret(s)." + >&2 echo "Current public key: ${old_public_key}" + >&2 printf "Continue? [y/N] " + local_confirm="" + read -r local_confirm </dev/tty + if [[ "${local_confirm}" != [yY] ]]; then + >&2 echo "Aborted." + exit 1 + fi +fi + +staging="$(mktemp -d "${TMPDIR:-/tmp}/szecret-rotate.XXXXXX")" +temp_private_key="$(mktemp -u "${TMPDIR:-/tmp}/szecret-rotate-key.XXXXXX")" +temp_public_key="$(mktemp "${TMPDIR:-/tmp}/szecret-rotate-pub.XXXXXX")" + +cleanup() { + rm -rf "${staging}" + rm -f "${temp_private_key}" "${temp_public_key}" +} +trap cleanup EXIT + +age-keygen -o "${temp_private_key}" 2>/dev/null +new_public_key="$(age-keygen -y "${temp_private_key}")" +echo "${new_public_key}" > "${temp_public_key}" + +for secret_file in "${store_files[@]}"; do + secret_name="$(basename "${secret_file}" .age)" + age_decrypt "${old_private_key}" "${secret_file}" \ + | age_encrypt "${temp_public_key}" "${staging}/${secret_name}.age" +done + +for secret_file in "${store_files[@]}"; do + secret_name="$(basename "${secret_file}" .age)" + mv "${staging}/${secret_name}.age" "${secret_file}" +done + +echo "${new_public_key}" > "${public_key}" + +installed_private_key="${private_key_dir}/${new_public_key}" +mv "${temp_private_key}" "${installed_private_key}" +temp_private_key="" +chmod 600 "${installed_private_key}" 2>/dev/null || true + +cat <<EOF + +Rotation complete. + + Old public key: ${old_public_key} + New public key: ${new_public_key} + Secrets re-encrypted: ${secret_count} + New private key: ${installed_private_key} + +Next steps: + 1. Commit the updated store and $(basename "${public_key}") + 2. Update the szecret private key in 1Password + +The old private key remains at ${old_private_key} until you remove it. +EOF diff --git a/secrets/test/README.md b/secrets/test/README.md new file mode 100644 index 0000000..49d70b2 --- /dev/null +++ b/secrets/test/README.md @@ -0,0 +1,148 @@ +# szecret Test Suite + +Unit tests for the szecret secret management system. + +## Running Tests + +```bash +# Run all tests +./test/run.sh + +# Run specific test files +./test/run.sh list get + +# Run a single test file directly +./test/list.sh +``` + +## Test Structure + +``` +test/ +├── common.sh # Shared utilities, setup/teardown, assertions +├── run.sh # Test runner entrypoint +├── create-local.sh # Tests for create-local command +├── edge-cases.sh # Edge case tests (special chars, unicode, etc.) +├── ensure.sh # Tests for ensure command +├── get.sh # Tests for get command +├── list.sh # Tests for list command +├── local.sh # Tests for put-local/get-local commands +├── main.sh # Tests for main entrypoint +├── paths.sh # Tests for where the store and public key are found +├── put.sh # Tests for put command +└── ready.sh # Tests for ready command +``` + +## Writing New Tests + +### Basic test file template + +```bash +#!/bin/bash +# Tests for <command> command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_something() { + # Test code here + pass "description of what passed" +} + +test_something_else() { + # Test code here + pass "another test" +} + +run_tests() { + test_something + test_something_else +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests +``` + +### Available helpers + +From `common.sh`: + +| Function | Description | +|----------|-------------| +| `szecret <cmd> [args]` | Run a szecret command via main.sh | +| `szecret_script <name> [args]` | Run a specific script directly (e.g., `szecret_script get secret-name`) | +| `pass <msg>` | Print green PASS message | +| `fail <msg>` | Print red FAIL message and exit | +| `skip <msg>` | Print yellow SKIP message | +| `assert_success <cmd>` | Assert command exits 0 | +| `assert_failure <cmd>` | Assert command exits non-zero | +| `assert_equals <expected> <actual> [msg]` | Assert string equality | +| `assert_contains <haystack> <needle> [msg]` | Assert string contains substring | +| `assert_file_exists <path> [msg]` | Assert file exists | + +### Environment variables + +Tests run in an isolated environment with these variables set: + +| Variable | Description | +|----------|-------------| +| `TEST_DIR` | Temporary directory for test artifacts | +| `SZECRET_STORE` | Points to `$TEST_DIR/store` | +| `SZECRET_PUBLIC_KEY` | Points to test public key | +| `SZECRET_PRIVATE_KEY` | Points to test private key | + +The test environment is created automatically when `common.sh` is sourced and cleaned up on exit. + +## Design Notes + +### Isolation + +Each test file runs in its own subprocess with a fresh environment. This ensures tests don't interfere with each other. The `run.sh` runner executes each test file as a separate process. + +### Auto-setup + +The `common.sh` file automatically runs `setup` and registers the `teardown` trap when first sourced. A guard variable (`_SZECRET_TEST_INITIALIZED`) prevents duplicate initialization if common.sh is sourced multiple times. + +### Configurable paths + +The szecret scripts support these environment variables for testing: + +- `SZECRET_STORE` - Override the store directory +- `SZECRET_PUBLIC_KEY` - Override the public key path +- `SZECRET_PRIVATE_KEY` - Override the private key path + +This allows tests to use a temporary directory without affecting the real secret store. + +## Known Limitations + +### Trailing newlines + +Bash command substitution `$()` strips trailing newlines. This means: + +```bash +# This will lose the trailing newline +retrieved="$(szecret get my-secret)" +``` + +This is bash behavior, not a szecret bug. The encrypted data preserves trailing newlines correctly. + +### Interactive commands + +The `ensure` command prompts for private key input when run interactively. This flow isn't fully tested since it requires TTY input. The tests verify it fails gracefully in non-interactive mode. + +### Binary data + +The tests don't currently cover binary data. Secrets are expected to be text (the encryption handles binary, but shell pipelines may have issues with null bytes). + +## Adding Tests for New Commands + +1. Create `test/<command>.sh` following the template above +2. Make it executable: `chmod +x test/<command>.sh` +3. The runner will automatically discover and run it + +## CI Integration + +The test suite exits with code 0 on success, non-zero on failure. Example CI usage: + +```bash +./secrets/test/run.sh || exit 1 +``` diff --git a/secrets/test/common.sh b/secrets/test/common.sh new file mode 100755 index 0000000..33d7d78 --- /dev/null +++ b/secrets/test/common.sh @@ -0,0 +1,108 @@ +# Common test utilities for szecret tests +# Source this file from individual test files + +set -euo pipefail + +SECRETS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +pass() { echo -e "${GREEN}PASS${NC}: $1"; } +fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; } +skip() { echo -e "${YELLOW}SKIP${NC}: $1"; } + +# Create temporary test environment +setup() { + export TEST_DIR="$(mktemp -d)" + export SZECRET_STORE="${TEST_DIR}/store" + export SZECRET_PUBLIC_KEY="${TEST_DIR}/.id.pub" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/.id" + + mkdir -p "${SZECRET_STORE}" + + # Generate test keypair + age-keygen -o "${SZECRET_PRIVATE_KEY}" 2>/dev/null + age-keygen -y "${SZECRET_PRIVATE_KEY}" > "${SZECRET_PUBLIC_KEY}" +} + +teardown() { + if [[ -n "${TEST_DIR:-}" && -d "${TEST_DIR}" ]]; then + rm -rf "${TEST_DIR}" + fi +} + +# Run a command from the secrets directory +szecret() { + "${SECRETS_DIR}/main.sh" "$@" +} + +# Direct access to individual scripts (useful for testing specific scripts) +szecret_script() { + local script="$1" + shift + "${SECRETS_DIR}/${script}.sh" "$@" +} + +# Assert that a command succeeds +assert_success() { + if "$@"; then + return 0 + else + fail "Command failed: $*" + fi +} + +# Assert that a command fails +assert_failure() { + if "$@" 2>/dev/null; then + fail "Command should have failed: $*" + else + return 0 + fi +} + +# Assert string equality +assert_equals() { + local expected="$1" + local actual="$2" + local msg="${3:-values}" + if [[ "${expected}" == "${actual}" ]]; then + return 0 + else + fail "${msg}: expected '${expected}', got '${actual}'" + fi +} + +# Assert string contains substring +assert_contains() { + local haystack="$1" + local needle="$2" + local msg="${3:-string}" + if [[ "${haystack}" == *"${needle}"* ]]; then + return 0 + else + fail "${msg}: '${haystack}' does not contain '${needle}'" + fi +} + +# Assert file exists +assert_file_exists() { + local file="$1" + local msg="${2:-file}" + if [[ -f "${file}" ]]; then + return 0 + else + fail "${msg}: file '${file}' does not exist" + fi +} + +# Auto-setup: only run once per process +if [[ -z "${_SZECRET_TEST_INITIALIZED:-}" ]]; then + export _SZECRET_TEST_INITIALIZED=1 + trap teardown EXIT + setup +fi diff --git a/secrets/test/create-local.sh b/secrets/test/create-local.sh new file mode 100755 index 0000000..310ba23 --- /dev/null +++ b/secrets/test/create-local.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Tests for create-local command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_create_local() { + local target_dir="${TEST_DIR}/local-store-dir" + szecret create-local "test-local-store" "${target_dir}" + + assert_file_exists "${SZECRET_STORE}/local-key-test-local-store.age" "local key file" + assert_file_exists "${target_dir}/.szecret" "metadata file" + + # Check metadata format + assert_contains "$(cat "${target_dir}/.szecret")" "store=test-local-store" "store in metadata" + assert_contains "$(cat "${target_dir}/.szecret")" "pubkey=age1" "pubkey in metadata" + pass "create-local creates local key and metadata" +} + +test_create_local_creates_directory() { + local target_dir="${TEST_DIR}/nested/new/directory" + szecret create-local "nested-store" "${target_dir}" + + assert_file_exists "${target_dir}/.szecret" "metadata file in created directory" + pass "create-local creates target directory" +} + +test_create_local_migration() { + # Simulate existing store without metadata (old-style) + local dir1="${TEST_DIR}/dir1" + local dir2="${TEST_DIR}/dir2" + + # Create store with first directory + szecret create-local "migration-store" "${dir1}" + + # Now "migrate" to a second directory - should just add metadata + szecret create-local "migration-store" "${dir2}" + + assert_file_exists "${dir2}/.szecret" "metadata in second directory" + + # Check metadata format and that pubkey is same in both dirs + assert_contains "$(cat "${dir2}/.szecret")" "store=migration-store" "store in metadata" + + local pubkey1 pubkey2 + pubkey1="$(grep pubkey "${dir1}/.szecret" | cut -d= -f2)" + pubkey2="$(grep pubkey "${dir2}/.szecret" | cut -d= -f2)" + assert_equals "${pubkey1}" "${pubkey2}" "pubkey matches across directories" + pass "create-local adds metadata for existing key (migration)" +} + +test_create_local_migration_preserves_key() { + local target_dir="${TEST_DIR}/preserve-key" + szecret create-local "preserve-store" "${target_dir}" + + # Encrypt a secret + local secret_value="test-secret-value" + echo "${secret_value}" | szecret put-local "${target_dir}/secret.age" + + # Remove metadata to simulate old-style store + rm "${target_dir}/.szecret" + + # Run create-local again - should add metadata without regenerating key + szecret create-local "preserve-store" "${target_dir}" + + # Verify we can still decrypt - proves key wasn't overwritten + local retrieved + retrieved="$(szecret get-local "${target_dir}/secret.age")" + assert_equals "${secret_value}" "${retrieved}" "decrypted value after migration" + pass "create-local migration preserves existing key" +} + +test_create_local_reuses_key_across_directories() { + local dir1="${TEST_DIR}/multi-dir-1" + local dir2="${TEST_DIR}/multi-dir-2" + + # Create store with first directory and encrypt a secret + szecret create-local "shared-store" "${dir1}" + local secret_value="shared-secret" + echo "${secret_value}" | szecret put-local "${dir1}/secret.age" + + # Add same store to second directory + szecret create-local "shared-store" "${dir2}" + + # Verify secret in dir1 still decrypts - proves key was reused not overwritten + local retrieved + retrieved="$(szecret get-local "${dir1}/secret.age")" + assert_equals "${secret_value}" "${retrieved}" "secret from first directory" + + # Verify we can also encrypt/decrypt in dir2 with same key + echo "dir2-secret" | szecret put-local "${dir2}/secret.age" + retrieved="$(szecret get-local "${dir2}/secret.age")" + assert_equals "dir2-secret" "${retrieved}" "secret from second directory" + + pass "create-local reuses key across directories" +} + +test_create_local_existing_metadata_fails() { + local target_dir="${TEST_DIR}/existing-metadata" + mkdir -p "${target_dir}" + echo "other-store" > "${target_dir}/.szecret" + + assert_failure szecret create-local "new-store" "${target_dir}" + pass "create-local fails if metadata exists" +} + +test_create_local_requires_args() { + assert_failure szecret create-local + assert_failure szecret create-local "store-only" + pass "create-local requires store name and directory" +} + +run_tests() { + test_create_local + test_create_local_creates_directory + test_create_local_migration + test_create_local_migration_preserves_key + test_create_local_reuses_key_across_directories + test_create_local_existing_metadata_fails + test_create_local_requires_args +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/edge-cases.sh b/secrets/test/edge-cases.sh new file mode 100755 index 0000000..ab1a56e --- /dev/null +++ b/secrets/test/edge-cases.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Edge case tests + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_special_characters_in_value() { + local secret_value='!@#$%^&*()_+-=[]{}|;:",.<>?/`~'"'" + echo "${secret_value}" | szecret put "special-chars" + + local retrieved + retrieved="$(szecret get "special-chars")" + assert_equals "${secret_value}" "${retrieved}" "special characters" + pass "handles special characters in value" +} + +test_whitespace_in_value() { + local secret_value=$' leading and trailing spaces ' + echo "${secret_value}" | szecret put "whitespace" + + local retrieved + retrieved="$(szecret get "whitespace")" + assert_equals "${secret_value}" "${retrieved}" "whitespace" + pass "preserves whitespace in value" +} + +test_empty_value() { + echo -n "" | szecret put "empty-secret" + + local retrieved + retrieved="$(szecret get "empty-secret")" + assert_equals "" "${retrieved}" "empty value" + pass "handles empty value" +} + +test_multiline_value() { + # Note: trailing newlines are stripped by bash $() substitution + # This is bash behavior, not a szecret limitation + local secret_value=$'line1\nline2\n\nline4 with spaces' + echo "${secret_value}" | szecret put "multiline" + + local retrieved + retrieved="$(szecret get "multiline")" + assert_equals "${secret_value}" "${retrieved}" "multiline value" + pass "handles multiline value with blank lines" +} + +test_unicode_in_value() { + local secret_value='Hello 世界 🔐 émoji café' + echo "${secret_value}" | szecret put "unicode" + + local retrieved + retrieved="$(szecret get "unicode")" + assert_equals "${secret_value}" "${retrieved}" "unicode value" + pass "handles unicode in value" +} + +test_secret_name_with_dashes() { + echo "value" | szecret put "my-secret-with-dashes" + assert_success szecret get "my-secret-with-dashes" >/dev/null + pass "handles dashes in secret name" +} + +test_secret_name_with_underscores() { + echo "value" | szecret put "my_secret_with_underscores" + assert_success szecret get "my_secret_with_underscores" >/dev/null + pass "handles underscores in secret name" +} + +test_secret_name_with_dots() { + echo "value" | szecret put "my.secret.with.dots" + assert_success szecret get "my.secret.with.dots" >/dev/null + pass "handles dots in secret name" +} + +test_long_value() { + # Generate a long value (10KB) + local secret_value + secret_value="$(head -c 10240 /dev/urandom | base64)" + echo "${secret_value}" | szecret put "long-secret" + + local retrieved + retrieved="$(szecret get "long-secret")" + assert_equals "${secret_value}" "${retrieved}" "long value" + pass "handles long value (10KB)" +} + +run_tests() { + test_special_characters_in_value + test_whitespace_in_value + test_empty_value + test_multiline_value + test_unicode_in_value + test_secret_name_with_dashes + test_secret_name_with_underscores + test_secret_name_with_dots + test_long_value +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/ensure.sh b/secrets/test/ensure.sh new file mode 100755 index 0000000..720d328 --- /dev/null +++ b/secrets/test/ensure.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Tests for ensure command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_ensure_succeeds_when_key_exists() { + # Key already exists from setup, should succeed silently + assert_success szecret ensure + pass "ensure succeeds when key exists" +} + +test_ensure_fails_noninteractive_without_key() { + local saved_key="${SZECRET_PRIVATE_KEY}" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/nonexistent-key" + + # Force non-interactive mode by redirecting stdin from /dev/null + assert_failure szecret ensure < /dev/null + + export SZECRET_PRIVATE_KEY="${saved_key}" + pass "ensure fails non-interactive without key" +} + +run_tests() { + test_ensure_succeeds_when_key_exists + test_ensure_fails_noninteractive_without_key +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/get.sh b/secrets/test/get.sh new file mode 100755 index 0000000..eb056d9 --- /dev/null +++ b/secrets/test/get.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Tests for get command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_get_retrieves_secret() { + local secret_value="my-secret-value-123" + echo "${secret_value}" | szecret put "test-secret" + + local retrieved + retrieved="$(szecret get "test-secret")" + assert_equals "${secret_value}" "${retrieved}" "retrieved value" + pass "get retrieves secret" +} + +test_get_nonexistent_fails() { + assert_failure szecret get "nonexistent-secret" + pass "get nonexistent returns error" +} + +test_get_requires_name() { + assert_failure szecret get + pass "get requires secret name" +} + +test_get_preserves_newlines() { + local secret_value=$'line1\nline2\nline3' + echo "${secret_value}" | szecret put "multiline-secret" + + local retrieved + retrieved="$(szecret get "multiline-secret")" + assert_equals "${secret_value}" "${retrieved}" "multiline value" + pass "get preserves newlines" +} + +run_tests() { + test_get_retrieves_secret + test_get_nonexistent_fails + test_get_requires_name + test_get_preserves_newlines +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/list.sh b/secrets/test/list.sh new file mode 100755 index 0000000..08671c8 --- /dev/null +++ b/secrets/test/list.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests for list command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_list_shows_secrets() { + echo "value1" | szecret put "secret-a" + echo "value2" | szecret put "secret-b" + echo "value3" | szecret put "secret-c" + + local list_output + list_output="$(szecret list)" + + assert_contains "${list_output}" "secret-a" "list output" + assert_contains "${list_output}" "secret-b" "list output" + assert_contains "${list_output}" "secret-c" "list output" + pass "list shows all secrets" +} + +test_list_empty_store() { + local list_output + list_output="$(szecret list)" + assert_equals "" "${list_output}" "empty list" + pass "list handles empty store" +} + +test_list_sorted() { + echo "v" | szecret put "zebra" + echo "v" | szecret put "apple" + echo "v" | szecret put "mango" + + local list_output + list_output="$(szecret list)" + + # Check that apple comes before mango comes before zebra + local apple_pos mango_pos zebra_pos + apple_pos=$(echo "${list_output}" | grep -n "apple" | cut -d: -f1) + mango_pos=$(echo "${list_output}" | grep -n "mango" | cut -d: -f1) + zebra_pos=$(echo "${list_output}" | grep -n "zebra" | cut -d: -f1) + + if [[ "${apple_pos}" -lt "${mango_pos}" && "${mango_pos}" -lt "${zebra_pos}" ]]; then + pass "list is sorted" + else + fail "list is not sorted: apple=${apple_pos}, mango=${mango_pos}, zebra=${zebra_pos}" + fi +} + +run_tests() { + test_list_empty_store + test_list_shows_secrets + test_list_sorted +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/local.sh b/secrets/test/local.sh new file mode 100755 index 0000000..e8d174b --- /dev/null +++ b/secrets/test/local.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Tests for put-local and get-local commands + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_put_local_and_get_local() { + local target_dir="${TEST_DIR}/secrets" + szecret create-local "test-store" "${target_dir}" + + local secret_value="local-secret-value-456" + local encrypted_file="${target_dir}/secret.age" + + echo "${secret_value}" | szecret put-local "${encrypted_file}" + + assert_file_exists "${encrypted_file}" "encrypted file" + + local retrieved + retrieved="$(szecret get-local "${encrypted_file}")" + assert_equals "${secret_value}" "${retrieved}" "local secret value" + pass "put-local and get-local" +} + +test_put_local_no_metadata_fails() { + local target_dir="${TEST_DIR}/no-metadata" + mkdir -p "${target_dir}" + + assert_failure bash -c "echo 'value' | szecret put-local '${target_dir}/secret.age'" + pass "put-local fails without metadata" +} + +test_get_local_no_metadata_fails() { + local target_dir="${TEST_DIR}/no-metadata-get" + mkdir -p "${target_dir}" + touch "${target_dir}/fake.age" + + assert_failure szecret get-local "${target_dir}/fake.age" + pass "get-local fails without metadata" +} + +test_get_local_legacy_syntax_warns() { + local target_dir="${TEST_DIR}/legacy-test" + szecret create-local "legacy-store" "${target_dir}" + + local secret_value="legacy-value" + echo "${secret_value}" | szecret put-local "${target_dir}/secret.age" + + # Use legacy 2-arg syntax - should warn even with metadata present + local stderr_output + stderr_output="$(szecret get-local "legacy-store" "${target_dir}/secret.age" 2>&1 >/dev/null)" + + assert_contains "${stderr_output}" "WARN" "deprecation warning" + assert_contains "${stderr_output}" "deprecated" "deprecation message" + + # Should still work despite warning + local retrieved + retrieved="$(szecret get-local "legacy-store" "${target_dir}/secret.age" 2>/dev/null)" + assert_equals "${secret_value}" "${retrieved}" "legacy syntax result" + pass "get-local legacy syntax warns but works" +} + +test_get_local_nonexistent_file_fails() { + local target_dir="${TEST_DIR}/nonexistent-file" + szecret create-local "file-test-store" "${target_dir}" + + assert_failure szecret get-local "${target_dir}/nonexistent.age" + pass "get-local fails for nonexistent file" +} + +test_put_local_requires_arg() { + assert_failure szecret put-local + pass "put-local requires file path" +} + +test_get_local_requires_arg() { + assert_failure szecret get-local + pass "get-local requires file path" +} + +test_put_local_pubkey_mismatch_fails() { + local target_dir="${TEST_DIR}/pubkey-mismatch-put" + szecret create-local "mismatch-put-store" "${target_dir}" + + # Corrupt the pubkey in metadata + sed -i.bak 's/pubkey=.*/pubkey=age1invalid000000000000000000000000000000000000000000000000000/' "${target_dir}/.szecret" + + # Should fail on put-local + assert_failure bash -c "echo 'value' | szecret put-local '${target_dir}/secret.age'" + pass "put-local fails on pubkey mismatch" +} + +test_get_local_pubkey_mismatch_fails() { + local target_dir="${TEST_DIR}/pubkey-mismatch-get" + szecret create-local "mismatch-get-store" "${target_dir}" + + # Encrypt a secret first + echo "secret-value" | szecret put-local "${target_dir}/secret.age" + + # Corrupt the pubkey in metadata + sed -i.bak 's/pubkey=.*/pubkey=age1invalid000000000000000000000000000000000000000000000000000/' "${target_dir}/.szecret" + + # Should fail on get-local (new syntax) + assert_failure szecret get-local "${target_dir}/secret.age" + pass "get-local fails on pubkey mismatch" +} + +test_invalid_metadata_fails() { + local target_dir="${TEST_DIR}/invalid-metadata" + mkdir -p "${target_dir}" + + # Create invalid metadata (old format without pubkey) + echo "just-store-name" > "${target_dir}/.szecret" + touch "${target_dir}/secret.age" + + assert_failure szecret get-local "${target_dir}/secret.age" + pass "invalid metadata format fails" +} + +run_tests() { + test_put_local_and_get_local + test_put_local_no_metadata_fails + test_get_local_no_metadata_fails + test_get_local_legacy_syntax_warns + test_get_local_nonexistent_file_fails + test_put_local_requires_arg + test_get_local_requires_arg + test_put_local_pubkey_mismatch_fails + test_get_local_pubkey_mismatch_fails + test_invalid_metadata_fails +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/main.sh b/secrets/test/main.sh new file mode 100755 index 0000000..23c8dd2 --- /dev/null +++ b/secrets/test/main.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Tests for main entrypoint + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_main_help() { + local output + output="$(szecret --help)" + assert_contains "${output}" "szecret" "help output" + assert_contains "${output}" "Usage" "help output" + pass "main --help shows usage" +} + +test_main_help_short() { + local output + output="$(szecret -h)" + assert_contains "${output}" "szecret" "help output" + pass "main -h shows usage" +} + +test_main_unknown_command() { + assert_failure szecret nonexistent-command + pass "main fails on unknown command" +} + +test_main_no_command() { + assert_failure szecret + pass "main fails without command" +} + +test_main_dispatches_to_command() { + echo "dispatch-test-value" | szecret put "dispatch-test" + local retrieved + retrieved="$(szecret get "dispatch-test")" + assert_equals "dispatch-test-value" "${retrieved}" "dispatched command" + pass "main dispatches to commands" +} + +test_command_help() { + local output + output="$(szecret get --help)" + assert_contains "${output}" "get" "command help" + assert_contains "${output}" "Usage" "command help" + pass "command --help shows command usage" +} + +run_tests() { + test_main_help + test_main_help_short + test_main_unknown_command + test_main_no_command + test_main_dispatches_to_command + test_command_help +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/paths.sh b/secrets/test/paths.sh new file mode 100755 index 0000000..97c8599 --- /dev/null +++ b/secrets/test/paths.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Tests for where szecret finds the global store and its public key + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +# A copy of the scripts with no store beside them, so the tests do not depend +# on whether this checkout still carries one. +make_code_dir() { + local dir="$1" + mkdir -p "${dir}" + cp "${SECRETS_DIR}"/*.sh "${dir}/" +} + +# Runs szecret from a code dir with only XDG_DATA_HOME to go on. +szecret_at() { + local code_dir="$1" + shift + env -u SZECRET_STORE -u SZECRET_PUBLIC_KEY -u SZECRET_PRIVATE_KEY -u SZECRET_PRIVATE_KEY_DIR \ + XDG_DATA_HOME="${TEST_DIR}/xdg" \ + "${code_dir}/main.sh" "$@" +} + +# Makes a keypair: the public key goes to $1, the private key into the private +# key directory, named after its public key. +make_key() { + local pub_file="$1" + local key_dir="${TEST_DIR}/xdg/szecret" + mkdir -p "${key_dir}" "$(dirname "${pub_file}")" + age-keygen -o "${TEST_DIR}/new-key" 2>/dev/null + age-keygen -y "${TEST_DIR}/new-key" > "${pub_file}" + mv "${TEST_DIR}/new-key" "${key_dir}/$(cat "${pub_file}")" +} + +reset_xdg() { + rm -rf "${TEST_DIR}/xdg" +} + +test_no_store_fails() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + local err + err="$(szecret_at "${code}" list 2>&1 >/dev/null)" && fail "list should fail without a store" + assert_contains "${err}" "no szecret store" "error message" + pass "no store: commands fail with instructions" +} + +test_no_store_ready_is_quiet() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + local err + err="$(szecret_at "${code}" ready 2>&1)" && fail "ready should fail without a store" + assert_equals "" "${err}" "ready output" + pass "no store: ready fails silently" +} + +test_data_dir_store() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + # The store and public key live elsewhere and are linked in, as on a host. + local elsewhere="${TEST_DIR}/checkout/szecret" + mkdir -p "${elsewhere}/store" + make_key "${elsewhere}/id.pub" + ln -s "${elsewhere}/store" "${TEST_DIR}/xdg/szecret/store" + ln -s "${elsewhere}/id.pub" "${TEST_DIR}/xdg/szecret/id.pub" + + echo "linked-value" | szecret_at "${code}" put "linked-secret" + assert_file_exists "${elsewhere}/store/linked-secret.age" "secret written through the link" + assert_equals "linked-value" "$(szecret_at "${code}" get "linked-secret")" "round trip" + pass "store and id.pub are found in the data dir" +} + +test_data_dir_wins_over_a_stray_store() { + reset_xdg + local code="${TEST_DIR}/code-stray" + make_code_dir "${code}" + mkdir -p "${code}/store" + make_key "${code}/.id.pub" + + mkdir -p "${TEST_DIR}/xdg/szecret/store" + make_key "${TEST_DIR}/xdg/szecret/id.pub" + + echo "v" | szecret_at "${code}" put "where" + assert_file_exists "${TEST_DIR}/xdg/szecret/store/where.age" "secret in data dir store" + if [[ -e "${code}/store/where.age" ]]; then + fail "secret was written to the store beside the scripts" + fi + pass "data dir is used even with a store beside the scripts" +} + +test_env_beats_data_dir() { + reset_xdg + mkdir -p "${TEST_DIR}/xdg/szecret/store" + make_key "${TEST_DIR}/xdg/szecret/id.pub" + + # The common.sh setup exported SZECRET_STORE and friends; use them as-is. + echo "v" | XDG_DATA_HOME="${TEST_DIR}/xdg" szecret put "env-wins" + assert_file_exists "${SZECRET_STORE}/env-wins.age" "secret in SZECRET_STORE" + if [[ -e "${TEST_DIR}/xdg/szecret/store/env-wins.age" ]]; then + fail "secret was written to the data dir store" + fi + pass "SZECRET_STORE takes precedence over the data dir" +} + +run_tests() { + test_no_store_fails + test_no_store_ready_is_quiet + test_data_dir_store + test_data_dir_wins_over_a_stray_store + test_env_beats_data_dir +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/put.sh b/secrets/test/put.sh new file mode 100755 index 0000000..7cfb117 --- /dev/null +++ b/secrets/test/put.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Tests for put command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_put_creates_file() { + echo "test-value" | szecret put "test-secret" + assert_file_exists "${SZECRET_STORE}/test-secret.age" "put creates encrypted file" + pass "put creates file" +} + +test_put_overwrites() { + echo "original" | szecret put "overwrite-test" + echo "updated" | szecret put "overwrite-test" + + local retrieved + retrieved="$(szecret get "overwrite-test")" + assert_equals "updated" "${retrieved}" "overwritten value" + pass "put overwrites existing secret" +} + +test_put_requires_name() { + assert_failure szecret put + pass "put requires secret name" +} + +run_tests() { + test_put_creates_file + test_put_overwrites + test_put_requires_name +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/ready.sh b/secrets/test/ready.sh new file mode 100755 index 0000000..26a000c --- /dev/null +++ b/secrets/test/ready.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Tests for ready command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_ready_with_key() { + assert_success szecret ready + pass "ready succeeds with private key" +} + +test_ready_without_key() { + local saved_key="${SZECRET_PRIVATE_KEY}" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/nonexistent-key" + + assert_failure szecret ready + + export SZECRET_PRIVATE_KEY="${saved_key}" + pass "ready fails without private key" +} + +run_tests() { + test_ready_with_key + test_ready_without_key +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/rotate.sh b/secrets/test/rotate.sh new file mode 100755 index 0000000..02abe39 --- /dev/null +++ b/secrets/test/rotate.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Tests for rotate command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +setup_key_dir_layout() { + local keys_dir="${TEST_DIR}/keys" + mkdir -p "${keys_dir}" "${SZECRET_STORE}" + rm -f "${SZECRET_STORE}"/*.age + unset SZECRET_PRIVATE_KEY + export SZECRET_PRIVATE_KEY_DIR="${keys_dir}" + + local temp_key="${TEST_DIR}/initial-key" + age-keygen -o "${temp_key}" 2>/dev/null + local pub + pub="$(age-keygen -y "${temp_key}")" + echo "${pub}" > "${SZECRET_PUBLIC_KEY}" + mv "${temp_key}" "${keys_dir}/${pub}" + chmod 600 "${keys_dir}/${pub}" + SETUP_PUB="${pub}" +} + +test_rotate_reencrypts_secrets() { + setup_key_dir_layout + local old_pub="${SETUP_PUB}" + + echo "rotate-me" | szecret put "rotate-test" + echo "other" | szecret put "rotate-other" + + szecret rotate --yes + + local new_pub + new_pub="$(cat "${SZECRET_PUBLIC_KEY}")" + if [[ "${new_pub}" == "${old_pub}" ]]; then + fail "public key should change after rotate" + fi + + assert_equals "rotate-me" "$(szecret get "rotate-test")" "rotated secret" + assert_equals "other" "$(szecret get "rotate-other")" "other rotated secret" + assert_file_exists "${SZECRET_PRIVATE_KEY_DIR}/${new_pub}" "new private key file" + pass "rotate re-encrypts secrets and updates keys" +} + +test_rotate_empty_store() { + setup_key_dir_layout >/dev/null + + szecret rotate --yes + + local new_pub + new_pub="$(cat "${SZECRET_PUBLIC_KEY}")" + assert_file_exists "${SZECRET_PRIVATE_KEY_DIR}/${new_pub}" "new private key with empty store" + pass "rotate succeeds with empty store" +} + +test_rotate_requires_confirmation() { + setup_key_dir_layout >/dev/null + + assert_failure szecret rotate + pass "rotate fails without confirmation" +} + +run_tests() { + test_rotate_reencrypts_secrets + test_rotate_empty_store + test_rotate_requires_confirmation +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/run.sh b/secrets/test/run.sh new file mode 100755 index 0000000..0969618 --- /dev/null +++ b/secrets/test/run.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Test runner for szecret tests +# Runs all test files or specific ones passed as arguments +# Each test file runs in its own isolated environment + +set -euo pipefail + +this_dir="$(cd "$(dirname "$0")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Find all test files (excluding common.sh and run.sh) +find_test_files() { + find "${this_dir}" -maxdepth 1 -name '*.sh' -type f \ + | xargs -n1 basename \ + | grep -Ev '^(common|run)\.sh$' \ + | sort +} + +run_test_file() { + local test_file="$1" + local test_path="${this_dir}/${test_file}" + + if [[ ! -x "${test_path}" ]]; then + chmod +x "${test_path}" + fi + + echo "--- ${test_file%.sh} ---" + + # Run each test file in its own subprocess with isolated environment + if "${test_path}"; then + return 0 + else + return 1 + fi +} + +main() { + echo "=== szecret test suite ===" + echo + + local test_files=() + local failed=0 + + if [[ $# -gt 0 ]]; then + # Run specific test files + for arg in "$@"; do + # Allow both "list" and "list.sh" + local file="${arg%.sh}.sh" + if [[ -f "${this_dir}/${file}" ]]; then + test_files+=("${file}") + else + echo "Unknown test file: ${arg}" >&2 + exit 1 + fi + done + else + # Run all test files + while IFS= read -r file; do + test_files+=("${file}") + done < <(find_test_files) + fi + + for test_file in "${test_files[@]}"; do + if ! run_test_file "${test_file}"; then + failed=1 + fi + echo + done + + if [[ ${failed} -eq 0 ]]; then + echo -e "${GREEN}=== All tests passed ===${NC}" + else + echo -e "${RED}=== Some tests failed ===${NC}" + exit 1 + fi +} + +main "$@" |
