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) <noreply@anthropic.com>
This commit is contained in:
63
scripts/gen-secret
Executable file
63
scripts/gen-secret
Executable file
@@ -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
|
||||||
82
specs/gen-secret.spec.md
Normal file
82
specs/gen-secret.spec.md
Normal file
@@ -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._+\-:@^~]
|
||||||
|
```
|
||||||
120
tests/test-gen-secret.sh
Executable file
120
tests/test-gen-secret.sh
Executable file
@@ -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
|
||||||
Reference in New Issue
Block a user