오늘 Product Hunt에 올라왔어요! 댓글 남기기

Authenticator 1.16.0이(가) 출시되었습니다. 새로운 기능 보기

작동 방식

보안을 검토하는 분들을 위한 기술 개요입니다. 여기 적힌 내용은 모두 소스 코드로 확인할 수 있습니다. 우리에게 불리한 부분도 빼지 않고 그대로 적었습니다.

구조

이 확장 프로그램은 Manifest V3 기반 Chrome 확장 프로그램으로, OTPAuth를 사용해 TOTP 코드를 기기에서 생성합니다. 코드 생성, 계정 저장, 암호화에는 백엔드가 관여하지 않으며, 회원 가입이나 사용자 계정도 없습니다. 저희가 운영하는 서버는 무료 요금제와 Pro를 위한 api.authenticator.sh 하나이며(아래에서 설명합니다), 이 서버는 비밀 키, 계정 이름, 서비스 이름, 코드 중 어느 것도 받지 않습니다.

이 서버는 누구의 계정에 대해서도 키 자료를 보관하지 않습니다. 키 위탁도, 우리가 통제하는 복구 경로도 없습니다. 사용자가 비밀번호를 잊고 복구 코드까지 잃으면 우리가 해줄 수 있는 일은 없습니다. 의도된 설계입니다.

암호화

비밀번호 보호는 선택 사항이며 기본값은 꺼짐입니다. 켜면 다음과 같습니다:

암호 알고리즘
AES-256-GCM, 레코드마다·재기록마다 무작위 96비트 IV
키 유도
PBKDF2-HMAC-SHA256, 600,000회 반복, 128비트 무작위 솔트(OWASP 권고 기준)
마스터 키
256비트, crypto.getRandomValues로 한 번만 생성하며 비밀번호는 이를 감싸는 역할만 합니다
지문
HKDF로 유도한 하위 키를 사용하는 HMAC-SHA256, 기기 간 레코드 병합에 사용합니다
잠금 해제된 키의 보관
chrome.storage.session — 메모리에만 존재하고 디스크에 기록되지 않으며, 브라우저를 닫으면 삭제되고 콘텐츠 스크립트에서 접근할 수 없습니다
자동 잠금
열 때마다 / 5, 15, 60분간 사용하지 않으면 / 브라우저를 닫을 때까지
구현
WebCrypto만 사용합니다. 직접 구현한 암호 프리미티브가 없고, 암호 라이브러리를 번들에 포함하지도 않습니다.

키를 두 단계로 두는 이유

데이터를 비밀번호에서 유도한 키로 직접 암호화하는 일은 없습니다. 레코드는 무작위 마스터 키가 암호화하고, PBKDF2 출력은 그 마스터 키를 감싸기만 합니다. 여기서 두 가지 결과가 따라오는데, 둘 다 중요합니다:

  • 비밀번호를 바꿔도 모든 레코드·모든 백업·모든 내보내기를 다시 암호화하는 대신 32바이트만 다시 씁니다. 저장소가 데이터를 잃는 지점이 바로 그런 대량 재기록입니다.
  • 160비트 복구 코드가 같은 마스터 키를 독립적으로 감쌀 수 있어, 비밀번호를 잊어도 되돌릴 수 있습니다. 코드는 한 번만 표시되고, 확인을 위해 다시 입력해야 하며, 사용할 때마다 새로 발급됩니다. 사용자가 확인하기 전에는 디스크에 아무것도 기록하지 않습니다.

암호화되는 것과 되지 않는 것

비밀번호 보호를 켜면 식별자와 지문을 제외한 계정 레코드 전체가 암호화됩니다. 서비스 이름도 포함되므로, 프로필이 유출돼도 사용자가 어떤 서비스에 계정을 두고 있는지는 드러나지 않습니다. 기능을 켜면 레코드를 암호화한 뒤 복호화해 대조하는 방식으로 검증하고, 그다음에야 평문 사본을 모두 제거합니다. 브라우저 로컬 저장소, 동기화, 그리고 7일치 순환 백업 스냅샷 전부가 대상입니다.

평가에 영향을 주는 부분이라 그대로 밝힙니다:

  • 비밀번호 보호가 꺼져 있으면 계정은 암호화되지 않은 채 저장되고, Chrome 동기화가 켜져 있다면 사용자 본인의 Google 계정을 통해 복제됩니다. 우리에게는 결코 오지 않지만 Google은 보관하게 됩니다. 동기화는 끌 수 있으며, 끄면 이미 올라간 내용도 함께 삭제됩니다.
  • 사이트별 사용 기록(어떤 계정을 어떤 도메인에서 썼는지)은 로컬에 저장되며, 수집되는 동안에는 보관함의 보호를 받지 않습니다. 비밀번호 보호를 켜면 삭제되고, 따로 끌 수도 있습니다.

위협 모델

막아 주는 것

  • 브라우저 프로필 디렉터리를 통째로 훔치거나 복사하는 경우
  • 브라우저 데이터를 대량으로 빼내는 인포스틸러 악성코드
  • 자리를 비운 컴퓨터에 물리적으로 접근하는 사람
  • Chrome 동기화를 통해 계정 레코드가 Google로 넘어가는 것
  • 백업 파일의 분실이나 도난(비밀번호로 보호된 내보내기)

막지 못하는 것

  • 보관함이 잠금 해제된 상태에서 사용자 권한으로 실행되는 악성코드
  • 입력하는 순간 비밀번호를 가로채는 키로거
  • 이미 침해된 브라우저나 운영체제
  • 코드의 대상인 제3자 서비스 자체가 뚫리는 경우
  • 직접 저장하기로 한 암호화되지 않은 내보내기 — 순수 JSON, otpauth:// 링크, CXF는 파일을 여는 누구나 읽을 수 있습니다

요약하면, 이것이 지키는 것은 저장된 상태의 데이터입니다. 사용 중에 이미 공격자 통제 아래 있는 기기는 지켜 주지 못하며, 지킬 수도 없습니다.

권한

매니페스트가 선언하는 권한은 다섯 개이며, 호스트 권한은 없습니다:

storage
계정과 설정은 로컬 저장소에, 잠금 해제된 키는 세션 저장소에, 그리고 선택적인 동기화 사본
activeTab
일치하는 계정을 강조하기 위해 현재 탭의 호스트 이름을 읽고, 사용자가 화면에서 QR 코드를 스캔할 때 해당 탭을 캡처합니다
contextMenus
오른쪽 클릭 메뉴에 “2FA 코드 입력” 항목을 추가합니다. http(s) 페이지의 편집 가능한 입력란에서만 나타납니다
scripting
코드를 입력란에 넣는 함수 하나만 주입합니다 — 그 탭 하나, 그 호출 한 번에 한해서이고, 그 메뉴 항목을 고른 뒤에만 이뤄집니다
sidePanel
사이드 패널로 열도록 설정했을 때 앱을 거기에 엽니다. 어떤 페이지에도 접근 권한을 주지 않습니다

콘텐츠 스크립트가 전혀 없고도, tabs·cookies·webRequest 권한도, web-accessible resources도 없습니다. 여러분이 그 페이지에서 코드를 요청하기 전까지 저희 것은 페이지에 아무것도 존재하지 않습니다. 그때 실행되는 것은 그 탭 하나와 그 호출 한 번을 위해, 여러분 자신의 클릭이 주는 허가 아래 주입되며, 뒤에 아무것도 남기지 않습니다.

카메라

카메라 QR 스캔은 팝업이 아니라 탭으로 열린 확장 프로그램 페이지에서 실행됩니다. 팝업은 포커스를 잃으면 파괴되는데, 권한 요청이 바로 그 포커스를 빼앗기 때문입니다. 여기에는 매니페스트 권한이 필요하지 않습니다. 브라우저의 표준 카메라 요청을 사용하고, 확장 프로그램 출처 단위로 부여되며 사이트 설정에서 취소할 수 있고, 사용자가 스캐너를 열기 전에는 절대 요청하지 않습니다. 영상은 기기에서 디코딩되며 전송되거나 저장되지 않습니다.

무료 요금제, Pro, 그리고 하루 한 번의 체크인

1.14.0부터 확장 프로그램은 하루에 한 번 api.authenticator.sh에 체크인합니다. 브라우저가 시작될 때나 팝업이 화면에 그려진 뒤에 이뤄지며, 코드를 표시하는 과정에 끼어드는 일은 없습니다. 요청은 정해진 필드 목록으로만 이뤄지고, src/billing/sync.ts의 함수 하나에서 만들어지며, 테스트가 그 목록을 벗어나지 못하게 붙들어 둡니다:

iid
이 설치에서 만든 무작위 ID. Google 계정, 이메일 주소, 브라우저와 연결되지 않습니다. 단, Pro를 구매하면 구매 기록이 구매한 설치와 라이선스 키가 활성화된 모든 설치를 결제에 사용한 이메일 주소와 연결합니다
ver, ui_lang, browser_lang
확장 프로그램 버전, 인터페이스 언어, 브라우저 언어
installed_at, legacy, legacy_src
설치된 시점, 그리고 요금제 도입 이전의 설치인지 여부
accounts, groups
보관 중인 계정 수와 그 계정들이 나뉜 그룹 수 — 개수일 뿐, 어떤 계정인지나 그룹 이름은 절대 담지 않습니다
vault, sync, open_mode, view_mode, style, dark, os
비밀번호 보호와 동기화가 켜져 있는지, 그리고 확장 프로그램이 어떤 방식으로 열리는지, 계정 목록을 어떻게 보여 주는지(기본, 간단히, 코드 숨김), 어떤 스타일을 라이트·다크 중 무엇으로 쓰는지, 그리고 운영체제(Windows, macOS, Linux, ChromeOS)
pid, lid, has_key, key_hint
설치가 현재 보유한 서명된 정책과 라이선스가 무엇인지, 라이선스 키를 이미 보유하고 있는지, 그리고 그 키의 마지막 네 글자
opens
지난 체크인 이후 날짜별로 확장 프로그램이 열린 횟수(최근 14일까지). 하루당 횟수일 뿐, 무엇을 보았는지는 담기지 않습니다
events
정해진 목록 안에서 대기열에 쌓인 결제 이벤트: 제한에 도달함, 결제 화면(페이월)이 표시되거나 닫힘, 요금제를 선택함, 결제나 키 입력을 시도함

절대 보내지 않는 것: 비밀 키, 계정 이름, 서비스 이름, 코드, 그리고 코드를 사용하는 사이트. 서버는 엣지에서 요청을 보고 국가를 판단하며, IP 주소는 저장하지 않습니다. 삭제 페이지는 같은 설치 ID를 ?i= 매개변수로 받아, 해당 설치가 삭제되었음을 서버에 알리고 선택 사항인 설문 답변을 그 설치와 연결합니다.

이를 위해 chrome.storage.local에 보관하는 것: 설치 ID, 마지막으로 받은 서명된 정책과 라이선스, 이 설치가 보유한 라이선스 키(직접 입력했거나 이 설치에서 구매한 뒤 받은 것), 서버가 받을 때까지의 날짜별 열린 횟수, 대기 중인 이벤트. 계정 데이터는 없습니다. 동기화가 켜져 있는 동안에는 요금제 도입 이전의 설치인지 여부와 그 라이선스 키도 Chrome 동기화에 들어가, 사용자의 다른 Chrome 프로필이 이를 이어받아 Pro를 저절로 켭니다. 동기화를 끄면 둘 다 Chrome 동기화에서 삭제되고, 다시 켜면 다시 들어갑니다.

돌아오는 것, 그리고 확장 프로그램이 그것을 다루는 방식:

토큰
정책(무료 요금제의 계정 수 제한)과 라이선스(Pro)는 ES256(ECDSA P-256)으로 서명된 JWS입니다
검증
빌드에 컴파일된 공개 키(src/billing/keys.ts)를 기준으로 WebCrypto로 검증합니다. Chrome 웹 스토어 빌드는 프로덕션 키만 신뢰하며, 다른 키가 하나라도 들어 있는 빌드는 빌드 검사에서 거부됩니다
오프라인
제한과 라이선스는 마지막으로 받은 서명된 토큰을 바탕으로 기기에서 확인합니다. 서명된 정책을 한 번도 받지 않았다면 아무것도 제한되지 않습니다. 서버에 닿지 못한다는 이유로 누군가가 잠겨 버리는 일은 결코 없습니다
제한이 할 수 있는 일
계정 추가를 막는 것, 그뿐입니다. 계정을 숨기거나 잠그거나 삭제하거나 바꿀 수 없고, 코드, 내보내기, 백업, 복원, 동기화를 막을 수도 없습니다
구매
새 탭에서 열리는 Stripe Checkout이며, 판매자(merchant of record)는 Link입니다. 구매자의 이메일은 라이선스 키를 발급하고 복구하기 위해 Stripe에서 저희에게 전달됩니다. 키는 해시로 저장하며, 잃어버린 키를 다시 보낼 수 있도록 암호화된 사본을 함께 보관합니다
라이선스 키
체크인 응답에는 Pro를 구매한 설치에만, 그리고 그 설치가 key_hint로 알리는 네 글자가 그 키와 일치할 때까지만 키 자체가 담깁니다. 그래서 나중에 구매한 키가 이전 키를 대신합니다. 키를 직접 입력한 설치에는 절대 보내지 않습니다

이 제한은 DRM이 아닙니다. 코드는 오픈 소스이며, 포크하면 제한을 없앨 수 있습니다. 결코 가능해서는 안 되는 것은 한 사람이 다른 사람의 라이선스를 얻거나 사용하는 것, 또는 다른 사람의 설치를 제한 상태로 만드는 것입니다. 그런 일은 취약점이며, 저희 보안 정책의 적용 범위에 해당합니다.

외부로 나가는 통신

스스로 일어나는 요청은 두 가지, 시계 확인과 하루 한 번의 요금제 체크인입니다. 아래의 나머지는 모두 사용자가 눌렀기 때문에 일어납니다. Manifest V3는 원격 코드를 금지하며, 여기 있는 어느 것도 계정이나 비밀 키, 생성된 코드를 담아 보내지 않습니다. 전체 목록은 다음과 같습니다:

호스트시점사용자 데이터를 담나요?
time.akamai.com
timeapi.io
cloudflare.com
시계 오차 확인. 캐시되며, 어느 출처에도 닿지 못하면 설정에서 그 사실을 알려 줍니다. 시계가 어긋나면 TOTP가 어긋납니다.아니요
authenticator.sh설치 시 환영 페이지, 삭제 시 피드백 페이지(주소에 설치 ID가 들어 있음), 팝업에서 열었을 때의 도움말·지원 페이지.삭제 페이지에서만 설치 ID. 그 밖에는 아니요(일반적인 웹 서버 로그)
api.authenticator.sh하루 한 번의 요금제 체크인. 그리고 사용자가 Pro를 구매하거나, 키를 입력하거나, 구매를 복원하거나, 구독을 관리할 때의 결제·라이선스·포털 호출.위에 나열한 필드뿐이며 계정 데이터는 절대 담지 않습니다. 라이선스 키나 이메일 주소는 사용자가 직접 입력했을 때만
checkout.stripe.com
billing.stripe.com
Stripe Checkout과 고객 포털. 사용자가 Pro를 구매하거나 구독을 관리할 때 탭으로 열립니다.구매자가 그곳에 입력한 내용. Stripe와 Link의 자체 약관이 적용됩니다
chromewebstore.google.com스토어 등록 페이지. 눌렀을 때만 탭으로 열립니다. 평가 안내에서는 리뷰 작성란.아니요(구글에는 여느 스토어 페이지 방문과 똑같이 보입니다)

글꼴, 스크립트, 스타일, 이미지 중 어느 것도 제3자 호스트에서 불러오지 않습니다. 화면을 그리는 데 필요한 것은 모두 패키지 안에 들어 있습니다.

우리가 배포한 것을 검증하기

위 내용 중 어느 것도 그냥 믿을 필요가 없습니다. 확장 프로그램은 오픈 소스이며 모든 릴리스를 재현할 수 있습니다:

  1. Chrome 웹 스토어에서 배포된 .crx를 내려받아 압축을 풉니다
  2. 해당하는 git 태그를 체크아웃합니다
  3. Node 20 LTS에서 npm ci && npm run build를 실행합니다
  4. 생성된 dist/를 압축을 푼 패키지와 비교합니다

빌드된 dist/ 안 모든 파일의 SHA-256을 GitHub 릴리스마다 SHA256SUMS-v<version>.txt 파일로, sha256sum 형식으로 함께 배포합니다. 그래서 4단계는 sha256sum -c 한 번 실행으로 끝납니다. 차이는 아카이브 내부의 파일 순서와, Node 패치 버전에 따른 최소화 출력의 공백 정도로 제한되어야 합니다.

런타임 의존성은 의도적으로 적습니다. OTPAuth, React, jsQR, Zustand, Framer Motion, Lucide뿐이며 저장소에 커밋된 lockfile로 고정되어 있습니다. CycloneDX 형식의 SBOM은 요청하시면 제공합니다.

소스 코드 보기

우리에게 없는 것

저희는 작은 독립 개발사입니다. 다음 중 하나라도 귀사 절차의 필수 요건이라면, 평가가 3주쯤 진행된 뒤보다 지금 아시는 편이 낫습니다:

  • SOC 2, ISO 27001 또는 이에 준하는 인증이 없습니다
  • 현재까지 외부 기관 모의침투 테스트를 의뢰한 적이 없습니다
  • 유료 버그 바운티 프로그램이 없습니다

대신 제공하는 것은 소스 코드의 완전한 공개, 검증 가능한 빌드, 최소한의 권한, 그리고 비밀 키, 계정 이름, 코드가 애초에 전달되지 않는 서버입니다.

문의

취약점 제보와 보안 문의는 security@authenticator.sh로 보내주세요. 공개 정책, 적용 범위, 응답 시간, 세이프 하버 조건은 보안 정책 페이지에 있고, 무엇이 저장되고 무엇이 기기를 떠나는지는 개인정보처리방침에 정리되어 있습니다.

서면 공급업체 평가 설문에 답변하거나, 보안팀과 함께 코드를 짚어 나가는 일도 기꺼이 하겠습니다.

보안과 무관한 사안(설치, 계정 복구, 배포 과정의 청구 문의 등)은 지원 페이지을 참고하세요. 취약점 제보는 그쪽으로 보내지 말아 주세요. 그 메일함은 기밀로 취급하지 않습니다.