2FA 코드 하나 보려고 폰을 꺼내는 게 싫어서 만든 OTPeek

OTP 코드를 위젯에서 힐끗 보고 한 번 탭으로 복사하는 크로스플랫폼 인증기 OTPeek. C#으로 시작해 반년을 방치했다가, 하루 만에 계약을 얼리고 다섯 에이전트로 병렬 이식한 Rust 코어 재작성과, 위젯이 절대 Argon2를 돌리지 않게 만든 키 설계 이야기.

2FA 코드 하나 보려고 폰을 꺼내는 게 싫어서 만든 OTPeek

데스크톱 앞에 앉아 로그인을 하는데 2FA 코드를 넣으라고 한다. 주머니에서 폰을 꺼내고, 잠금을 풀고, 인증기 앱을 열고, 수십 개 계정 중에 그 계정을 찾아서, 6자리를 눈으로 읽고, 만료되기 전에 키보드로 옮겨 친다. 이걸 하루에 몇 번씩 한다. 손 뻗으면 닿는 곳에 컴퓨터가 있는데 굳이 폰과 눈 사이를 왕복하는 게 매번 거슬렸다. 코드는 그냥 화면 어딘가에 항상 떠 있으면 되는 거 아닌가?

그래서 OTPeek을 만들었다. iOS·macOS·Windows의 홈스크린/데스크톱 위젯에 코드가 상시 떠 있고, 탭(클릭) 한 번이면 클립보드에 복사된다. 이름 그대로 힐끗(peek) 보고 가져다 쓰는 게 전부다.

데스크톱 앱에서 계정별 2FA 코드가 한눈에 나열되고 클릭 한 번으로 복사되는 화면

깔끔하게 들리지만, 여기까지 오는 길은 깔끔하지 않았다. 커밋 로그를 열어보면 이 프로젝트는 두 번 갈아엎어졌고, 중간에 반년을 통째로 방치됐다.

12월의 C#, 새해 첫날 새벽의 빌드 에러

첫 커밋은 2025년 12월 28일 밤이다. 이름도 OTPeek이 아니라 “OTP Authenticator”였다. Windows는 WinUI 3, 클라우드 동기화는 OneDrive, QR 스캔은 ZXing — 전부 C#/.NET 하나로 커버할 생각이었다. 그날 밤에만 코어 서비스, 앱, 동기화, Windows 11 위젯 프로바이더까지 커밋이 줄줄이 올라갔으니 출발은 기세가 좋았다.

문제는 새해 첫날 새벽이다. 1월 1일 02:45에 ”.NET MAUI로 macOS/iOS 지원 추가” 커밋을 넣고 나서, 03:11부터 04:26까지 커밋 메시지가 전부 fix(build), fix(cloud), fix(core)다. Graph SDK v5 시그니처 맞추기, ZXing 패키지 호환성, Windows App SDK의 PRI 생성 오류. 새해 벽두에 한 시간 십오 분 동안 잡은 게 인증기의 본질과는 아무 상관 없는 SDK 삽질이었다. 그리고 MAUI가 그려주는 애플 쪽 화면은 아무리 만져도 어정쩡했다.

그래서 1월 3일, 애플만 네이티브 SwiftUI로 다시 짰다. 첫 번째 갈아엎기다. 화면은 좋아졌는데 대신 같은 TOTP·볼트 로직이 세 벌이 됐다. Swift에 하나, Windows C#에 하나, 아직 다 걷어내지 못한 MAUI 잔재에 하나. 버그 하나에 수정 세 번, RFC 6238 테스트 벡터도 언어마다 따로. 1월 4일에 프로젝트 구조를 정리하는 커밋을 하나 넣고 — 거기서 손을 놨다. 다음 커밋은 6개월 뒤다. 거창한 이유가 있었던 건 아니고, 세 군데를 고칠 생각을 하면 손이 안 갔다는 게 정직한 설명일 거다.

7월 5일: 계약을 얼리고, 다섯 갈래로 갈랐다

다시 잡은 건 올해 7월 5일이다. 이번에는 로직을 통째로 Rust 코어로 옮기기로 했다. 두 번째 갈아엎기. 아키텍처 문서 첫 줄에 이렇게 박아뒀다: “Status: Frozen contract for the big-bang migration.” 점잖게 써놨지만 그냥 한 번에 다 뜯어고쳤다는 뜻이다.

빅뱅으로 간 이유가 있다. 세 벌을 한 벌로 조금씩 합치면 옛 코드와 새 코어가 공존하는 동안 데이터 포맷이 어긋나서 볼트가 깨질 수 있다. 그래서 반대로, 먼저 계약(contract)을 못 박아 얼리고 모든 플랫폼을 동시에 새 코어 위로 올렸다. 얼린 결정 몇 개만 옮기면:

  • 타임스탬프는 어디서든 i64 epoch 밀리초(UTC)다. 로컬 타임존이나 문자열 포맷이 경계를 넘지 못한다.
  • 비밀번호 변경은 볼트를 재암호화하지 않는다. 마스터 키를 새 비밀번호로 다시 감싸기만 한다.
  • 키 로테이션(새 마스터 키 발급)은 이번 v2 범위에서 뺀다. 계약을 얼리려면 “안 하는 것”도 똑같이 명시해야 한다.

이렇게 한 이유는 이 마이그레이션을 에이전트 다섯을 병렬로 돌려서 했기 때문이다. 문서에는 워크스트림 A~E로 소유권 표까지 있다. A는 코어 알고리즘, B는 볼트·동기화, C는 FFI와 CLI, D는 애플, E는 Windows. 각자 자기 디렉토리에만 쓰기 권한이 있고, 크레이트 이름과 공개 시그니처는 못 바꾸고, 커밋도 직접 못 한다. 계약이 흔들리지 않는 한 다섯이 서로 기다릴 필요가 없다. 실제로 7월 5일 오후에 계약 문서, Rust 코어 워크스페이스, 애플·Windows 이식 커밋이 한꺼번에 들어갔다.

하나의 뇌, 세 개의 껍데기

새 구조의 원칙은 단순하다. 로직은 Rust로 딱 한 번만 짜고, UI는 각 플랫폼 네이티브로 얇게 유지한다.

core/crates/
otpeek-core/ # TOTP/HOTP, otpauth:// URI, 구글 인증기 마이그레이션
otpeek-vault/ # 암호화 볼트 컨테이너, 파일 I/O
otpeek-sync/ # 동기화 엔진, 병합 규칙, WebDAV 백엔드
otpeek-ffi/ # UniFFI 파사드 (Swift / C# 바인딩)
otpeek-cli/ # otpeek 커맨드라인 클라이언트

이 코어를 UniFFI로 노출시켜서, 애플은 XCFramework를 SwiftUI 앱이 부르고 Windows는 uniffi-bindgen-cs로 만든 C# 바인딩을 WinUI 3 앱이 부른다. 두 앱 모두 OTP·암호화·동기화 로직을 한 줄도 다시 구현하지 않는다. RFC 테스트 벡터도 코어에서 한 번만 통과시키면 된다. 참고로 코어 안에서는 std::time을 절대 부르지 않고 시간을 항상 인자로 받는데, 이래야 특정 시각의 코드를 결정론적으로 테스트할 수 있다.

iOS 앱 화면. 데스크톱과 같은 계정 목록을 SwiftUI가 같은 Rust 코어를 호출해 그대로 보여준다

위젯은 절대 Argon2를 돌리면 안 된다

이 프로젝트에서 제일 재밌었던 설계 문제는 위젯이다. OTPeek의 핵심 가치는 “위젯에 코드가 상시 떠 있다”인데, 위젯은 백그라운드에서 잠깐 깨어나 화면을 그리고 다시 잠드는, 제약이 심한 환경이다. 무거운 연산을 돌릴 수도 없고 비밀번호를 물어볼 UI도 없다. 그런데 볼트는 암호화되어 있다. 코드를 만들려면 키가 필요하다.

그래서 키를 두 겹으로 나눴다.

  • VMK (Vault Master Key): 볼트 생성 때 한 번 만드는 랜덤 32바이트. 실제로 볼트 내용을 암호화한다.
  • KEK: 마스터 비밀번호를 Argon2id(64 MiB, t=3, p=1)로 돌려 만든 키. VMK를 감싸는(wrap) 용도다.

그리고 로컬에서는 감싸지 않은 raw VMK를 OS 키스토어(Keychain / DPAPI / Secret Service)에 직접 저장한다. 위젯은 키스토어에서 VMK를 꺼내 곧바로 볼트를 열면 되고, 무거운 Argon2는 건드릴 일이 없다. 마스터 비밀번호가 필요한 순간은 볼트 생성, 새 기기 등록, 비밀번호 변경, 키스토어 없이 볼트 파일을 열 때 — 전부 사용자가 비밀번호를 입력할 수 있는 “앱”의 맥락이다. 아키텍처 문서에는 이렇게 못 박혀 있다. “Argon2 never runs in widget processes.” 위에서 “비밀번호 변경은 VMK를 다시 감싸기만 한다”고 한 것도 여기서 이어진다. VMK가 그대로니 키스토어 값도, 볼트 본문도 건드릴 필요가 없다.

정직하게 짚을 트레이드오프도 문서에 같이 적어뒀다. 위젯은 설계상 별도 인증 없이 코드를 보여준다. 잠금 화면에 위젯을 올리면 폰을 안 열고도 코드가 보인다는 뜻이다. 계정별로 생체인증을 요구하는 require_auth 플래그를 나중에 붙일 수 있게 여지만 남겨둔 상태다.

파일 하나로 저장하고, 백업하고, 동기화한다

.otpvault 파일 포맷 하나가 세 역할을 전부 한다. 로컬 볼트, 내보내기한 백업, 원격 서버에 올라가는 동기화 블롭. 전부 같은 AES-256-GCM 컨테이너라서 동기화 백엔드는 어떤 경우에도 암호문 바이트만 본다. 애플은 CloudKit, 나머지는 WebDAV(Nextcloud 같은)를 쓰는데, 백엔드는 불투명한 덩어리를 옮기는 멍청한 역할만 하고 병합 로직은 전부 Rust 코어에 있다. 계정별 last-writer-wins, 삭제는 tombstone으로 전파, 그리고 HOTP 카운터만은 예외적으로 절대 뒤로 가지 않는다 — 카운터를 재사용하면 코드가 깨지니까.

이 구조가 실제로 작동한다는 걸 확인한 건 Windows 테스트를 준비하면서다. macOS에서 쓰던 볼트를 Windows 머신으로 옮기려고 보니, 볼트 파일만 복사해서는 열 수가 없다. raw VMK가 macOS Keychain에 묶여 있고, Windows는 DPAPI라는 다른 키스토어를 쓰기 때문이다. 버그가 아니라 “raw 키는 그 기기의 보안 저장소를 떠나지 않는다”는 원칙 그대로다. 정답은 이미 만들어 둔 길에 있었다. 암호화된 .otpvault로 export하고 Windows에서 import하면, 비밀번호 한 번에 그 기기의 DPAPI에 VMK가 새로 자리잡는다. 같은 원리로 새 기기 등록도 iCloud 복원이든 WebDAV든 AirDrop이든 “블롭 받고 비밀번호 한 번”으로 끝난다. 구글 인증기의 내보내기 QR(otpauth-migration://)도 그대로 읽고, 12월에 만들었던 v1 볼트 포맷(PBKDF2)은 import 전용으로만 남겨뒀다 — 옛 데이터를 버리게 하고 싶지 않았다.

Windows는 늘 막내였다

7월 6일에 프로젝트를 OTPeek으로 리브랜딩하고(이름이 생긴 게 사실상 출시 직전이다), 7일 하루에 CLI 인스톨러, 랜딩 페이지, App Store용 수익화까지 밀어넣었다. 그런데 Windows는 계속 한 발씩 늦었다. 12월엔 첫째였다가 1월에 애플이 SwiftUI를 받는 동안 밀렸고, Rust 코어 위에 올라온 뒤에도 화면·아이콘·위젯이 애플 대비 한참 뒤였다. 7월 15일에 에이전트한테 쓴 첫 문장이 기록에 그대로 남아 있다. “현재 otpeek의 windows는 macos나 ios에 비해 너무 뒤떨어졌습니다.”

이걸 따라잡는 과정에서 두 번 제대로 헤맸다.

첫 번째는 시작하자마자 죽는 앱이다. 실제 Windows 머신에서 Release 빌드는 통과하는데, 실행하면 창도 안 뜨고 즉시 종료. 이벤트 로그에는 Microsoft.UI.Xaml.dll 안에서 STATUS_STOWED_EXCEPTION(0xc000027b). 처음엔 이번 UI 작업이 만든 회귀인 줄 알고, 기준 커밋을 별도 worktree에 빌드해서 예전 코드도 죽는지부터 비교했다. 똑같이 죽었다. 그럼 원래부터 안 됐다는 건데 — 한참 SDK 버전과 resources.pri를 파다가 찾은 진짜 원인은 완벽한 레드헤링이었다. 나는 원격 머신에 SSH로 붙어 앱을 실행하고 있었고, WinUI는 SSH가 붙는 세션 0(서비스 세션)에서는 초기화 자체가 안 된다. 실제 로그인된 데스크톱 세션에서 같은 빌드를 띄우니 멀쩡히 떴다. 앱은 처음부터 정상이었고, 내가 앱을 잘못된 자리에서 실행한 거였다. 그 와중에 진짜 버그도 하나 건졌다. TypeComboBox 이벤트가 XAML 초기화 도중 너무 일찍 발생해 아직 안 만들어진 컨트롤을 건드리고 있어서, 이벤트 연결을 InitializeComponent() 뒤로 옮겼다.

두 번째는 트레이 메뉴다. 우클릭 메뉴를 macOS 수준으로 만들고 싶어서 favicon에 실시간 카운트다운까지 넣었더니, 우클릭할 때마다 별도 WinUI 창과 ViewModel 뭉치와 타이머들이 새로 생성되면서 메뉴가 메뉴답지 않게 굼떠졌다. 결국 방향을 틀었다. H.NotifyIcon의 네이티브 메뉴는 Win32 TrackPopupMenuEx라 반응은 즉각인데 이미지 아이콘을 지원하지 않는다. 그래서 우클릭에서는 favicon과 애니메이션을 의도적으로 포기하고 코드와 남은 초를 텍스트로 정렬했고, 화려한 라이브 팝업은 좌클릭에만 남겼다. 원래 사양도 “좌클릭=라이브 팝업, 우클릭=가벼운 메뉴”였으니 결과적으로 제자리를 찾은 셈이다. 위젯 쪽도 “click to copy” 같은 안내 문구를 걷어내서 그 공간에 계정을 더 보여주게 다듬었다.

터미널에서도 쓰고 싶었다

로직을 Rust로 몰아넣고 나니 공짜로 딸려온 게 있다. CLI다. 나는 하루 대부분을 터미널에서 보내는데, SSH로 서버에 붙어 있다가 2FA 코드가 필요할 때 GUI를 찾는 것도 번거롭다. 그래서 코어를 그대로 쓰는 otpeek 바이너리를 만들었다.

Terminal window
otpeek init # 볼트 생성
otpeek add 'otpauth://totp/GitHub:me?secret=...&issuer=GitHub'
otpeek code github --copy # 코드 생성 후 클립보드 복사

--watch로 카운트다운을 보고, --json으로 스크립트에 물리고, 헤드리스 리눅스에서는 OTPEEK_VAULT_PASSWORD 환경변수로 키스토어 없이 돌린다. CLI는 코어·볼트·동기화 크레이트에만 의존하고 FFI 레이어는 거치지 않는다. 여담으로 Intel 맥용 CLI 바이너리는 GitHub Actions 맥 러너가 요즘 arm64라 x86_64 타깃을 따로 붙여 크로스컴파일해야 했다. 이런 데서 꼭 한 번씩 삽질이 나온다.

지금 상태

OTPeek은 App Store에 올라가 있고, 랜딩 페이지와 개인정보 처리방침까지 붙은 상태다. 위젯, 생체인증 앱 잠금, QR 가져오기, 폴더/즐겨찾기, iCloud·WebDAV 동기화까지 매일 내가 쓰는 도구가 됐다. 팁 자(tip jar)와 광고 제거 IAP도 붙여봤는데 이건 솔직히 실험에 가깝다.

안 된 것도 그대로 적어둔다. 클립보드에 복사한 코드의 자동 삭제는 v2에 없다(문서에도 “no auto-clear in v2”라고 적어둔 의도된 미포함이다). 위젯 계정별 생체인증도 플래그 자리만 있고 구현은 없다. 키 로테이션도 v2 범위 밖으로 밀어둔 그대로다.

돌이켜보면 이 프로젝트의 전환점은 화려한 코드가 아니라 1월 4일과 7월 5일 사이의 공백이다. 같은 로직 세 벌을 유지할 자신이 없어서 반년을 놓았던 프로젝트가, “본질은 한 번만 구현한다”로 구조를 바꾸자 사흘 만에 스토어까지 갔다. 그리고 그 위에서 원래 하고 싶었던 것 — 폰을 꺼내지 않고 코드를 힐끗 보고 가져다 쓰는 것 — 은 이제 잘 된다.


프로젝트: OTPeek · App Store

Comments

Loading comments...