두 가지 생각이 대부분의 무게를 지탱합니다. 계정은 우리가 비밀번호를 대조하는 행이 아니라 실제 인증 세션입니다. 그리고 무엇을 읽을 수 있는지는 앱이 아니라 데이터베이스가 정합니다. 그래서 개조된 클라이언트도, 공개 키로 API에 직접 말을 거는 사람도 앱과 정확히 같은 답을 받습니다.
계정: bcrypt, 실제 세션, 그리고 프로필로 가는 다리
신원은 Supabase Auth에 있습니다. 비밀번호는 서버에서 bcrypt로 해시되며, 앱은 해시를 본 적이 없고 로그인 경로 어디에도 직접 만든 비교 코드가 없습니다. 로그인하면 나에게 한정된 JWT가 생기고, 프로필 행의 한 컬럼(auth_uid)이 인증 사용자와 프로필을 잇는 유일한 연결입니다. 아래의 모든 정책이 표현 가능한 것도 그 덕분입니다.
이메일과 비밀번호로 로그인할 수 있고, 안드로이드의 Credential Manager를 통한 Google 로그인도 됩니다. 후자는 진짜 Google ID 토큰을 Supabase에 넘겨 교환합니다. 모든 기기에서 로그아웃하면 세션이 전역으로 끊깁니다.
Supabase Auth 이전에 남은 레거시 PIN 컬럼이 하나 있습니다. 아무것도 그것을 읽지 않고, 새 계정은 비어 있는 채로 만들어지며, 삭제 대기 중입니다. 여기 적는 이유는 그것이 존재하고 언젠가 발견하실 것이기 때문입니다.
passkey: 서버에서 검증하고, 재생 공격을 막습니다
Korat은 passkey를 지원합니다. 휴대폰의 보안 하드웨어 안에서 만들어지고 지문이나 얼굴로 열리며 기기를 떠나지 않는 키 쌍입니다. relying party는 koratland.com이고 assetlinks.json으로 안드로이드 앱과 묶여 있으며, 검증은 라이브러리가 아니라 Cloudflare Worker 안에서 돕니다.
- 챌린지는 단발성이고 수명은 5분이며, 사용되는 순간 행이 삭제됩니다
- authenticator data의
rpIdHash는koratland.com의 SHA-256과 같아야 합니다 - user-present 플래그가 켜져 있어야 합니다
- 서명은 WebCrypto로 검증합니다 — ECDSA P-256 또는 RS256
- 서명 카운터는 반드시 증가해야 합니다. 같은 값이거나 되돌아간 카운터는 재생으로 보고 거부합니다
등록에는 기존 세션이 필요하고, 호출자의 신원은 요청 본문의 사용자 id가 아니라 bearer 토큰에서 가져옵니다. 그렇지 않으면 누구든 남의 계정에 passkey를 등록할 수 있습니다. 저장된 자격 증명 행에는 UPDATE 정책이 아예 없어서 소유자조차 자기 공개 키나 서명 카운터를 다시 쓸 수 없습니다. passkey 이름 변경은 라벨만 건드릴 수 있는 함수를 지나갑니다.
의도적으로 빠뜨린 것이 하나 있습니다. attestation은 검증하지 않습니다. Korat은 authenticator를 어느 제조사가 만들었는지 확인하지 않으므로, 여기의 어떤 것도 하드웨어가 보증한 주장으로 읽혀서는 안 됩니다.
솔직한 상태. passkey *등록*은 동작이 확인되었습니다. passkey *로그인*은 아직 끝에서 끝까지 한 번도 돌려본 적이 없고, 웹 앱에는 passkey 지원이 전혀 없습니다. 롤아웃 중인 안드로이드 기능입니다. 매일 쓰이는 경로는 이메일·비밀번호와 Google 로그인입니다.
행 수준 보안: 답하는 것은 앱이 아니라 데이터베이스입니다
행 수준 보안이 데이터베이스 전반에 켜져 있습니다. 정책은 네 개의 함수로 쓰이며, Korat의 거의 모든 규칙이 그중 하나입니다.
- app_uid()
- 호출자의 프로필 id, 로그인하지 않았으면 null. 모든 「자기 행만」 정책의 토대입니다.
- is_member_of(business_id)
- 소유자이거나 그 비즈니스에 활성 멤버십 행을 가진 사람이면 참.
- has_capability(business_id, capability)
- 소유자·매니저·홀·주방 같은 역할 프리셋을 서버에서 펼칩니다. 데이터베이스는 역할이 무엇을 뜻하는지에 대한 클라이언트의 생각을 믿지 않습니다.
- can_review(business_id)
- 자격 뷰를 읽습니다. 「실제 손님만 리뷰할 수 있다」를 앱이 하는 약속이 아니라 데이터베이스의 성질로 만듭니다.
값비싸게 배운 세 가지 규칙은 적어 둘 만합니다. 가게 관점으로만 쓴 정책은 손님을 배제합니다. 식사 세션도, 직원 호출도, 거래도 정당한 독자가 둘이므로 user_id = app_uid() or is_member_of(business_id)로 씁니다. 자식 테이블은 정확히 부모만큼만 읽혀야 합니다. 그래서 댓글과 좋아요는 누가 읽어도 되는지에 대한 자기 판단을 갖는 대신 게시물에 위임합니다. 그리고 definer 함수는 소유자 권한으로 돌기 때문에 스스로 권한을 확인해야 합니다.
RLS가 할 수 없는 일, 그리고 그 자리를 대신하는 것
행 수준 보안은 *누가 호출했는가*에 답할 수 있습니다. *이 요청이 유효한 토큰을 들고 있는가*에는 결코 답할 수 없습니다. 테이블 QR, 공유 링크, 초대장이 그렇습니다. 그래서 Korat에서 토큰으로 여닫히는 모든 경로는 토큰을 직접 검사하는 SECURITY DEFINER 함수이고, 테이블 자체는 닫혀 있습니다.
테이블 합류. QR을 찍으려는 사람은 세션의 호스트도 직원도 아니어서 식사 세션 테이블을 아예 읽지 못합니다. 토큰을 조회해 보는 것조차 불가능합니다. join_session_by_token이 서버에서 토큰을 대조하고, 처음 찍은 사람을 호스트로 만들고, 참가자를 추가하며, 토큰이 틀리면 추측의 폭을 좁히는 데 쓸 수 있는 메시지 대신 아무것도 돌려주지 않습니다.
공유 링크. resolve_share는 링크가 어떤 종류의 무엇을 가리키는지만 돌려주고 그 외에는 아무것도 돌려주지 않습니다. 누가 공유했는지도, 클릭 수도 아닙니다. 클릭 집계는 record_share_click을 지나가므로, 분석 테이블에 삽입 권한이 전혀 없어도 누구나 집계될 수 있습니다. 그 테이블에는 누구를 위한 INSERT 정책도 일부러 두지 않았습니다.
돈. 주문 접수는 전부 서버에서 돕니다. 모든 줄의 가격을 직접 매기고 배달비는 비즈니스 자신의 행에서 읽으며 클라이언트가 보낸 값은 무시합니다. 클라이언트가 정할 수 있는 요금은 클라이언트가 스스로에게 주는 할인이기 때문입니다.
연락처, 기기, 그리고 내가 하지 않은 로그인
보조 이메일용 인증 코드는 service_role만 호출할 수 있는 함수가 만듭니다. 저장되는 것은 코드의 SHA-256뿐이고, 10분 뒤 만료되며, 시도는 5회까지, 요청은 분당 1회로 제한됩니다. 메일을 보내는 Worker는 코드를 앱으로 되돌려 주지 않습니다.
자기 연락처를 스스로 확인 완료로 표시할 수는 없습니다. 데이터베이스 트리거가 클라이언트 쓰기에서 verified 플래그를 강제로 false로 만들고, 실제로 코드를 검사한 definer 함수만 그것을 켤 수 있습니다. 과하게 들리다가도 따라가 보면 달라집니다. 이것이 없으면 남의 계정에 내 주소를 추가하고 「비밀번호 찾기」를 누르는 것만으로 계정 탈취가 완성됩니다. 같은 이유로 연락처는 확인된 뒤에만 기본으로 지정할 수 있습니다. 기본 주소는 시스템이 메일을 보내는 주소이기 때문입니다.
로그인된 모든 기기가 설정에 나열되고 개별로 해지할 수 있습니다. 기기 테이블에는 삽입·수정·삭제 정책이 하나도 없습니다. 방금 해지된 기기도 여전히 유효한 토큰을 들고 있어서, 그렇지 않으면 자기 해지를 스스로 지울 수 있기 때문입니다. 기기를 해지하면 밑에 깔린 세션도 삭제되므로 리프레시 토큰이 함께 죽습니다.
새 기기에서의 로그인은 기기 행을 만드는 바로 그 트랜잭션 안에서 인앱 알림을 기록하므로 유실될 수 없고, 메일도 함께 나갑니다. 다만 확인된 연락처로만 보냅니다. 확인되지 않은 주소는 다른 사람의 것일 수 있기 때문입니다. 메일은 기기당 최대 한 번, 그리고 최근 한 시간 안에 만들어진 기기에 대해서만 발송되므로, 이 엔드포인트를 계정 주인에게 메일을 퍼붓는 수단으로 바꿀 수 없습니다.
해지의 한계를 그대로 적습니다. 이미 발급된 액세스 토큰은 약 한 시간을 살고 중간에 회수할 수 없습니다. 해지된 기기가 오프라인이라면 토큰이 만료될 때까지 계속 동작합니다. 버그가 아니라 JWT의 성질이고, 「해지는 즉시 반영됩니다」라고 주장하는 대신 기기 목록을 둔 이유입니다.
내 계정에 대해 내가 할 수 있는 것
계정 삭제는 설정에서 직접 할 수 있습니다 — 누구에게도 메일을 쓸 필요가 없습니다. 그것은 기록되고 취소할 수 있는 *요청*이지, 새벽 두 시에 눌렀다가 되돌릴 수 없는 버튼이 아닙니다. 요청의 상태는 앱 안에서 확인할 수 있습니다. privacy@koratland.com은 열람·정정·이동권·동의 철회 같은 다른 PDPA 권리를 위해 그대로 있습니다.
신고는 사람뿐 아니라 내용도 대상으로 합니다 — 프로필, 게시물, 댓글, 스토리, 메시지, 비즈니스 페이지. 신고를 받는 함수는 SECURITY INVOKER이고, 백오피스 전체에서 유일하게 그렇습니다. 소유자 권한으로 돌면 숨겨진 게시물과 비공개 게시물이 *보이고* 그것들에 다른 답을 하게 되어, 신고 버튼이 「내가 볼 수 없는 내용이 존재하는가」를 캐묻는 장치가 됩니다. 호출자의 권한으로 돌면 「그런 게시물 없음」과 「당신이 볼 수 있는 게시물이 아님」이 하나의 답으로 합쳐집니다 — 신고하는 사람의 자리에서는 그 둘이 같은 일이니 정직한 답입니다. 횟수 제한은 앱이 아니라 트리거에 있습니다.
Korat이 하지 않는 것
이 목록이 위의 내용을 읽을 가치가 있게 만듭니다. 여기 있는 것은 모두 오늘 사실입니다.
- 어떤 것도 종단간 암호화되지 않습니다. 메시지와 파일과 통화는 전송 중에는 TLS로, 저장 상태에서는 행 수준 보안으로 보호됩니다. 서버는 그것들을 읽을 수 있습니다. Korat에는 기기 키도 키 관리도 없으므로, 그것이 필요한 일에 선택해서는 안 됩니다.
- 관리형 데이터베이스가 제공하는 것 이상의 애플리케이션 수준 저장 암호화는 없습니다.
- 신분증이 확인된 적이 한 번도 없습니다. eKYC가 없습니다. Korat 어디에도 「신원 인증됨」이라는 말이 없고, 신뢰 배지는 대신 어떤 증거가 있는지를 말합니다.
- 전화번호는 인증할 수 없습니다. 연결된 SMS 사업자가 없고, 엔드포인트는 그런 척하는 대신 그렇다고 답합니다.
- 업로드된 미디어는 URL만 있으면 누구나 읽을 수 있습니다. 프로필 사진과 게시물 이미지, 채팅 이미지는 공개 버킷에 있습니다. 쓰기에는 세션이 필요하지만, 새어 나간 URL은 곧 가져갈 수 있는 파일입니다. 서명된 URL은 아직 없습니다.
- 비즈니스 콘솔의 권한 검사는 현재 기기에서 돕니다. 같은 행렬이 서버 쪽에
has_capability()로 존재합니다. 그 롤아웃이 끝날 때까지 콘솔 권한은 보안 경계가 아니라 조직상의 통제로 다루십시오. - 통화에는 TURN 서버가 없고 종단간 암호화도 되지 않습니다. 일부 네트워크를 가로지르는 통화는 연결에 실패합니다.
- 현재 플랜에는 데이터베이스 백업이 없고, 자동화된 테스트 스위트도 없습니다.
- 웹 앱에는 푸시 알림이 없습니다. 푸시는 안드로이드 앱의 기능이고, 웹에서는 알림이 도착한 순간이 아니라 탭을 열 때 보입니다.
그중 무엇이든 바뀌면 이 페이지도 함께 바뀝니다. 은행 수준 암호화에 대한 문단 하나를 쓰고 넘어가는 편이 쉬웠겠지만, 이쪽이 더 쓸모 있습니다.