From 6ca9a3bf6fe3cfe3afca777d611a159da1ca4f45 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Wed, 22 Jul 2026 06:55:51 +0100 Subject: [PATCH 1/2] Add Steam Guard TOTP variant (#44) Steam's mobile authenticator uses the same HMAC-SHA1/30s TOTP construction as everyone else, but renders the truncated value as 5 characters from Steam's own 26-symbol alphabet instead of decimal digits. Adds a new account type ("steam", vault type id 2) with its own renderer (src/core/steamguard.c, sharing otp.c's RFC 4226 dynamic-truncation step via the new otp_truncate()), reachable via otpauth://steam/... URIs, a STEAM switch on the CLI's bare-secret ADD, and the GUI's existing typed- URI/secret entry point and IPC forwarding. Vectors in tests/test_steamguard.c were derived independently via Python's stdlib hmac/hashlib, not this codebase's own HMAC, so the test doesn't just check the implementation against itself. Co-Authored-By: Claude Sonnet 5 --- Makefile | 4 +- docs/VAULT_FORMAT.md | 9 ++- src/amiga/guiport.h | 4 +- src/cli/main.c | 31 ++++++---- src/core/otp.c | 37 +++++++---- src/core/otp.h | 7 +++ src/core/steamguard.c | 19 ++++++ src/core/steamguard.h | 25 ++++++++ src/core/uri.c | 46 +++++++++++--- src/core/uri.h | 18 ++++-- src/core/vault.c | 14 +++-- src/gui/main.c | 23 ++++--- tests/test_main.c | 2 + tests/test_otp.c | 13 ++++ tests/test_steamguard.c | 55 +++++++++++++++++ tests/test_uri.c | 24 ++++++++ tests/test_vault.c | 96 ++++++++++++++++++++--------- userdocs/CLI-Reference.md | 16 +++-- userdocs/Managing-Accounts.md | 34 +++++++++- userdocs/Troubleshooting-and-FAQ.md | 5 +- 20 files changed, 389 insertions(+), 93 deletions(-) create mode 100644 src/core/steamguard.c create mode 100644 src/core/steamguard.h create mode 100644 tests/test_steamguard.c diff --git a/Makefile b/Makefile index 2eeff52..b9148ed 100644 --- a/Makefile +++ b/Makefile @@ -193,8 +193,8 @@ asm-tests-docker: # real m68k, and checks the codes it emits over serial. See tests/copperline. # OTP core chain (hotp_sha1 -> hmac -> sha1) + the DRBG; no vault/prefs/front-end. SERIALTEST_SRCS := src/core/otp.c src/core/hmac.c src/core/sha1.c \ - src/core/sha256.c src/core/sha512.c src/core/drbg.c \ - tests/copperline/serialtest.c + src/core/sha256.c src/core/sha512.c src/core/steamguard.c \ + src/core/drbg.c tests/copperline/serialtest.c serialtest-m68k: | $(BUILD) $(M68K_CC) $(M68K_CFLAGS) $(SERIALTEST_SRCS) -o $(BUILD)/serialtest diff --git a/docs/VAULT_FORMAT.md b/docs/VAULT_FORMAT.md index b4a6b10..9f202cb 100644 --- a/docs/VAULT_FORMAT.md +++ b/docs/VAULT_FORMAT.md @@ -131,7 +131,7 @@ always-unlocked) is the ordered account list: +------+------------------------------------------------------------+ then account_count records, each: +------+------------------------------------------------------------+ -| 1 | type (0 = TOTP, 1 = HOTP) | +| 1 | type (0 = TOTP, 1 = HOTP, 2 = Steam Guard) | | 1 | algorithm (0 = SHA1, 1 = SHA256, 2 = SHA512) | | 1 | digits (6 or 8) | | 4 | period (u32, seconds; TOTP) | @@ -145,8 +145,11 @@ always-unlocked) is the ordered account list: +------+------------------------------------------------------------+ ``` -- `period` is ignored for HOTP; `counter` is ignored for TOTP. Both are always - present for a fixed record shape. +- `period` is ignored for HOTP; `counter` is ignored for TOTP and Steam Guard. + `digits` and `period` are both ignored for Steam Guard — its code is always + 5 characters on a fixed 30-second step (`src/core/steamguard.c`), not + independently configurable like ordinary TOTP. All fields are always + present for a fixed record shape regardless of which the type consults. - Length prefixes are single bytes; the field caps (`OTP_MAX_SECRET = 64`, `issuer ≤ 64`, `label ≤ 128`, `VAULT_MAX_ACCOUNTS = 64`) all fit in a byte / the u16 count. diff --git a/src/amiga/guiport.h b/src/amiga/guiport.h index db5aac3..ad3f7c5 100644 --- a/src/amiga/guiport.h +++ b/src/amiga/guiport.h @@ -23,8 +23,10 @@ enum { AAP_ADD, /* arg = otpauth:// URI */ AAP_REMOVE, /* arg = account name */ AAP_SHOW, /* pop the GUI window to the front */ - AAP_ADD_SECRET /* arg = "issuer\nlabel\nbase32secret" (bare-secret ADD; + AAP_ADD_SECRET, /* arg = "issuer\nlabel\nbase32secret" (bare-secret ADD; * issuer may be empty, the other two must not be) */ + AAP_ADD_SECRET_STEAM /* same arg shape; builds a Steam Guard account + * instead of an ordinary TOTP one (#44) */ }; /* Result codes (aar_Result / *result). */ diff --git a/src/cli/main.c b/src/cli/main.c index b8de6f9..ba739ed 100644 --- a/src/cli/main.c +++ b/src/cli/main.c @@ -89,6 +89,7 @@ typedef struct { int open; /* OPEN / --open */ long iterations; /* ITERATIONS / --iterations (-1 = auto) */ int no_rekey; /* NOREKEY / --no-rekey */ + int steam; /* STEAM / --steam (bare-secret ADD; #44) */ } cli_args; /* ---- platform hooks ---- */ @@ -673,9 +674,10 @@ static int cmd_add(const char *path, const char *uri) /* ADD with a bare Base32 secret (no otpauth:// wrapper): the common case for * services that show only the raw secret. Defaults to a SHA-1/6-digit/30s - * TOTP; anything else still needs the full URI form (#83). */ + * TOTP, or a Steam Guard account when `steam` is set (#44); anything else + * still needs the full URI form (#83). */ static int cmd_add_secret(const char *path, const char *secret, - const char *issuer, const char *label) + const char *issuer, const char *label, int steam) { static vault v; /* ~19 KB: keep it off the (small, ~4 KB) AmigaShell stack */ static char packed[320]; /* issuer \n label \n secret for the GUI port */ @@ -687,11 +689,12 @@ static int cmd_add_secret(const char *path, const char *secret, fprintf(stderr, "AmiAuth: adding a bare secret needs ISSUER and LABEL, e.g.\n" " %s ADD %s ISSUER GitHub LABEL you@example.com\n" - "(makes a 6-digit/30s TOTP; use the otpauth:// URI form for " - "anything else)\n", g_prog, secret); + "(makes a 6-digit/30s TOTP, or a Steam Guard account with STEAM; " + "use the otpauth:// URI form for anything else)\n", g_prog, secret); return 2; } - if (otp_account_from_secret(issuer, label, secret, &acct) != 0) { + if ((steam ? otp_account_from_secret_steam : otp_account_from_secret) + (issuer, label, secret, &acct) != 0) { fprintf(stderr, "AmiAuth: that does not look like a Base32 secret\n"); return 2; } @@ -699,7 +702,7 @@ static int cmd_add_secret(const char *path, const char *secret, /* Resident GUI owns the vault? Same routing as the URI form. */ if (strlen(issuer) + strlen(label) + strlen(secret) + 3 <= sizeof packed) { sprintf(packed, "%s\n%s\n%s", issuer, label, secret); - fc = try_forward(AAP_ADD_SECRET, packed); + fc = try_forward(steam ? AAP_ADD_SECRET_STEAM : AAP_ADD_SECRET, packed); memset(packed, 0, sizeof packed); if (fc >= 0) { memset(&acct, 0, sizeof acct); return fc; } } @@ -811,7 +814,8 @@ static int usage(void) " INIT [OPEN] Create a vault\n" " ADD \"\" Import an account from a URI\n" " ADD ISSUER LABEL Import a bare Base32\n" - " secret (6-digit/30s SHA-1 TOTP)\n" + " secret (6-digit/30s SHA-1 TOTP;\n" + " add STEAM for a Steam Guard code)\n" " LIST List account names\n" " GET Print an account's code\n" " REMOVE Delete an account\n" @@ -825,7 +829,7 @@ static int usage(void) p, p); #ifdef AMIAUTH_AMIGA fprintf(stderr, - "Options: ISSUER/K LABEL/K (bare-secret ADD) VAULT OPEN/S\n" + "Options: ISSUER/K LABEL/K STEAM/S (bare-secret ADD) VAULT OPEN/S\n" " ITERATIONS/N/K (INIT) NOREKEY/S\n" "Quote URIs (they contain '?'). Default vault (first set wins):\n" " VAULT ; env AMIAUTH_VAULT; pref AmiAuth/vault\n" @@ -833,7 +837,7 @@ static int usage(void) DEFAULT_VAULT); #else fprintf(stderr, - "Options: --issuer S --label S (bare-secret ADD) -v/--vault PATH\n" + "Options: --issuer S --label S --steam (bare-secret ADD) -v/--vault PATH\n" " --iterations N --no-rekey\n" "Default vault: -v, else $AMIAUTH_VAULT, else the 'vault' pref, else %s\n", DEFAULT_VAULT); @@ -883,7 +887,7 @@ static int dispatch(const cli_args *a) if (!a->value) return usage(); return otpauth_is_uri(a->value) ? cmd_add(vault, a->value) - : cmd_add_secret(vault, a->value, a->issuer, a->label); + : cmd_add_secret(vault, a->value, a->issuer, a->label, a->steam); } if (ci_streq(a->command, "LIST")) return cmd_list(vault); if (ci_streq(a->command, "GET")) return a->value ? cmd_get(vault, a->value) : usage(); @@ -906,9 +910,9 @@ int main(int argc, char **argv) { static const char TMPL[] = "COMMAND,VALUE,DIGITS,PERIOD,ISSUER/K,LABEL/K,VAULT/K,OPEN/S," - "ITERATIONS/N/K,NOREKEY/S"; + "ITERATIONS/N/K,NOREKEY/S,STEAM/S"; enum { P_COMMAND, P_VALUE, P_DIGITS, P_PERIOD, P_ISSUER, P_LABEL, P_VAULT, - P_OPEN, P_ITERATIONS, P_NOREKEY, P_N }; + P_OPEN, P_ITERATIONS, P_NOREKEY, P_STEAM, P_N }; LONG opt[P_N]; struct RDArgs *rda; cli_args a; @@ -931,6 +935,7 @@ int main(int argc, char **argv) a.open = opt[P_OPEN] ? 1 : 0; a.iterations = opt[P_ITERATIONS] ? *(LONG *)opt[P_ITERATIONS] : -1; a.no_rekey = opt[P_NOREKEY] ? 1 : 0; + a.steam = opt[P_STEAM] ? 1 : 0; rc = dispatch(&a); FreeArgs(rda); @@ -972,6 +977,8 @@ int main(int argc, char **argv) a.label = argv[++i]; else if (strncmp(argv[i], "--label=", 8) == 0) a.label = argv[i] + 8; + else if (strcmp(argv[i], "--steam") == 0) + a.steam = 1; else if (npos < (int)(sizeof pos / sizeof pos[0])) pos[npos++] = argv[i]; } diff --git a/src/core/otp.c b/src/core/otp.c index 7e1f970..bd37064 100644 --- a/src/core/otp.c +++ b/src/core/otp.c @@ -5,6 +5,7 @@ #include "otp.h" #include "uri.h" #include "hmac.h" +#include "steamguard.h" int otp_alg_from_name(const char *name) { @@ -32,17 +33,14 @@ const char *otp_alg_name(otp_alg alg) } } -uint32_t hotp(otp_alg alg, const uint8_t *key, size_t keylen, - uint64_t counter, int digits) +uint32_t otp_truncate(otp_alg alg, const uint8_t *key, size_t keylen, + uint64_t counter) { uint8_t msg[8]; uint8_t mac[SHA512_DIGEST_SIZE]; /* big enough for every variant */ size_t maclen; - uint32_t bin, mod; int i, offset; - if (digits < 1 || digits > 9) digits = OTP_DEFAULT_DIGITS; - /* 8-byte big-endian counter. */ for (i = 7; i >= 0; i--) { msg[i] = (uint8_t)(counter & 0xff); @@ -67,12 +65,20 @@ uint32_t hotp(otp_alg alg, const uint8_t *key, size_t keylen, /* Dynamic truncation (RFC 4226 §5.3): low nibble of the last byte selects a * 4-byte window; mask the high bit to stay positive. */ offset = mac[maclen - 1] & 0x0f; - bin = ((uint32_t)(mac[offset] & 0x7f) << 24) - | ((uint32_t)mac[offset + 1] << 16) - | ((uint32_t)mac[offset + 2] << 8) - | ((uint32_t)mac[offset + 3]); + return ((uint32_t)(mac[offset] & 0x7f) << 24) + | ((uint32_t)mac[offset + 1] << 16) + | ((uint32_t)mac[offset + 2] << 8) + | ((uint32_t)mac[offset + 3]); +} + +uint32_t hotp(otp_alg alg, const uint8_t *key, size_t keylen, + uint64_t counter, int digits) +{ + uint32_t bin = otp_truncate(alg, key, keylen, counter); + uint32_t mod = 1; + int i; - mod = 1; + if (digits < 1 || digits > 9) digits = OTP_DEFAULT_DIGITS; for (i = 0; i < digits; i++) mod *= 10; return bin % mod; } @@ -109,11 +115,18 @@ uint32_t totp_seconds_remaining(uint64_t unix_time, uint64_t t0, uint32_t period void otp_render(const struct otp_account *a, uint64_t unix_time, char buf[OTP_CODE_BUF]) { - int alg = otp_alg_from_name(a->algorithm); - int digits = (a->digits >= 1 && a->digits <= 9) ? a->digits : OTP_DEFAULT_DIGITS; + int alg, digits; uint32_t code; int i; + if (strcmp(a->type, "steam") == 0) { + steam_totp(a->secret, a->secret_len, unix_time, buf); + return; + } + + alg = otp_alg_from_name(a->algorithm); + digits = (a->digits >= 1 && a->digits <= 9) ? a->digits : OTP_DEFAULT_DIGITS; + /* Both entry points (otpauth parse, vault load) reject algorithms we don't * implement, so this is only a defensive fallback. */ if (alg < 0) alg = OTP_ALG_SHA1; diff --git a/src/core/otp.h b/src/core/otp.h index f010b38..af5339e 100644 --- a/src/core/otp.h +++ b/src/core/otp.h @@ -23,6 +23,13 @@ typedef enum { int otp_alg_from_name(const char *name); const char *otp_alg_name(otp_alg alg); +/* RFC 4226 §5.3 dynamic truncation: HMAC(alg, key, 8-byte BE counter), then + * select/mask a 31-bit value. Shared by hotp() (reduced mod 10^digits below) + * and other code-rendering schemes built on the same primitive but a + * different final encoding (Steam Guard's base-26, src/core/steamguard.c). */ +uint32_t otp_truncate(otp_alg alg, const uint8_t *key, size_t keylen, + uint64_t counter); + /* HOTP: HMAC over an 8-byte counter, dynamic truncation, modulo 10^digits. * Returns the code as an integer (zero-pad to `digits` when displaying). */ uint32_t hotp(otp_alg alg, const uint8_t *key, size_t keylen, diff --git a/src/core/steamguard.c b/src/core/steamguard.c new file mode 100644 index 0000000..1136694 --- /dev/null +++ b/src/core/steamguard.c @@ -0,0 +1,19 @@ +/* steamguard.c — Steam Guard's TOTP variant (#44). See steamguard.h. */ +#include "steamguard.h" +#include "otp.h" + +static const char STEAM_ALPHABET[] = "23456789BCDFGHJKMNPQRTVWXY"; + +void steam_totp(const uint8_t *key, size_t keylen, uint64_t unix_time, + char out[STEAM_CODE_DIGITS + 1]) +{ + uint64_t counter = unix_time / STEAM_PERIOD; + uint32_t v = otp_truncate(OTP_ALG_SHA1, key, keylen, counter); + int i; + + for (i = 0; i < STEAM_CODE_DIGITS; i++) { + out[i] = STEAM_ALPHABET[v % 26]; + v /= 26; + } + out[STEAM_CODE_DIGITS] = '\0'; +} diff --git a/src/core/steamguard.h b/src/core/steamguard.h new file mode 100644 index 0000000..d0b03e9 --- /dev/null +++ b/src/core/steamguard.h @@ -0,0 +1,25 @@ +/* steamguard.h — Steam Guard's TOTP variant (#44). + * + * Not an RFC scheme: Steam's mobile authenticator uses the same RFC 6238 + * HMAC-SHA1/30s construction as ordinary TOTP, but renders the truncated + * value as 5 characters from Steam's own 26-symbol alphabet instead of + * decimal digits (no vowels, no 0/1/O/I — avoids mistaken-identity typos). + * Reverse-engineered and used identically by every third-party Steam Guard + * client; validated in tests/test_steamguard.c against vectors derived + * independently via Python's hmac/hashlib, not this codebase's own HMAC. */ +#ifndef AMIAUTH_STEAMGUARD_H +#define AMIAUTH_STEAMGUARD_H + +#include +#include + +#define STEAM_CODE_DIGITS 5 +#define STEAM_PERIOD 30 + +/* Render the current Steam Guard code into out[STEAM_CODE_DIGITS + 1] + * (NUL-terminated). Always HMAC-SHA1; `key`/`keylen` is the raw shared + * secret (Base32-decoded already, as stored in otp_account). */ +void steam_totp(const uint8_t *key, size_t keylen, uint64_t unix_time, + char out[STEAM_CODE_DIGITS + 1]); + +#endif /* AMIAUTH_STEAMGUARD_H */ diff --git a/src/core/uri.c b/src/core/uri.c index e9b7475..2872242 100644 --- a/src/core/uri.c +++ b/src/core/uri.c @@ -9,6 +9,7 @@ #include "uri.h" #include "otp.h" #include "base32.h" +#include "steamguard.h" static int hexval(int c) { @@ -101,8 +102,9 @@ int otpauth_parse(const char *uri, otp_account *out) slash = strchr(p, '/'); if (!slash) return -1; typelen = (size_t)(slash - p); - if (typelen == 4 && ci_eq(p, "totp", 4)) strcpy(out->type, "totp"); - else if (typelen == 4 && ci_eq(p, "hotp", 4)) strcpy(out->type, "hotp"); + if (typelen == 4 && ci_eq(p, "totp", 4)) strcpy(out->type, "totp"); + else if (typelen == 4 && ci_eq(p, "hotp", 4)) strcpy(out->type, "hotp"); + else if (typelen == 5 && ci_eq(p, "steam", 5)) strcpy(out->type, "steam"); else return -1; /* LABEL up to '?' (or end) */ @@ -166,6 +168,15 @@ int otpauth_parse(const char *uri, otp_account *out) } if (!have_secret) return -1; /* the secret is mandatory */ + + /* Steam Guard is not itself configurable: force SHA1/5 regardless of any + * algorithm=/digits= query parameter, rather than storing values the + * renderer (otp_render, which dispatches on type before consulting + * either) will never actually use. */ + if (strcmp(out->type, "steam") == 0) { + strcpy(out->algorithm, "SHA1"); + out->digits = STEAM_CODE_DIGITS; + } return 0; } @@ -174,8 +185,8 @@ int otpauth_is_uri(const char *s) return s != NULL && ci_startswith(s, "otpauth://"); } -int otp_account_from_secret(const char *issuer, const char *label, - const char *secret_b32, otp_account *out) +static int account_from_secret(const char *issuer, const char *label, + const char *secret_b32, otp_account *out) { int n; @@ -183,11 +194,6 @@ int otp_account_from_secret(const char *issuer, const char *label, memset(out, 0, sizeof(*out)); if (!label || !label[0] || !secret_b32) return -1; - strcpy(out->type, "totp"); - strcpy(out->algorithm, "SHA1"); - out->digits = OTP_DEFAULT_DIGITS; - out->period = OTP_DEFAULT_PERIOD; - n = base32_decode(secret_b32, out->secret, sizeof(out->secret)); if (n <= 0) { memset(out, 0, sizeof(*out)); return -1; } out->secret_len = (size_t)n; @@ -196,3 +202,25 @@ int otp_account_from_secret(const char *issuer, const char *label, copy_str(out->label, sizeof(out->label), label); return 0; } + +int otp_account_from_secret(const char *issuer, const char *label, + const char *secret_b32, otp_account *out) +{ + if (account_from_secret(issuer, label, secret_b32, out) != 0) return -1; + strcpy(out->type, "totp"); + strcpy(out->algorithm, "SHA1"); + out->digits = OTP_DEFAULT_DIGITS; + out->period = OTP_DEFAULT_PERIOD; + return 0; +} + +int otp_account_from_secret_steam(const char *issuer, const char *label, + const char *secret_b32, otp_account *out) +{ + if (account_from_secret(issuer, label, secret_b32, out) != 0) return -1; + strcpy(out->type, "steam"); + strcpy(out->algorithm, "SHA1"); + out->digits = STEAM_CODE_DIGITS; + out->period = STEAM_PERIOD; + return 0; +} diff --git a/src/core/uri.h b/src/core/uri.h index af1d812..4fa8e71 100644 --- a/src/core/uri.h +++ b/src/core/uri.h @@ -12,19 +12,22 @@ /* A single OTP account. This is the unit stored in the vault. (The struct tag * lets otp.h's renderer take a pointer without pulling in this header.) */ typedef struct otp_account { - char type[8]; /* "totp" or "hotp" */ + char type[8]; /* "totp", "hotp" or "steam" (#44) */ char algorithm[8]; /* "SHA1", "SHA256" or "SHA512" */ char issuer[OTP_MAX_ISSUER]; char label[OTP_MAX_LABEL]; uint8_t secret[OTP_MAX_SECRET]; /* raw key bytes (Base32-decoded) */ size_t secret_len; - int digits; /* 6 or 8 */ - uint32_t period; /* TOTP step, seconds */ + int digits; /* 6 or 8 (ignored for "steam": fixed 5) */ + uint32_t period; /* TOTP/steam step, seconds (steam fixed 30) */ uint64_t counter; /* HOTP counter */ } otp_account; /* Parse an otpauth:// URI into `out`. Returns 0 on success, -1 on malformed - * input. Unspecified fields are filled with sensible defaults (SHA1/6/30). */ + * input. Unspecified fields are filled with sensible defaults (SHA1/6/30). + * TYPE accepts "totp", "hotp" or "steam" (#44); for "steam" the algorithm and + * digits are forced to SHA1/5 regardless of any algorithm=/digits= query + * parameter, since Steam Guard is not itself configurable. */ int otpauth_parse(const char *uri, otp_account *out); /* Does `s` look like an otpauth:// URI (case-insensitive prefix test)? Used @@ -40,4 +43,11 @@ int otpauth_is_uri(const char *s); int otp_account_from_secret(const char *issuer, const char *label, const char *secret_b32, otp_account *out); +/* Same as otp_account_from_secret, but builds a Steam Guard account (#44): + * Steam's shared secret is typically extracted from its own maFile format + * and never carries an otpauth:// URI in practice, so this is the primary + * entry point rather than a fallback. */ +int otp_account_from_secret_steam(const char *issuer, const char *label, + const char *secret_b32, otp_account *out); + #endif /* AMIAUTH_URI_H */ diff --git a/src/core/vault.c b/src/core/vault.c index 87e82e7..19f123e 100644 --- a/src/core/vault.c +++ b/src/core/vault.c @@ -130,7 +130,9 @@ static size_t serialize_payload(const vault *v, uint8_t *out) size_t ll = strlen(a->label); int alg = otp_alg_from_name(a->algorithm); - out[p++] = (uint8_t)(strcmp(a->type, "hotp") == 0 ? 1 : 0); + uint8_t type = strcmp(a->type, "hotp") == 0 ? 1 + : strcmp(a->type, "steam") == 0 ? 2 : 0; + out[p++] = type; /* 0/1/2 = TOTP/HOTP/Steam */ out[p++] = (uint8_t)(alg > 0 ? alg : 0); /* 0/1/2 = SHA1/256/512 */ out[p++] = (uint8_t)a->digits; put_u32(out + p, a->period); p += 4; @@ -168,11 +170,11 @@ static vault_result parse_payload(vault *v, const uint8_t *in, size_t len) a.digits = in[p++]; a.period = get_u32(in + p); p += 4; a.counter = get_u64(in + p); p += 8; - /* Reject algorithm ids we don't implement: guessing would produce - * plausible-looking but wrong codes (the format doc says new ids - * extend the scheme and old readers must refuse them). */ - if (alg > OTP_ALG_SHA512) return VAULT_ERR_FORMAT; - strcpy(a.type, type ? "hotp" : "totp"); + /* Reject algorithm/type ids we don't implement: guessing would + * produce plausible-looking but wrong codes (the format doc says new + * ids extend the scheme and old readers must refuse them). */ + if (alg > OTP_ALG_SHA512 || type > 2) return VAULT_ERR_FORMAT; + strcpy(a.type, type == 1 ? "hotp" : type == 2 ? "steam" : "totp"); strcpy(a.algorithm, otp_alg_name((otp_alg)alg)); if (p + 1 > len) return VAULT_ERR_FORMAT; diff --git a/src/gui/main.c b/src/gui/main.c index 756d9ac..7e65aab 100644 --- a/src/gui/main.c +++ b/src/gui/main.c @@ -1244,6 +1244,11 @@ static int edit_request(otp_account *acct) STRPTR t = NULL; char issuer[OTP_MAX_ISSUER], label[OTP_MAX_LABEL]; int d; long p; + /* Steam Guard's digits/period aren't independently + * configurable (otp_render never consults them for + * this type, #44) — the fields still show for layout + * consistency, but only issuer/label are applied. */ + int is_steam = strcmp(acct->type, "steam") == 0; issuer[0] = label[0] = '\0'; GetAttr(STRINGA_TextVal, issuerg, (ULONG *)&t); if (t) { strncpy(issuer, (char *)t, OTP_MAX_ISSUER - 1); issuer[OTP_MAX_ISSUER - 1] = '\0'; } @@ -1251,15 +1256,16 @@ static int edit_request(otp_account *acct) if (t) { strncpy(label, (char *)t, OTP_MAX_LABEL - 1); label[OTP_MAX_LABEL - 1] = '\0'; } GetAttr(STRINGA_TextVal, digitsg, (ULONG *)&t); d = t ? atoi((char *)t) : 0; GetAttr(STRINGA_TextVal, periodg, (ULONG *)&t); p = t ? atol((char *)t) : 0; - if (label[0] && d >= 6 && d <= 8 && p > 0 && p <= 86400) { + if (label[0] && (is_steam || (d >= 6 && d <= 8 && p > 0 && p <= 86400))) { strcpy(acct->issuer, issuer); strcpy(acct->label, label); - acct->digits = d; - acct->period = (uint32_t)p; + if (!is_steam) { acct->digits = d; acct->period = (uint32_t)p; } done = 1; } else { - gui_requester(w, "Label is required; digits must be 6-8, period 1-86400.", - "OK", NULL); /* stay open to fix */ + gui_requester(w, is_steam + ? "Label is required." + : "Label is required; digits must be 6-8, period 1-86400.", + "OK", NULL); /* stay open to fix */ } } break; @@ -1950,10 +1956,12 @@ int main(int argc, char **argv) memset(&acct, 0, sizeof acct); break; } - case AAP_ADD_SECRET: { /* arg = "issuer\nlabel\nsecret" (#83) */ + case AAP_ADD_SECRET: /* arg = "issuer\nlabel\nsecret" (#83) */ + case AAP_ADD_SECRET_STEAM: { /* same arg shape, Steam Guard (#44) */ static char meta[320]; /* our own copy: never scribble on the CLI's buffer */ otp_account acct; char *lab, *sec; + int steam = (req->aar_Cmd == AAP_ADD_SECRET_STEAM); if (!v.unlocked) { req->aar_Result = AAR_LOCKED; break; } if (!arg || strlen(arg) >= sizeof meta) { req->aar_Result = AAR_BADARG; break; } strcpy(meta, arg); @@ -1962,7 +1970,8 @@ int main(int argc, char **argv) if (!sec) { memset(meta, 0, sizeof meta); req->aar_Result = AAR_BADARG; break; } *lab++ = '\0'; *sec++ = '\0'; - if (otp_account_from_secret(meta, lab, sec, &acct) != 0) + if ((steam ? otp_account_from_secret_steam : otp_account_from_secret) + (meta, lab, sec, &acct) != 0) req->aar_Result = AAR_BADARG; else { vault_result r = vault_add(&v, &acct); diff --git a/tests/test_main.c b/tests/test_main.c index 5240dad..61b52c3 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -8,6 +8,7 @@ void run_sha256_tests(void); void run_sha512_tests(void); void run_hmac_tests(void); void run_otp_tests(void); +void run_steamguard_tests(void); void run_base32_tests(void); void run_chacha20_tests(void); void run_pbkdf2_tests(void); @@ -27,6 +28,7 @@ int main(void) run_sha512_tests(); run_hmac_tests(); run_otp_tests(); + run_steamguard_tests(); run_base32_tests(); run_chacha20_tests(); run_pbkdf2_tests(); diff --git a/tests/test_otp.c b/tests/test_otp.c index b130ff7..1e7d2d6 100644 --- a/tests/test_otp.c +++ b/tests/test_otp.c @@ -111,5 +111,18 @@ void run_otp_tests(void) a.counter = 5; otp_render(&a, 999999ULL, buf); TEST_CHECK(strcmp(buf, "254676") == 0); + + /* Steam Guard dispatch (#44) — algorithm/digits stored on the account + * are irrelevant; otp_render always renders the 5-char base-26 code. + * Same vector as tests/test_steamguard.c's independent derivation. */ + memset(&a, 0, sizeof(a)); + strcpy(a.type, "steam"); + strcpy(a.algorithm, "SHA1"); + memcpy(a.secret, secret, slen); + a.secret_len = slen; + a.digits = 5; + a.period = 30; + otp_render(&a, 1234567890ULL, buf); + TEST_CHECK(strcmp(buf, "VHHQY") == 0); } } diff --git a/tests/test_steamguard.c b/tests/test_steamguard.c new file mode 100644 index 0000000..31a1f65 --- /dev/null +++ b/tests/test_steamguard.c @@ -0,0 +1,55 @@ +/* test_steamguard.c — Steam Guard TOTP variant (#44). + * + * There is no RFC for this scheme, so the vectors below were derived + * independently via Python's stdlib hmac/hashlib (not this codebase's own + * HMAC-SHA1), reimplementing the well-known truncation + base-26 encoding: + * + * counter = unix_time // 30 + * mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest() + * offset = mac[19] & 0x0f + * bin = (mac[offset]&0x7f)<<24 | mac[offset+1]<<16 | mac[offset+2]<<8 | mac[offset+3] + * code = ''.join(alphabet[(bin // 26**i) % 26] for i in range(5)) + * + * using the RFC 4226/6238 standard test secret "12345678901234567890" (20 + * bytes) so the underlying HMAC step is independently cross-checkable + * against the RFC vectors already in test_otp.c. */ +#include + +#include "test.h" +#include "steamguard.h" + +void run_steamguard_tests(void) +{ + const uint8_t *secret = (const uint8_t *)"12345678901234567890"; + const size_t slen = 20; + char code[STEAM_CODE_DIGITS + 1]; + + steam_totp(secret, slen, 0, code); + TEST_CHECK(strcmp(code, "GG5F5") == 0); + + steam_totp(secret, slen, 59, code); /* counter = 59/30 = 1 */ + TEST_CHECK(strcmp(code, "PV9M4") == 0); + + steam_totp(secret, slen, 1111111109ULL, code); + TEST_CHECK(strcmp(code, "PY4YB") == 0); + + steam_totp(secret, slen, 1111111111ULL, code); + TEST_CHECK(strcmp(code, "5PP3V") == 0); + + steam_totp(secret, slen, 1234567890ULL, code); + TEST_CHECK(strcmp(code, "VHHQY") == 0); + + steam_totp(secret, slen, 2000000000ULL, code); + TEST_CHECK(strcmp(code, "9N776") == 0); + + /* Every character comes from the 26-symbol Steam alphabet (no vowels, + * no 0/1/O/I), and the code is always exactly 5 characters. */ + { + static const char alphabet[] = "23456789BCDFGHJKMNPQRTVWXY"; + int i; + steam_totp(secret, slen, 42, code); + TEST_CHECK(strlen(code) == STEAM_CODE_DIGITS); + for (i = 0; i < STEAM_CODE_DIGITS; i++) + TEST_CHECK(strchr(alphabet, code[i]) != NULL); + } +} diff --git a/tests/test_uri.c b/tests/test_uri.c index 85e513b..fd86842 100644 --- a/tests/test_uri.c +++ b/tests/test_uri.c @@ -63,6 +63,18 @@ void run_uri_tests(void) TEST_CHECK(otpauth_parse( "otpauth://totp/x?secret=JBSWY3DP&algorithm=SHA224", &a) == -1); + /* otpauth://steam/... (#44): forces SHA1/5 regardless of any + * algorithm=/digits= parameter, since Steam Guard isn't configurable. */ + TEST_CHECK(otpauth_parse( + "otpauth://steam/Steam:you?secret=JBSWY3DP&issuer=Steam", &a) == 0); + TEST_CHECK(strcmp(a.type, "steam") == 0); + TEST_CHECK(strcmp(a.algorithm, "SHA1") == 0); + TEST_CHECK(a.digits == 5); + TEST_CHECK(otpauth_parse( + "otpauth://steam/Steam:you?secret=JBSWY3DP&algorithm=SHA256&digits=8", &a) == 0); + TEST_CHECK(strcmp(a.algorithm, "SHA1") == 0); + TEST_CHECK(a.digits == 5); + /* Rejections: missing secret, wrong scheme, malformed input. */ TEST_CHECK(otpauth_parse("otpauth://totp/x?issuer=Y", &a) == -1); TEST_CHECK(otpauth_parse("otpauth://weird/x?secret=JBSWY3DP", &a) == -1); @@ -106,4 +118,16 @@ void run_bare_secret_tests(void) TEST_CHECK(otp_account_from_secret("X", "y", "not!base32", &a) == -1); TEST_CHECK(a.secret_len == 0 && a.label[0] == '\0'); TEST_CHECK(otp_account_from_secret("X", "y", "JBSWY3DP", NULL) == -1); + + /* otp_account_from_secret_steam (#44): same argument rules, but forced + * "steam"/SHA1/5/30 rather than the ordinary TOTP defaults. */ + TEST_CHECK(otp_account_from_secret_steam("Steam", "friend", + "JBSWY3DPEHPK3PXP", &a) == 0); + TEST_CHECK(strcmp(a.type, "steam") == 0); + TEST_CHECK(strcmp(a.algorithm, "SHA1") == 0); + TEST_CHECK(a.digits == 5 && a.period == 30); + TEST_CHECK(strcmp(a.issuer, "Steam") == 0 && strcmp(a.label, "friend") == 0); + TEST_CHECK(a.secret_len == 10 && memcmp(a.secret, "Hello", 5) == 0); + TEST_CHECK(otp_account_from_secret_steam("X", "", "JBSWY3DP", &a) == -1); + TEST_CHECK(otp_account_from_secret_steam("X", "y", "not!base32", &a) == -1); } diff --git a/tests/test_vault.c b/tests/test_vault.c index 59001b6..12c709a 100644 --- a/tests/test_vault.c +++ b/tests/test_vault.c @@ -53,6 +53,39 @@ static int accounts_equal(const otp_account *x, const otp_account *y) && x->counter == y->counter; } +/* Hand-write a minimal always-unlocked vault (cipher/kdf = none) from a + * pre-built payload, computing the empty-key HMAC-SHA1 MAC the format + * requires — for exercising parse_payload's per-record rejection rules + * directly, without vault_add's own validation getting in the way. */ +static void write_raw_vault(const char *path, const uint8_t *payload, size_t plen) +{ + uint8_t header[44]; + uint8_t mac[20]; + FILE *f; + + memset(header, 0, sizeof(header)); + memcpy(header, "AAVT", 4); + header[4] = 0x01; /* format_version */ + header[43] = (uint8_t)plen; /* payload_len (u32 BE, plen < 256 here) */ + + { + hmac_sha1_ctx m; + hmac_sha1_init(&m, NULL, 0); + hmac_sha1_update(&m, header, sizeof(header)); + hmac_sha1_update(&m, payload, plen); + hmac_sha1_final(&m, mac); + } + + f = fopen(path, "wb"); + TEST_CHECK(f != NULL); + if (f) { + fwrite(header, 1, sizeof(header), f); + fwrite(mac, 1, sizeof(mac), f); + fwrite(payload, 1, plen, f); + fclose(f); + } +} + void run_vault_tests(void) { const char *path = tmp_path(); @@ -136,12 +169,27 @@ void run_vault_tests(void) TEST_CHECK(accounts_equal(&w.accounts[1], &c)); } + /* --- a Steam Guard account round-trips via type id 2 (#44) --- */ + { + otp_account s; + sample_account(&s); + strcpy(s.type, "steam"); + s.digits = 5; + s.period = 30; + + TEST_CHECK(vault_create(&v, NULL, 0, NULL) == VAULT_OK); + TEST_CHECK(vault_add(&v, &s) == VAULT_OK); + TEST_CHECK(vault_save(&v, path, NULL) == VAULT_OK); + TEST_CHECK(vault_load(&w, path, NULL) == VAULT_OK); + TEST_CHECK(w.count == 1); + TEST_CHECK(accounts_equal(&w.accounts[0], &s)); + } + /* --- an algorithm id we don't implement is refused (format rule) --- */ { - /* Hand-build a minimal always-unlocked vault whose one record claims - * algorithm id 9, with a valid (empty-key) MAC so only the id check - * can reject it. Payload: count=1; type 0, alg 9, digits 6, - * period 30, counter 0, secret "K", no issuer, label "x". */ + /* One record claiming algorithm id 9, with a valid (empty-key) MAC so + * only the id check can reject it. Payload: count=1; type 0, alg 9, + * digits 6, period 30, counter 0, secret "K", no issuer, label "x". */ static const uint8_t payload[] = { 0x00, 0x01, 0x00, 0x09, 0x06, @@ -151,31 +199,23 @@ void run_vault_tests(void) 0x00, 0x01, 'x' }; - uint8_t header[44]; - uint8_t mac[20]; - FILE *f; - - memset(header, 0, sizeof(header)); - memcpy(header, "AAVT", 4); - header[4] = 0x01; /* format_version */ - header[43] = (uint8_t)sizeof(payload); /* payload_len (u32 BE) */ - - { - hmac_sha1_ctx m; - hmac_sha1_init(&m, NULL, 0); - hmac_sha1_update(&m, header, sizeof(header)); - hmac_sha1_update(&m, payload, sizeof(payload)); - hmac_sha1_final(&m, mac); - } + write_raw_vault(path, payload, sizeof(payload)); + TEST_CHECK(vault_load(&w, path, NULL) == VAULT_ERR_FORMAT); + } - f = fopen(path, "wb"); - TEST_CHECK(f != NULL); - if (f) { - fwrite(header, 1, sizeof(header), f); - fwrite(mac, 1, sizeof(mac), f); - fwrite(payload, 1, sizeof(payload), f); - fclose(f); - } + /* --- a type id we don't implement is refused the same way (#44) --- */ + { + /* Same record, but type 9 (valid algorithm 0) instead. */ + static const uint8_t payload[] = { + 0x00, 0x01, + 0x09, 0x00, 0x06, + 0x00, 0x00, 0x00, 0x1e, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 'K', + 0x00, + 0x01, 'x' + }; + write_raw_vault(path, payload, sizeof(payload)); TEST_CHECK(vault_load(&w, path, NULL) == VAULT_ERR_FORMAT); } diff --git a/userdocs/CLI-Reference.md b/userdocs/CLI-Reference.md index 56dcd48..8676927 100644 --- a/userdocs/CLI-Reference.md +++ b/userdocs/CLI-Reference.md @@ -28,7 +28,7 @@ When the GUI is running as a resident commodity, `ADD`, `LIST`, `GET`, ## The ReadArgs template AmiAuth ? - COMMAND,VALUE,DIGITS,PERIOD,ISSUER/K,LABEL/K,VAULT/K,OPEN/S,ITERATIONS/N/K,NOREKEY/S + COMMAND,VALUE,DIGITS,PERIOD,ISSUER/K,LABEL/K,VAULT/K,OPEN/S,ITERATIONS/N/K,NOREKEY/S,STEAM/S - `COMMAND` — one of the commands above (case-insensitive). - `VALUE` — the command's main argument (secret, URI, account name, seconds or @@ -36,6 +36,8 @@ When the GUI is running as a resident commodity, `ADD`, `LIST`, `GET`, - `DIGITS`, `PERIOD` — optional positionals for `CODE`. - `ISSUER `, `LABEL ` — keywords for `ADD` with a bare secret (both required in that form; unused with a URI). +- `STEAM` — switch for `ADD` with a bare secret: build a Steam Guard account + (5-character code) instead of an ordinary TOTP one. - `VAULT ` — use this vault file instead of the configured one. - `OPEN` — switch for `INIT`: create an always-unlocked vault, no prompts. - `ITERATIONS ` — keyword for `INIT`: explicit PBKDF2 iteration count. @@ -43,7 +45,7 @@ When the GUI is running as a resident commodity, `ADD`, `LIST`, `GET`, The host (development) build takes the equivalent Unix-style options: `--vault PATH`, `--open`, `--iterations N`, `--no-rekey`, `--issuer S`, -`--label S`. +`--label S`, `--steam`. ## Commands @@ -93,18 +95,22 @@ you at the `VAULT` keyword. AmiAuth ADD "otpauth://totp/GitHub:you@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub" AmiAuth ADD JBSWY3DPEHPK3PXP ISSUER GitHub LABEL you@example.com + AmiAuth ADD 5J8K2Q9F3M7XW4NP ISSUER Steam LABEL you STEAM Imports an account, in either of two forms: - **`otpauth://` URI** (the format behind enrolment QR codes; most services show it under a "can't scan the code?" link). Issuer, label, secret, algorithm, digits, period/counter are all taken from the URI. **Quote the - URI** — it contains `?` and `&`. + URI** — it contains `?` and `&`. A `TYPE` of `steam` (`otpauth://steam/...`) + imports a Steam Guard account; see [Managing Accounts](Managing-Accounts.md#steam-guard). - **Bare Base32 secret** — the raw "setup key" some services show instead. A bare secret carries no name, so `ISSUER` and `LABEL` are required, and the account gets the defaults nearly every service issues: TOTP, SHA-1, - 6 digits, 30 seconds. Anything else (HOTP, 8 digits, a custom period) - still needs the URI form. + 6 digits, 30 seconds. Add `STEAM` to build a Steam Guard account instead + (Steam's own shared secret, not an otpauth URI — see + [Managing Accounts](Managing-Accounts.md#steam-guard)). Anything else + (HOTP, 8 digits, a custom period) still needs the URI form. Prints `Added issuer:label`. The vault holds up to 64 accounts. See [Managing Accounts](Managing-Accounts.md) for URI details. diff --git a/userdocs/Managing-Accounts.md b/userdocs/Managing-Accounts.md index 132319d..80d8658 100644 --- a/userdocs/Managing-Accounts.md +++ b/userdocs/Managing-Accounts.md @@ -64,6 +64,33 @@ runs on-Amiga using the bundled quirc decoder — the image never leaves your machine. A busy pointer shows while decoding; clear, screen-sized screenshots decode most reliably. +## Steam Guard + +Steam's own mobile authenticator is a TOTP variant: the same HMAC-SHA1/30-second +construction as ordinary TOTP, but rendered as **5 characters from Steam's own +26-symbol alphabet** (no vowels, no `0`/`1`/`O`/`I`) instead of decimal digits. +AmiAuth supports it as a distinct account type. + +Unlike most services, Steam does not hand out its shared secret through a +scannable `otpauth://` QR code or setup-key link — it is enrolled through +Steam's own app-linking flow, and the secret is normally recovered from a +Steam Guard export (e.g. a `maFile`'s `shared_secret`) or a third-party tool. +Once you have that Base32 secret: + +- **CLI:** add `STEAM` to a bare-secret `ADD`: + + AmiAuth ADD ISSUER Steam LABEL you STEAM + +- **GUI:** *Account → Add (type URI/secret)…*, then type or paste a + `otpauth://steam/Steam:you?secret=…` URI — the typed-URI field accepts any + `otpauth://` scheme, including `steam`. + +`LIST` and `GET` (CLI) and the account list (GUI) work exactly the same as any +other account; `GET` just prints the 5-character code. Digits and period +aren't independently configurable for Steam Guard (always 5 characters on a +fixed 30-second step) — the *Edit* form still shows those fields for layout +consistency, but only issuer and label are actually applied. + ## Listing and generating codes See [CLI Reference](CLI-Reference.md) (`LIST`, `GET`) and the [GUI Guide](GUI-Guide.md) (live list, @@ -73,9 +100,12 @@ against the label, the issuer, or `issuer:label`. ## Editing *Account → Edit selected…* in the GUI lets you change the **issuer**, **label** -(required), **digits** (6–8) and **period** (1–86400 seconds). +(required), **digits** (6–8) and **period** (1–86400 seconds) — except for +Steam Guard accounts, where digits/period are fixed and only issuer/label +apply (see [Steam Guard](#steam-guard) above). -The **secret and the account type (TOTP/HOTP) are deliberately not editable** — +The **secret and the account type (TOTP/HOTP/Steam) are deliberately not +editable** — if a service issues you a new secret, remove the account and re-add the new enrolment. There is no CLI edit command; edit in the GUI, or remove and re-add. diff --git a/userdocs/Troubleshooting-and-FAQ.md b/userdocs/Troubleshooting-and-FAQ.md index 29a61cd..95f3deb 100644 --- a/userdocs/Troubleshooting-and-FAQ.md +++ b/userdocs/Troubleshooting-and-FAQ.md @@ -145,10 +145,11 @@ Vault: `PROGDIR:AmiAuth.vault` (one file — copy it to back up). Settings: The data is unrecoverable — that is the point of the encryption. Re-enrol each account using the services' recovery codes or a second enrolled device. -**Steam Guard? Exporting QR codes?** +**Exporting QR codes?** On the v2 candidate list, along with an ARexx port and translations. See the [v2 milestone](https://github.com/sidick/amiauth/milestone/2). (SHA-256/512 -TOTP is already supported — see [Managing Accounts](Managing-Accounts.md).) +TOTP and Steam Guard are already supported — see +[Managing Accounts](Managing-Accounts.md).) **Why SHA-1? Isn't that broken?** SHA-1's collision attacks do not affect HMAC-SHA1 as used by TOTP/HOTP (it From 1f3335db288bdbf1aec72fd7ee971b10dd37dbee Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Wed, 22 Jul 2026 07:00:58 +0100 Subject: [PATCH 2/2] docs: note why SHA-256/512 and Steam Guard skip the asm dispatch Mirrors the ChaCha20 dead-end already documented here: unlike SHA-1 (which backs PBKDF2's iteration-heavy vault KDF), the new primitives each do at most one HMAC per code render, so there's no hot loop for hand-written asm to pay off in. Co-Authored-By: Claude Sonnet 5 --- docs/ARCHITECTURE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8b62518..6fb23ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -129,6 +129,20 @@ that gap needs a substantially more involved rewrite that wasn't judged worth the risk for this pass. `g_chacha20_block` stays on the C default; see `src/amiga/crypto_select.c` and `src/core/crypto_dispatch.h`. +SHA-256/SHA-512 (`sha256.c`/`sha512.c`, added for the RFC 6238 algorithm +variants in #43) and Steam Guard's renderer (`steamguard.c`, #44) deliberately +have no asm path either, but for a different reason than ChaCha20: they aren't +hot loops in the first place. Unlike SHA-1, which backs PBKDF2's +iteration-heavy vault KDF (the table above), SHA-256/512 and Steam Guard each +compute at most one HMAC per TOTP code render - seconds apart, not thousands +of iterations back-to-back - so there's no wall-clock bottleneck for +hand-written asm to fix, and no measured case (per the ChaCha20 lesson above) +to justify writing one speculatively. Steam Guard's HMAC-SHA1 truncation +already goes through the existing `g_sha1_compress` dispatch at no extra cost. +Revisit only if either primitive ends up in a genuine hot loop, the same way +ChaCha20's asm case was decided on real-hardware measurement rather than +assumption. + An optional AmiSSL-backed provider (`CRYPTO=BUILTIN|AMISSL|AUTO`) plugging into the same dispatch seam is tracked separately as #85 - AmiSSL itself requires AmigaOS 3.0+/68020+, so unlike the asm above it can only ever sit