|
| 1 | +#!/usr/bin/env bash |
| 2 | + |
| 3 | +############################################################################### |
| 4 | +# Author: Amin Abbaspour (pure bash implementation by Junie) |
| 5 | +# Date: 2025-09-01 |
| 6 | +# License: LGPL 2.1 (https://github.com/abbaspour/auth0-myaccout-bash/blob/master/LICENSE) |
| 7 | +# |
| 8 | +# Description: |
| 9 | +# Build a WebAuthn assertion (get) in pure Bash using OpenSSL and jq. |
| 10 | +# Outputs JSON fields compatible with authentication-methods/login.sh which |
| 11 | +# expects: |
| 12 | +# - .response.authenticatorData (base64url) |
| 13 | +# - .response.clientDataJSON (base64url) |
| 14 | +# - .response.signature (base64url) |
| 15 | +# |
| 16 | +# Limitations: supports EC P-256 private key (ES256) only. |
| 17 | +############################################################################### |
| 18 | + |
| 19 | +set -euo pipefail |
| 20 | + |
| 21 | +command -v jq >/dev/null || { echo >&2 "error: jq not found"; exit 3; } |
| 22 | +command -v openssl >/dev/null || { echo >&2 "error: openssl not found"; exit 3; } |
| 23 | +command -v xxd >/dev/null || { echo >&2 "error: xxd not found"; exit 3; } |
| 24 | + |
| 25 | +usage() { |
| 26 | + cat <<'END' >&2 |
| 27 | +USAGE: assertion.sh --rp <rp_id> --challenge <base64url> --username <name> --userid <id> --key <private-key.pem> --credId <base64url> [-h] [-v] |
| 28 | +
|
| 29 | +Required: |
| 30 | + --rp RP_ID # Relying Party ID / domain (e.g., example.auth0.com) |
| 31 | + --challenge STR # base64url-encoded challenge |
| 32 | + --username NAME # username (informational) |
| 33 | + --userid ID # user handle/id (string) |
| 34 | + --key FILE # EC P-256 private key path (PEM/PKCS#8) |
| 35 | + --credId STR # credential ID (base64url) |
| 36 | +
|
| 37 | +Options: |
| 38 | + -v # verbose |
| 39 | + -h # help |
| 40 | +
|
| 41 | +Example: |
| 42 | + ./assertion.sh --rp my-tenant.auth0.com \ |
| 43 | + --challenge AABBCC... --username alice --userid 1234 \ |
| 44 | + --key ./private-key.pem --credId zzz... |
| 45 | +END |
| 46 | + exit ${1:-0} |
| 47 | +} |
| 48 | + |
| 49 | +opt_verbose="" |
| 50 | +rp="" |
| 51 | +challenge="" |
| 52 | +username="" |
| 53 | +userid="" |
| 54 | +key_file="" |
| 55 | +cred_id_b64url="" |
| 56 | + |
| 57 | +# Parse long options |
| 58 | +while [[ $# -gt 0 ]]; do |
| 59 | + case "$1" in |
| 60 | + --rp) rp=${2:-}; shift 2 ;; |
| 61 | + --challenge) challenge=${2:-}; shift 2 ;; |
| 62 | + --username) username=${2:-}; shift 2 ;; |
| 63 | + --userid) userid=${2:-}; shift 2 ;; |
| 64 | + --key) key_file=${2:-}; shift 2 ;; |
| 65 | + --credId) cred_id_b64url=${2:-}; shift 2 ;; |
| 66 | + -v) opt_verbose=1; shift ;; |
| 67 | + -h|--help) usage 0 ;; |
| 68 | + *) echo >&2 "Unknown argument: $1"; usage 1 ;; |
| 69 | + esac |
| 70 | +done |
| 71 | + |
| 72 | +# Validate required params |
| 73 | +[[ -n "$rp" ]] || { echo >&2 "error: --rp is required"; usage 2; } |
| 74 | +[[ -n "$challenge" ]] || { echo >&2 "error: --challenge is required"; usage 2; } |
| 75 | +[[ -n "$username" ]] || { echo >&2 "error: --username is required"; usage 2; } |
| 76 | +[[ -n "$userid" ]] || { echo >&2 "error: --userid is required"; usage 2; } |
| 77 | +[[ -n "$key_file" ]] || { echo >&2 "error: --key is required"; usage 2; } |
| 78 | +[[ -r "$key_file" ]] || { echo >&2 "error: key file not readable: $key_file"; exit 2; } |
| 79 | +[[ -n "$cred_id_b64url" ]] || { echo >&2 "error: --credId is required"; usage 2; } |
| 80 | + |
| 81 | +# Validate base64url values |
| 82 | +validate_b64url() { |
| 83 | + local s="$1" |
| 84 | + jq -rn --arg s "$s" '$s | gsub("-"; "+") | gsub("_"; "/") | try @base64d | empty' >/dev/null 2>&1 |
| 85 | +} |
| 86 | +if ! validate_b64url "$challenge"; then |
| 87 | + echo >&2 "error: --challenge is not a valid base64url string"; exit 2; |
| 88 | +fi |
| 89 | +if ! validate_b64url "$cred_id_b64url"; then |
| 90 | + echo >&2 "error: --credId is not a valid base64url string"; exit 2; |
| 91 | +fi |
| 92 | + |
| 93 | +# Ensure EC P-256 key to sign ES256 |
| 94 | +# Extract public key to verify curve |
| 95 | +_tmp_pub=$(mktemp) |
| 96 | +trap 'rm -f "$_tmp_pub"' EXIT |
| 97 | +if ! openssl pkey -in "$key_file" -pubout -out "$_tmp_pub" >/dev/null 2>&1; then |
| 98 | + echo >&2 "error: failed to read private key with openssl: $key_file"; exit 2; |
| 99 | +fi |
| 100 | +if ! openssl ec -pubin -in "$_tmp_pub" -noout -text 2>/dev/null | grep -q 'ASN1 OID: prime256v1'; then |
| 101 | + echo >&2 "error: only EC P-256 (prime256v1) keys are supported"; exit 2; |
| 102 | +fi |
| 103 | + |
| 104 | +# Helpers |
| 105 | +b64url() { openssl base64 -A | tr -d '=' | tr '+/' '-_'; } |
| 106 | +hex_of_string() { printf %s "$1" | xxd -p -c9999 | tr -d '\n' | tr 'A-F' 'a-f'; } |
| 107 | +sha256_hex_bytes() { # stdin bytes -> hex digest |
| 108 | + openssl dgst -sha256 -binary | xxd -p -c9999 | tr -d '\n' |
| 109 | +} |
| 110 | + |
| 111 | +# Build authenticatorData for assertion |
| 112 | +# - rpIdHash: SHA-256 of rp string (domain) |
| 113 | +# - flags: 0x01 (User Present). We do not set UV or AT for assertion. |
| 114 | +# - signCount: 4 bytes, we can use 00000001 |
| 115 | +rp_hash_hex=$(printf %s "$rp" | openssl dgst -sha256 -binary | xxd -p -c9999 | tr -d '\n') |
| 116 | +flags_hex="01" |
| 117 | +sign_cnt_hex="00000001" |
| 118 | +auth_data_hex="${rp_hash_hex}${flags_hex}${sign_cnt_hex}" |
| 119 | + |
| 120 | +# Build clientDataJSON with type webauthn.get |
| 121 | +origin="https://${rp}" |
| 122 | +client_data_json=$(jq -nc --arg chal "$challenge" --arg origin "$origin" '{type:"webauthn.get", challenge:$chal, origin:$origin}') |
| 123 | +client_data_b64url=$(printf '%s' "$client_data_json" | b64url) |
| 124 | + |
| 125 | +# Compute signature base: authenticatorData || SHA256(clientDataJSON) |
| 126 | +client_data_hash_hex=$(printf '%s' "$client_data_json" | openssl dgst -sha256 -binary | xxd -p -c9999 | tr -d '\n') |
| 127 | +sig_base_hex="${auth_data_hex}${client_data_hash_hex}" |
| 128 | + |
| 129 | +# Sign using ECDSA with SHA-256, producing DER signature, then base64url. |
| 130 | +# openssl pkeyutl -sign on raw digest may require -pkeyopt digest:sha256 when using pkeyutl. |
| 131 | +# Simpler: use openssl dgst -sha256 -sign which hashes input; so we must feed raw bytes, not hex. |
| 132 | +# Therefore convert sig_base_hex -> raw bytes and sign. |
| 133 | +signature_der_b64url=$(printf '%s' "$sig_base_hex" | xxd -r -p | openssl dgst -sha256 -sign "$key_file" -binary | b64url) |
| 134 | + |
| 135 | +# Encode authData as base64url |
| 136 | +auth_data_b64url=$(printf '%s' "$auth_data_hex" | xxd -r -p | b64url) |
| 137 | + |
| 138 | +# Output JSON to match login.sh expectations |
| 139 | +jq -n \ |
| 140 | + --arg authenticatorData "$auth_data_b64url" \ |
| 141 | + --arg clientDataJSON "$client_data_b64url" \ |
| 142 | + --arg signature "$signature_der_b64url" \ |
| 143 | + --arg id "$cred_id_b64url" \ |
| 144 | + --arg rawId "$cred_id_b64url" \ |
| 145 | + --arg userId "$userid" \ |
| 146 | + --arg userName "$username" \ |
| 147 | + '{ |
| 148 | + user: { id: $userId, name: $userName, displayName: $userName }, |
| 149 | + responseDecoded: { id: $id, rawId: $rawId }, |
| 150 | + response: { authenticatorData: $authenticatorData, clientDataJSON: $clientDataJSON, signature: $signature } |
| 151 | + }' |
0 commit comments