#!/usr/bin/env python3 """휴대폰이 없을 때 인증 앱 대신 6자리를 만듭니다 (실습 ② · 운영 매뉴얼 M9). 🟢 아무 곳에도 접속하지 않습니다. 계정도 토큰도 필요 없습니다 — 시계와 설정키만 씁니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. python totp.py JBSWY3DPEHPK3PXP # 등록 화면의 「설정 키」를 그대로 python totp.py JBSWY3DPEHPK3PXP --watch # 30초마다 새 코드를 계속 찍습니다 어디서 설정키를 얻나 — 2차 인증 등록 화면(App Launcher › Account › MFA devices, 또는 `<팀이름>.cloudflareaccess.com/AddMfaDevice`)에서 QR 아래의 「Can't scan?」 같은 문구를 누르면 **영문·숫자 문자열**이 나옵니다. 그것이 설정 키(base32)입니다. 무엇을 계산하나 — RFC 6238 그대로입니다. 「지금 시각 ÷ 30초」를 8바이트로 만들고, 설정 키로 HMAC-SHA1 을 걸어 그 결과에서 6자리를 뽑습니다. 인증 앱이 하는 일과 같습니다. 🔴 **이 파일은 그 자체가 인증 수단입니다.** 설정 키를 넣고 돌리는 순간 그 계정의 6자리를 만들 수 있으므로, 설정 키를 **파일이나 채팅에 남기지 마십시오**. 실습용으로 한 번 쓰고 나면 등록을 지우고 휴대폰으로 다시 등록하시는 편이 낫습니다. 🔴 **시계가 틀리면 코드도 틀립니다.** 30초 창이라 앞뒤로 몇 십 초만 어긋나도 거절됩니다 — Cloudflare 쪽은 시계가 20초 이상 어긋나면 인증 토큰 자체를 무효로 봅니다(M15). 공식 문서 Independent MFA https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/ RFC 6238 (TOTP) https://www.rfc-editor.org/rfc/rfc6238 """ import argparse import base64 import hashlib import hmac import struct import sys import time def code(secret_b32, when=None, step=30, digits=6): """설정 키(base32) 로 그 시각의 코드를 만듭니다.""" raw = secret_b32.strip().replace(" ", "").replace("-", "").upper() raw += "=" * ((8 - len(raw) % 8) % 8) # base32 는 8자 단위 — 부족한 만큼 채웁니다 try: key = base64.b32decode(raw, casefold=True) except Exception: sys.exit("설정 키가 base32 가 아닙니다. 등록 화면에 보이는 문자열을 그대로 넣어 주십시오.") counter = int((time.time() if when is None else when) // step) mac = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() off = mac[-1] & 0x0F # 마지막 4비트가 「어디서 자를지」를 가리킵니다 num = struct.unpack(">I", mac[off:off + 4])[0] & 0x7FFFFFFF return str(num % (10 ** digits)).zfill(digits) def left(step=30): return step - int(time.time()) % step def main(): ap = argparse.ArgumentParser(description="인증 앱 대신 6자리를 만듭니다 (RFC 6238)") ap.add_argument("secret", help="등록 화면의 설정 키(base32)") ap.add_argument("--watch", action="store_true", help="30초마다 계속 찍습니다 (Ctrl+C 로 중단)") ap.add_argument("--digits", type=int, default=6) ap.add_argument("--step", type=int, default=30) a = ap.parse_args() if not a.watch: print("%s (남은 시간 %d초)" % (code(a.secret, step=a.step, digits=a.digits), left(a.step))) return print("30초마다 새 코드를 찍습니다. Ctrl+C 로 멈춥니다.") last = None try: while True: c = code(a.secret, step=a.step, digits=a.digits) if c != last: print("%s (남은 시간 %d초)" % (c, left(a.step))) last = c time.sleep(1) except KeyboardInterrupt: print("\n멈췄습니다.") if __name__ == "__main__": main()