summaryrefslogtreecommitdiff
path: root/secrets
diff options
context:
space:
mode:
Diffstat (limited to 'secrets')
-rw-r--r--secrets/.gitattributes1
-rw-r--r--secrets/.gitignore2
-rw-r--r--secrets/README.md163
-rw-r--r--secrets/common.sh307
-rwxr-xr-xsecrets/create-local.sh75
-rwxr-xr-xsecrets/ensure.sh32
-rwxr-xr-xsecrets/get-local.sh96
-rwxr-xr-xsecrets/get.sh46
-rwxr-xr-xsecrets/list.sh36
-rwxr-xr-xsecrets/main.sh69
-rwxr-xr-xsecrets/put-local.sh71
-rwxr-xr-xsecrets/put.sh40
-rwxr-xr-xsecrets/ready.sh31
-rwxr-xr-xsecrets/rotate.sh119
-rw-r--r--secrets/test/README.md148
-rwxr-xr-xsecrets/test/common.sh108
-rwxr-xr-xsecrets/test/create-local.sh122
-rwxr-xr-xsecrets/test/edge-cases.sh100
-rwxr-xr-xsecrets/test/ensure.sh29
-rwxr-xr-xsecrets/test/get.sh44
-rwxr-xr-xsecrets/test/list.sh55
-rwxr-xr-xsecrets/test/local.sh132
-rwxr-xr-xsecrets/test/main.sh57
-rwxr-xr-xsecrets/test/paths.sh119
-rwxr-xr-xsecrets/test/put.sh34
-rwxr-xr-xsecrets/test/ready.sh27
-rwxr-xr-xsecrets/test/rotate.sh69
-rwxr-xr-xsecrets/test/run.sh82
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 "$@"