보안팀과 앱 심사 담당자를 위해
Peel의 보안
요약하면, 보드 내용은 링크 프래그먼트에 있는 키로 브라우저에서 암호화되기 때문에 저희 서버는 읽을 수 없는 암호문만 저장하고 전달해요. 정확히 어떻게 하는지, 한계는 어디까지인지 아래에 설명해요.
요약
- 카드 내용, 이름, 보드 제목, 투표, 스티커는 브라우저에서 생성한 보드 키로 AES-256-GCM 암호화돼요. 키는 URL 프래그먼트(
#k=)에 담겨 다니며 서버로 전송되지 않아요. - 진행자 작업, 보드 헤더, 스냅숏은 진행자의 Ed25519 키로 서명돼요. 보드 ID는 이 키에서 파생되므로 클라이언트가 키 바꿔치기를 감지할 수 있어요.
- 숨긴 카드는 공개 전까지 진행자 키로 봉인(X25519 sealed box)되고, 솔트를 넣은 커밋먼트로 묶여 있어 공개된 내용을 바꿔치기할 수 없어요.
- 기본은 익명: 작업에는 작성자 ID가 없고, 투표와 반응에는 보드별 무작위 ID를 써요.
- 참여자는 인증을 하지 않아요. 분석 SDK도, 광고도, 서드파티 스크립트도 없어요. 결제 페이지의 Paddle.js만 예외예요.
- Cloudflare만 써요. 계정에 연결되지 않은 보드는 마지막 방문 후 60일이 지나면 삭제돼요.
데이터 흐름
URL의 프래그먼트(# 뒤의 모든 부분)는 브라우저가 처리하며 HTTP 요청에 포함되지 않아요. Peel은 보드 키를 바로 여기에 넣어요.
위협 모델
서버가 완전히 침해되거나 호기심을 갖더라도(저희 자신을 포함해서) 팀이 나눈 이야기를 최대한 알 수 없도록 설계했어요.
보호하는 대상
- 카드 내용, 선택한 GIF, 실명 모드에서는 작성자 이름.
- 참여자 이름과 아바타, 보드 이름, 사용자 지정 템플릿 제목.
- 누가 무엇에 투표하거나 반응했는지, 누가 어떤 카드를 썼는지.
- 회고의 무결성: 단계, 공개, 진행자의 결정은 위조할 수 없어요.
고려하는 공격자
- 저희 서버, 악의적인 내부자, 저장소에 침입한 사람: 이들이 얻는 건 암호문과 메타데이터뿐이에요.
- 네트워크 공격자: 모든 구간에 TLS를 쓰고, 키는 네트워크를 지나지 않아요.
- 키를 바꿔치기하는 서버: 보드 ID는 진행자의 공개 키에서 파생되고, 클라이언트는 서명된 헤더를 검증해요.
- 훔쳐보거나 위조하려는 참여자: 숨긴 카드는 진행자 키로 봉인되고, 진행자 작업에는 진행자 서명이, 카드 수정에는 그 카드의 키가 필요해요.
- 오프라인 작성자 대신 다시 게시하는 진행자: 커밋먼트 때문에 내용을 바꿀 수 없어요.
솔직히 말해 범위 밖인 것
- 참여자 링크를 가진 사람은 누구나 보드를 읽을 수 있어요. 링크가 곧 접근 권한이에요. 보드를 다루듯 신중하게 공유하세요.
- 침해된 기기, 악성 브라우저 확장 프로그램, 어깨 너머로 화면을 보는 사람.
- 트래픽 분석: 서버는 타이밍, 크기, 개수를 볼 수 있어요(데이터 맵 참고).
- 서버에 의한 서비스 거부: 메시지를 버리거나 늦출 수는 있지만 읽거나 위조할 수는 없어요.
암호화 방식
모든 프리미티브는 브라우저의 WebCrypto API에서 가져와요. 자체 제작 암호도, 서드파티 암호 라이브러리도 쓰지 않아요.
| 키 | 알고리즘 | 용도 |
|---|---|---|
| K_board | AES-256-GCM, 96비트 무작위 IV | 모든 작업, 스냅숏, 접속 상태 업데이트를 암호화해요. 추가 데이터(AAD)에 보드 ID, 작업 유형, 작업 ID를 묶어 두어 암호문을 다른 보드나 슬롯에 재사용할 수 없어요. 링크 프래그먼트 #k=에만 존재해요. |
| K_owner | Ed25519 | 진행자의 권한이에요. 진행자 작업(단계, 타이머, 스포트라이트, 템플릿, 설정, 투표 공개, 카드 숨기기), 보드 헤더, 스냅숏에 서명해요. 엄격하게 증가하는 카운터로 재전송 공격을 막아요. 진행자 링크(&o=)에만 들어 있어요. |
| 보드 ID | SHA-256, 앞 80비트, base32 | 진행자의 공개 키에서 파생되므로 ID 자체가 어떤 키가 보드의 주인인지 증명해요. 키의 해시가 ID와 맞지 않는 헤더는 클라이언트가 거부해요. |
| K_fac | X25519 sealed box: 임시 X25519 + HKDF-SHA256 + AES-256-GCM | 카드를 쓰는 동안 숨긴 카드는 진행자의 공개 키로 봉인돼요. 진행자 링크(&f=)로만 열 수 있어요. |
| 카드 키 | Ed25519, 카드마다 키 하나 | 작성자만 가지고 있어요. 수정과 공개에 이 키로 서명해요. 신원은 드러내지 않고 진위만 증명해요. |
| 커밋먼트 | 128비트 무작위 솔트를 쓴 HMAC-SHA256 | 숨긴 카드는 내용에 대한 커밋먼트를 게시해요. 공개할 때 내용과 솔트가 게시되고 모든 클라이언트가 이를 확인하므로, 아무도 내용을 바꿔치기할 수 없어요. |
| 접속 상태 | K_board 기반 AES-256-GCM | 이름, 아바타, 입력 중 표시도 다른 모든 것처럼 암호화돼요. |
| 키 번들 | 패스키 PRF 또는 복구 코드에서 HKDF-SHA256으로 파생, AES-256-GCM | 로그인한 진행자는 보드 키를 여러 기기에 동기화할 수 있어요. 번들은 기기에서 암호화되고, 서버에는 암호문과 래핑된 키만 저장돼요. |
서명 메시지는 도메인이 분리되어 있어(예: peel/sig/v1, peel/header/v1, peel/snap/v1) 한 용도의 서명을 다른 용도에 재사용할 수 없어요.
익명성
- 작업에는 작성자 ID가 없어요. 서버는 카드와 사람을 연결할 수 없고, 기본 설정에서는 진행자도 마찬가지예요.
- 투표와 반응은 이름과 연결되지 않은 보드별 무작위 ID로 기록돼요.
- 입력 중 표시는 누군가 쓰고 있다는 것만 보여 주고, 무엇을 쓰는지는 절대 보여 주지 않아요.
- 실명 모드는 보드에서 명시적으로 켜는 설정이에요. 이때 작성자 이름은 암호화된 카드 안에 담겨 전달돼요.
- 참여자는 로그인하지 않으므로, 연결 지을 계정 자체가 없어요.
데이터 맵
저희가 가진 모든 데이터와 그 위치, 읽을 수 있는 사람, 보관 기간이에요.
| 데이터 | 위치 | 읽을 수 있는 주체 | 보관 기간 |
|---|---|---|---|
| 카드, 투표, 반응, 스티커, 액션 아이템, 보드 이름, 사용자 지정 템플릿 제목 | Durable Object 저장소, 암호문 형태 | 보드 링크를 가진 사람 | 보드가 삭제될 때까지 |
| 작업 봉투: 유형, 서버 순번과 시각, 크기, 진행자 카운터 | Durable Object 저장소 | Peel | 보드가 삭제될 때까지 |
| 참여자 이름, 아바타, 입력 중 표시 | 암호문으로 전달, 연결된 동안 메모리에 보관 | 보드 링크를 가진 사람 | 연결된 동안 |
| 보드 상태: 단계, 참여자 수와 카드 수, 카탈로그 템플릿 ID, 잠금 여부, 생성 및 만료 시각 | Durable Object, D1, 설치된 경우 Slack/Teams 메시지 | Peel, 우리 팀의 Slack/Teams 채널 | 보드가 존재하는 동안 |
| 진행자 계정: 제공자 ID, 이름, 공유한 경우 이메일, 연결된 제공자, 생성일, 세션 | D1 | Peel | 계정을 삭제할 때까지 |
| 키 번들과 래핑된 키 | D1, 암호문 형태 | 나만 (패스키 또는 복구 코드) | 계정을 삭제할 때까지 |
| 요금제, 구독 상태, Paddle 고객 ID와 구독 ID | D1, Paddle | Peel, Paddle | 세법이 정한 기간 동안 |
| Slack/Teams 워크스페이스, 채널, 메시지 ID, 봇 토큰(AES-GCM 암호화) | D1 | Peel | 앱을 제거할 때까지 |
| IP 주소 | Cloudflare 엣지, 일시적으로(전송, 요청 제한) | Cloudflare, Peel의 요청 제한기 | 저희 데이터베이스에 저장하지 않음 |
| 일별 카운터(생성된 보드 수, 도달한 단계) | D1 | Peel | 집계만, 식별자 없음 |
서버가 할 수 있는 일과 없는 일
할 수 있는 일
- 작업 유형, 크기, 타이밍, 단계, 참여자 수와 카드 수, 카탈로그 템플릿 ID, 잠금 여부 보기.
- 작업 전달을 버리거나, 늦추거나, 거부하기(서비스 거부).
- 네트워크 엣지에서 IP 주소 보기.
- 보드 수와 도달한 단계를 집계로 세기.
- 보드 삭제하기(만료, 신고 처리).
할 수 없는 일
- 카드 내용, 이름, 보드 제목, 사용자 지정 템플릿 제목, 투표, 스티커 읽기.
- 공개 전 숨긴 카드 읽기(공개 후에도 마찬가지).
- 진행자 작업 위조하기: 진행자의 서명이 필요해요.
- 자기 키로 바꿔치기하기: 보드 ID 때문에 바로 들통나요.
- 공개된 카드 내용 바꾸기: 커밋먼트와 카드 서명 때문에 들통나요.
- 누가 어떤 카드를 썼는지 알아내기.
다른 웹 앱과 마찬가지로, 저희가 제공하는 코드는 믿고 쓰셔야 해요. 그래서 그 범위를 작게 유지해요. 엄격한 콘텐츠 보안 정책(CSP), 자체 출처로만 제한한 script-src, 서드파티 스크립트 없음. 프로토콜은 요청하시면 검토용으로 공개해요.
Slack과 Microsoft Teams
- 메시지에는 보드 링크와 메타데이터(제목, 단계, 개수)만 담겨요. 카드 내용은 진행자가 요약을 올리고 확인했을 때만 전송돼요.
- 참여 카드에는 키가 포함된 참여자 링크가 들어 있어요. 그 채널을 볼 수 있는 사람은 누구나 참여할 수 있어요. 채널에 링크를 붙여 넣는 것과 같아요.
- 링크 미리보기에는 단계와 개수만 보이고, 내용은 절대 보이지 않아요.
- Slack 권한 범위:
commands,chat:write,links:read,links:write.users:read는 팀이 멘션을 켤 때만 요청해요. - 봇 토큰은 저장 시 암호화돼요(AES-GCM, 키는 Worker 시크릿으로 보관). Slack 요청 서명과 Bot Framework 토큰은 요청마다 검증해요.
- 앱을 제거하면 모든 게시가 멈추고 토큰이 삭제돼요.
웹과 인프라
- Cloudflare만 써요: 1801 Labs 계정의 Pages, Workers, Durable Objects, D1, R2, KV. 다른 서버는 없어요.
- 모든 구간 TLS, HSTS, 엄격한 콘텐츠 보안 정책.
- 분석 SDK도, 광고도, 서드파티 스크립트도 없어요. 유일한 예외는 결제 페이지에서만 불러오는 Paddle.js예요.
- 쿠키는 하나, 로그인한 진행자에게만:
peel_session, httpOnly, Secure, SameSite=Lax. - GIF 검색과 미디어는 저희 API를 거치므로 참여자의 IP 주소가 GIPHY에 닿지 않아요.
- 폰트와 에셋은 직접 호스팅해요. CDN 폰트도, 추적 픽셀도 없어요.
- IP별·보드별 요청 제한, 신고 기능, 진행자 잠금.
계정과 보관
- 참여자는 인증을 하지 않아요. 진행자는 보드를 보관하거나 유료 기능을 쓸 때만 Google(Teams에서는 Entra ID)로 로그인해요.
- 계정에는 ID, 제공자에게서 받은 이름과 이메일, 그리고 패스키(WebAuthn PRF)나 복구 코드로 여는 암호화된 키 번들이 저장돼요. 로그인은 소유권을 줄 뿐, 내용에 접근하게 해 주지는 않아요.
- 보드를 계정에 연결할 때는 진행자 서명으로 소유권을 증명해요. 보드 키는 절대 전송되지 않아요.
- 계정 없는 보드는 마지막 방문 후 60일이 지나면 삭제되고, 방문할 때마다 기간이 다시 시작돼요. 저장소는 Durable Object 알람이 삭제해요.
- 계정을 삭제하면 세션, 키 번들, 라이브러리가 삭제되고, 보드는 다시 60일 만료 대상이 돼요.
책임 있는 공개
문제를 발견했나요? 먼저 알려 주시면 함께 해결할게요. 신고는 영업일 기준 3일 안에 확인해요.
- 공개하기 전에 고칠 수 있도록 충분한 시간(목표는 90일)을 주세요.
- 본인의 보드와 계정으로만 테스트해 주세요. 다른 사람의 데이터에 접근하거나 서비스 품질을 떨어뜨리면 안 돼요.
- 이 규칙을 지킨 선의의 연구에는 법적 조치를 취하지 않아요.
- 기계가 읽을 수 있는 연락처:
/.well-known/security.txt.
Company 요금제 고객에게는 보안 검토 자료(설문 답변, 하위 처리자, 데이터 흐름)를 제공해요. DPA · 개인정보 처리방침