diff --git a/skills/agent-provisioning/SKILL.md b/skills/agent-provisioning/SKILL.md new file mode 100644 index 0000000..c3a5591 --- /dev/null +++ b/skills/agent-provisioning/SKILL.md @@ -0,0 +1,166 @@ +--- +name: agent-provisioning +description: > + Provision TSYS Group agent identities end-to-end: Cloudron account (unique + password, TOTP with seed captured to the TSGCOO vault), SSO first-login into + Redmine / Gitea / Discourse / GLPI, per-system API key minting, vault item + schema, and verification. Use whenever asked to "stand up" an identity or + agent, create/fix Cloudron users, wire SSO, debug identity logins or 2FA, + or store identity credentials — even if the word "provisioning" is not used. + Policy-critical: encodes founder rulings #942 (unique passwords, Cloudron-only + accounts, no reachableceo credentials). +--- + +# Agent identity provisioning (TSYS Group) + +Stand up an agent identity so it can operate its own accounts in Cloudron and +the four SSO-wired systems, with every credential landing in the TSGCOO +Bitwarden vault. The Cloudron is the ONLY identity source: never create local +accounts in Redmine, Gitea, Discourse, or GLPI — the system account comes into +existence when the identity completes its first SSO login there. + +## Non-negotiable policy (founder rulings — violating these invalidates the work) + +1. **Unique password per identity.** One identity, one password, used only for + its Cloudron account. Shared passwords are banned (Redmine #942). +2. **Cloudron-only accounts.** System access = SSO first-login. No local + accounts, no admin-created users in the downstream systems. +3. **Never use reachableceo credentials.** Items like `creds/gitea-admin`, + `creds/gitea`, `creds/redmine` are the founder's personal accounts and + carry RESTRICTED notes. If an auth test resolves to user `reachableceo`, + stop — you are holding the founder's identity. +4. **TOTP seeds MUST be stored in Bitwarden** (founder ruling 2026-09-08). + Enroll TOTP and write the base32 seed to the vault item in the same + breath — a seed that is not in the vault is treated as lost, because + there is no admin 2FA reset on this Cloudron and no other record exists. +5. **Secrets live only in the vault** (KNELSecretsManager `sm` CLI) — never in + repos, command output, logs, or transcripts. Mask credentials in every + command's visible output. +6. **Bookkeeping in the same turn:** Redmine ticket on the provisioning + program (#942 or its successor), cross-linked; vault item created;STATUS + updated. + +## Scope check + +Identity stand-up is the COO C2 lane's assigned program (founder directive, +2026-09-08). If your chat runs in another lane (SRE, security, OAM), confirm +the founder assigned the stand-up to you before executing, or route a ticket +to the COO C2 lane instead. + +## Admin lever: Cloudron API token + +`creds/cloudron` holds a working admin API token (service user `api-token`, +64 chars). Use it as `Authorization: Bearer $PASSWORD`. Do NOT log into the +dashboard interactively for admin work — the token is the compliant path. + +```bash +scripts/cloudron-api.sh GET /users?limit=100 # list users (note uid- UUIDs) +scripts/cloudron-api.sh GET /users/ # fetch one user object +``` + +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 +flow); there is NO admin route to reset another user's 2FA — so the seed +capture in step 2 is not optional. + +## Procedure + +### 0. Prerequisites + +- Vault reachable: `sm get creds/cloudron` works (see references/vault.md). +- For a NEW Cloudron user: the founder generates the invite, or you create the + user via the admin token if the founder has authorized self-service. +- A Redmine ticket to record the work (create one if none exists). + +### 1. Cloudron account (unique password) + +Verify the account exists and matches the identity email pattern +(`tsgstaff-@turnsys.com` for COO-line identities). Generate the unique +password (32 chars), set it, and confirm a login works (step 2 proves it). + +### 2. TOTP 2FA — enroll and CAPTURE THE SEED + +Enroll TOTP via the profile self-service surface and write the base32 seed to +the vault item before anything else: + +```bash +sm setfield " Cloudron" totp_seed "" +``` + +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). +Compute codes offline when needed: `scripts/totp.sh `. + +### 3. SSO first-login into each system + +The interactive login is scriptable with a cookie jar — no browser needed. +`scripts/oidc-login.sh ` runs the whole flow and leaves a session +cookie jar at `/tmp/oidc--.txt`. Entry points: + +| System | SSO start point | After login | +|---|---|---| +| GLPI | `https://cmdb.knownelement.com/plugins/singlesignon/front/callback.php/provider/1?remember=1` | account auto-provisioned | +| Gitea | `https://git.knownelement.com/user/oauth2/cloudron` | account auto-provisioned | +| Discourse | `https://community.turnsys.com/auth/openid_connect` | account auto-provisioned | +| Redmine | `https://projects.knownelement.com/oauth` | account auto-provisioned (if the bounce in references/systems.md is hit, see that file) | + +Key gotcha (cost a debugging session — do not rediscover): the OIDC +interaction login is `POST /openid/interaction//login` with JSON body +`{"username": "...", "password": "..."}` — the field is **`username`**, not +`login`, and the response is `{"redirectTo": ...}` on success. + +### 4. Mint per-system API keys (as the identity, via its session) + +- **Redmine**: My account → API key (visible to self); also returned by + `GET /users/current.json` with the session. +- **Gitea**: Settings → Applications → new token with write scopes; or + `POST /api/v1/users//tokens` using the session (requires basic auth + of the same user for token endpoints). +- **Discourse**: user API key flow; or admin-generated key if an admin lever + is ever provided (none currently is — do not use founder keys). +- **GLPI**: My account → API token (personal token); pair with the app token + from `creds/glpi` (password field) for REST calls. + +Store each key in the vault item immediately (schema below). + +### 5. Vault item schema — `creds/` + +One item per identity (NOT per system). Create it as a LOGIN item with: + +- login username = Cloudron username; password = the unique Cloudron password +- URI = `https://my.knownelement.com` +- fields: `email`, `organization`, `role`, `reports_to`, `created`, + `services`, plus per-system: `redmine_api_key`, `gitea_token`, + `discourse_user_api_key`, `glpi_user_token`, and `totp_seed` + +Historical items that predate this schema (e.g. `sec-*`, `sre-*` shared- +password items) are remediation targets under #942 — upgrade them when the +owner lane assigns the work, do not replicate their shape. + +### 6. Verify the trail + +Prove each system knows the identity (expected: 200 + the identity's own +name; a 200 from the wrong identity is a FAIL): + +```bash +curl -sf -H "X-Redmine-API-Key: $KEY" https://projects.knownelement.com/users/current.json | jq .user.login +curl -sf -H "Authorization: token $TOKEN" https://git.knownelement.com/api/v1/user | jq .login +``` + +Then update the Redmine ticket (full URLs, `[agent] ` note) and +the workspace STATUS/memory. + +## References — read before touching the corresponding system + +- `references/cloudron.md` — admin API routes, OIDC interaction flow, 2FA. +- `references/systems.md` — per-system SSO/API details and known breakage. +- `references/vault.md` — sm CLI entry points, gotchas, fallbacks. +- `references/incident-log.md` — what went wrong on 2026-09-07/08 and why, so + the same failures are not re-derived. + +## Scripts + +- `scripts/cloudron-api.sh METHOD PATH [JSON]` — authenticated admin call. +- `scripts/oidc-login.sh ` — full SSO + login via cookie jar; reads the identity's creds from the vault. +- `scripts/totp.sh ` — current 6-digit TOTP code (pure bash). diff --git a/skills/agent-provisioning/references/cloudron.md b/skills/agent-provisioning/references/cloudron.md new file mode 100644 index 0000000..a8e498b --- /dev/null +++ b/skills/agent-provisioning/references/cloudron.md @@ -0,0 +1,62 @@ +# Cloudron admin API + OIDC (verified 2026-09-08) + +## Instance + +- Dashboard: `https://my.knownelement.com` (admin surface; this host also + fronts the org's public vhosts — it is NOT the tailscale-only control box). +- API token: vault item `creds/cloudron`, password field, 64 chars, service + user `api-token`. Verified: reads full user directory. + +## Routes (this is a newer Cloudron — older route knowledge may not apply) + +- Users are addressed by `uid-`, never username. + `GET /api/v1/users?limit=100` → list; `GET /api/v1/users/` → object + (`twoFactorAuthenticationEnabled` is null/absent on objects even when + enforced — the OIDC interaction is the source of truth). +- There is NO `POST /api/v1/login`. Dashboard auth is its own OIDC browser + flow; the SPA bundle (as of 2026-09-08) contains no login route. +- There is NO admin route to reset another user's 2FA (PUT/PATCH/DELETE on + `/users/` 2FA variants all 404; the dashboard UI only offers profile + self-service enrollment). Consequence: TOTP seeds not captured at + enrollment are unrecoverable without the founder. +- Other useful routes seen in the SPA bundle: `/api/v1/tokens`, + `/api/v1/oidc/clients`, `/api/v1/groups`, `/api/v1/eventlog`, + `/api/v1/app_passwords`. Verify behavior at runtime before relying on a + specific payload shape. + +## OIDC interaction login (how identity SSO works) + +The GLPI/Gitea/Discourse/Redmine SSO flows all redirect here: + +1. `GET ` → 302 to + `https://my.knownelement.com/openid/interaction/` (Cloudron runs a + node-oidc-provider at `/openid`). Cookies matter — keep a cookie jar. +2. `POST /openid/interaction//login` with JSON + `{"username": "", "password": ""}`. + - Field name is **`username`** — `login` returns + `"A username must be non-empty string"` (400). + - Success → `{"redirectTo": "..."}` (or + `{"twoFactorRequired":true,"totpRequired":true}` if 2FA is enabled). + - Wrong credentials → 401 `{"status":"Unauthorized","message":"Username + and password does not match"}`. +3. `GET` the `redirectTo` (same cookie jar) → completes at the app. + +Email-style usernames work (`tsgstaff-coo@turnsys.com`); bare usernames also +exist (`coo`). Identity emails follow `tsgstaff-@turnsys.com`. + +## 2FA + +- TOTP enforced per user at the interaction step + (`twoFactorRequired`/`totpRequired` in the login response). +- Enrollment is profile self-service ONLY. Capture the base32 seed into the + vault (`sm setfield " Cloudron" totp_seed `) at enrollment — + there is no admin reset and no recovery path. +- Policy RULED (founder, 2026-09-08): TOTP seeds MUST be stored in Bitwarden. + Enroll 2FA only when the seed is being captured to the vault item in the + same step. Legacy identities whose seeds were never captured (the whole + August batch) are reset-on-sight candidates: founder clears 2FA or hands + over the seed, then re-enroll with capture. + +--- + +**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) diff --git a/skills/agent-provisioning/references/incident-log.md b/skills/agent-provisioning/references/incident-log.md new file mode 100644 index 0000000..2e8e2d9 --- /dev/null +++ b/skills/agent-provisioning/references/incident-log.md @@ -0,0 +1,52 @@ +# Incident log — what broke and what it taught (append-only) + +## 2026-09-07 ~20:00 CT — shared-box degradation (RESOLVED 09-08) + +One box (152.53.37.179, mail.knownelement.com) hosts gitea, redmine, +discourse, GLPI, pwvault, and fronts my.knownelement.com. During the +incident: + +- Gitea `git-receive-pack` → HTTP 500 on ANY ref (pushes impossible); + `POST /api/v1/repos` → plain-text 404. Reads + `POST /api/v1/markdown` + worked fine — partial failure, not an outage. +- my.knownelement.com → HTTP 500 leaking + `"Access denied for user 'root'@'localhost'"` (a MySQL root credential + breakage in whatever backs that vhost). +- pwvault: unauthenticated endpoints answered in 0.2s, but AUTHENTICATED + `/api/sync` hung to timeout — a lock/db-layer signature. All vault item + reads fleet-wide were down; nothing credential-driven could proceed. +- tsys-cloudron (tailscale 100.107.35.78, self-signed cert — use `curl -k`) + 404'd its hostname; with `Host: my.knownelement.com` it served the same + JSON as the public vhost (proxy topology). devbox/preprod/sectestbed + cloudron peers were offline for 1–2 days. + +Lessons: (1) when several "unrelated" systems on that box fail at once, +treat it as one box incident — file one ticket, verify recovery per system; +(2) HTTP-status-only checks lie — the vault was "up" (200s on static paths) +while 100% of useful reads hung; test the authenticated path you actually +need; (3) correlate carefully — the MySQL leak was the public box's vhost, +not the cloudron control box; wrong attribution sent diagnosis down a +rabbit hole (see #939 correction). + +## 2026-09-07 — identity/SSO archaeology (the reason this skill exists) + +- The August "10 identities enrolled + TOTP" batch captured NO seeds. coo's + TOTP is therefore enforced-but-unrecoverable; no admin reset exists in + this Cloudron version. Every future enrollment MUST store the seed. +- `creds/sec-*` / `creds/sre-*` (Sep 07 batch, by VpEngOps) used ONE shared + password across cloudron+gitea+redmine+discourse, and the stored password + already fails Cloudron auth (401 tested). Shared passwords are banned + (#942); treat those items as stale until their owning lane remediates. +- Several "admin" vault candidates resolved to the founder's personal + reachableceo accounts (gitea id 3, redmine user 5) and none were admin + (gitea: 403 "user should be the site admin"; redmine: 403 on /users.json). + They now carry RESTRICTED notes. The compliant Cloudron admin lever is the + `api-token` user's token in `creds/cloudron`. +- Debug order that works for auth weirdness: (1) what identity does this + credential ACTUALLY resolve to (call /user or /users/current)? + (2) does the route exist at all (SPA-bundle grep beats guessing)? + (3) is the failure at nginx (HTML status pages) or the app (JSON errors)? + +--- + +**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) diff --git a/skills/agent-provisioning/references/systems.md b/skills/agent-provisioning/references/systems.md new file mode 100644 index 0000000..6286beb --- /dev/null +++ b/skills/agent-provisioning/references/systems.md @@ -0,0 +1,73 @@ +# Per-system SSO + API key details (verified 2026-09-07/08 unless noted) + +## GLPI (cmdb.knownelement.com) — SSO VERIFIED WORKING + +- SSO: "Login with KNEL Cloud" = plugin route + `/plugins/singlesignon/front/callback.php/provider/1?remember=1` → Cloudron + OIDC → auto-provision. +- The login page defaults to whichever tab was last toggled ("Use GLPI login + form" / "Use Single Sign-on"); a hidden `noAUTO=1` input forces the local + form — irrelevant under Cloudron-only policy, useful to recognize. +- API: GLPI 11 is OAuth/token-based. The legacy REST at + `/apirest.php/initSession` exists: password login is DISABLED (400); + user_token auth exists but the tokens in `creds/glpi` / `creds/mglpi` are + STALE (401 ERROR_GLPI_LOGIN_USER_TOKEN). The app token in `creds/glpi` + password field IS accepted (used as `App-Token` header). +- Per-user API token: My account (self-service) after SSO provisioning. Pair + with the app token: `GET /apirest.php/initSession` with headers + `App-Token: ` + `Authorization: user_token `. +- Local web login as `glpi` super-admin hits a 6-digit TOTP at + `/MFA/Prompt` whose secret is not stored anywhere — do not rely on the + admin UI; the SSO path makes it unnecessary. + +## Gitea (git.knownelement.com) + +- SSO: `/user/oauth2/cloudron` (button "Sign in with …" on the login page). +- Standalone install behind nginx on the shared box — local accounts exist + from the August batch; Cloudron-only policy means new identities use SSO + and legacy ones migrate. +- API: tokens via `POST /api/v1/users//tokens` REQUIRE basic auth AS + that user (admin tokens cannot mint for others; there is no + `/admin/users//tokens` route; `Sudo` header needs a real site admin). +- Token auth against `/api/v1/user`: the JSON has `login`; admin-ness shows + in `site_admin` (absent = not admin; do not misread null as false). +- KNOWN BROKEN (2026-09-07/08): receive-pack 500s during the box incident — + RESOLVED after the 09-08 repair (pushes verified). If pushes 500 again, + check the shared box first (see incident-log.md). + +## Discourse (community.turnsys.com) + +- SSO: `/auth/openid_connect` — a bare GET returns 403 (CSRF-gated); that is + NORMAL and means the route exists. Complete the flow from the login page + button or with a session + CSRF token. +- The login page is an Ember SPA: server-rendered HTML shows no SSO markers, + which fooled one audit (2026-09-07) into "no SSO" — wrong. Check routes, + not page source. +- API: user API keys (User-Api-Key header) minted via the user-approval flow; + admin Api-Key + Api-Username impersonation needs an admin key. The vault's + `creds/discourse` NOTE says its keys are NOT admin (they are also not hex — + do not assume key format). `creds/vptechops-discourse` holds a WORKING + user key (user `vptechopsvptechops`) — #933's "dead key" note is wrong. + +## Redmine (projects.knownelement.com) + +- SSO: "Continue with KNEL Cloud" button POSTs/GETs `/oauth`. + KNOWN BROKEN: `/oauth` 302-bounces straight back to /login with no + provider redirect — verified 2026-09-07 AND 2026-09-08 (IdP healthy both + times), so it is a Redmine plugin configuration issue, NOT the IdP. + Retest before relying on it; until fixed, Redmine accounts cannot be + created via SSO. +- API keys: `GET /users/current.json` (session or basic auth) returns the + user's `api_key`; X-Redmine-API-Key header for calls. Vault convention: + the API key is stored in the item's password field. +- Admin surface: `/users.json` lists users (admin only — 403 otherwise). + Every admin candidate tested in the vault so far is NON-admin (they are + all the founder's personal accounts anyway — RESTRICTED). + +## Shared infrastructure hazard + +Gitea, Redmine, Discourse, GLPI, the pwvault, and the my. vhost all run on +ONE box (152.53.37.179 = mail.knownelement.com). Symptoms there cluster: +when several systems misbehave at once, suspect the box (see +incident-log.md), file/update one ticket, and do not burn hours debugging +four clients. diff --git a/skills/agent-provisioning/references/vault.md b/skills/agent-provisioning/references/vault.md new file mode 100644 index 0000000..76b4c18 --- /dev/null +++ b/skills/agent-provisioning/references/vault.md @@ -0,0 +1,48 @@ +# TSGCOO vault (`sm` CLI) — entry points and gotchas + +The KNELSecretsManager vault (Vaultwarden at pwvault.turnsys.com, account +coo@turnsys.com) is the ONLY credential store. Full doctrine: +OAM workspace memory `tsgcoo-account-ops` + `creds-migration`; tool +reference: KNEL workspace memory `knelsecretsmanager-reference`. + +## Invocation + +```bash +# from the reachableceo account (this workstation): +sudo -u TSGCOO /data2/TSGCOO/.local/bin/sm # TSGCOO crossover +# from inside the TSGCOO account: +/data2/TSGCOO/.local/bin/sm +# raw container form: +docker exec -i ukrrs-secretsmgr-cli smcli +``` + +Commands: `status`, `list [pattern]`, `get [--field KEY]`, +`env `, `set`, `setfield `, `rm`, `folders`. +Item naming: `creds/` for services; ` ` for identity +login items; `creds/` for the new per-identity schema. + +## Gotchas that have actually bitten + +1. `sm env` / `sm get` in scripts: append ` stdout; HTTP status -> stderr. Secrets never printed. +set -euo pipefail +BASE="https://my.knownelement.com" +METHOD="${1:?usage: cloudron-api.sh METHOD /api/v1/PATH [JSON-BODY]}" +PATHPART="${2:?usage: cloudron-api.sh METHOD /api/v1/PATH [JSON-BODY]}" +BODY="${3:-}" +if [ "$(id -un)" = "TSGCOO" ]; then + SM() { /data2/TSGCOO/.local/bin/sm "$@" &2; exit 1; } +ARGS=(-sk -X "$METHOD" "$BASE$PATHPART" + -H "Authorization: Bearer $TOKEN" + -H 'Content-Type: application/json' + -w '\nHTTP %{http_code}\n') +[ -n "$BODY" ] && ARGS+=(-d "$BODY") +curl "${ARGS[@]}" >&2 diff --git a/skills/agent-provisioning/scripts/oidc-login.sh b/skills/agent-provisioning/scripts/oidc-login.sh new file mode 100755 index 0000000..46f141e --- /dev/null +++ b/skills/agent-provisioning/scripts/oidc-login.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Full SSO login for an identity into one of the four systems, via cookie jar. +# usage: oidc-login.sh +# Reads the Cloudron password from vault item " Cloudron" (env login +# triple; USERNAME may be the email form). Success leaves cookie jar at +# /tmp/oidc--.txt and prints the landing URL. +# TOTP-enforced identity? Set TOTP_CODE=$(scripts/totp.sh ) first. +set -euo pipefail +SYS="${1:?usage: oidc-login.sh }" +LOGIN="${2:?usage: oidc-login.sh }" +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 "$@" &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 diff --git a/skills/agent-provisioning/scripts/totp.sh b/skills/agent-provisioning/scripts/totp.sh new file mode 100755 index 0000000..3bfb732 --- /dev/null +++ b/skills/agent-provisioning/scripts/totp.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Current TOTP code from a base32 seed (pure bash + openssl + coreutils). +# usage: totp.sh [step-seconds] [digits] +set -euo pipefail +SECRET="${1:?usage: totp.sh [step] [digits]}" +STEP="${2:-30}"; DIGITS="${3:-6}" +NOW="${TOTP_NOW:-$(date +%s)}" # TOTP_NOW: RFC-6238 test vector override +KEYHEX=$(printf '%s' "$SECRET" | tr -d ' =\n-' | base32 -d 2>/dev/null | od -An -tx1 | tr -d ' \n') +[ -n "$KEYHEX" ] || { echo "bad base32 seed" >&2; exit 2; } +COUNTER=$(printf '%016x' $(( NOW / STEP ))) +# bash vars cannot hold null bytes - keep the counter as a FORMAT string and +# let printf emit the raw bytes straight into the pipe. +FMT=$(printf '%s' "$COUNTER" | sed 's/../\\x&/g') +# intentional: FMT emits raw counter bytes incl. NULs - the whole point +# shellcheck disable=SC2059 +MAC=$(printf "$FMT" | openssl dgst -sha1 -mac HMAC -macopt "hexkey:$KEYHEX" -binary \ + | od -An -tx1 | tr -d ' \n') +OFFSET=$(( 0x${MAC: -1} )) +CODE=$(( (0x${MAC:$((OFFSET*2)):8} & 0x7fffffff) % (10 ** DIGITS) )) +printf "%0${DIGITS}d\n" "$CODE"