패스키(Passkey)의 동작 원리 – 비밀번호 유출 없는 안전한 방법

요즘, 모든 것이 온라인화된 시대에 살고 있다보니, 온라인을 통한 비밀번호 유출 사고가 심심치 않게 들립니다. 이렇게 유출된 비밀번호는 다른 웹사이트 로그인이 사용되기도 하고, 아이디/비밀번호 구조의 로그인은 사살 무한 대입법으로 뚫릴 수 있는 취약점이 여전히 존재합니다. 물론 이를 막고자 여러 방법을 사용하기는 하지만, 비밀번호를 서버에 저장하는 방식 자체가 좋은 방법은 아닌 것 같습니다. 물론 해시값으로 저장되겠지만, 해쉬도 완전히 안전하다고 보기 어렵죠.
그러다보니, MFA나 패스키같은 기술이 주목받고 있습니다. 둘 다 공개키 암호화 방식을 사용하는것인데요. 오늘은 패스키의 실제 동작 방식과 예제 코드까지 알아보도록 하겠습니다.

패스키란 무엇인가?

패스키(Passkey)는 비밀번호 없는 인증을 실현하는 최신 기술로, Apple, Google, Microsoft가 협력하여 만든 FIDO2/WebAuthn 기반 인증 방식입니다. 사용자 인증은 생체인식 또는 PIN으로 이루어지고, 서버에는 더 이상 비밀번호가 저장되지 않습니다.

비밀번호 탈취, 피싱, 무차별 대입 공격 등 기존 인증 방식의 취약점을 보완하는 매우 강력한 보안 수단으로 주목받고 있습니다.

https://www.google.com/intl/ko/account/about/passkeys

패스키의 동작 원리 – 단계별 설명

패스키는 공개키 기반 비대칭 암호화(PKI)를 기반으로 작동합니다. 핵심은 다음 단계와 같습니다.

🔐 1. 패스키 등록 (Registration)

절차

  1. 사용자가 패스키 생성을 요청합니다.
  2. 브라우저 또는 OS 레벨에서 공개키/개인키 쌍이 생성됩니다.
  3. 개인키는 안전하게 디바이스 내에 저장되며, 공개키만 서버로 전송됩니다.
    • 개인키 – 디바이스, 공개키 – 서버

예제 코드 (서버: Node.js + Express + @simplewebauthn/server)

import { generateRegistrationOptions } from '@simplewebauthn/server';

const registrationOptions = generateRegistrationOptions({
  rpName: 'IoT Things Maker',
  rpID: 'iothingsmaker.com',
  userID: 'user-1234',
  userName: 'john.doe@example.com',
  timeout: 60000,
  attestationType: 'direct',
});
res.json(registrationOptions);

서버는 registrationOptions를 클라이언트(브라우저)에 전송하고, 브라우저는 보안 모듈을 통해 키 쌍을 생성하고 서버에 다시 공개키를 반환합니다.

🔑 2. 로그인 (Authentication)

절차

  1. 사용자가 로그인 시도 → 서버는 challenge 값을 생성합니다.
  2. 디바이스는 이 challenge에 대해 개인키로 서명합니다.
  3. 서버는 등록된 공개키로 서명을 검증하여 로그인 여부를 결정합니다.

예제 코드 (서버: Node.js)

import { generateAuthenticationOptions } from '@simplewebauthn/server';

const authOptions = generateAuthenticationOptions({
  rpID: 'iothingsmaker.com',
  userVerification: 'preferred',
});
res.json(authOptions);

사용자 디바이스는 생체인식으로 인증을 완료한 후, 개인키로 challenge에 서명하여 credential.response를 생성하고 서버에 보냅니다.

서버에서는 다음과 같이 서명을 검증합니다:

import { verifyAuthenticationResponse } from '@simplewebauthn/server';

const verification = await verifyAuthenticationResponse({
  response: clientCredential,
  expectedChallenge: storedChallenge,
  expectedOrigin: 'https://iothingsmaker.com',
  expectedRPID: 'iothingsmaker.com',
  authenticator: storedAuthenticator,
});

검증이 통과되면 로그인 성공입니다.

왜 패스키가 강력할까요?

보안 이점설명
🔒 피싱 방지공개키는 도메인에 귀속되므로 가짜 사이트에서 사용 불가
🧠 기억할 필요 없음사용자는 비밀번호 대신 생체 인증 또는 PIN 사용
🗄️ 서버에 비밀번호 저장 안함유출 위험 감소
📱 다중 디바이스 간 연동Apple iCloud, Google 계정으로 다른 기기와 동기화 가능

IoT 환경에서의 적용 사례

패스키는 IoT 디바이스와 매우 궁합이 좋습니다. 대부분의 IoT 장치는 키보드 입력이 불가능하거나 제한적인데, QR 코드 또는 BLE를 통한 패스키 인증 연동이 가능합니다.

  • 스마트 도어락: 스마트폰으로 패스키 인증 후 잠금 해제
  • 산업 IoT: 관리자 로그인 없이 단말기 근접 인증
  • 스마트 홈: 집 안에서 자동 로그인을 통한 보안 인증

결론

패스키는 단순한 비밀번호 대체를 넘어, 개인정보 보호와 사용자 경험의 혁신을 동시에 이끄는 기술입니다. 안전하고 직관적인 인증 수단입니다.

부록

클라이언트 예제 코드를 추가했습니다.

📌 1. 패스키 등록 (Registration) – 클라이언트 코드

async function startRegistration() {
  // 서버에서 등록 옵션 받아오기
  const resp = await fetch('/webauthn/register-options');
  const options = await resp.json();

  // base64 → ArrayBuffer 변환
  options.challenge = base64urlToBuffer(options.challenge);
  options.user.id = base64urlToBuffer(options.user.id);

  // 브라우저가 패스키 생성 (publicKeyCredential 생성)
  const credential = await navigator.credentials.create({
    publicKey: options,
  });

  // 결과를 서버로 전송 (base64로 인코딩)
  const credentialData = {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
      attestationObject: bufferToBase64url(credential.response.attestationObject),
    },
  };

  await fetch('/webauthn/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentialData),
  });
}

대부분의 모던 브라우저는 패스키를 지원합니다. 지원되는 브라우저 버전이 궁금하시면, https://caniuse.com/?search=passkey 를 방문해보세요.

📌 2. 패스키 인증 (Authentication) – 클라이언트 코드

async function startAuthentication() {
  // 서버에서 로그인 옵션 받아오기
  const resp = await fetch('/webauthn/authenticate-options');
  const options = await resp.json();

  options.challenge = base64urlToBuffer(options.challenge);
  options.allowCredentials = options.allowCredentials.map((cred) => ({
    ...cred,
    id: base64urlToBuffer(cred.id),
  }));

  // 패스키로 로그인 (privateKey로 서명)
  const assertion = await navigator.credentials.get({
    publicKey: options,
  });

  // 결과 전송
  const authData = {
    id: assertion.id,
    rawId: bufferToBase64url(assertion.rawId),
    type: assertion.type,
    response: {
      authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
      clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
      signature: bufferToBase64url(assertion.response.signature),
      userHandle: assertion.response.userHandle
        ? bufferToBase64url(assertion.response.userHandle)
        : null,
    },
  };

  await fetch('/webauthn/authenticate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(authData),
  });
}

🔧 함수: Base64url 변환

function bufferToBase64url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

function base64urlToBuffer(base64url) {
  const base64 = base64url
    .replace(/-/g, '+')
    .replace(/_/g, '/')
    .padEnd(base64url.length + (4 - (base64url.length % 4)) % 4, '=');
  const binary = atob(base64);
  const buffer = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i);
  return buffer;
}

✅ 클라이언트/서버 흐름 요약

단계서버 응답클라이언트 동작
등록registrationOptionsnavigator.credentials.create()
인증authenticationOptionsnavigator.credentials.get()

댓글 남기기