Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/VAULT_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/amiga/guiport.h
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
31 changes: 19 additions & 12 deletions src/cli/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---- */
Expand Down Expand Up @@ -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 */
Expand All @@ -687,19 +689,20 @@ 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;
}

/* 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; }
}
Expand Down Expand Up @@ -811,7 +814,8 @@ static int usage(void)
" INIT [OPEN] Create a vault\n"
" ADD \"<otpauth://...>\" Import an account from a URI\n"
" ADD <secret> ISSUER <name> LABEL <acct> 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 <account> Print an account's code\n"
" REMOVE <account> Delete an account\n"
Expand All @@ -825,15 +829,15 @@ static int usage(void)
p, p);
#ifdef AMIAUTH_AMIGA
fprintf(stderr,
"Options: ISSUER/K LABEL/K (bare-secret ADD) VAULT <path> OPEN/S\n"
"Options: ISSUER/K LABEL/K STEAM/S (bare-secret ADD) VAULT <path> OPEN/S\n"
" ITERATIONS/N/K (INIT) NOREKEY/S\n"
"Quote URIs (they contain '?'). Default vault (first set wins):\n"
" VAULT <path>; env AMIAUTH_VAULT; pref AmiAuth/vault\n"
" (SetEnv SAVE AmiAuth/vault <path>); else %s\n",
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);
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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];
}
Expand Down
37 changes: 25 additions & 12 deletions src/core/otp.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "otp.h"
#include "uri.h"
#include "hmac.h"
#include "steamguard.h"

int otp_alg_from_name(const char *name)
{
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/core/otp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/core/steamguard.c
Original file line number Diff line number Diff line change
@@ -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';
}
25 changes: 25 additions & 0 deletions src/core/steamguard.h
Original file line number Diff line number Diff line change
@@ -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 <stddef.h>
#include <stdint.h>

#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 */
46 changes: 37 additions & 9 deletions src/core/uri.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "uri.h"
#include "otp.h"
#include "base32.h"
#include "steamguard.h"

static int hexval(int c)
{
Expand Down Expand Up @@ -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) */
Expand Down Expand Up @@ -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;
}

Expand All @@ -174,20 +185,15 @@ 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;

if (!out) return -1;
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;
Expand All @@ -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;
}
Loading