Validate cell characters and digit/period sanity

Halt import on control characters or a ':'/'/' inside an issuer or
account (they collide with the OATH issuer:account separator and period/
prefix), on a composed name over 64 bytes with a clearer per-field
message, and on a digit count outside 6-8 or a non-positive period.
This commit is contained in:
2026-07-08 13:49:48 -07:00
parent 88a600ba66
commit cd705a446b
4 changed files with 73 additions and 9 deletions

View File

@@ -58,9 +58,11 @@ Authenticating to a password-protected applet uses the standard YKOATH scheme
challenge-response). `--set-password` only sets a password on an applet that has
none; it does not change or remove an existing one. `--reset` clears everything.
The input is validated before any reader is touched. Duplicate credential names,
invalid Base32, over-long names/keys, and unsupported algorithms halt the run
with a report so they can be fixed in the source data.
The input is validated before any reader is touched. The run halts with a
per-row report if it finds: duplicate credential names; invalid Base32;
over-long names or keys; an unsupported algorithm; control characters or a
`:`/`/` inside an issuer or account (they break the OATH name convention); or a
digit count outside 6-8 / a non-positive period. Fix the source data and re-run.
## Tests

View File

@@ -180,9 +180,38 @@ def validate_input(creds):
else:
seen[cid] = c.source
# name length
if len(cid.encode("utf-8")) > MAX_NAME:
problems.append(f"Name too long (>{MAX_NAME} bytes): '{cid}' ({c.source}).")
# invalid characters in issuer/account. The applet stores raw bytes, but
# control characters and the OATH name separators (':' / 'period/')
# corrupt display and round-trip in authenticator apps.
for fname, fval in (("issuer", c.issuer), ("account", c.account)):
ctrl = next((ch for ch in fval if ord(ch) < 0x20 or ord(ch) == 0x7F), None)
if ctrl is not None:
problems.append(
f"Control character (0x{ord(ctrl):02X}) in {fname} for "
f"'{cid}' ({c.source}).")
if ":" in fval:
problems.append(
f"Character ':' in {fname} for '{cid}' ({c.source}); it "
f"collides with the issuer:account separator.")
if "/" in fval:
problems.append(
f"Character '/' in {fname} for '{cid}' ({c.source}); it "
f"collides with the 'period/' name prefix.")
# composed name length (the applet's hard 64-byte limit)
name_bytes = len(cid.encode("utf-8"))
if name_bytes > MAX_NAME:
problems.append(
f"Composed name too long ({name_bytes}>{MAX_NAME} bytes): "
f"'{cid}' ({c.source}); shorten the issuer or account.")
# digits / period sanity
if c.digits not in (6, 7, 8):
problems.append(
f"Unusual digit count {c.digits} for '{cid}' ({c.source}); "
f"OATH codes are 6-8 digits.")
if not isinstance(c.period, int) or c.period <= 0:
problems.append(f"Invalid period {c.period!r} for '{cid}' ({c.source}).")
# algorithm supported by the applet
if c.algorithm not in (HMAC_SHA1, HMAC_SHA256):

View File

@@ -144,12 +144,21 @@ Companion `test_oath.py` — hardware-free unit tests.
## Pre-flight validation rules (halt on any)
- Duplicate `cred_id` within the input (e.g. the two
`SAW:michelle@dangerousthings.com` entries with different secrets).
- Duplicate `cred_id` within the input (e.g. two entries that map to the same
`issuer:account`).
- Secret missing or not valid Base32 after uppercasing and removing spaces.
- Decoded key length > 64 bytes.
- `cred_id` (UTF-8) length > 64 bytes.
- Composed name (`issuer:account`, UTF-8) length > 64 bytes.
- Algorithm SHA512 (unsupported by the applet), or a non-TOTP/HOTP type.
- Control characters (byte < 0x20 or 0x7F) anywhere in the issuer or account.
- A literal `:` or `/` inside the issuer or account (they collide with the OATH
`issuer:account` separator and the `period/` name prefix, so they break
round-trip/display in authenticator apps, even though the applet stores them).
- Digit count outside 6-8, or a non-positive period.
The applet itself imposes only the length limits; the character and
digit/period rules are host-side guards for OATH-convention and app
compatibility.
Each problem is reported with the offending issuer/account and source row.

View File

@@ -173,6 +173,30 @@ class TestValidateInput(unittest.TestCase):
self.assertTrue(any("sha512" in p.lower() or "algorithm" in p.lower()
for p in problems))
def test_control_char_in_account_reported(self):
problems = validate_input([cred(account="a\x01b")])
self.assertTrue(any("control" in p.lower() for p in problems))
def test_colon_in_issuer_reported(self):
problems = validate_input([cred(issuer="Goo:gle")])
self.assertTrue(any(":" in p and "issuer" in p.lower() for p in problems))
def test_slash_in_account_reported(self):
problems = validate_input([cred(account="a/b")])
self.assertTrue(any("/" in p and "account" in p.lower() for p in problems))
def test_digits_out_of_range_reported(self):
problems = validate_input([cred(digits=9)])
self.assertTrue(any("digit" in p.lower() for p in problems))
def test_bad_period_reported(self):
problems = validate_input([cred(period=0)])
self.assertTrue(any("period" in p.lower() for p in problems))
def test_tab_is_control_char(self):
problems = validate_input([cred(issuer="Goo\tgle")])
self.assertTrue(any("control" in p.lower() for p in problems))
class TestLoadCredentials(unittest.TestCase):
def _make_xlsx(self, rows):