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.
74 lines
2.4 KiB
Python
74 lines
2.4 KiB
Python
"""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)
|