패스키, MFA, TOTP, OIDC, JWT는 뭐가 다른가요? 웹 DB 에디터에 패스키 로그인을 넣으며 정리한 것 (피드백 부탁드립니다)
안녕하세요. 오픈소스(MIT) 웹 SQL 에디터인 [LibreDB Studio](https://github.com/libredb/libredb-studio)를만들고 있는 메인테이너입니다. 최근 패스키 로그인을 넣으면서 정리한 내용을 공유하고, 실제로 운영하시는 분들 의견을 듣고 싶어서 글 올립니다.
브라우저에서 쓰는 DB 에디터는 로그인의 무게가 대부분의 앱과 다릅니다. 로그인한 사람이 얻는 건 계정 하나가 아니라 저장된 연결 전부이고, 인증 정보도 이미 설정된 상태입니다. 운영 Postgres, Redis, Mongo. 여기서 비밀번호가 새면 새는 건 프로필이 아니라 데이터입니다.
작업하면서 계속 같은 혼동에 부딪혔습니다. MFA, TOTP, 패스키, OIDC, JWT는 로그인 얘기만 나오면 같이 등장하지만, 각자 답하는 질문이 다릅니다.
## 각자 답하는 질문
- 비밀번호: "비밀을 알고 있나?" 요소가 하나뿐이고, 재사용되거나 유출되거나 가짜 사이트에 입력될 수 있습니다.
- MFA: 기술이 아니라 규칙입니다. 종류가 다른 요소(아는 것, 가진 것, 자신의 특징)를 두 개 이상 증명하는 것. 비밀번호 + TOTP는 MFA이고, PIN이나 생체 인증으로 여는 패스키도 MFA입니다.
- TOTP: "휴대폰을 가지고 있다"를 증명하는 방법 중 하나입니다. 서버와 앱이 비밀을 공유하고, 코드는 그 비밀과 30초 단위 시각의 HMAC을 6자리로 자른 값입니다(RFC 6238).
- 패스키(WebAuthn): 키 쌍입니다. 개인 키는 기기나 비밀번호 관리자에 남고, 서버는 공개 키만 가집니다. 로그인할 때 기기가 서버가 보낸 챌린지에 서명합니다.
- OIDC: 로그인 방식이 아니라 위임입니다. 다른 시스템(Keycloak, Entra ID, Okta, Google)이 자기 방식으로 사용자를 인증하고, 앱에 ID 토큰을 돌려줍니다.
- JWT: 이것도 로그인이 아니라 서명된 토큰 형식입니다. 사용자가 위의 방법 중 하나로 본인임을 증명한 뒤 서버가 세션을 발급하는데, 그 세션이 JWT일 수 있다는 것뿐입니다.
그래서 "OIDC냐 패스키냐"는 같은 층위의 선택이 아닙니다. OIDC를 쓰면 패스키는 IdP 쪽에 있습니다. 팀에 이미 IdP가 있다면 그걸 쓰세요. MFA, 패스키, 기기 정책이 한곳에 모입니다. 앱 자체의 패스키는 IdP 없이 운영하는 경우, 그러니까 모든 걸 셀프 호스팅하는 작은 팀이라는 흔한 경우를 위한 것입니다. 그리고 "JWT 씁니다"는 로그인이 얼마나 강한지에 대해 아무것도 말해주지 않습니다.
## TOTP만으로는 부족한 이유
TOTP는 유출된 비밀번호는 막아주지만 피싱은 막지 못합니다. 사용자는 6자리를 읽고 입력하라는 곳에 입력합니다. Evilginx 같은 피싱 프록시는 진짜 로그인 화면을 보여주고, 비밀번호와 코드를 받아서 30초 안에 둘 다 진짜 사이트로 넘깁니다. 두 번째 요소는 제대로 동작했습니다. 공격자를 위해서요.
패스키는 이걸 사용자의 주의력이 아니라 구조로 해결합니다. 브라우저는 페이지의 오리진을 서명 대상에 넣고, 패스키를 만들어진 도메인에서만 씁니다. studio-login.example.com 페이지에서는 studio.example.com의 패스키가 아예 뜨지 않고, 다른 오리진에서 만든 서명은 서버 검증을 통과하지 못합니다.
잘 언급되지 않는 장점도 있습니다. 서버는 공개 키만 가지고 있으니 계정 DB가 유출돼도 로그인에 쓸 수 있는 건 아무것도 없습니다. TOTP 비밀은 다릅니다. 서버가 코드를 계산하려면 그걸 저장해 둬야 합니다.
## 구현하면서 배운 것
1. user verification을 필수로. 이게 없으면 PIN 없는 보안 키는 "키를 가지고 있다"만 증명합니다. 패스키 로그인이 TOTP를 건너뛴다면 두 요소를 하나로 바꾼 셈입니다. 등록과 로그인 모두에서 PIN이나 생체 인증을 필수로 하면 패스키는 완전한 MFA(NIST SP 800-63B-4, AAL2)가 되고, 비밀번호와 코드를 모두 대신할 수 있습니다.
2. 오리진은 요청이 아니라 설정에서. 기대하는 오리진과 RP ID는 환경 변수PASSKEY_ORIGIN)에서 가져오고, Host, X-Forwarded-Host, 요청 URL에서는 절대 가져오지 않습니다. 요청에서 가져오면 서명을 요청 스스로의 주장과 비교하게 되니, 아무것도 검증하지 않는 것과 같습니다. 게다가 리버스 프록시 뒤에서는 그 값이 브라우저가 본 주소가 아니라 내부 이름인 경우가 많습니다.
3. 패스키 등록 때는 비밀번호를 다시 묻기. 탈취한 세션 쿠키가 있으면 공격자가 자기 패스키를 등록할 수 있습니다. 세션이 끝난 뒤에도 계속 열리는 뒷문입니다. 그래서 패스키를 추가하거나 삭제할 때는 현재 비밀번호와, 계정에 TOTP가 있으면 그 코드도 요구합니다.
JWT는 마지막에 등장합니다. 어떤 로그인(비밀번호 + TOTP, 패스키, OIDC) 뒤에도 서버는 같은 세션, 즉 HttpOnly 쿠키에 담긴 JWT를 발급합니다. JWT의 고전적인 약점은 이미 발급된 토큰을 취소할 수 없다는 점입니다. 그래서 저희 토큰에는 세션 버전이 들어 있고, 서버가 저장된 계정과 대조합니다. 계정을 비활성화하거나 패스키를 삭제하면 이 버전이 바뀌고, 다른 토큰은 더 이상 통하지 않습니다.
## 직접 해보기
패스키 로그인은 [버전] 버전부터 들어 있습니다.
```bash
docker run -d --name libredb-studio -p 3000:3000 \
-v libredb-data:/app/data \
-e STORAGE_PROVIDER=sqlite \
-e PASSKEY_ORIGIN=http://localhost:3000 \
ghcr.io/libredb/libredb-studio:latest
```
반드시 http://localhost:3000 으로 여세요. 127.0.0.1은 안 됩니다. 브라우저는 IP 주소에서 패스키를 쓰지 않습니다. docker logs libredb-studio에 찍힌 비밀번호로 admin@libredb.org 계정에 로그인한 뒤, 사용자 메뉴의 Sign-in security에서 Add passkey를 누르세요. 그다음 로그아웃하고 로그인 화면에서 Use a passkey를 쓰면 됩니다.
전체 흐름과, 무엇을 막고 무엇을 막지 못하는지는 docs/PASSKEYS.md에 정리해 두었습니다: https://github.com/libredb/libredb-studio/blob/main/docs/PASSKEYS.md
운영 중인 분들께 여쭤보고 싶습니다. 팀에서 DB 에디터 접근은 SSO로 하시나요, 로컬 계정으로 하시나요? 실시간 TOTP 피싱을 실제로 겪어 보신 분 있나요?