Skip to content

Instantly share code, notes, and snippets.

@GuyBarros
Created October 24, 2025 15:28
Show Gist options
  • Save GuyBarros/1f62ba4ecfe31b56c8d22558f74d3d43 to your computer and use it in GitHub Desktop.
Save GuyBarros/1f62ba4ecfe31b56c8d22558f74d3d43 to your computer and use it in GitHub Desktop.
HOTP script
#!/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