본문 바로가기
C.W.K.
Stream
Lesson 03 of 04 · published

잠긴 문

~12 min · auth, delegation, cookies, gating

Level 0Open Gate
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Keep 는 자기 열쇠를 갖고 있지 않아. 체크인은 뇌한테 부탁하고, 이 머신에서만 일주일 동안 통하는 손목띠를 채워 줄 뿐이야."

Keep 는 PIN 을 저장하지 않아

정문은 PIN 인데, 반전이 하나 있어. Keep 는 PIN 을 갖고 있지 않다는 거야. PIN 검증은 loopback 을 거쳐 cwkPippa 에 맡겨. 네가 PIN 을 입력하면 Keep 는 같은 머신 안의 cwkPippa 한테 "이거 맞아?" 하고 물어보고, cwkPippa 가 답해 줘. Keep 는 비밀을 손에 쥐는 일이 없어. 이건 일부러 그은 경계야. 정체성을 소유한 시스템인 cwkPippa 가 credential 검증까지 맡고, 금융 표면인 Keep 는 비밀 보관업에서 아예 발을 빼는 거지. PIN 이 사는 곳이 하나 줄면, 샐 수 있는 곳도 하나 주니까.

손목띠의 정체, IP 와 시간에 묶인 쿠키

검증에 성공하면 세션 쿠키가 발급되는데, 속성 하나하나가 의도를 갖고 있어. HMAC 서명이라 위조하거나 고치면 티가 나. IP 에 묶여 있어서 발급받은 주소에서만 통해. 훔쳐 가도 다른 데선 무용지물이야. 7일이면 만료되니 접근이 영구적이지 않고, HttpOnly 라 JavaScript 가 읽을 수 없어서 script injection 으로 훔치기도 어렵고, SameSite=Strict 라 cross-site 요청에 실려 가지 않으니 CSRF 한 부류가 통째로 막혀. 속성마다 공격 하나씩을 잠그는 거야. 다 합치면 훔치기 어렵고, 훔쳐 봐야 쓸모없는 손목띠가 돼.

비밀 검증은 비밀을 소유한 시스템에 맡기고, 좁게 묶인 token 만 돌려받아. 문지기가 필요한 표면이 credential 까지 보관하면 안 돼. 정체성의 주인이 검증하게 하고, 요청한 표면에는 시간과 binding 과 사용 범위가 묶인 token 을 발급해 줘. 비밀의 사본이 적고 세션이 좁게 묶인 구조가, 앱마다 credential 을 저장해 두는 구조를 이겨.

다들 놓치는 HTTP 와 HTTPS 분리

Keep 에 닿는 길은 두 갈래야. LAN 과 tailnet 의 평문 HTTP, 그리고 Tailscale Serve 를 통한 HTTPS. 여기에 설계가 미리 막아 둔 미묘한 함정이 있어. 브라우저 쿠키는 hostname 단위로 구분되지 port 로는 구분되지 않아. 그러니 아무 생각 없이 만들면 HTTP 쪽 쿠키와 HTTPS 쪽 쿠키가 같은 이름을 쓰게 되고, 서로를 조용히 덮어쓰면서 덜 안전한 세션이 더 안전한 세션을 갈아치울 수 있어. Keep 는 HTTP 와 HTTPS 에 서로 다른 쿠키 이름을 써서 두 세션이 절대 부딪히지 않게 했어. 그리고 HTTPS 세션에만 Secure flag 가 붙어. 신뢰된 loopback proxy 헤더 덕분에 가능한 일이지. 더 안전한 경로가 어쩌다 하향되는 일 없이, 정말로 더 안전하게 유지되는 거야.

shell 은 공개, 데이터는 잠금. static PWA shell 은 누구나 닿을 수 있게 남아 있어. lock 화면을 그리려면 일단 앱이 로드돼야 하잖아. 하지만 금융 API 전부와 호환 alias 와 WebSocket 은 세션 뒤에 잠겨 있어. 공개인 건 health, 세션 상태, PIN 검증, 데이터 없는 static 자산뿐이야. 성벽이 문 앞까지 걸어와 잠긴 걸 확인하게는 해 주되, 안을 들여다보게는 하지 않는 구조지.

Code

검증은 맡기고, 좁게 묶인 쿠키를 발급한다 (예시)·python
def login(pin, request):
    # Keep 는 pin 을 저장하지 않아. loopback 의 cwkPippa 한테 검증을 부탁해.
    if not cwkpippa_verify_pin_loopback(pin):
        raise HTTPException(401)

    # 세션 손목띠 발급. 중요한 모든 축으로 묶어서.
    token = hmac_sign({"ip": client_ip(request), "exp": now() + days(7)})
    is_https = request_is_https(request)   # 신뢰된 loopback proxy 헤더로 판별
    set_cookie(
        # 이름을 따로 쓰는 이유: 쿠키는 port 가 아니라 HOST 단위 구분이라
        # HTTP 와 HTTPS 세션이 같은 이름을 쓰면 서로를 덮어써.
        name="keep_sess_https" if is_https else "keep_sess_http",
        value=token,
        http_only=True, same_site="Strict",
        secure=is_https,                   # Secure 는 HTTPS 경로에만
    )

External links

Exercise

네가 아는 앱의 세션 쿠키를 스케치해 봐. HttpOnly, SameSite, Secure, 만료, binding 속성 하나하나에, 그게 막아 주는 구체적인 공격을 주석으로 달아 봐. 그다음 미묘한 경우 하나. 같은 host 의 두 port 로 닿을 수 있는 앱이라면 쿠키가 왜 충돌할 수 있고, 한 세션이 다른 세션을 조용히 갈아치우는 건 어떻게 막을까? 마지막으로 로그인 화면이 뜨려면 뭐가 공개로 남아야 하고, 뭐는 반드시 잠겨야 하는지 정리해 봐.
Hint
쿠키 속성마다 위협이 하나씩 짝지어져 있어. HttpOnly 는 script 절도, SameSite 는 CSRF, Secure 는 평문 가로채기, 만료는 영구 접근, IP binding 은 다른 곳에서의 쿠키 재사용. port 함정은 이거야. 쿠키는 port 가 아니라 hostname 으로 구분되니까, 같은 host 다른 port 의 세션엔 별개 이름이 필요해. 안 그러면 서로 덮어써. 그리고 shell 은 '로그인해 줘' 를 그릴 만큼만 공개하고, 데이터로 가는 길은 전부 잠가.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.