Add QR-to-CSV utility and switch canonical format to CSV

Add qr_to_sheet.py: decode saved OTP QR screenshots and append new
credentials to the canonical CSV, deduplicated on the otpauth:// data.
Add otpauth.py (parse_otpauth / canonical_key / build_otpauth), shared
by both tools. The canonical format becomes a minimal CSV (issuer,
account, otpauth_uri, source_file); load_credentials reads CSV or xlsx
and derives crypto from the immutable otpauth_uri while taking
issuer/account from their editable label columns.
This commit is contained in:
2026-07-08 14:49:59 -07:00
parent cd705a446b
commit 07f65835d0
7 changed files with 577 additions and 48 deletions

73
otpauth.py Normal file
View File

@@ -0,0 +1,73 @@
"""Parse and canonicalize otpauth:// URIs. Shared by the QR-to-CSV tool and the
importer; the otpauth:// string is the immutable source of truth for a
credential."""
from dataclasses import dataclass
from urllib.parse import urlparse, parse_qs, unquote, quote
@dataclass
class OtpAuth:
type: str # "totp" / "hotp"
issuer: str
account: str
secret: str # Base32, uppercased, spaces removed
algorithm: str # "SHA1" / "SHA256" / "SHA512"
digits: int
period: int
def parse_otpauth(uri: str) -> OtpAuth:
"""Parse an otpauth://TYPE/LABEL?params URI. Issuer comes from the `issuer`
parameter when present, else the label prefix before ':'. Account is the
label after that prefix."""
u = urlparse(uri)
otype = u.netloc.lower()
label = unquote(u.path.lstrip("/"))
q = parse_qs(u.query)
def g(k, d=""):
return q.get(k, [d])[0]
issuer = unquote(g("issuer"))
if ":" in label:
label_issuer, account = label.split(":", 1)
if not issuer:
issuer = label_issuer.strip()
else:
account = label
return OtpAuth(
type=otype,
issuer=issuer.strip(),
account=account.strip(),
secret=g("secret").upper().replace(" ", ""),
algorithm=(g("algorithm", "SHA1") or "SHA1").upper(),
digits=int(g("digits", "6") or "6"),
period=int(g("period", "30") or "30"),
)
def canonical_key(otp: OtpAuth) -> tuple:
"""Normalized dedup key for the full otpauth data. Equal for two scans of the
same credential (including label-issuer vs. param-issuer forms); different
when any crypto-relevant field differs."""
return (
otp.type.lower(),
otp.issuer.strip(),
otp.account.strip(),
otp.secret.upper().replace(" ", ""),
otp.algorithm.upper(),
otp.digits,
otp.period,
)
def build_otpauth(issuer: str, account: str, secret: str, otype: str = "totp",
algorithm: str = "SHA1", digits: int = 6, period: int = 30) -> str:
"""Reconstruct an otpauth:// URI (used to seed URIs for legacy rows)."""
label = quote(f"{issuer}:{account}" if issuer else account)
params = [f"secret={secret}"]
if issuer:
params.append(f"issuer={quote(issuer)}")
params += [f"algorithm={algorithm}", f"digits={digits}", f"period={period}"]
return f"otpauth://{otype}/{label}?" + "&".join(params)