WebAuthn 표준을 사용한 Passkey 사용법
2024. 1. 31. 18:37

WebAuthn? Passkey?

WebAuthn은 2019년에 W3C(World Wide Web Consortium)에서 발표한 웹 표준으로, 웹 기반 애플리케이션 및 서비스에서 사용자를 인증하는 데 사용된다. 이 표준은 공개키 암호화를 기반으로 하며, 사용자의 생체 인증 장치를 사용하여 안전하고 강력한 인증을 제공한다. FIDO 얼라이언스의 FIDO2 프로젝트의 핵심으로 자리 잡은 WebAuthn은 사용자의 생체 인증을 포함한 다양한 인증 수단을 지원하며, 이는 이전의 비밀번호 기반 인증 체계를 대체하거나 보완한다. 이러한 생체 인증 기능을 통해 사용자는 더욱 안전하고 편리하게 인증을 수행할 수 있다.

 

특히, 최근 Passkey의 사용이 증가함에 따라 WebAuthn은 인증 체계의 핵심적인 요소로 자리 잡고 있다. Passkey는 고유한 사용자 장치(예: 스마트폰)를 통해 생성되고, 인증 과정에서 사용된다. 이는 사용자가 신원을 확인하고 인증하는 데 사용되며, 더 나은 보안성과 사용자 경험을 제공한다.

 

간단히 정리하면 WebAuthn은 웹 인증의 인터페이스이며, Passkey는 이를 활용한 자격인증(credential) 도구라고 볼 수 있다.

 

 

Passkey 사용 플로우

휴대폰(디바이스)으로 인증을 할 경우를 예시로 들었을 때 기본적인 작동방식 순서를 간단하게 정리하면 아래와 같다

 

회원가입

  1. Key Pair 생성을 위해 credentials.create()에 들어갈 관련정보들을 사용자 및 서버로부터 받아옴
  2. create()가 실행되면 브라우저에 인증방식 선택창이 뜨고 휴대폰을 선택하면 QR코드가 생김
  3. 휴대폰을 통해 QR코드 인증을 완료하면 디바이스 내부 FIDO2 보안 토큰 내부에 생성된 개인키(Private Key)와 공개키(Public Key), rawId(키쌍 id) 등의 정보가 저장됨
  4. create 응답값인 credential 정보(서명데이터, 공개키, 클라이언트 데이터 등)가 넘어오고 이것을 서버로 전송해 생성된 키 검증 및 데이터 베이스에 저장

로그인

  1. credentials.get()에 들어갈 정보를 서버에서 받아옴
  2. get()이 실행되면 브라우저 인증방식 선택창이 뜨고 휴대폰을 선택하면 QR코드가 생김
  3. 휴대폰을 통해 QR코드 인증을 완료하면 rawId(키 식별자)를 통해 디바이스 내부에서 해당 개인키를 찾아 서명하고 응답을 보냄
  4. 받은 응답인 credential 정보를 서버로 보내 기존에 저장된 공개키로 디코딩하여 본인임을 인증
  5. 서버에서 클라이언트로 증명 여부를 전달하여 로그인 처리 완료

 

 

Key Pair 생성 및 사용자 등록

let credential = await navigator.credentials.create({
  publicKey: {
    challenge: new Uint8Array([117, 61, 252, 231, 191, 241, ...]),
    rp: { id: "acme.com", name: "ACME Corporation" },
    user: {
      id: new Uint8Array([79, 252, 83, 72, 214, 7, 89, 26]),
      name: "jamiedoe",
      displayName: "Jamie Doe"
    },
    pubKeyCredParams: [ {type: "public-key", alg: -7} ]
  }
});

 

JavaScript에서는 브라우저의 정보를 담고 있는 navigator객체를 통해서 자격증명(credential)을 생성하고 활용하여 WebAuthn 인증방식을 사용할 수 있다.

 

사용자 등록(회원가입)을 위해서는 create()를 사용함

  • challenge : 인증이 재활용되어 악용되는 것을 방지하기 위해 인증에 대한 고유 값을 부여하는 것 (보통 서버에서 받음)
  • rp : Relying Party의 약자로 WebAuthn 인증을 제공하는 서비스의 정보
  • user : 서비스를 사용할 사용자에 대한 정보
    • id : 백 단의 데이터 베이스에서 사용할 사용자 고유 식별자, 인증 완료 시 디바이스에 키쌍과 함께 바인딩됨
    • name : 인간 친화적인 고유 식별자로 이메일이나 휴대폰번호 등을 사용
    • displayName : 일반적으로 우리가 생각하는 닉네임 개념
  • pubKeyCredParams : 알고리즘 타입 지정
    • type : 현재 기준 'public-key'밖에 옵션이 없음
    • alg: 알고리즘 선택하는 부분으로 -7은 ES256(ECDSA의 한 종류, secp256r1 커브 사용)을 뜻함
      1. 알고리즘 코드표:  https://www.iana.org/assignments/cose/cose.xhtml#algorithms
      2. 참고로 우리가 잘 아는 RSA 알고리즘을 사용하는 RS256도 있는데 ES256에 비해 호환성이 좋은 편이지만 데이터가 더 큰 키를 사용해 성능상으로 더 무거움

 

create()를 완료하면 아래와 같은 응답값인 credential을 받고 해당 데이터를 활용해 인증에 대한 유효성 검증 및 개인키 정보를 데이터 베이스에 저장한다

credential = {
  id: "dGhpcy1pcy1hLXBhc3N3b3Jk",
  rawId: Uint8Array(...),	// 등록 시에 생성된 키 쌍에 대한 원시 바이너리 식별자
  response: {
    attestationObject: Uint8Array(...),	// 서명된 데이터와 공개 키에 대한 정보
    clientDataJSON: Uint8Array(...),	// 클라이언트 데이터에 대한 정보
  },
  type: "public-key"
}
  • id : Key Pair에 대한 고유식별자, rawId의 base64url 인코딩 버전
  • rawId : Key Pair에 대한 고유식별자, 개인키 저장소(디바이스)용 식별자
  • attestationObject : 서명데이터와 공개키 정보가 있어 해당 공개키로 서명을 복호화하여 정상적인 키인지 검증 가능
  • clientDataJSON : challenge(인증 고유키) 내용이 포함되어 서버에서 발행한 인증 요청에 대한 응답인지 확인 가능

참고로 attestationObject를 디코딩하면 authData가 나오고 이것을 또 디코딩하면 아래와 같은 데이터가 나온다

{
  "rpIdHash": "abcdefgh12345678",
  "flags": {
    "userPresent": true,
    "userVerified": true,
    "attestedCredentialDataIncluded": true,
    "extensionDataIncluded": false
  },
  "signCount": 1234,
  "attestedCredentialData": {
    "aaguid": "abcdefgh-1234-5678-90ab-cdef01234567",
    "credentialIdLength": 32,
    "credentialId": "abcdefghijklmnopqrstuvwx123456",
    "credentialPublicKey": {
      "kty": "EC",	// 공개키 유형, 여기서는 ECDSA
      "alg": -7,
      "crv": "P-256",	// 커브 종류
      "x": "abcdefghijklmnopqrstuvwx123456",
      "y": "abcdefghijklmnopqrstuvwx123456"
    }
  }
}

 

credentialPublicKey가 개인키이며 알고리즘 종류와 커브곡선의 좌표를 확인할 수 있다.

 

사용자 인증

let credential = await navigator.credentials.get({
  publicKey: {
    challenge: new Uint8Array([139, 66, 181, 87, 7, 203, ...]),
    rpId: "acme.com",
    allowCredentials: [{
      type: "public-key",
      id: new Uint8Array([64, 66, 25, 78, 168, 226, 174, ...])
    }],
    userVerification: "required",
  }
});

 

사용자 인증(로그인)을 위해서는 credentials.get()을 사용함

  • challenge : 인증이 재활용되어 악용되는 것을 방지하기 위해 인증에 대한 고유 값을 부여하는 것 (보통 서버에서 받음)
  • rpId : 서비스 제공자 도메인 id
    • 키 식별자 충돌 방지용
    • rp가 요청한 건지에 대한 유효성 검증용
    • 사용자에게 어떤 서비스의 요청인지 알려주기 용
  • allowCredentials : 인증 정보
    • id : create()에서 생성됐던 키쌍의 고유식별자 id 또는 rawId를 입력, 인증 도구(디바이스)에서 개인키를 찾기 위함
  • userVerification : 사용자 인증의 보안성 강도를 설정하는 것으로 보이나 옵션 별 정확한 차이는 모르겠음... 
    • required 
    • preferred
    • discouraged

 

get()을 완료하면 아래와 같은 응답값인 credential을 받고 해당 데이터를 활용해 인증에 대한 유효성을 검증한다

credential = {
  id: "dGhpcy1pcy1hLXBhc3N3b3Jk",
  rawId: new Uint8Array([64, 66, 25, 78, 168, 226, 174, ...]), // 등록 시에 생성된 키 쌍에 대한 원시 바이너리 식별자
  type: "public-key",
  response: {
    authenticatorData: new Uint8Array([...]), // 인증기 데이터
    clientDataJSON: new Uint8Array([...]), // 클라이언트 데이터에 대한 정보
    signature: new Uint8Array([...]), // 서명
    userHandle: new Uint8Array([...]), // 사용자 식별자
  },
};
  • id : Key Pair에 대한 고유식별자, 서버용 식별자
  • rawId : Key Pair에 대한 고유식별자, 개인키 저장소(디바이스)용 식별자
  • athenticatorData : rpId, 서명 횟수, 사용자 확인 여부 등의 데이터가 포함되어 인증기에서 내려온 데이터가 올바른 것인지 확인하는 데 사용됨
  • clientDataJSON : challenge(인증 고유키), Origin(출처) 등이 포함되어 본서비스에서 발행한 인증 요청에 대한 응답인지 확인 가능
  • signature : 개인키로 서명된 값으로 인증 유효성 검사의 핵심 데이터
  • userHandle : create()에 사용된 user.id와 같은 값으로 인증 결과를 통해 어떤 사용자 것인지 맵핑이 가능함. 따라서 3달마다 비밀번호 변경을 권유하는 것처럼 사용자를 확인하고 패스키 교체를 권유하는데 활용될 수 있음

 

참고로 서명을 검증하는 예시 코드는 아래와 같은데 signature, publicKey, challenge, clientDataJSON을 활용해 서명값이 유효한지 boolean 값으로 확인이 가능하다.

// 서버에서 제공한 도전 값(challenge)과 클라이언트 데이터(clientDataJSON)입니다. 
const challenge = /* 서버에서 제공한 도전 값 */;
const clientDataJSON = /* 클라이언트 데이터 */;

// 서버에서 제공한 인증서(credential) 객체입니다. 여기서 서명(signature)을 가져옵니다.
const signature = /* 서버에서 제공한 서명(signature) */;

// 사용자의 공개 키를 이용하여 서명을 검증하는 함수입니다.
async function verifySignature(publicKey, challenge, clientDataJSON, signature) {
    // 서명 데이터를 ArrayBuffer로 변환합니다.
    const signatureArrayBuffer = new Uint8Array(signature).buffer;

    // 클라이언트 데이터를 ArrayBuffer로 변환합니다.
    const clientDataArrayBuffer = new Uint8Array(clientDataJSON).buffer;

    // 서명을 검증하기 위해 Web Crypto API의 SubtleCrypto 객체를 사용합니다.
    const crypto = window.crypto.subtle;

    // 서버에서 제공한 공개 키를 형식에 맞게 가져와서 사용합니다.
    const importedPublicKey = await crypto.importKey(
        'spki', // 공개 키 형식
        publicKey, // 공개 키
        {
            name: 'ECDSA',
            namedCurve: 'P-256' // ECDSA 알고리즘과 사용된 타원 곡선
        },
        true, // 추출 가능 여부
        ['verify'] // 사용할 암호화 기능
    );

    // 서명을 검증합니다.
    const isVerified = await crypto.verify(
        {
            name: 'ECDSA',
            hash: { name: 'SHA-256' }, // 해시 알고리즘
        },
        importedPublicKey, // 검증에 사용할 공개 키
        signatureArrayBuffer, // 서명 데이터
        clientDataArrayBuffer // 검증할 데이터 (클라이언트 데이터)
    );

    return isVerified;
}

// 사용자의 공개 키와 서명 데이터를 사용하여 서명을 검증합니다.
verifySignature(publicKey, challenge, clientDataJSON, signature)
    .then(isValid => {
        if (isValid) {
            console.log('서명이 유효합니다.');
        } else {
            console.log('서명이 유효하지 않습니다.');
        }
    })
    .catch(error => {
        console.error('서명 검증 중 오류가 발생했습니다:', error);
    });

 

 

 

 

참고문헌

https://www.hahwul.com/2023/10/22/webauthn_and_passkey/

https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API

https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create

https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get

https://www.corbado.com/blog/webauthn-user-id-userhandle#webauthn-specifications-complications

https://www.w3.org/TR/webauthn/#dictionary-authenticatorSelection

'Crypto' 카테고리의 다른 글

암호화와 복호화 정리  (2) 2024.01.26
Crypto 의 다른 글
퓨어맥스
퓨어맥스
의미있는 기록을 남기기 위해 시작한 개발 블로그 입니다. 사랑해주세요~❤️