AXLR / Catalog / security

totp_generate

Generate and verify one-time passwords: TOTP (RFC 6238) for a time given in the input and HOTP (RFC 4226) for a counter, from a secret in base32 (as in authenticator apps), hex or ascii, with SHA-1, SHA-256 or SHA-512, 6 to 8 digits, any period and epoch. Verification accepts a window of steps and reports the matching offset. The clock is never read and the secret is never echoed.

Install

/tools install totp_generate
/tools install totp_generate@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · totp_generate 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool totp_generate; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
secretstringyesShared key. length ≤ 4096
secret_encodingstringno"base32"one of "base32", "hex", "ascii"
modestringno"totp"one of "totp", "hotp"
timeinteger | stringnoTOTP: Unix seconds or YYYY-MM-DDTHH:MM:SSZ (required for totp)
counterintegernoHOTP moving factor (required for hotp). ≥ 0
algorithmstringno"SHA1"one of "SHA1", "SHA256", "SHA512"
digitsintegerno6≥ 6; ≤ 8
periodintegerno30TOTP time step X in seconds. ≥ 1; ≤ 86400
t0integerno0TOTP epoch T0 in Unix seconds
codestring | integernoA code to verify
windowintegerno1Steps or counters accepted on each side when verifying. ≥ 0; ≤ 100
Input schema (JSON)
{
  "type": "object",
  "required": [
    "secret"
  ],
  "additionalProperties": false,
  "properties": {
    "secret": {
      "type": "string",
      "maxLength": 4096,
      "description": "Shared key"
    },
    "secret_encoding": {
      "type": "string",
      "enum": [
        "base32",
        "hex",
        "ascii"
      ],
      "default": "base32"
    },
    "mode": {
      "type": "string",
      "enum": [
        "totp",
        "hotp"
      ],
      "default": "totp"
    },
    "time": {
      "type": [
        "integer",
        "string"
      ],
      "description": "TOTP: Unix seconds or YYYY-MM-DDTHH:MM:SSZ (required for totp)"
    },
    "counter": {
      "type": "integer",
      "minimum": 0,
      "description": "HOTP moving factor (required for hotp)"
    },
    "algorithm": {
      "type": "string",
      "enum": [
        "SHA1",
        "SHA256",
        "SHA512"
      ],
      "default": "SHA1"
    },
    "digits": {
      "type": "integer",
      "minimum": 6,
      "maximum": 8,
      "default": 6
    },
    "period": {
      "type": "integer",
      "minimum": 1,
      "maximum": 86400,
      "default": 30,
      "description": "TOTP time step X in seconds"
    },
    "t0": {
      "type": "integer",
      "default": 0,
      "description": "TOTP epoch T0 in Unix seconds"
    },
    "code": {
      "type": [
        "string",
        "integer"
      ],
      "description": "A code to verify"
    },
    "window": {
      "type": "integer",
      "minimum": 0,
      "maximum": 100,
      "default": 1,
      "description": "Steps or counters accepted on each side when verifying"
    }
  }
}

Example

Input

{
  "secret": "12345678901234567890",
  "secret_encoding": "ascii",
  "time": 59,
  "digits": 8
}

Output (stdout)

{
  "code": "94287082",
  "counter": 1,
  "seconds_remaining": 1
}

Files

The command the install card registers is python3 totp_generate.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

totp_generate.py · sha256 60739a56326703f4a327ca3d39d3cb8645ced1098fbd4143d40b6715e127a6f7
#!/usr/bin/env python3
"""HOTP (RFC 4226) and TOTP (RFC 6238) one-time passwords from a secret given in the input,
for a time or counter given in the input (the clock is never read), and verification of a
code within a window of steps.

Input (JSON on stdin):
  secret           the shared key: base32 (default, as in otpauth:// URIs), hex or ascii
  secret_encoding  "base32" | "hex" | "ascii"
  mode             "totp" (default) | "hotp"
  time             TOTP: Unix seconds (integer) or "YYYY-MM-DDTHH:MM:SSZ"; required
  counter          HOTP: the moving factor, integer >= 0; required
  algorithm        "SHA1" (default) | "SHA256" | "SHA512"
  digits           6 (default), 7 or 8
  period           TOTP step in seconds, default 30
  t0               TOTP epoch in Unix seconds, default 0
  code             verify this code instead of only generating
  window           steps (or counters) accepted on each side when verifying, default 1
"""
import datetime as dt
import hashlib
import hmac
import json
import re
import sys

ALGORITHMS = {"SHA1": hashlib.sha1, "SHA256": hashlib.sha256, "SHA512": hashlib.sha512}
B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def base32_decode(text):
    """RFC 4648 base32, case-insensitive, spaces and hyphens ignored, padding optional."""
    clean = re.sub(r"[\s-]", "", text).upper().rstrip("=")
    if not clean:
        fail("secret is empty")
    bits = buffer = 0
    out = bytearray()
    for char in clean:
        value = B32.find(char)
        if value < 0:
            fail("secret is not valid base32 (letters A-Z and digits 2-7)")
        buffer = (buffer << 5) | value
        bits += 5
        if bits >= 8:
            bits -= 8
            out.append((buffer >> bits) & 0xFF)
    if len(clean) * 5 % 8 >= 5:
        fail("secret has an impossible base32 length")
    return bytes(out)


def decode_secret(request):
    secret = request.get("secret")
    if not isinstance(secret, str) or not secret.strip():
        fail("secret is required (a string)")
    encoding = request.get("secret_encoding", "base32")
    if encoding == "base32":
        key = base32_decode(secret)
    elif encoding == "hex":
        try:
            key = bytes.fromhex(re.sub(r"\s", "", secret))
        except ValueError:
            fail("secret is not valid hex")
    elif encoding == "ascii":
        key = secret.encode("utf-8")
    else:
        fail("secret_encoding must be base32, hex or ascii")
    if not key:
        fail("secret is empty")
    return key


def integer(request, field, default=None, low=None, high=None):
    value = request.get(field, default)
    if isinstance(value, bool) or not isinstance(value, int):
        fail(f"{field} must be an integer")
    if (low is not None and value < low) or (high is not None and value > high):
        fail(f"{field} must be between {low} and {high}" if high is not None else f"{field} must be at least {low}")
    return value


def parse_time(value):
    if isinstance(value, bool):
        fail("time must be Unix seconds or YYYY-MM-DDTHH:MM:SSZ")
    if isinstance(value, int):
        return value
    if isinstance(value, float) and value.is_integer():
        return int(value)
    if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})", value):
        try:
            moment = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
        except ValueError:
            fail("time is not a valid date and time")
        return int((moment - dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)).total_seconds())
    fail("time is required: Unix seconds (integer) or YYYY-MM-DDTHH:MM:SSZ; the clock is never read")


def hotp(key, counter, digest, digits):
    """RFC 4226 section 5.3: HMAC over the 8-byte counter, dynamic truncation, modulo 10^digits."""
    mac = hmac.new(key, counter.to_bytes(8, "big"), digest).digest()
    offset = mac[-1] & 0x0F
    binary = int.from_bytes(mac[offset:offset + 4], "big") & 0x7FFFFFFF
    return str(binary % 10 ** digits).zfill(digits)


def iso(seconds):
    try:
        return (dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) + dt.timedelta(seconds=seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")
    except OverflowError:
        return None


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("stdin must be a JSON object")
    known = {"secret", "secret_encoding", "mode", "time", "counter", "algorithm", "digits", "period", "t0", "code", "window"}
    unknown = sorted(set(request) - known)
    if unknown:
        fail(f"unknown field: {unknown[0]}")
    key = decode_secret(request)
    algorithm = str(request.get("algorithm", "SHA1")).upper().replace("-", "")
    if algorithm not in ALGORITHMS:
        fail("algorithm must be SHA1, SHA256 or SHA512")
    digits = integer(request, "digits", 6, 6, 8)
    mode = request.get("mode", "totp")
    result = {"mode": mode, "algorithm": algorithm, "digits": digits, "key_bytes": len(key)}
    if mode == "totp":
        if "time" not in request:
            fail("time is required: Unix seconds (integer) or YYYY-MM-DDTHH:MM:SSZ; the clock is never read")
        now = parse_time(request["time"])
        period = integer(request, "period", 30, 1, 86400)
        t0 = integer(request, "t0", 0)
        if now < t0:
            fail("time is before t0")
        counter = (now - t0) // period
        start = t0 + counter * period
        result.update({"time": now, "time_utc": iso(now), "period": period, "t0": t0, "counter": counter,
                       "counter_hex": format(counter, "016X"), "valid_from_utc": iso(start),
                       "valid_until_utc": iso(start + period), "seconds_remaining": start + period - now})
    elif mode == "hotp":
        counter = integer(request, "counter", None, 0, 2 ** 64 - 1)
        result["counter"] = counter
    else:
        fail("mode must be totp or hotp")
    digest = ALGORITHMS[algorithm]
    result["code"] = hotp(key, counter, digest, digits)
    if "code" in request:
        code = request["code"]
        if isinstance(code, int) and not isinstance(code, bool):
            code = str(code).zfill(digits)
        if not isinstance(code, str) or not re.fullmatch(r"\d{6,8}", code.strip()):
            fail("code must be a string of 6 to 8 digits")
        code = code.strip()
        window = integer(request, "window", 1, 0, 100)
        match = None
        for delta in sorted(range(-window, window + 1), key=abs):
            candidate = counter + delta
            if 0 <= candidate < 2 ** 64 and len(code) == digits and hmac.compare_digest(hotp(key, candidate, digest, digits), code):
                match = delta
                break
        result["verify"] = {"valid": match is not None, "matched_offset": match, "matched_counter": None if match is None else counter + match,
                            "window": window}
        if len(code) != digits:
            result["verify"]["note"] = f"the code has {len(code)} digits, the configuration {digits}"
    result["note"] = "The secret is not echoed. Codes depend only on the input; the clock was not read."
    print(json.dumps(result))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/totp_generate/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/totp_generate/1.0.0/totp_generate.py