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

코드로 쓴 신뢰 구역

~15 min · permissions, origins, secure-contexts, honest-gaps

Level 0새 탭
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

문서 위의 세 구역

불변식 5는 Voyager 아키텍처에서 제일 야심 찬 문장이야. 신뢰는 구역이고, 구역은 검증된다. 구역은 셋이야. 이 Mac과 집 네트워크의 가족 출처, 아빠의 테일넷, 바깥세상. 테일넷 출처는 로컬 tailscaled가 피어라고 불러 주는 호스트야. 집 출처는 카탈로그가 가족 Mac에 대해 적어 둔 정확한 출처고, "LAN의 아무 주소"는 절대 아니야. 손님도 같은 Wi-Fi를 쓰니까. 나머지는 바깥세상이고, 검증할 수 없는 것도 바깥세상이야.

불변식 6은 구역으로 뭘 얻는지 말해. 가족 구역은 마이크와 카메라를 묻지 않고 써. 그 밖의 허가는 이유를 붙여 이름으로 하나씩 더하고, 바깥세상은 Safari처럼 물어봐. 없애려는 불편은 트랙 1이 처음에 꺼낸 그거야. 아빠 앱에서 아빠 마이크인데 뜨는 권한 창.

판정은 함수 하나

첫 번째 블록이 정책의 전부야. TrustPolicy는 출처(스킴, 호스트, 포트)를 구역으로 바꾸는 함수를 감싸고, 그 함수는 확인할 수 있는 쪽이 넣어 줘. 아무것도 안 넣으면 모든 출처가 바깥세상이야. decision(for:)은 출처 두 개를 봐. 묻는 프레임, 그리고 그 프레임이 들어 있는 페이지. 둘 다 바깥세상이 아닐 때만 허가해. 바깥 페이지 안의 가족 프레임은 물어봐. 가족 페이지 안의 바깥 프레임도 물어봐. 남한테 끼워 넣어진다고, 혹은 남을 끼워 넣는다고 콘텐츠가 자기 구역을 올릴 순 없어.

일부러 빠진 게 둘 있어. 정책은 절대 .deny라고 답하지 않아. 바깥세상엔 WebKit의 자체 권한 창이 뜨고 아빠가 정해. 아빠 대신 거절하는 건 없고, nothingIsRefusedOnDadsBehalf 테스트가 종류마다 확인해. 그리고 허가할 수 있는 목록은 닫혀 있어. PermissionKind엔 마이크, 카메라, 둘 다만 있고, 새 종류는 거기에 이유와 함께 이름으로만 들어가. 그 enum과, WebKit의 캡처 질문을 이 요청으로 바꾸는 엔진 자식은 트랙 3에서 봤어. 신뢰는 조용히 하나를 더 정해. 트랙 4의 잠재우기 정책은 바깥세상 탭만 재워.

Voyager가 설치하는 검증기

두 번째 블록이 코드가 만드는 유일한 검증기야. TrustPolicy.family(origins:)는 둘을 집으로 쳐. 어떤 loopback 표기로 부르든 이 Mac(localhost, ::1, 127.로 시작하는 주소 전부), 그리고 넘겨받은 정확한 출처들. 나머지는 바깥세상이야. 기본 포트는 먼저 맞춰 둬. WebKit은 443의 HTTPS를 포트 없이 부르고, 카탈로그는 포트를 적을 수도 있거든.

세 번째 블록이 그 출처들을 넘겨. Settings가 가리키는 허브와 피파, 그리고 지금 카탈로그 투영의 웹 대상 전부. 셸은 Settings나 카탈로그가 바뀔 때마다 이걸 불러. Mac에선 확장을 막는 바로 그 자리에서(다음 레슨), 폰에선 폰의 가족 훅에서. 그러니 신뢰는 카탈로그를 따라가. 오늘 카탈로그에 있는 앱은 오늘 집이고, 카탈로그에서 빠진 앱은 투영이 바뀌는 순간 바깥세상이야.

정확하다는 건 정말 정확하다는 뜻이야. 가족 신뢰 테스트는 이웃이 가족이 아닌지 확인해. 같은 테일넷 호스트의 다른 포트는 바깥이고, 같은 호스트의 평문 HTTP도 바깥이야. 적힌 출처 하나는 자기만 보증하고, 그 옆은 하나도 보증 안 해.

검증기 없이 보낸 19일

구역 타입, 판정, 그 테스트들은 2026-10-03 첫 벽돌부터 있었어. 없던 건 호출하는 쪽이었어. BrowserServices는 검증기 없는 기본값 TrustPolicy()를 그대로 들고 있었어. 그러니 모든 출처가 바깥세상이었고, 가족 앱마다 낯선 사이트처럼 WebKit 권한 창을 받았지. 그동안 신뢰 테스트는 전부 통과했어. 테스트마다 정책을 손으로 만들었으니까. 2026-10-22의 수정(3638f97, todo #603)은 두 번째 블록의 검증기를 더했어. 그리고 그만큼 중요한 것, 정책을 설치하는 코드도 더했어. 세 번째 블록과 셸의 호출이야.

원칙: 테스트를 통과한 정책이 곧 시행 중인 정책은 아니야. 설치하는 자리를 시험하든지, 조립된 앱을 통해 동작을 시험해. 아무도 설치하지 않은 타입은 서랍 속 약속이야.

타입으로만 있는 구역

이제 빈 곳을 정직하게 보자. TrustZone은 .tailnet을 선언하고, 주석엔 로컬 tailscaled가 피어라고 부르는 호스트라고 적혀 있어. 아키텍처도 집 간판도 README도 Voyager가 아빠 테일넷을 묻지 않고 믿는다고 써. 코드는 tailscaled한테 아무것도 안 물어. family(origins:)는 .home이나 .outsideWorld만 돌려줘. 선언을 빼면 앱 소스에서 .tailnet이 나오는 곳은 Site Information의 문구와 아이콘뿐이야. 어떤 검증기도 만들어 내지 않는 구역의 문구지. (테스트 하나가 .tailnet을 답하는 정책을 손으로 만들어서, 판정이 그걸 허가하는지만 확인해.)

그러니까 지금은 이래. 테일넷의 가족 앱은 Firelink 카탈로그나 Settings가 그 정확한 출처를 적어 줘서 신뢰받아. 카탈로그에 없는 테일넷 호스트는 웹의 아무 사이트랑 똑같이 물어봐. 테일넷 구역은 아직 안 만들어졌어. 검증할 수 없으면 이미 바깥세상이니까, 이 빈틈은 묻는 쪽으로 실패해. 안전한 방향이야. 빠진 것 때문에 아빠는 권한 창을 하나 더 볼 뿐이고, 원하지 않은 허가가 나가진 않아.

같은 패널에 작은 어긋남이 하나 더 있어. Site Information은 집 구역을 Family app on this Mac(이 Mac의 가족 앱)이라고 설명하는데, 다른 Mac이 서비스하는 카탈로그 앱도 집이야. 그 문구는 검증기가 생기기 19일 전, 첫날에 쓰였거든.

먼저 보안 컨텍스트

허가는 페이지가 물어볼 수 있는 곳에서만 의미가 있어. WebKit은 보안 컨텍스트에만 캡처를 내주고, 평문 HTTP를 봐 달라고 할 방법도 없어. 엔진 게이트가 그걸 쟀어(W6). Tailscale HTTPS 이름의 가족 앱은 물을 수 있어. 그런데 같은 앱을 테일넷 이름에 평문 HTTP로 열면 navigator.mediaDevices가 아예 없어. 평문 HTTP loopback은 보안 컨텍스트로 쳐. 그래서 가족 앱은 HTTPS 트윈이나 loopback으로 열어야 하고, Firelink 카탈로그도 tailnet에 대답할 땐 HTTPS 트윈을 먼저 골라. hardened runtime에선 앱이 entitlement로 마이크와 카메라를 따로 신청해야 하고, Voyager에 대한 macOS 자체 권한도 그대로 적용돼. 구역은 Voyager와 페이지 사이의 문제를 정리할 뿐이야. 시스템을 넘어서진 않아.

Code

cwkVoyager, Sources/VoyagerCore/TrustPolicy.swift: 세 구역, 기본은 검증기 없음, 그리고 프레임과 페이지를 같이 보는 판정 (원문 그대로, 1-41행)·swift
// Trust zones (architecture invariants 5 and 6). A zone is verified, never
// claimed: the verifier is handed in by whoever can check (the launch
// catalog for a home origin, the local tailscaled for a tailnet peer). Until
// one is handed in, every origin is the outside world.

import CwkKitCore
import Foundation

public enum TrustZone: String, Sendable, Codable {
    /// This Mac, and the exact origins the catalog lists for a fleet Mac.
    case home
    /// A host the local tailscaled names as a peer.
    case tailnet
    /// Everything else, and anything that cannot be verified.
    case outsideWorld
}

public struct TrustPolicy: Sendable {
    private let zoneOf: @Sendable (PageOrigin) -> TrustZone

    /// With no verifier, every origin is the outside world.
    public init(zoneOf: @escaping @Sendable (PageOrigin) -> TrustZone = { _ in .outsideWorld }) {
        self.zoneOf = zoneOf
    }

    public func zone(of origin: PageOrigin) -> TrustZone {
        zoneOf(origin)
    }

    /// Family zones use the microphone and camera without a prompt; the
    /// outside world is asked, as Safari asks. Nothing is ever granted to the
    /// outside world here, and nothing is refused on Dad's behalf.
    ///
    /// Both the frame that asks and the page it sits in must be family. An
    /// outside page cannot borrow a family frame's grant, and a family page
    /// cannot lend its own to a frame from outside.
    public func decision(for request: PermissionRequest) -> PermissionDecision {
        let zones = [zone(of: request.origin), zone(of: request.topOrigin)]
        return zones.contains(.outsideWorld) ? .ask : .grant
    }
}
cwkVoyager, Sources/VoyagerCore/TrustPolicy.swift: 지금 만드는 유일한 검증기, loopback과 정확히 적힌 출처 (원문 그대로, 43-66행)·swift
extension TrustPolicy {
    /// The family's own origins, as Voyager verifies them today: this Mac
    /// (loopback, a secure context to WebKit) and the exact origins the family
    /// names, Firelink's catalog and the hub and Pippa that Settings reach. A
    /// tailnet host the catalog does not list stays the outside world: the
    /// local tailscaled is not asked yet. Before 2026-10-22 no verifier was
    /// installed at all, so every family app was asked like a stranger.
    public static func family(origins: Set<PageOrigin>) -> TrustPolicy {
        let listed = Set(origins.map(\.withoutDefaultPort))
        return TrustPolicy { origin in
            if ConnectionHint.isLoopback(origin.host) { return .home }
            return listed.contains(origin.withoutDefaultPort) ? .home : .outsideWorld
        }
    }
}

extension PageOrigin {
    /// The same origin with a port its scheme implies written as none: WebKit
    /// names https on 443 with no port, a catalog may write it out.
    public var withoutDefaultPort: PageOrigin {
        let implied = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
        return implied ? PageOrigin(scheme: scheme, host: host) : self
    }
}
cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: 19일 동안 없던 설치 코드 (원문 그대로, 85-95행)·swift
    /// The microphone and camera without a prompt for the family's own
    /// origins (invariant 6): the catalog's web targets, the hub, Pippa. The
    /// shell calls it whenever the settings or the catalog move.
    public func applyFamilyTrust() {
        let settings = settings.value
        var origins = Set([settings.familyHub, settings.pippaBase].compactMap(PageOrigin.init(url:)))
        for item in familyCatalog.projection?.items ?? [] {
            if case .web(let url) = item.destination, let origin = PageOrigin(url: url) { origins.insert(origin) }
        }
        trust = .family(origins: origins)
    }

External links

Exercise

네 앱에 들어간 웹 뷰를 위한 권한 정책을 만들어 봐. 필요한 건 출처를 구역으로 바꾸는 함수, 묻는 프레임과 최상위 페이지가 둘 다 믿을 만할 때만 허가하는 판정, 허가할 수 있는 종류를 닫아 둔 enum이야. deny라는 답은 아예 두지 마. 그리고 Voyager에 19일 동안 없던 테스트를 써. 앱이 실행될 때와 똑같이 진짜 서비스 객체를 조립하고, 목록에 있는 출처가 권한 창 없이 허가되는지 확인해. 마지막으로 이웃 테스트. 믿는 호스트의 다른 포트와 평문 HTTP는 둘 다 물어봐야 해.
Hint
설치 테스트는 정책을 직접 만들면 안 돼. 앱이 쓰는 조립 코드를 그대로 거쳐야, 그 코드가 정책을 빼먹었을 때 실패하거든. 비교할 때마다 양쪽 다 기본 포트(https는 443, http는 80)를 맞춰.

Progress

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

댓글 0

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

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