Created
October 24, 2025 15:28
-
-
Save GuyBarros/1f62ba4ecfe31b56c8d22558f74d3d43 to your computer and use it in GitHub Desktop.
HOTP script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # ================== Config (env overrides allowed) ============================ | |
| VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" | |
| VAULT_TOKEN="${VAULT_TOKEN:?set VAULT_TOKEN}" | |
| VAULT_NAMESPACE="${VAULT_NAMESPACE:-}" # leave empty if not using namespaces | |
| TRANSIT_MOUNT="${TRANSIT_MOUNT:-transit}" # transit mount | |
| HOTP_KEY_NAME="${HOTP_KEY_NAME:-hotp-demo}" # transit HMAC key name | |
| HOTP_DIGITS="${HOTP_DIGITS:-6}" # usually 6 | |
| KV_MOUNT="${KV_MOUNT:-secret}" # KV v2 mount path (name only, not /v1/) | |
| KV_PATH="${KV_PATH:-hotp/demo}" # logical key path under KV v2 | |
| DEFAULT_WINDOW="${DEFAULT_WINDOW:-5}" # default look-ahead window for verify-kv | |
| CURL_NS_HDR=() | |
| if [[ -n "$VAULT_NAMESPACE" ]]; then | |
| CURL_NS_HDR=(-H "X-Vault-Namespace: $VAULT_NAMESPACE") | |
| fi | |
| # ======================= Helpers ============================================= | |
| api() { | |
| # curl wrapper with token + (optional) namespace header | |
| curl -fsS -H "X-Vault-Token: $VAULT_TOKEN" "${CURL_NS_HDR[@]}" "$@" | |
| } | |
| enable_transit() { | |
| api -X POST "$VAULT_ADDR/v1/sys/mounts/$TRANSIT_MOUNT" \ | |
| -d '{"type":"transit"}' >/dev/null || true | |
| } | |
| ensure_hmac_key() { | |
| if ! api "$VAULT_ADDR/v1/$TRANSIT_MOUNT/keys/$HOTP_KEY_NAME" >/dev/null 2>&1; then | |
| api -X POST "$VAULT_ADDR/v1/$TRANSIT_MOUNT/keys/$HOTP_KEY_NAME" \ | |
| -d '{"type":"hmac"}' >/dev/null | |
| fi | |
| } | |
| # --- HOTP compute (RFC 4226 using Transit HMAC-SHA1) -------------------------- | |
| hotp() { | |
| local counter="$1" | |
| local counter_hex counter_b64 hmac_field mac_b64 mac_hex last_byte_hex offset start slice bin_val mod code | |
| counter_hex=$(printf "%016x" "$counter") | |
| counter_b64=$(printf "%s" "$counter_hex" | xxd -r -p | base64) | |
| hmac_field=$(api -H "Content-Type: application/json" \ | |
| -X POST "$VAULT_ADDR/v1/$TRANSIT_MOUNT/hmac/$HOTP_KEY_NAME" \ | |
| -d "{\"input\":\"$counter_b64\",\"algorithm\":\"sha1\"}" | jq -r '.data.hmac') | |
| # hmac_field = "vault:vN:BASE64" | |
| mac_b64=$(printf "%s" "$hmac_field" | awk -F: '{print $3}') | |
| # hex-encode bytes (lowercase, no newlines) | |
| mac_hex=$(printf "%s" "$mac_b64" | base64 -d | xxd -p -c 1000 | tr -d '\n' | tr '[:upper:]' '[:lower:]') | |
| last_byte_hex="${mac_hex: -2}" | |
| offset=$(( 0x${last_byte_hex} & 0x0f )) | |
| start=$(( offset * 2 )) | |
| slice=${mac_hex:start:8} | |
| bin_val=$(( 0x${slice} & 0x7fffffff )) | |
| mod=1; for ((i=0;i<HOTP_DIGITS;i++)); do mod=$((mod*10)); done | |
| code=$(( bin_val % mod )) | |
| printf "%0${HOTP_DIGITS}d\n" "$code" | |
| } | |
| # ========================= KV v2 (CAS) ======================================== | |
| kv_read_counter() { | |
| # Returns: "<counter> <version>" | |
| # If the key doesn't exist, prints "MISSING 0" and exits 0. | |
| local resp | |
| set +e | |
| resp=$(api "$VAULT_ADDR/v1/$KV_MOUNT/data/$KV_PATH" 2>/dev/null) | |
| local rc=$? | |
| set -e | |
| if [[ $rc -ne 0 || -z "$resp" ]]; then | |
| echo "MISSING 0" | |
| return 0 | |
| fi | |
| # KV v2 read: .data.data contains fields; .data.metadata.version has version | |
| local counter version | |
| counter=$(jq -r '.data.data.counter // "MISSING"' <<<"$resp") | |
| version=$(jq -r '.data.metadata.version' <<<"$resp") | |
| if [[ "$counter" == "MISSING" || -z "$version" ]]; then | |
| echo "MISSING 0" | |
| else | |
| echo "$counter $version" | |
| fi | |
| } | |
| kv_init_counter_if_missing() { | |
| # Create with cas=0 so it only succeeds if key does NOT exist | |
| local initial="${1:-0}" | |
| api -H "Content-Type: application/json" \ | |
| -X POST "$VAULT_ADDR/v1/$KV_MOUNT/data/$KV_PATH" \ | |
| -d "{\"options\":{\"cas\":0},\"data\":{\"counter\":$initial}}" >/dev/null || true | |
| } | |
| kv_cas_update_counter() { | |
| # kv_cas_update_counter <expected_version> <new_counter> | |
| local expect="$1" newval="$2" | |
| api -H "Content-Type: application/json" \ | |
| -X POST "$VAULT_ADDR/v1/$KV_MOUNT/data/$KV_PATH" \ | |
| -d "{\"options\":{\"cas\":$expect},\"data\":{\"counter\":$newval}}" >/dev/null | |
| } | |
| # ========================= Verify with KV & CAS =============================== | |
| verify_kv() { | |
| # verify_kv <code> [window] | |
| local code="$1" | |
| local window="${2:-$DEFAULT_WINDOW}" | |
| if [[ ! "$code" =~ ^[0-9]+$ ]] || [[ "${#code}" -ne "$HOTP_DIGITS" ]]; then | |
| echo "FAIL (bad format: expected ${HOTP_DIGITS} digits)" >&2 | |
| return 2 | |
| fi | |
| # Ensure KV is initialized | |
| local pair counter version | |
| pair=$(kv_read_counter) | |
| if [[ "${pair%% *}" == "MISSING" ]]; then | |
| kv_init_counter_if_missing 0 | |
| pair=$(kv_read_counter) | |
| fi | |
| read -r counter version <<<"$pair" | |
| # Try up to N CAS retry attempts if there is contention | |
| local retries=6 | |
| while (( retries-- > 0 )); do | |
| # Search window counter..counter+window | |
| local i gen matched=-1 | |
| for ((i=0; i<=window; i++)); do | |
| gen=$(hotp $((counter+i))) | |
| if [[ "$gen" == "$code" ]]; then | |
| matched=$((counter+i)) | |
| break | |
| fi | |
| done | |
| if (( matched < 0 )); then | |
| echo "FAIL" | |
| return 1 | |
| fi | |
| # Attempt CAS to advance to matched+1 | |
| local new_counter=$((matched+1)) | |
| # Expected version is the one we read. If someone else wrote, this will fail -> retry. | |
| if kv_cas_update_counter "$version" "$new_counter"; then | |
| echo "OK (matched at counter=$matched, advanced to $new_counter)" | |
| return 0 | |
| fi | |
| # CAS failed: re-read and try again (another verifier moved it) | |
| pair=$(kv_read_counter) | |
| read -r counter version <<<"$pair" | |
| done | |
| echo "FAIL (conflict: too many concurrent updates)" | |
| return 1 | |
| } | |
| # ============================ CLI ============================================ | |
| usage() { | |
| cat <<USAGE | |
| Usage: | |
| $(basename "$0") init | |
| - Ensure Transit is enabled and HMAC key "$HOTP_KEY_NAME" exists. | |
| - Ensure KV key $KV_MOUNT/$KV_PATH exists with counter=0 (no overwrite). | |
| $(basename "$0") gen <counter> | |
| - Print HOTP for an explicit counter (does not touch KV). | |
| $(basename "$0") current | |
| - Show current KV counter and version. | |
| $(basename "$0") verify-kv <code> [window=${DEFAULT_WINDOW}] | |
| - Verify HOTP <code> against KV-backed counter with look-ahead window. | |
| - On success, advances counter atomically to matched+1 using CAS. | |
| Env vars: | |
| VAULT_ADDR, VAULT_TOKEN, VAULT_NAMESPACE (optional) | |
| TRANSIT_MOUNT, HOTP_KEY_NAME, HOTP_DIGITS | |
| KV_MOUNT, KV_PATH, DEFAULT_WINDOW | |
| USAGE | |
| } | |
| cmd="${1:-}" | |
| case "$cmd" in | |
| init) | |
| enable_transit | |
| ensure_hmac_key | |
| kv_init_counter_if_missing 0 | |
| echo "Ready. Transit '$TRANSIT_MOUNT' key '$HOTP_KEY_NAME' ensured; KV '$KV_MOUNT/$KV_PATH' initialized (if missing)." | |
| ;; | |
| gen) | |
| [[ -n "${2:-}" ]] || { echo "Usage: $(basename "$0") gen <counter>" >&2; exit 2; } | |
| hotp "$2" | |
| ;; | |
| current) | |
| pair=$(kv_read_counter) | |
| if [[ "${pair%% *}" == "MISSING" ]]; then | |
| echo "No KV record yet at $KV_MOUNT/$KV_PATH" | |
| else | |
| read -r counter version <<<"$pair" | |
| echo "counter=$counter version=$version" | |
| fi | |
| ;; | |
| verify-kv) | |
| [[ -n "${2:-}" ]] || { echo "Usage: $(basename "$0") verify-kv <code> [window]" >&2; exit 2; } | |
| verify_kv "$2" "${3:-$DEFAULT_WINDOW}" | |
| ;; | |
| *) | |
| usage | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment