"""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)