728x90

1. 도입: 카메라가 왜 갑자기 보안 이슈가 되는가

요즘 엔터프라이즈 CCTV는 예전처럼 "화면 찍어서 NVR에 던지는 상자"가 아니다. AXIS가 자사 카메라에서 리눅스 애플리케이션을 직접 돌릴 수 있게 밀고 있는 것처럼, 카메라 자체가 리눅스가 돌아가는 엣지 노드다. 즉 패치 관리, 크리덴셜 관리, 취약점 스캐닝 대상이 되는 서버 한 대라는 얘기다. 그런데 실무에서 우리는 이 기기들을 "그냥 벽에 붙은 카메라" 취급하고 방화벽 뒤에 던져두는 경우가 많다.

이번에 화제가 된 사례는 한화비전(구 삼성테크윈) 카메라 펌웨어에서 GitHub admin 권한 토큰이 발견된 건이다. 그것도 로그인 UI 파일 안에, 그리고 조직의 수백 개 리포지토리에 접근 가능한 관리자 토큰이. 이게 남 일 같지 않은 이유는, 여기서 벌어진 실수가 우리 CI 파이프라인에서도 똑같이 벌어질 수 있는 구조적 실수이기 때문이다. 오늘은 이 사건을 훑으면서 "펌웨어에서 시크릿을 어떻게 찾는지", "왜 이런 게 반복되는지", "그래서 우리 파이프라인은 어떻게 막아야 하는지"까지 실무 관점으로 정리해본다.

2. 핵심: 펌웨어에서 토큰이 나오기까지의 흐름

원문 저자가 밟은 경로를 실무 순서대로 재구성하면 이렇다. 이 흐름 자체가 "서드파티 하드웨어 신뢰성 검증"의 기본기라 알아두면 유용하다.

2-1. 펌웨어 blob 확보 → binwalk로 열어보기

제조사 사이트에서 모델별 펌웨어를 그냥 다운받을 수 있는 경우가 의외로 많다. 받은 이미지를 일단 binwalk로 던져서 안에 뭐가 들었는지 본다.

$ binwalk firmware.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             POSIX tar archive (GNU)
1245184       0x130000        gzip compressed data
...
$ binwalk -e firmware.bin   # 추출까지

여기서 저자는 내부에 AI 관련 tarball과 fwimage.tgz가 있었는데, 이게 암호화되어 있어서 binwalk가 "encrypted"로 플래그를 띄웠다고 한다. 여기까지가 1차 관문이다.

2-2. 암호화 계층 뚫기

흥미로운 건 복호화 방식이다. 저자에 따르면 fwupgrader 바이너리 안에서:

  • AES 키가 바이너리 내부의 작은 static key table과 XOR 되어 있고, 런타임에 재조립된다.
  • IV는 그냥 평문으로 박혀 있다.
  • fwupgrader가 openssl CLI로 shell out 하는데, 그 명령어 조각들조차 같은 방식으로 XOR 난독화되어 있다.

재구성된 복호화 명령은 이런 형태였다고 한다:

openssl enc -md sha256 -aes-256-cbc -d \
  -K KEY -iv IV -in INPUT -out OUTPUT

여기서 핵심 포인트는 키가 모델 라인 전체에 걸쳐 하드코딩되어 동일하다는 것. 이게 왜 문제냐면, 하드웨어에 키를 구워 넣는(fuse) 방식과 달리 기기를 소유하지 않아도 펌웨어만 있으면 누구나 복호화 가능하다는 뜻이기 때문이다. 소위 "burn key into hardware"가 완벽한 방어는 아니지만, 최소한 "카메라를 물리적으로 가진 사람만" 이라는 진입 장벽이라도 생긴다. 반면 정적 키는 그 장벽이 0이다.

2-3. rootfs 확보 후 trufflehog로 시크릿 스캔

복호화된 rootfs가 손에 들어오면, 그 다음은 우리 실무에서도 매일 쓰는 도구가 나온다. trufflehog다.

$ trufflehog filesystem ./rootfs --only-verified

🐷🔑🐷  TruffleHog. Unearth your secrets. 🐷🔑🐷

Found verified result 🐷🔑
Detector Type: Github
Decoder Type: PLAIN
Raw result: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
File: ./rootfs/var/www/admin/assets/index-a1b2c3.js
Rotation_guide: https://howtorotate.com/docs/tutorials/github/

저자는 이 토큰이 약 30개 파일에 중복되어 있었고, 확인해보니 조직 내 수백 개 리포지토리에 admin 권한을 가진 토큰이었다고 한다. 즉 이 토큰 하나면 그 조직 GitHub의 사실상 전권을 쥘 수 있었다는 뜻이다.

2-4. 왜 30개 파일에 중복됐나 — 진짜 범인

이 부분이 이 글에서 가장 실무적으로 중요하다. 저자가 파악한 원인은 Vite 빌드 설정 실수였다. UI를 Vite로 빌드하는데, 어떤 변수에 process.env 전체를 통째로 주입하도록 설정되어 있었던 것.

var W = {
  DATAPORT: 9090,
  GIT_LFS_SKIP_SMUDGE: 1,
  npm_command: "run-script",
  KUBERNETES_SERVICE_PORT_HTTPS: 443,
  GITHUB_NPM_TOKEN: "ghp_...REDACTED...",
  npm_config_userconfig: "/home/docker/.npmrc",
  // ...
}

결과적으로 CI 잡의 환경변수 전체가 프론트엔드 번들 파일에 그대로 박혀서 출하된 것이다. 카메라 admin UI에 접속하는 사람은 이 토큰을 네트워크 너머로 전송받았을 가능성이 있다는 뜻. 덤으로 미국 국방부(DoD)에 할당된 IP 대역이 내부 서비스 env에 박혀 있는 것도 발견됐는데(한화가 방산 계열사를 두고 있는 점과 엮여 흥미로운 추측을 낳긴 했지만) 이 부분은 저자도 "SPECULATION"이라고 명시했으니 우리도 추측으로만 남겨두자.

참고로 대응은 빨랐다. 저자가 신고 메일을 보냈고 한화 측은 12시간 내에 토큰을 폐기했다고 한다. 사고 자체는 나쁘지만 대응 속도는 모범적이었다.

3. 실무 관점: 왜 반복되고, 우리는 어떻게 막나

3-1. 이 실수가 반복되는 근본 패턴

"토큰을 코드에 하드코딩하지 마라"는 다 안다. 그런데도 계속 터진다. 이유는 개발자가 손으로 넣는 하드코딩이 아니라 빌드 도구가 자동으로 환경변수를 삼켜버리는 구조이기 때문이다. 이번 케이스가 정확히 그렇다. 흔한 함정 몇 가지:

  • 프론트엔드 번들에 env 통주입: Vite/Webpack에서 define이나 DefinePluginprocess.env를 통째로 넣는 실수. Vite는 원래 VITE_ 프리픽스 붙은 것만 클라이언트에 노출하는데, 이걸 우회해서 전체를 넣으면 서버 시크릿까지 클라이언트로 샌다.
  • CI 러너 env에 장기 토큰 상주: GitHub Actions/GitLab CI 러너 환경에 오래 사는 PAT를 넣어두면, 빌드 산출물 어딘가로 새기 쉽다.
  • admin 권한 토큰 사용: npm private 패키지 하나 받자고 조직 admin 권한 PAT를 쓰는 건 최악. 필요한 최소 권한(read:packages 정도)만 줘야 한다.

3-2. 그래서 우리 CI에 시크릿 스캐너를 붙인다

가장 먼저 할 일은 "새어나가기 전에 잡는" 파이프라인 게이트다. GitHub Actions 예시:

name: secret-scan
on: [push, pull_request]

jobs:
  trufflehog:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 전체 히스토리 스캔
      - name: TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

커밋 들어가기 전 로컬에서 막고 싶으면 pre-commit 훅으로 gitleaks를 건다:

$ gitleaks detect --source . -v

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     GITHUB_NPM_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
Secret:      ghp_xxxxxxxxxxxxxxxxxxxx
RuleID:      github-pat
File:        web/.env.production
Commit:      a1b2c3d
Author:      dev@example.com

WARN[0000] leaks found: 1

3-3. 흔한 함정 — 실제로 만나는 에러

스캐너를 붙이면 처음엔 오탐과 씨름한다. 특히 CI에서 히스토리가 얕게 clone되면 이런 에러를 만난다:

Error: TruffleHog scan failed: unable to scan commits:
git rev-parse HEAD~1: fatal: ambiguous argument 'HEAD~1':
unknown revision or path not in the working tree

원인은 대부분 actions/checkout의 기본 fetch-depth: 1 때문이다. diff 기반 스캔을 하려는데 이전 커밋이 없어서 터지는 것. 위 예시처럼 fetch-depth: 0으로 전체 히스토리를 가져오면 해결된다. 다만 리포가 크면 clone 시간이 늘어나니, PR 스캔은 base...head 범위만 스캔하도록 좁히는 게 낫다.

또 하나 자주 보는 오탐 관련 이슈:

gitleaks detect --config .gitleaks.toml
WARN leaks found: 47

테스트 픽스처나 예제 코드의 더미 값까지 다 잡혀서 47개가 뜨는 경우다. 이때 CI를 통째로 빨갛게 만들지 말고, .gitleaks.toml[allowlist]에 파일 경로/정규식을 명시적으로 등록해서 걸러야 한다. 무작정 스캐너를 끄면 진짜 시크릿도 같이 놓친다.

3-4. 공급망 관점 — 서드파티 하드웨어 도입 시 체크

이번 사건의 진짜 교훈은 "우리가 산 카메라도 이럴 수 있다"는 것이다. IoT/OT 기기를 도입할 때 실무에서 확인할 것:

  • 펌웨어가 공개 다운로드 가능한가? 가능하다면 우리도 binwalk + trufflehog로 한 번 열어보는 게 좋다(계약/법적 검토는 별도).
  • 기기가 나가는 아웃바운드 트래픽 통제: 카메라는 원칙적으로 내부 세그먼트에 격리하고, 필요한 목적지 외에는 egress를 막는다. 이번처럼 admin UI가 토큰을 브라우저로 뿌려도, 세그먼트 격리가 되어 있으면 피해 범위가 줄어든다.
  • 펌웨어 업데이트 검증: 서명 검증 없이 업데이트를 받으면, 하드코딩 키를 아는 공격자가 악성 펌웨어를 밀어넣을 여지가 생긴다.

3-5. 재발 방지 아키텍처 — 시크릿을 코드/이미지 밖으로

근본 해법은 "빌드 산출물에 시크릿이 아예 존재하지 않게" 만드는 것이다. 트레이드오프를 곁들여 비교하면:

  • SOPS + age/KMS: 시크릿을 암호화해서 git에 커밋. 관리 단순, GitOps와 궁합 좋음. 단, 복호화 키 관리가 여전히 숙제다.
  • HashiCorp Vault: 동적 시크릿·짧은 TTL 발급 가능. 이번 사건처럼 장기 admin 토큰을 아예 없앨 수 있다. 단, Vault 자체 운영 부담이 크다.
  • External Secrets Operator(ESO): 쿠버네티스 환경이면 AWS Secrets Manager/Vault의 시크릿을 K8s Secret으로 동기화. CI/런타임이 클러스터 위라면 가장 매� 끄럽다.

Kubernetes 환경이라면 ESO로 이렇게 선언한다:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: github-npm-token
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: github-npm-token
  data:
    - secretKey: GITHUB_NPM_TOKEN
      remoteRef:
        key: ci/npm
        property: token

핵심은 토큰이 Vault에만 있고, 필요한 순간 짧은 수명으로만 주입된다는 점이다. 빌드 산출물이나 이미지 레이어에는 절대 남지 않는다. 그리고 원칙적으로 발급하는 토큰 권한을 최소화해야 한다. npm 패키지 읽기용이면 admin이 아니라 read 스코프면 충분하다.

4. 정리: 한 줄 요약과 적용 대상

한 줄 요약: 이번 사건은 "개발자가 토큰을 하드코딩한" 사건이 아니라 "빌드 도구가 CI 환경변수를 통째로 산출물에 삼킨" 구조적 사고다. 그래서 사람 교육만으로는 안 막히고, 파이프라인 게이트(스캐너) + 시크릿 외부화(Vault/ESO/SOPS) + 최소 권한 세 가지가 동시에 필요하다.

  • 지금 당장 할 것: gitleaks/trufflehog를 CI에 게이트로 붙이고, 프론트엔드 빌드에서 process.env 통주입이 없는지 확인한다.
  • IoT/OT 기기 담당자: 도입 전 펌웨어를 한 번 열어보고, 기기 egress를 세그먼트로 격리한다.
  • 플랫폼/DevOps 팀: 장기 PAT를 Vault 동적 시크릿이나 ESO로 대체하고, 토큰 권한을 최소 스코프로 재발급한다.

스캐너는 최후의 방어선이지 첫 방어선이 아니다. 첫 방어선은 "시크릿이 애초에 이미지·번들에 들어갈 경로 자체를 없애는" 아키텍처다.

참고 자료

728x90
728x90

어젯밤 HSA 사이트에서 로그인하다가 갑자기 "패스키를 설정하시겠어요?" 팝업이 떴다. 무심코 눌렀다가 회사 노트북의 비밀번호 관리자에 패스키가 등록됐고, 오늘 아침 개인 컴퓨터에서 그 계정에 접근할 수 없게 됐다. 이건 원문 Hacker News 댓글에 나온 실제 사례인데, 나도 비슷한 경험이 있다. 5년째 인프라/DevOps를 하면서 SSH 키, TPM, YubiKey를 매일 다루는 사람인데도 Passkey는 "이게 대체 어디 저장되는 거지?"라는 질문에 즉답을 못 한다.

이번 글은 X 프로덕트 헤드 Nikita Bier의 "Passkey는 소비자 심리를 이해 못 하는 엔지니어들이 만들었다"는 비판을 출발점으로 삼는다. 하지만 단순히 "UX가 나쁘다"고 끝내지 않고, 백엔드/인프라 엔지니어가 실제로 로그인 서비스에 Passkey를 붙일 때 무엇을 알아야 하는지, 특히 디바이스 분실·복구·계정 공유 같은 지뢰밭을 어떻게 설계해야 하는지를 다룬다.

1. 도입: 왜 지금 Passkey가 다시 도마에 올랐나

Passkey는 새로운 기술이 아니다. FIDO2/WebAuthn 표준 자체는 몇 년 전부터 있었고, Apple·Google·Microsoft가 밀면서 Amazon, GitHub 같은 대형 서비스가 이미 지원한다. 문제는 표준은 우아한데 사용자 경험이 산산조각 나 있다는 점이다.

원문에서 가장 뼈아픈 지적은 이거다. Hacker News는 컴퓨터 활용 상위 1%에 가까운 사람들이 모인 곳인데, 그곳의 26년차 엔지니어조차 이렇게 말한다:

"공개키·개인키 원리도 이해하지만, 여러 기기와 브라우저에서 패스키를 어떻게 써야 로그인에 지장이 없는지는 모르겠다. iPad, iPhone, Windows, MacBook에서 Brave·Firefox·Safari를 쓰는데, 휴대폰 Safari에서 우연히 패스키를 만들면 다른 기기에서도 되는지, 동기화되는지, 사이트마다 몇 개까지 되는지 불분명하다."

여기서 핵심을 짚자. Passkey의 "개념"이 어려운 게 아니라, "구현이 사이트마다 제각각"이라 멘탈 모델을 세울 수 없다는 것이다. 이건 UX 문제인 동시에, 우리 같은 서비스 구현자가 만들어내는 문제다. 즉 우리가 어떻게 붙이느냐에 따라 사용자 경험이 결정된다는 뜻이다.

2. 핵심: Passkey 동작 원리를 SSH 키로 이해하기

원문 댓글 중 가장 정확한 설명은 이거다: "웹용으로 자동화된 SSH authorized_keys라고 생각하면 된다." 인프라 엔지니어라면 이 비유 하나로 90%가 이해된다.

등록(Registration): 키 쌍을 만든다

SSH를 처음 세팅할 때 우리가 하는 일을 떠올려보자.

$ ssh-keygen -t ed25519 -C "my-laptop"
Generating public/private ed25519 key pair.
Your identification has been saved in ~/.ssh/id_ed25519
Your public key has been saved in ~/.ssh/id_ed25519.pub

# 서버에 공개키만 등록
$ ssh-copy-id user@server

Passkey도 똑같다. 사이트에 가입할 때 브라우저(정확히는 인증기, Authenticator)가 비대칭 키 쌍을 만들고, 공개키만 서버로 보낸다. 개인키는 절대 서버로 가지 않는다. 차이점은 개인키가 파일(~/.ssh/)이 아니라 다음 중 한 곳에 저장된다는 것이다.

  • 하드웨어 보안 모듈: YubiKey, 스마트폰의 Secure Enclave, PC의 TPM
  • 클라우드 동기화 저장소: iCloud Keychain, Google Password Manager, 1Password, Bitwarden

바로 이 "어디에 저장되느냐"가 사용자를 혼란에 빠뜨리는 지점이다. SSH는 ~/.ssh/에 있다는 게 명확한데, Passkey는 "휴대폰인가? 브라우저인가? 지문인가?" 알 수가 없다. 여기서 중요한 오해 하나를 풀자. 지문/얼굴은 개인키가 아니다. 생체 인증은 그저 기기에 저장된 개인키를 꺼내 쓰기 위한 잠금 해제 수단일 뿐이다. Touch ID는 "너 정말 이 폰 주인 맞아?"를 확인하는 것이고, 실제 인증은 그 뒤 개인키가 한다.

인증(Authentication): 챌린지-응답

로그인할 때는 SSH의 챌린지-응답과 동일하다. 서버가 랜덤한 챌린지를 보내면, 개인키로 서명해서 돌려준다. 서버는 저장해둔 공개키로 서명을 검증한다.

Passkey의 진짜 강점은 여기서 나온다: 피싱 저항성. WebAuthn은 서명할 때 도메인(origin)을 함께 묶어 서명한다. 즉 google.com용 Passkey는 google-login.evil.com에서는 절대 동작하지 않는다. TOTP 코드는 가짜 페이지가 중계(relay)할 수 있지만, Passkey 챌린지-응답은 중계가 원천 불가능하다. 이게 SMS OTP나 TOTP보다 확실히 나은 점이다.

서버는 무엇을 저장하나

백엔드 관점에서 Passkey 사용자 레코드는 대략 이렇게 생겼다. 서버가 저장하는 건 "비밀"이 아니라 "공개키"라는 게 핵심이다.

{
  "user_id": "u_8f3a2b",
  "credentials": [
    {
      "credential_id": "AQIDBAUGBwgJCgsMDQ4PEA...",
      "public_key": "pQECAyYgASFYI...",
      "sign_count": 42,
      "transports": ["internal", "hybrid"],
      "aaguid": "adce0002-35bc-c60a-648b-0b25f1f05503",
      "created_at": "2024-11-20T09:12:00Z",
      "device_name": "iPhone 15 (iCloud)"
    }
  ]
}

여기서 실무적으로 중요한 두 가지:

  • credentials배열이라는 점. 한 사용자가 여러 Passkey를 등록할 수 있고, 반드시 그렇게 설계해야 한다(뒤에서 설명).
  • sign_count는 복제 탐지용 카운터다. 하지만 iCloud처럼 클라우드 동기화되는 Passkey는 카운터가 0으로 고정되거나 증가하지 않는 경우가 많다. sign_count가 0이라고 무조건 거부하면 안 된다. 이건 흔한 함정이라 뒤에서 다시 언급한다.

3. 실무 관점: 도입 시 고려사항과 지뢰밭

3-1. 절대 "Passkey 온리"로 강제하지 마라

원문에서 가장 많이 나온 불만이 디바이스 분실 = 계정 접근 상실이다. 이건 UX 문제가 아니라 아키텍처 설계 문제다. 비밀번호는 종이에 적어 금고에 넣을 수 있고, TOTP는 여러 기기에 설치할 수 있다. 그런데 Passkey를 유일한 인증 수단으로 두고 그게 하드웨어에만 있으면, 기기 분실 시 복구가 악몽이 된다.

xguru의 댓글에 나온 BeeBS의 접근이 현실적인 정답에 가깝다:

"먼저 이메일 매직링크로 가입한 뒤 Passkey를 등록하도록 했다. 이 조합이면 서버에 비밀번호를 전혀 저장하지 않아도 되고, 기기를 잃어도 이메일로 다시 로그인해 새 Passkey를 등록할 수 있다."

실무 설계 원칙을 정리하면:

  • Passkey는 "빠른 로그인 수단"으로 붙이고, 복구 경로는 항상 별도로 확보한다. 이메일 매직링크, 백업 코드, 또는 두 번째 Passkey.
  • 등록 UX에서 "두 개 이상 등록"을 강하게 유도한다. "이 기기 + 다른 기기(또는 YubiKey)". 원문의 78세 부모님 사례처럼 "예비 열쇠도 두자"는 비유가 실제로 통했다.

3-2. 흔한 함정: 실제로 마주칠 에러들

(a) origin 불일치 에러 — 개발 환경에서 가장 먼저 만난다. 로컬에서 localhost로 테스트하다가 스테이징 도메인으로 넘어갈 때, 혹은 RP ID(Relying Party ID) 설정을 잘못하면 브라우저 콘솔에 이게 뜬다:

SecurityError: The relying party ID is not a registrable domain
suffix of, nor equal to the current domain.

이건 rpId를 현재 접속한 도메인과 맞지 않게 설정했을 때 발생한다. 예를 들어 app.example.com에서 접속했는데 rpIdlogin.other.com으로 줬거나, localhost가 아닌 IP(127.0.0.1)로 접속한 경우다. WebAuthn은 localhost는 예외적으로 허용하지만 IP 주소는 허용하지 않는다는 걸 기억하자.

(b) sign_count 검증 실패 — 위에서 언급한 문제. iCloud 동기화 Passkey로 로그인했는데 서버가 이렇게 거부하는 경우:

AuthenticationError: signature counter did not increase
(stored: 42, received: 0) — possible cloned authenticator

이론적으로는 복제 탐지 로직이 맞지만, 클라우드 동기화 Passkey는 카운터가 0으로 오는 게 정상 동작이다. sign_count가 0이면 카운터 검증을 스킵하도록 예외 처리해야 한다. 이걸 모르고 엄격하게 막으면 Apple 사용자 전체가 로그인 불가에 빠진다. (관련 동작은 사용하는 라이브러리 문서 확인 필요 — SimpleWebAuthn, WebAuthn4J 등마다 기본값이 다르다.)

(c) 사용자 검증(User Verification) 정책 충돌 — 등록 시 userVerification: "required"로 했는데 하드웨어 키가 PIN을 지원하지 않으면:

NotAllowedError: The operation either timed out or was not allowed.

이 에러는 원인 범위가 넓어서 악명 높다. 사용자가 그냥 취소했을 수도 있고, UV 정책이 안 맞을 수도 있고, 타임아웃일 수도 있다. 로그만 보고는 원인을 특정하기 어려우니, 클라이언트에서 등록/인증 옵션을 상세 로깅해두는 게 디버깅에 필수다.

3-3. 계정 공유는 근본적으로 깨진다

원문에서 반복적으로 나오는 현실 문제. "Netflix 비밀번호 보내줄래?"가 안 된다. 비밀번호는 문자로 보낼 수 있지만 Passkey는 특정 기기/클라우드에 묶여 있어 남에게 넘길 수 없다. 배우자와 Amazon Prime을 공유하는 흔한 시나리오조차 막힌다.

실무적으로는 이렇게 접근해야 한다:

  • 가족/팀 공유가 핵심 유스케이스인 서비스라면, 계정 공유를 Passkey로 풀지 말고 "초대/멤버 권한" 같은 제대로 된 멀티유저 모델로 설계하라. Passkey는 개인 인증 수단이지 공유 수단이 아니다.
  • 공유가 불가피하면 비밀번호 로그인 경로를 함께 남겨둔다.

3-4. 트레이드오프 요약

항목Passkey비밀번호 + 관리자
피싱 저항성강함 (origin 바인딩)약함 (사람이 붙여넣기 가능)
서버 유출 시공개키만 유출 → 안전해시라도 크래킹 위험
기기 분실 복구어려움 (설계 필수)비교적 쉬움
계정 공유사실상 불가가능 (보안상 비권장이지만)
구현 일관성사이트마다 제각각성숙, 예측 가능
벤더 종속클라우드 동기화 시 종속 우려낮음

원문의 균형 잡힌 결론 하나를 인용하자면: "비밀번호는 근본적으로 망가진 보안 모델이고, 사용자들도 제대로 쓴 적이 거의 없다. 새 방식이 어렵다는 이유로 쉬운 문제에만 머무르는 건 바람직하지 않다." 즉 Passkey를 무작정 배격할 이유는 없지만, 복구·공유·일관성 문제를 우리가 설계로 메꿔야 한다는 뜻이다.

4. 정리: 한 줄 요약과 도입 체크리스트

한 줄 요약: Passkey는 SSH 키를 웹에 자동화한 것이고, 개념은 우아하지만 "복구·공유·일관성"을 서비스 구현자가 책임지고 설계하지 않으면 사용자에게는 "마법의 요정 가루"로 남는다.

누가 언제 써야 하나:

  • 써라: 피싱이 실제 위협인 서비스(금융, 관리자 콘솔), Apple/Google 생태계에 익숙한 일반 소비자 대상 서비스. 특히 "모든 사이트에서 같은 비밀번호를 직접 입력하던" 사용자를 대체할 때 보안 이득이 크다.
  • 미뤄라 / 병행하라: 계정 공유가 핵심인 서비스, 오프라인 백업/자체 동기화를 중시하는 기술직 사용자 비중이 높은 경우, 다양한 OS/브라우저 조합을 지원해야 하는 경우.

도입 체크리스트:

  1. Passkey를 유일 인증 수단으로 강제하지 않는다. 복구 경로(이메일 매직링크/백업 코드) 필수.
  2. 한 사용자당 여러 credential 등록을 지원하고, 등록 시 두 번째 키를 유도한다.
  3. sign_count == 0 케이스(클라우드 동기화 Passkey)를 예외 처리한다.
  4. rpId와 origin 설정을 환경별(local/staging/prod)로 명확히 분리한다.
  5. 계정 공유는 Passkey가 아닌 멀티유저 권한 모델로 푼다.
  6. 사용자에게 "이 폰이 열쇠이고, 예비 열쇠도 만들어두자"는 물리적 열쇠 비유로 안내한다. 원문 사례상 이게 가장 잘 통했다.
  7. 등록/인증 옵션을 상세 로깅해 NotAllowedError 같은 모호한 에러를 디버깅할 수 있게 한다.

참고 자료

※ 본문의 에러 메시지와 코드는 실무에서 마주치는 형태를 재구성한 예시다. 라이브러리·OS·브라우저 버전에 따라 문구와 기본 동작이 다를 수 있으니, 실제 도입 시 사용하는 스택의 공식 문서를 확인하기 바란다.

728x90

+ Recent posts