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:
73
otpauth.py
Normal file
73
otpauth.py
Normal 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)
|
||||
Reference in New Issue
Block a user