skills: cloudron.md records pointer

This commit is contained in:
TSYS Group COO
2026-09-08 06:03:06 -05:00
parent 814188e582
commit 1a1a7ca581
6 changed files with 230 additions and 81 deletions
+81 -20
View File
@@ -60,8 +60,10 @@ scripts/cloudron-api.sh GET /users/<uid> # fetch one user object
Route notes (this Cloudron, 2026-09): users are addressed by `uid-…` UUIDs, Route notes (this Cloudron, 2026-09): users are addressed by `uid-…` UUIDs,
not usernames; there is NO `/api/v1/login` (dashboard auth is its own OIDC not usernames; there is NO `/api/v1/login` (dashboard auth is its own OIDC
flow); there is NO admin route to reset another user's 2FA — so the seed flow, client `cid-webadmin`, PKCE S256); admin CAN clear a user's TOTP via
capture in step 2 is not optional. `POST /api/v1/users/<uid>/totp_disable` (empty JSON body) — use it when a
seed was lost, then re-enroll with capture. Admin password set:
`POST /api/v1/users/<uid>/password {"password":"…"}`.
## Procedure ## Procedure
@@ -76,26 +78,61 @@ capture in step 2 is not optional.
Verify the account exists and matches the identity email pattern Verify the account exists and matches the identity email pattern
(`tsgstaff-<login>@turnsys.com` for COO-line identities). Generate the unique (`tsgstaff-<login>@turnsys.com` for COO-line identities). Generate the unique
password (32 chars), set it, and confirm a login works (step 2 proves it). password (32 chars), set it via the admin API, and store it in the vault item
login password field:
```bash
scripts/cloudron-api.sh POST /users/<uid>/password '{"password":"<32-char>"}'
sm setfield "<login> Cloudron" password "<32-char>"
```
### 2. TOTP 2FA — enroll and CAPTURE THE SEED ### 2. TOTP 2FA — enroll and CAPTURE THE SEED
Enroll TOTP via the profile self-service surface and write the base32 seed to Two cases:
the vault item before anything else:
```bash - **Seed lost / never captured** (TOTP enforced, no `totp_seed` in vault):
sm setfield "<login> Cloudron" totp_seed "<BASE32SEED>" clear first — `scripts/cloudron-api.sh POST /users/<uid>/totp_disable`
``` then proceed to enroll fresh.
- **No 2FA yet**: enroll directly.
Enrollment runs in the identity's own dashboard session (the totp routes are
self-scoped — an admin token gets 404s there):
1. `scripts/cloudron-oidc-login.sh <username> <vault-item>` — full OIDC
login (PKCE + interaction + consent) yielding the identity's Bearer at
`/tmp/.oidc-bearer-<username>`.
2. `scripts/cloudron-totp-enroll.sh <username> <vault-item>` — reads the
pending secret, enables with a computed code, stores the seed via
`sm setfield`, and verifies end-to-end.
If the API confirm path is rejected, fall back to the dashboard UI in the
browser: the forced "Enable TOTP" dialog shows the seed; read it, compute
with `scripts/totp.sh`, fill the Token field and submit. CRITICAL: the
pending secret REGENERATES every time the dialog re-renders — always read
the seed and submit the code within the same window, and verify enrollment
by a real TOTP login (a totpToken in a login is silently ignored for users
without 2FA — "success" there proves nothing).
If the identity already has 2FA enforced and no seed exists anywhere: STOP and If the identity already has 2FA enforced and no seed exists anywhere: STOP and
ask the founder. There is no programmatic recovery (verified 2026-09-08). ask the founder. There is no programmatic recovery (verified 2026-09-08).
Compute codes offline when needed: `scripts/totp.sh <seed>`. Compute codes offline when needed: `scripts/totp.sh <seed>`.
### 3. SSO first-login into each system ### 3. TOTP enrollment (in the identity's dashboard session)
```bash
scripts/cloudron-oidc-login.sh <username> <vault-item> # OIDC login -> Bearer
scripts/cloudron-totp-enroll.sh <username> <vault-item> # enroll + seed + verify
```
If the API enable path is rejected, fall back to the dashboard UI dialog (see
step 2's note). The verify pass inside the scripts performs a real TOTP login
and is the ONLY trustworthy success signal.
### 4. SSO first-login into each system
The interactive login is scriptable with a cookie jar — no browser needed. The interactive login is scriptable with a cookie jar — no browser needed.
`scripts/oidc-login.sh <system>` runs the whole flow and leaves a session `scripts/oidc-login.sh <system> <login>` runs the whole flow and leaves a
cookie jar at `/tmp/oidc-<login>-<system>.txt`. Entry points: session cookie jar at `/tmp/oidc-<login>-<system>.txt`. Entry points:
| System | SSO start point | After login | | System | SSO start point | After login |
|---|---|---| |---|---|---|
@@ -106,10 +143,11 @@ cookie jar at `/tmp/oidc-<login>-<system>.txt`. Entry points:
Key gotcha (cost a debugging session — do not rediscover): the OIDC Key gotcha (cost a debugging session — do not rediscover): the OIDC
interaction login is `POST /openid/interaction/<uid>/login` with JSON body interaction login is `POST /openid/interaction/<uid>/login` with JSON body
`{"username": "...", "password": "..."}` — the field is **`username`**, not `{"username": "...", "password": "...", "totpToken": "..."}` — the field is
`login`, and the response is `{"redirectTo": ...}` on success. **`username`**, not `login`, and the response is `{"redirectTo": ...}` on
success.
### 4. Mint per-system API keys (as the identity, via its session) ### 5. Mint per-system API keys (as the identity, via its session)
- **Redmine**: My account → API key (visible to self); also returned by - **Redmine**: My account → API key (visible to self); also returned by
`GET /users/current.json` with the session. `GET /users/current.json` with the session.
@@ -123,9 +161,12 @@ interaction login is `POST /openid/interaction/<uid>/login` with JSON body
Store each key in the vault item immediately (schema below). Store each key in the vault item immediately (schema below).
### 5. Vault item schema — `creds/<login>` ### 6. Vault item schema — `creds/<login>`
One item per identity (NOT per system). Create it as a LOGIN item with: One item per identity (NOT per system), created as a LOGIN item (never a
note — `sm set` makes notes; `sm convert <name>` converts, but REGENERATE
the password via the admin API afterwards because convert drops field
values):
- login username = Cloudron username; password = the unique Cloudron password - login username = Cloudron username; password = the unique Cloudron password
- URI = `https://my.knownelement.com` - URI = `https://my.knownelement.com`
@@ -137,7 +178,20 @@ Historical items that predate this schema (e.g. `sec-*`, `sre-*` shared-
password items) are remediation targets under #942 — upgrade them when the password items) are remediation targets under #942 — upgrade them when the
owner lane assigns the work, do not replicate their shape. owner lane assigns the work, do not replicate their shape.
### 6. Verify the trail ### 7. Update the org chart (MANDATORY — an uncharted identity is an incomplete task)
After provisioning changes the identity estate:
1. Update the org table in `TSYSGroupCorporate/TSYSGroupAIOS`
`agents/global/AGENTS.md` (branch + PR per the house-rules flow; the
symlink serves it live once merged).
2. Update `reachableceo/org-buildout` `docs/org-chart.md` (the rendered
chart) in the same turn.
3. Note the change on the provisioning ticket.
Details: `references/org-chart-update.md`.
### 7. Verify the trail
Prove each system knows the identity (expected: 200 + the identity's own Prove each system knows the identity (expected: 200 + the identity's own
name; a 200 from the wrong identity is a FAIL): name; a 200 from the wrong identity is a FAIL):
@@ -155,12 +209,19 @@ the workspace STATUS/memory.
- `references/cloudron.md` — admin API routes, OIDC interaction flow, 2FA. - `references/cloudron.md` — admin API routes, OIDC interaction flow, 2FA.
- `references/systems.md` — per-system SSO/API details and known breakage. - `references/systems.md` — per-system SSO/API details and known breakage.
- `references/vault.md` — sm CLI entry points, gotchas, fallbacks. - `references/vault.md` — sm CLI entry points, gotchas, fallbacks.
- `references/org-chart-update.md` — the mandatory post-provisioning chart sync.
- `references/incident-log.md` — what went wrong on 2026-09-07/08 and why, so - `references/incident-log.md` — what went wrong on 2026-09-07/08 and why, so
the same failures are not re-derived. the same failures are not re-derived.
## Scripts ## Scripts
- `scripts/cloudron-api.sh METHOD PATH [JSON]` — authenticated admin call. - `scripts/cloudron-api.sh METHOD PATH [JSON]` — authenticated admin call.
- `scripts/oidc-login.sh <glpi|gitea|discourse|redmine> <login>` — full SSO - `scripts/cloudron-oidc-login.sh <username> [vault-item]` — full OIDC
login via cookie jar; reads the identity's creds from the vault. dashboard login (PKCE + interaction + consent); Bearer at
- `scripts/totp.sh <base32-seed>` — current 6-digit TOTP code (pure bash). `/tmp/.oidc-bearer-<username>`.
- `scripts/cloudron-totp-enroll.sh <username> [vault-item]` — enroll TOTP,
capture the seed to the vault, verify end-to-end.
- `scripts/oidc-login.sh <glpi|gitea|discourse|redmine> <login>` — system SSO
login via cookie jar.
- `scripts/totp.sh <base32-seed>` — current 6-digit TOTP code (pure bash,
RFC-7636-vector tested).
@@ -0,0 +1,37 @@
# Org chart update (mandatory post-provisioning step)
The org chart lives in THREE places and all three move together in the same
turn a provisioning change lands:
| Where | What | How |
|---|---|---|
| `TSYSGroupCorporate/TSYSGroupAIOS` `agents/global/AGENTS.md` — "Org chart & agent lanes" | THE canonical operational map (every session loads it via symlink) | branch `coo/…` or lane branch → PR → founder merge |
| `reachableceo/org-buildout` `docs/org-chart.md` | The rendered full-group chart (publishable) | same-turn commit |
| Redmine provisioning ticket | Per-identity state (pw/2FA/seed/systems) | comment with commit URLs |
Rules:
1. **Chart before claimed-done.** A provisioned identity that isn't in the
chart is an incomplete task; a charted identity without a ticket is
invisible work. Both fail review.
2. House-rules/policy changes additionally require a `house-rules-v*` tag bump
(see the versioning section at the bottom of AGENTS.md).
3. The COO C2 lane charts; lanes below SVP execute. If you are not the COO
lane, your provisioning ticket IS the chart-change request.
4. When the chart changes, so does reality somewhere: verify the identity's
vault item (unique password, totp_seed, per-system tokens) before drawing
it as operational.
Sequence for one provisioning run (branch `coo/…` in TSYSGroupAIOS):
```bash
# 1. edit agents/global/AGENTS.md org table (if lane structure changed)
# 2. edit org-buildout docs/org-chart.md + STATUS.md
# 3. commit both, push both (HTTPS + the acting identity's token)
# 4. PR for TSYSGroupAIOS if policy-level; org-buildout pushes direct per its flow
# 5. Redmine comment links every commit URL
```
---
**Records:** Redmine #942 — https://projects.knownelement.com/issues/942 · Discourse docs home: https://community.turnsys.com (skill topic pending admin-key provisioning, tracked on #942)
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# Cloudron OIDC dashboard login (SPA-mimic, PKCE) for an identity.
# usage: cloudron-oidc-login.sh <cloudron-username> [vault-item]
# Performs: prompt=login auth -> interaction login (password + TOTP from
# vault totp_seed if present) -> consent confirm -> code exchange.
# Writes the access token to /tmp/.oidc-bearer-<username> and a session
# cookie jar /tmp/oidc-jar-<username>.txt. Prints checkpoints to stderr.
set -uo pipefail
U="${1:?usage: cloudron-oidc-login.sh <cloudron-username> [vault-item]}"
ITEM="${2:-$U Cloudron}"
BASE="https://my.knownelement.com"
CLIENT="cid-webadmin"
REDIRECT="$BASE/authcallback.html" # used in the auth request below via explicit URL encoding
DBG="${CLOUDRON_SELF_DEBUG:-0}"
if [ "$(id -un)" = "TSGCOO" ]; then
SM() { /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
else
SM() { sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
fi
PW=$(SM get "$ITEM" --field password)
[ -n "$PW" ] || { echo "FAIL: no password in vault item $ITEM" >&2; exit 1; }
SEED=$(SM get "$ITEM" --field totp_seed 2>/dev/null || true)
VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n')
AUTH="$BASE/openid/auth?client_id=$CLIENT&response_type=code&scope=openid%20email%20profile&redirect_uri=https%3A%2F%2Fmy.knownelement.com%2Fauthcallback.html&code_challenge=$CHALLENGE&code_challenge_method=S256"
JAR="/tmp/oidc-jar-$U.txt"; rm -f "$JAR"
INT=$(curl -sk -o /dev/null -w '%{url_effective}' -c "$JAR" -L --max-redirs 8 "$AUTH")
[ "$DBG" = 1 ] && echo "DBG int: ${INT:0:70}" >&2
case "$INT" in */openid/interaction/*) : ;; *) echo "FAIL: no interaction (auto-resolved session?)"; exit 3;; esac
UIDPATH=$(printf '%s' "$INT" | grep -o '/openid/interaction/[^?]*')
BODY="{\"username\":\"$U\",\"password\":\"$PW\""
if [ -n "$SEED" ]; then
TCODE=$(bash "$(dirname "$0")/totp.sh" "$SEED")
BODY="$BODY,\"totpToken\":\"$TCODE\""
fi
BODY="$BODY}"
LOGIN_RESP=$(curl -sk -b "$JAR" -c "$JAR" -X POST "$BASE$UIDPATH/login" \
-H 'Content-Type: application/json' -d "$BODY")
unset BODY
[ "$DBG" = 1 ] && echo "DBG login: ${LOGIN_RESP:0:70}" >&2
case "$LOGIN_RESP" in
*redirectTo*) : ;;
*twoFactorRequired*) echo "FAIL: 2FA required but no usable seed in vault" >&2; exit 4;;
*) echo "FAIL: login rejected: $LOGIN_RESP" >&2; exit 5;;
esac
RED=$(printf '%s' "$LOGIN_RESP" | sed -n 's/.*"redirectTo":"\([^"]*\)".*/\1/p')
CALLBACK=$(curl -sk -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' -L --max-redirs 8 "$RED")
[ "$DBG" = 1 ] && echo "DBG after resume: ${CALLBACK:0:70}" >&2
case "$CALLBACK" in
*/openid/interaction/*)
CUIDPATH=$(printf '%s' "$CALLBACK" | grep -o '/openid/interaction/[^?]*')
CLOC=$(curl -sk -b "$JAR" -c "$JAR" -D - -o /dev/null -X POST "$BASE$CUIDPATH/confirm" \
-d '' | grep -i '^location:' | head -1 | tr -d '\r' | sed 's/^[Ll]ocation: //')
[ -n "$CLOC" ] || { echo "FAIL: consent confirm produced no redirect" >&2; exit 6; }
case "$CLOC" in http*) : ;; *) CLOC="$BASE$CLOC" ;; esac
CALLBACK=$(curl -sk -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' -L --max-redirs 8 "$CLOC")
;;
esac
CODE=$(printf '%s' "$CALLBACK" | sed -n 's/[?&]code=\([^&]*\).*/\1/p')
[ "$DBG" = 1 ] && echo "DBG code len: ${#CODE}" >&2
[ -n "$CODE" ] || { echo "FAIL: no authorization code at callback: $CALLBACK" >&2; exit 6; }
TOKRESP=$(curl -sk -b "$JAR" -c "$JAR" -X POST "$BASE/openid/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "client_id=$CLIENT" \
--data-urlencode "redirect_uri=$REDIRECT" \
--data-urlencode "code_verifier=$VERIFIER")
ACCESS=$(printf '%s' "$TOKRESP" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
[ -n "$ACCESS" ] || { echo "FAIL: token exchange: $(printf '%s' "$TOKRESP" | head -c 120)" >&2; exit 7; }
printf '%s' "$ACCESS" > "/tmp/.oidc-bearer-$U"
chmod 600 "/tmp/.oidc-bearer-$U"
WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"username":"\([^"]*\)".*/\1/p')
echo "OK: bearer for '$WHO' at /tmp/.oidc-bearer-$U (session jar $JAR)"
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Enroll TOTP for an identity (2FA must NOT be enforced yet — run
# cloudron-oidc-login.sh first, or admin totp_disable for seed-lost users).
# usage: cloudron-totp-enroll.sh <cloudron-username> [vault-item]
# Captures the seed into the vault item and verifies end-to-end.
set -euo pipefail
U="${1:?usage: cloudron-totp-enroll.sh <cloudron-username> [vault-item]}"
ITEM="${2:-$U Cloudron}"
BASE="https://my.knownelement.com"
BEARER_FILE="/tmp/.oidc-bearer-$U"
[ -f "$BEARER_FILE" ] || { echo "FAIL: run cloudron-oidc-login.sh $U first" >&2; exit 1; }
ACCESS=$(cat "$BEARER_FILE")
MYUID=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"id":"\(uid-[^"]*\)".*/\1/p')
[ -n "$MYUID" ] || { echo "FAIL: bearer rejected" >&2; exit 2; }
SECRET=$(curl -sk "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p')
if [ -z "$SECRET" ]; then
SECRET=$(curl -sk -X POST "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d '{}' | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p')
fi
[ -n "$SECRET" ] || { echo "FAIL: no totp secret returned" >&2; exit 3; }
CODE=$(bash "$(dirname "$0")/totp.sh" "$SECRET")
ENAB=$(curl -sk -X POST "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d "{\"totpSecret\":\"$SECRET\",\"totpToken\":\"$CODE\"}")
case "$ENAB" in *Invalid*|*404*|*"No such route"*) echo "PARTIAL: enable said: $(printf '%s' "$ENAB" | head -c 100)";; esac
SM() { if [ "$(id -un)" = "TSGCOO" ]; then /data2/TSGCOO/.local/bin/sm "$@" </dev/null; else sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm "$@" </dev/null; fi; }
SM setfield "$ITEM" totp_seed "$SECRET" >/dev/null
# end-to-end verify: fresh login with the stored seed
bash "$(dirname "$0")/cloudron-oidc-login.sh" "$U" "$ITEM" >/dev/null 2>&1
if [ -s "$BEARER_FILE" ]; then
WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $(cat "$BEARER_FILE")" | sed -n 's/.*"username":"\([^"]*\)".*/\1/p')
[ "$WHO" = "$U" ] && echo "OK: $U 2FA enrolled, seed in vault ($ITEM), TOTP login verified as $WHO" \
&& exit 0
fi
echo "PARTIAL: verify failed — check state for $U"
@@ -1,59 +0,0 @@
#!/bin/bash
# Full SSO login for an identity into one of the four systems, via cookie jar.
# usage: oidc-login.sh <glpi|gitea|discourse|redmine> <login>
# Reads the Cloudron password from vault item "<login> Cloudron" (env login
# triple; USERNAME may be the email form). Success leaves cookie jar at
# /tmp/oidc-<login>-<system>.txt and prints the landing URL.
# TOTP-enforced identity? Set TOTP_CODE=$(scripts/totp.sh <seed>) first.
set -euo pipefail
SYS="${1:?usage: oidc-login.sh <glpi|gitea|discourse|redmine> <login>}"
LOGIN="${2:?usage: oidc-login.sh <glpi|gitea|discourse|redmine> <login>}"
case "$SYS" in
glpi) START="https://cmdb.knownelement.com/plugins/singlesignon/front/callback.php/provider/1?remember=1"; APP="https://cmdb.knownelement.com";;
gitea) START="https://git.knownelement.com/user/oauth2/cloudron"; APP="https://git.knownelement.com";;
discourse) START="https://community.turnsys.com/auth/openid_connect"; APP="https://community.turnsys.com";;
redmine) START="https://projects.knownelement.com/oauth"; APP="https://projects.knownelement.com";;
*) echo "unknown system: $SYS" >&2; exit 2;;
esac
if [ "$(id -un)" = "TSGCOO" ]; then
SM() { /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
else
SM() { sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm "$@" </dev/null; }
fi
eval "$(SM env "$LOGIN Cloudron")"
[ -n "${PASSWORD:-}" ] || { echo "vault item '$LOGIN Cloudron' empty" >&2; exit 1; }
USER_FIELD="${USERNAME:-$LOGIN}"
JAR="/tmp/oidc-${LOGIN}-${SYS}.txt"; rm -f "$JAR"
# 1. Start the SSO flow; follow redirects to the OIDC interaction page.
INTURL=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$JAR" "$START")
case "$INTURL" in
https://my.knownelement.com/openid/interaction/*) : ;;
*) echo "no interaction redirect (got: '${INTURL:-none}'). If this is redmine, see references/systems.md (known bounce). If discourse, the route may need CSRF via a real session." >&2; exit 3;;
esac
# 2. Interaction login. Field name is `username` (NOT `login`).
BODY="{\"username\":\"$USER_FIELD\",\"password\":\"$PASSWORD\""
if [ -n "${TOTP_CODE:-}" ]; then BODY="$BODY,\"totpToken\":\"$TOTP_CODE\""; fi
BODY="$BODY}"
RESP=$(curl -s -b "$JAR" -c "$JAR" -X POST "${INTURL}/login" \
-H 'Content-Type: application/json' -d "$BODY")
unset BODY
case "$RESP" in
*redirectTo*)
RED=$(printf '%s' "$RESP" | sed -n 's/.*"redirectTo":"\([^"]*\)".*/\1/p')
case "$RED" in http*) : ;; *) RED="$APP$RED" ;; esac
FINAL=$(curl -s -L -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' "$RED")
echo "logged in: cookie jar $JAR"
echo "landing: $FINAL"
echo "verify the identity before use (users/current or /api/v1/user)."
;;
*twoFactorRequired*)
echo "2FA enforced and no/invalid TOTP_CODE given." >&2
echo "If the seed is in the vault: TOTP_CODE=\$(scripts/totp.sh \$(sm get '$LOGIN Cloudron' --field totp_seed)) $0 $SYS $LOGIN" >&2
exit 4;;
*)
echo "login rejected: $RESP" >&2
exit 5;;
esac
+1 -1
View File
@@ -11,7 +11,7 @@ COUNTER=$(printf '%016x' $(( NOW / STEP )))
# bash vars cannot hold null bytes - keep the counter as a FORMAT string and # bash vars cannot hold null bytes - keep the counter as a FORMAT string and
# let printf emit the raw bytes straight into the pipe. # let printf emit the raw bytes straight into the pipe.
FMT=$(printf '%s' "$COUNTER" | sed 's/../\\x&/g') FMT=$(printf '%s' "$COUNTER" | sed 's/../\\x&/g')
# intentional: FMT emits raw counter bytes incl. NULs - the whole point # intentional: FMT emits raw counter bytes (NULs) - the whole point
# shellcheck disable=SC2059 # shellcheck disable=SC2059
MAC=$(printf "$FMT" | openssl dgst -sha1 -mac HMAC -macopt "hexkey:$KEYHEX" -binary \ MAC=$(printf "$FMT" | openssl dgst -sha1 -mac HMAC -macopt "hexkey:$KEYHEX" -binary \
| od -An -tx1 | tr -d ' \n') | od -An -tx1 | tr -d ' \n')