From 8bd4c7253b0a0df25df0c453f8c9ad28d16f5aac Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Fri, 27 Mar 2026 11:32:55 +1300 Subject: [PATCH] Add gen-secret: generate bash/YAML/JSON-safe random strings Outputs cryptographically random strings using only characters safe for unquoted use in bash, YAML, and JSON: [A-Za-z0-9._+\-:@^~] Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/gen-secret | 63 ++++++++++++++++++++ specs/gen-secret.spec.md | 82 ++++++++++++++++++++++++++ tests/test-gen-secret.sh | 120 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100755 scripts/gen-secret create mode 100644 specs/gen-secret.spec.md create mode 100755 tests/test-gen-secret.sh diff --git a/scripts/gen-secret b/scripts/gen-secret new file mode 100755 index 0000000..3128104 --- /dev/null +++ b/scripts/gen-secret @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -uo pipefail + +CHARSET='A-Za-z0-9._+\-:@^~' +DEFAULT_LENGTH=32 + +usage() { + cat <<'EOF' +Usage: gen-secret [OPTIONS] [LENGTH] + +Generate a cryptographically random string safe for bash, YAML, and JSON. + +Arguments: + LENGTH Number of characters (default: 32) + +Options: + -n, --dryrun Preview what would happen + -h, --help Show this help +EOF +} + +dryrun=false +length="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + -n|--dryrun) + dryrun=true + shift + ;; + -*) + echo "Error: Unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -n "$length" ]]; then + echo "Error: Too many arguments" >&2 + exit 1 + fi + length="$1" + shift + ;; + esac +done + +length="${length:-$DEFAULT_LENGTH}" + +if ! [[ "$length" =~ ^[0-9]+$ ]] || [[ "$length" -le 0 ]]; then + echo "Error: Length must be a positive integer" >&2 + exit 1 +fi + +if $dryrun; then + echo "[dryrun] Would generate a ${length}-character secret from charset: [${CHARSET}]" + exit 0 +fi + +tr -dc "$CHARSET" < /dev/urandom | head -c "$length" +echo diff --git a/specs/gen-secret.spec.md b/specs/gen-secret.spec.md new file mode 100644 index 0000000..02cf4e4 --- /dev/null +++ b/specs/gen-secret.spec.md @@ -0,0 +1,82 @@ +# gen-secret + +## Purpose + +Generate a cryptographically random string that is safe to embed unquoted in bash, YAML, and JSON without escaping. + +## Usage + +``` +gen-secret [OPTIONS] [LENGTH] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `LENGTH` | 32 | Number of characters in the generated secret | + +### Flags + +| Flag | Description | +|------|-------------| +| `--help`, `-h` | Show usage information | +| `--dryrun`, `-n` | Print what would happen without generating a secret | + +## Character Set + +The output uses only characters that need no escaping in bash (unquoted assignment), YAML (plain scalar), and JSON (string value): + +``` +A-Z a-z 0-9 . _ + - : @ ^ ~ +``` + +**Excluded** (unsafe in at least one context): `"`, `'`, `\`, `` ` ``, `$`, `!`, `{`, `}`, `(`, `)`, `[`, `]`, `#`, `%`, `&`, `|`, `<`, `>`, `*`, `?`, `;`, `,`, `=`, space, tab, newline, `/` + +Note: `/` is excluded because YAML plain scalars starting with `//` or containing `#` after a space can cause issues, and removing `/` keeps the set simpler without meaningful entropy loss. + +## Behaviour + +1. Validate that LENGTH is a positive integer. +2. Read random bytes from `/dev/urandom`. +3. Filter to the allowed character set. +4. Output exactly LENGTH characters followed by a newline. +5. Exit 0 on success. + +## Dryrun Behaviour + +When `--dryrun` or `-n` is passed: + +``` +[dryrun] Would generate a 32-character secret from charset: [A-Za-z0-9._+\-:@^~] +``` + +(Substituting the actual length if provided.) + +No random output is produced. + +## Edge Cases + +| Case | Handling | +|------|----------| +| LENGTH is 0 | Error: "Length must be a positive integer", exit 1 | +| LENGTH is negative | Error: "Length must be a positive integer", exit 1 | +| LENGTH is not a number | Error: "Length must be a positive integer", exit 1 | +| No arguments | Default to 32 | +| Multiple arguments | Error: "Too many arguments", exit 1 | + +## Examples + +```bash +# Default 32-character secret +$ gen-secret +xQ9.kT3+mR7:nW2@pF5^bY8~cH4_dL6a + +# Custom length +$ gen-secret 64 +xQ9.kT3+mR7:nW2@pF5^bY8~cH4_dL6axQ9.kT3+mR7:nW2@pF5^bY8~cH4_dL6a + +# Dryrun +$ gen-secret -n 16 +[dryrun] Would generate a 16-character secret from charset: [A-Za-z0-9._+\-:@^~] +``` diff --git a/tests/test-gen-secret.sh b/tests/test-gen-secret.sh new file mode 100755 index 0000000..f422434 --- /dev/null +++ b/tests/test-gen-secret.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +GEN_SECRET="$SCRIPT_DIR/scripts/gen-secret" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +pass=0 +fail=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo -e "${GREEN}PASS${NC}: $desc" + ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc" + echo " expected: $expected" + echo " actual: $actual" + ((fail++)) + fi +} + +assert_match() { + local desc="$1" pattern="$2" actual="$3" + if [[ "$actual" =~ $pattern ]]; then + echo -e "${GREEN}PASS${NC}: $desc" + ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc" + echo " pattern: $pattern" + echo " actual: $actual" + ((fail++)) + fi +} + +assert_exit() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" -eq "$actual" ]]; then + echo -e "${GREEN}PASS${NC}: $desc" + ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc" + echo " expected exit: $expected" + echo " actual exit: $actual" + ((fail++)) + fi +} + +echo "=== gen-secret tests ===" +echo + +# --- Dryrun tests --- + +out=$("$GEN_SECRET" --dryrun 2>&1) +assert_eq "dryrun default length" \ + '[dryrun] Would generate a 32-character secret from charset: [A-Za-z0-9._+\-:@^~]' \ + "$out" + +out=$("$GEN_SECRET" -n 16 2>&1) +assert_eq "dryrun custom length" \ + '[dryrun] Would generate a 16-character secret from charset: [A-Za-z0-9._+\-:@^~]' \ + "$out" + +# --- Help --- + +out=$("$GEN_SECRET" --help 2>&1) +rc=$? +assert_exit "help exits 0" 0 "$rc" +assert_match "help mentions LENGTH" "LENGTH" "$out" + +# --- Default generation --- + +out=$("$GEN_SECRET" 2>&1) +rc=$? +assert_exit "default exits 0" 0 "$rc" +assert_eq "default length is 32" 32 "${#out}" +assert_match "default uses safe charset" '^[A-Za-z0-9._+:@^~-]+$' "$out" + +# --- Custom length --- + +out=$("$GEN_SECRET" 64 2>&1) +assert_eq "custom length 64" 64 "${#out}" + +out=$("$GEN_SECRET" 1 2>&1) +assert_eq "minimum length 1" 1 "${#out}" + +# --- Error cases --- + +out=$("$GEN_SECRET" 0 2>&1) +rc=$? +assert_exit "length 0 exits 1" 1 "$rc" +assert_match "length 0 error message" "positive integer" "$out" + +out=$("$GEN_SECRET" -5 2>&1) +rc=$? +assert_exit "negative length exits 1" 1 "$rc" + +out=$("$GEN_SECRET" abc 2>&1) +rc=$? +assert_exit "non-numeric exits 1" 1 "$rc" +assert_match "non-numeric error message" "positive integer" "$out" + +out=$("$GEN_SECRET" 10 20 2>&1) +rc=$? +assert_exit "too many args exits 1" 1 "$rc" +assert_match "too many args error message" "Too many arguments" "$out" + +# --- Summary --- + +echo +total=$((pass + fail)) +echo -e "Results: ${GREEN}${pass}${NC}/${total} passed" +if [[ $fail -gt 0 ]]; then + echo -e "${RED}${fail} test(s) failed${NC}" + exit 1 +fi