WebAuthn
고친 사람 github-actions[bot]
WebAuthn 은 비밀번호 없이 웹사이트에 로그인하게 해 주는 웹 표준입니다. 사용자의 기기가 열쇠를 만들어 품고, 로그인할 때마다 그 열쇠로 서명해 자기가 맞다는 것을 보입니다. 웹사이트는 서명을 검사할 수 있는 절반만 받아 둡니다. 그래서 웹사이트의 데이터가 새어도 그것만으로는 로그인할 수 없습니다.
쉽고 빠른 이해
WebAuthn 은 로그인할 때 비밀번호를 치는 대신 휴대폰이나 노트북이 대신 증명해 주게 하는 약속입니다. 지문을 대거나 화면 잠금 번호를 누르면 로그인이 끝나는 방식이 이것입니다.
비밀번호는 서버에도 사본이 있어서 서버가 털리면 같이 샙니다. 가짜 사이트에 속아 직접 넣어 주기도 합니다. WebAuthn 에서는 서버가 비밀을 갖지 않고, 기기가 진짜 사이트에만 응답합니다.
어떻게 도는가:
- 가입할 때 기기가 열쇠 한 쌍을 만들고, 검사용 절반만 서버에 보냅니다
- 로그인할 때 서버가 매번 새 문제를 내면, 기기가 숨겨 둔 절반으로 서명합니다
- 서버는 받아 둔 절반으로 서명을 검사합니다
대가도 있습니다. 기기를 잃으면 다른 로그인 수단이 있어야 합니다. 사이트 주소를 바꾸면 만들어 둔 열쇠를 못 씁니다.
상세
WebAuthn 은 Web Authentication, 웹 인증의 줄임말입니다. W3C(World Wide Web Consortium, 월드 와이드 웹 컨소시엄)가 정한 웹 표준입니다. 브라우저가 웹 페이지에 내주는 API(Application Programming Interface, 프로그램끼리 부르는 약속)와, 그 뒤에서 오가는 메시지의 순서를 함께 정합니다.
WebAuthn 은 두 번의 왕복으로 이루어집니다. 가입할 때 한 번 도는 등록과, 로그인할 때마다 도는 로그인입니다.
비밀번호 대신 키 쌍
WebAuthn 은 공개 키 암호를 씁니다. 공개 키 암호는 짝을 이루는 두 키를 씁니다. 하나는 주인만 갖는 개인 키이고, 다른 하나는 누구에게 보여도 되는 공개 키입니다. 앞에서 말한 「검사용 절반」이 공개 키입니다.
개인 키로 만든 전자 서명은 짝이 되는 공개 키로만 검사가 통과합니다. 공개 키를 가진 쪽은 서명을 검사할 수는 있어도 서명을 만들 수는 없습니다.
이 성질이 비밀번호와 갈리는 지점입니다. 비밀번호는 서버도 같은 비밀을 알아야 맞춰 볼 수 있습니다. WebAuthn 에서 서버는 공개 키만 저장하므로, 그 저장소가 통째로 새어도 공격자는 서명을 못 만듭니다.
로그인에 참여하는 셋
주고받는 쪽은 셋입니다. 셋이 맡는 일은 저마다 다릅니다. 아래 절은 전부 이 셋 사이에 오가는 메시지입니다.
| 참여자 | 무엇인가 | 맡는 일 |
|---|---|---|
| 신뢰 당사자 | 로그인을 받는 웹사이트와 그 서버 | 문제를 내고, 공개 키를 저장하고, 서명을 검사한다 |
| 브라우저 | 사용자가 쓰는 브라우저 | 페이지와 인증기 사이를 잇고, 지금 어느 사이트인지 적어 넣는다 |
| 인증기 | 키를 만들어 품고 서명하는 장치 | 개인 키를 서버나 웹 페이지에 넘기지 않고 서명만 해 준다 |
신뢰 당사자는 영어로 Relying Party 이고 줄여서 RP 라고 씁니다. 「남이 서명한 것을 검사해 믿는 쪽」이라는 뜻의 이름입니다.
인증기는 휴대폰이나 노트북 안에 들어 있기도 하고, USB(Universal Serial Bus) 단자에 꽂는 작은 보안 키로 따로 있기도 합니다. 어느 쪽이든 개인 키는 서버에도, 웹 페이지에도 넘어가지 않습니다. 기기 사이에 키를 옮기는 경우는 아래 「패스키와 보안 키」에서 다룹니다.
챌린지와 서명
서명할 내용은 서버가 정합니다. 서버는 등록이든 로그인이든 시도마다 새 무작위 바이트를 만들어 보냅니다. 이 값이 챌린지입니다. 앞에서 서버가 낸다고 한 「새 문제」가 바로 챌린지입니다.
챌린지가 매번 바뀌는 까닭은 가로챈 응답을 다시 쓰지 못하게 하려는 것입니다. 어제의 서명은 어제의 챌린지에 대한 것이라, 오늘 다시 보내면 서버가 방금 낸 값과 안 맞아 거절합니다. 이런 공격을 재전송 공격이라고 합니다.
서버는 챌린지를 내준 뒤 세션에 기억해 둡니다. 응답이 오면 그 값과 맞춰 봅니다. 한 번 쓴 챌린지는 버립니다.
등록 절차
등록은 계정에 인증기를 처음 붙이는 왕복입니다. 이 왕복에서 인증기는 이 사이트 전용 키 쌍을 하나 만듭니다. 그 키 쌍에 어느 사이트용인지 같은 정보를 붙여 묶은 것이 크리덴셜입니다.
크리덴셜마다 크리덴셜 ID라는 이름표가 붙습니다. 등록이 끝나면 서버는 공개 키와 크리덴셜 ID 를 사용자 계정에 붙여 저장합니다. 로그인 때 서버는 이 ID 로 어느 공개 키를 꺼낼지 찾습니다.
페이지의 자바스크립트 코드는 navigator.credentials.create() 를 불러 등록을 시작합니다. 넘기는 값은 서버가 먼저 만들어 내려준 것입니다.
const cred = await navigator.credentials.create({
publicKey: {
challenge: fromServer.challenge,
rp: { id: "shop.example", name: "Shop" },
user: { id: userHandle, name: "kim", displayName: "김철수" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
});
넘긴 값 넷이 정하는 것은 이렇습니다.
| 필드 | 뜻 |
|---|---|
challenge |
이번 등록에 쓸 새 챌린지 |
rp.id |
이 키를 묶을 도메인. 아래 「도메인에 묶인 키」에서 다룬다 |
user.id |
서버가 정한 사용자 식별 바이트. 이메일 같은 개인 정보를 넣지 않는다 |
pubKeyCredParams |
서버가 받을 수 있는 서명 방식 목록. -7 은 ES256 이라는 [[타원 곡선 암호 |
호출이 성공하면 브라우저가 결과를 돌려줍니다. 서버로 보낼 것은 아래 세 값입니다.
const r = cred.response;
cred.rawId // 크리덴셜 ID
r.clientDataJSON // 브라우저가 쓴 JSON
r.attestationObject // 공개 키 묶음
clientDataJSON 은 브라우저가 챌린지와 지금 사이트의 주소를 적은 JSON(JavaScript Object Notation)입니다. attestationObject 에는 새 공개 키와 인증기 데이터가 들어 있습니다. 인증기 데이터는 아래 「로그인 절차」에서 풉니다.
sequenceDiagram
participant 서버
participant 브라우저
participant 인증기
서버->>브라우저: 챌린지 · 도메인 · 사용자 정보
브라우저->>인증기: 이 도메인용 키를 만들어 달라
Note over 인증기: 사용자 확인 뒤 키 쌍을 만든다
인증기-->>브라우저: 크리덴셜 ID · 공개 키
브라우저-->>서버: 크리덴셜 ID · 공개 키 · clientDataJSON
Note over 서버: 챌린지와 주소를 맞춰 보고 공개 키를 저장한다
서버가 챌린지를 내립니다. 인증기는 그 도메인 전용 키 쌍을 만듭니다. 서버는 챌린지가 맞는지 본 뒤 공개 키를 사용자 계정에 붙여 저장합니다.
로그인 절차
로그인은 등록해 둔 키로 서명을 받아 오는 왕복입니다. 페이지 코드는 navigator.credentials.get() 을 부릅니다. 이때 서버가 내려 준 새 챌린지와 도메인을 넘깁니다.
서버는 이 사용자가 등록해 둔 크리덴셜 ID 목록도 함께 내려 줍니다. 인증기는 그중 자기가 가진 키로 서명합니다. 응답에는 어느 키를 썼는지 알리는 크리덴셜 ID 가 함께 돌아옵니다. 서버는 돌아온 ID 로 저장해 둔 공개 키를 꺼냅니다.
인증기가 서명하는 내용은 두 조각을 이어 붙인 것입니다. 앞 조각은 브라우저가 쓴 clientDataJSON 의 해시입니다. 해시는 긴 데이터를 짧은 고정 길이 값으로 줄인 요약입니다. 원본이 한 글자만 바뀌어도 해시가 달라집니다.
뒤 조각은 인증기 데이터입니다. 인증기가 직접 적는 짧은 바이트 묶음입니다. 브라우저가 쓴 것과 인증기가 쓴 것을 한 서명으로 묶었으니, 어느 한쪽만 바꿔치기할 수 없습니다.
인증기 데이터에는 값이 셋 들어 있습니다. 셋은 저마다 막는 것이 다릅니다.
| 값 | 무엇인가 | 무엇을 막나 |
|---|---|---|
| 도메인 해시 | 키가 묶인 도메인의 해시 | 다른 사이트용 키로 서명한 응답 |
| 사용자 확인 플래그 | 서명 전에 사람을 확인했는지 알리는 표시. 「사용자 있음」과 「사용자 검증」 두 가지다. 아래 「사용자 확인」에서 푼다 | 사람 모르게 나온 서명 |
| 서명 카운터 | 서명할 때마다 오르는 수 | 복제된 인증기의 서명 |
서명 카운터는 서버가 받은 값을 저장해 두었다가 다음 로그인 값과 견주는 데 씁니다. 인증기가 복제되면 두 인증기가 따로 셉니다. 그러면 어느 쪽에서든 값이 뒤로 가거나 제자리에 머무는 때가 옵니다.
sequenceDiagram
participant 서버
participant 브라우저
participant 인증기
서버->>브라우저: 새 챌린지 · 도메인 · 크리덴셜 ID 목록
브라우저->>인증기: 이 도메인 키로 서명해 달라
Note over 인증기: 사용자 확인 뒤 개인 키로 서명한다
인증기-->>브라우저: 크리덴셜 ID · 인증기 데이터 · 서명
브라우저-->>서버: 크리덴셜 ID · 인증기 데이터 · 서명 · clientDataJSON
Note over 서버: ID 로 찾은 공개 키로 서명을 검사한다
등록과 모양은 같습니다. 다른 점은 인증기가 새 키를 만들지 않고 있던 키로 서명한다는 것, 그리고 서버가 공개 키를 저장하는 대신 꺼내 쓴다는 것입니다.
서버가 확인하는 항목
응답이 오면 서버는 먼저 clientDataJSON 을 풀어 읽습니다. 바이트를 글자로 바꾼 뒤 JSON 으로 해석하면 이런 값이 나옵니다.
const cd = JSON.parse(new TextDecoder().decode(r.clientDataJSON));
cd.type // "webauthn.get"
cd.challenge // 서버가 준 챌린지
cd.origin // "https://shop.example"
type 은 등록이면 "webauthn.create", 로그인이면 "webauthn.get" 입니다. origin 은 브라우저가 적어 넣은 사이트 주소라서 페이지 코드가 바꿔 쓸 수 없습니다.
서버는 이 값들과 인증기 데이터, 서명을 차례로 확인합니다. 하나라도 어긋나면 로그인을 거절합니다.
| 확인 | 어긋나면 무엇을 뜻하나 |
|---|---|
type 이 이번 절차와 맞나 |
등록 응답을 로그인에 돌려쓰려 한다 |
challenge 가 방금 준 값인가 |
가로챈 옛 응답을 다시 보냈다 |
origin 이 우리 사이트 주소인가 |
다른 사이트가 받은 응답을 넘겨 왔다 |
| 인증기 데이터의 도메인 해시가 우리 도메인인가 | 다른 도메인용 키로 서명했다 |
| 「사용자 있음」 플래그가 섰나. 사용자 검증을 요구했으면 「사용자 검증」 플래그까지 | 사람의 확인 없이 서명이 나왔다. 두 플래그는 아래 「사용자 확인」에서 푼다 |
| 서명 카운터가 저장한 값보다 커졌나 | 인증기가 복제됐을 수 있다. 0 을 보내는 인증기는 아래 「도입할 때 따라오는 일」에서 다룬다 |
| 저장한 공개 키로 서명이 검사되나 | 개인 키를 가진 인증기가 아니다 |
도메인에 묶인 키
등록 때 넘긴 rp.id 가 이 키가 묶이는 도메인입니다. 이 값을 RP ID 라고 부릅니다. 인증기는 키를 RP ID 별로 따로 보관합니다. 다른 RP ID 로 요청이 오면 그 키를 꺼내지 않습니다.
RP ID 는 아무 값이나 쓸 수 없습니다. 지금 페이지 주소의 호스트이거나 그 상위 도메인이어야 합니다. login.shop.example 페이지는 shop.example 을 RP ID 로 쓸 수 있고, other.example 은 쓸 수 없습니다.
이 규칙이 피싱을 막습니다. 가짜 사이트 shop-examp1e.com 에서 로그인을 누르면, 브라우저는 그 가짜 주소로 키를 찾습니다. 인증기에는 그 도메인용 키가 없으니 서명이 안 나옵니다.
공격자가 진짜 사이트의 챌린지를 받아다 중계해도 소용이 없습니다. 브라우저는 origin 에 가짜 사이트 주소를 적고, 진짜 서버는 그 값을 보고 거절합니다. 사용자가 주소에 속아도 브라우저와 인증기는 속지 않는다는 뜻입니다.
사용자 확인
인증기가 서명하기 전에 사람을 확인하는 단계는 두 층입니다. 서버는 둘 중 무엇이 일어났는지를 인증기 데이터의 플래그로 받습니다.
| 플래그 | 무엇을 확인했나 | 예 |
|---|---|---|
| 사용자 있음 | 누군가 곁에서 장치를 건드렸다 | 보안 키의 버튼을 누른다 |
| 사용자 검증 | 그 사람이 기기 주인이다 | 지문을 대거나 PIN(Personal Identification Number, 개인 식별 번호)을 넣는다 |
영어 이름은 user presence 와 user verification 입니다. 서버는 로그인 요청에 사용자 검증을 요구할지 적을 수 있습니다.
생체 인증을 써도 지문이나 얼굴 정보는 기기 밖으로 나가지 않습니다. 기기가 제 안에서 맞춰 보고, 서버에는 「검증했다」는 플래그 한 비트만 보냅니다.
패스키와 보안 키
인증기는 붙는 방식에 따라 둘로 나뉩니다. 휴대폰 · 노트북에 들어 있는 것을 플랫폼 인증기라고 합니다. USB · NFC(Near Field Communication, 근거리 무선 통신) · 블루투스로 붙였다 떼는 보안 키를 로밍 인증기라고 합니다.
WebAuthn 은 웹 페이지와 브라우저 사이를 정합니다. 브라우저와 로밍 인증기 사이의 대화는 CTAP(Client to Authenticator Protocol)이 맡습니다. CTAP 은 브라우저가 보안 키와 이야기하는 규격입니다.
WebAuthn 과 CTAP 을 묶어 FIDO2라고 부릅니다. FIDO 는 Fast IDentity Online 의 줄임말입니다. FIDO2 를 지원한다는 말은 이 두 규격을 함께 따른다는 뜻입니다.
크리덴셜 중에는 인증기가 사용자 정보까지 함께 품는 것이 있습니다. 이런 크리덴셜을 찾을 수 있는 크리덴셜(discoverable credential)이라고 합니다. 서버가 크리덴셜 ID 목록을 내려 주지 않아도 인증기가 스스로 찾아 씁니다. 서버가 누가 로그인하는지 미리 알 필요가 없으니, 사용자 이름을 치는 단계까지 없앨 수 있습니다.
흔히 패스키라고 부르는 것이 이 찾을 수 있는 크리덴셜입니다. 패스키 중에는 운영체제 계정이나 비밀번호 관리자를 거쳐 여러 기기에 동기화되는 것도 있습니다. 이런 패스키는 개인 키가 그 동기화 경로를 타고 기기 사이를 옮겨 다닙니다. 그래서 기기를 바꿔도 같은 키로 로그인합니다.
도입할 때 따라오는 일
WebAuthn 은 HTTPS(HyperText Transfer Protocol Secure) 페이지에서만 열립니다. 주소가 암호화 연결로 보증되어야 origin 을 믿을 수 있기 때문입니다. 개발 중에는 localhost 만 예외로 열어 줍니다.
도메인을 옮기면 기존 키를 못 씁니다. 키가 RP ID 에 묶여 있으니, 새 도메인에서는 사용자마다 다시 등록해야 합니다. 처음 RP ID 를 고를 때 오래 쓸 상위 도메인을 고르는 까닭입니다.
기기를 잃은 사용자를 위한 길도 서버가 마련해야 합니다. 한 계정에 인증기를 여럿 등록하게 두거나, 복구용 코드 같은 다른 로그인 수단을 남겨 둡니다.
응답 해석은 직접 짜기 까다롭습니다. attestationObject 는 CBOR(Concise Binary Object Representation)라는 이진 형식입니다. 공개 키도 그 안에 바이트로 들어 있습니다. 확인 항목이 많아 하나만 빠뜨려도 구멍이 되므로, 서버 쪽은 검증된 라이브러리를 쓰는 것이 보통입니다.
서명 카운터도 저장해 둡니다. 로그인마다 받은 값은 저장한 값보다 커야 합니다. 작거나 같으면 인증기가 복제됐을 수 있다는 신호입니다. 동기화되는 패스키는 카운터를 늘 0 으로 보내기도 해서, 0 은 「세지 않는 인증기」로 따로 다룹니다.
관련 항목
WebAuthn 절차에 참여하는 역할
신뢰 당사자 · 인증기 · 플랫폼 인증기 · 로밍 인증기 · 보안 키 · 브라우저 · 사용자 에이전트
WebAuthn 이 기대는 암호 기법
공개 키 암호 · 개인 키 · 공개 키 · 전자 서명 · 해시 함수 · 타원 곡선 암호 · 챌린지-응답 인증
WebAuthn 메시지를 이루는 구성 요소
챌린지 · 크리덴셜 · 크리덴셜 ID · 인증기 데이터 · clientDataJSON · 증명 · 서명 카운터 · RP ID
WebAuthn 과 함께 FIDO2 를 이루는 규격
FIDO2 · CTAP · FIDO U2F · FIDO Alliance · W3C · Credential Management API
WebAuthn 으로 만드는 로그인 방식
패스키 · 찾을 수 있는 크리덴셜 · 비밀번호 없는 인증 · 다중 인증 · 생체 인증 · PIN
WebAuthn 이 대신하거나 보태는 인증 수단
비밀번호 · 일회용 비밀번호 · TOTP · SMS 인증 · 매직 링크 · 싱글 사인온
WebAuthn 이 막는 공격
피싱 · 재전송 공격 · 크리덴셜 스터핑 · 중간자 공격 · 비밀번호 유출
WebAuthn 이 기대는 웹 보안 규칙
오리진 · HTTPS · 보안 컨텍스트 · 동일 출처 정책 · 도메인 · 세션
WebAuthn 이 속하는 상위 분류
다른 이름: Web Authentication · 웹 인증 · Web Authentication API · 웹어센