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

1Password, 있는 그대로

~14 min · 디버깅, 1password, 사례 연구, 멈춤 조건

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

서로 믿어야 하는 프로그램 둘

Chromium 브라우저에서 1Password는 프로그램 둘이야. 브라우저 안의 확장, 그리고 옆에 있는 데스크톱 앱. 둘은 네이티브 메시징으로 만나고, 데스크톱 앱의 헬퍼는 뭘 답하기 전에 브라우저부터 검증해. 모르는 브라우저를 위해선 데스크톱 앱에 Add browser가 있어. 아빠가 1Password를 쓰니까, 벽돌 하나 쌓기 전에 돌린 Voyager 엔진 게이트의 첫 케이스 W1이 바로 이거였어.

이 레슨은 그 얘기의 브라우저 쪽을 디버깅 사례로 풀어. 이야기는 일이 실제로 멈춘 자리에서 끝나. 그리고 그 자리는 고친 자리가 아니야.

통과한 것, 벽 하나씩

벽막고 있던 것치운 것
없는 Chrome 이름백그라운드가 WebKit에 없는 첫 이름에서 멈췄어호환 레이어(레슨 2)
서명헬퍼가 가족 서명 빌드의 서명을 못 읽었어. 1Password 코어는 Apple 체인 아래 번들 id와 팀 id가 든 hardened runtime 서명을 봐유료 팀의 Apple Development 아이덴티티와 hardened runtime으로 서명한 설치 번들. 아빠가 Add browser로 Voyager를 넣었고, 1Password가 연결된 브라우저로 올렸어. 그 결과는 트랙 2의 게이트 레슨에, 서명 자체는 트랙 9에 있어
프로세스 그룹브리지가 띄운 호스트가 전부 1분마다 거절당했어브라우저 자기 그룹 안에서 posix_spawn(레슨 3)
직접 부모중간에 낀 중계가 둘째 규칙에 걸렸어중간 없이 앱 자신이 부모야(레슨 3)
확장 페이지chrome-extension:// 주소가 버려지고, 스킴이 취소되고, 컨텍스트 고유 설정 없인 페이지가 거절됐어pageURL(for:)와 그 설정으로 만든 페이지(레슨 3)
프래그먼트확장의 앱 페이지가 깜깜한 탭으로 열렸어프래그먼트를 지키고 회귀 테스트로 묶었어(아래)

넷째 줄을 치우고 나자 헬퍼 로그엔 Voyager를 검증한다는 줄과 파트너 승인을 기다린다는 줄이 찍혔어. 헬퍼가 기다리는 건, 확장 자기 백그라운드가 앱 페이지의 요청을 받고 나서 보내는 메시지야. 그래서 마지막 두 줄이 중요했지.

깜깜한 페이지와 프래그먼트

앱 페이지는 처음에 "1Password"라는 제목의 깜깜한 탭으로 떴어. 초기 기록은 그걸 확장 프런트엔드 앱이 마운트에 실패한 걸로 읽고 프레임워크 이름까지 붙였어. 나중에 설치된 패키지를 들여다보니 둘 다 틀렸어. 앱 진입점은 해시 라우터를 쓰는 React야. /엔 라우트가 없고, 환영 라우트는 #/page/welcome이야. Voyager의 pageURL(for:)가 프래그먼트를 빼고 페이지 URL을 만들고 있었으니 라우터는 갈 데가 없었던 거야. 그걸 확인하던 프로브도 프래그먼트 없이 맨 app/app.html을 열고 있었어. 계측기가 찾던 버그를 자기도 갖고 있었던 셈이지.

이제 매핑은 프래그먼트를 인코딩 그대로 옮겨(첫째 코드 블록). anExtensionPageKeepsItsFragmentAndRendersItsHashRoute가 그걸 붙잡아. 없는 프래그먼트, 빈 프래그먼트, 인코딩된 프래그먼트, 프래그먼트 안 라우트 자기 쿼리, 그리고 해시 라우트가 도착해야만 그려지는 픽스처 페이지까지(둘째 코드 블록). 집 규칙은 이걸 한 문장으로 남겨. 깜깜한 페이지 하나만으론 그 JavaScript가 마운트에 실패했다고 단정할 수 없다.

죽은 툴바 버튼도 같은 식으로 풀렸어. 페어링 전이면 1Password 백그라운드가 올라오고 몇 초 뒤 자기 팝업을 스스로 지워. 그러면 클릭은 백그라운드의 클릭 이벤트 몫이 되고, 그건 페어링이 끝나기 전엔 아무것도 안 보여 줘. 죽은 버튼은 페어링 안 된 상태가 겉으로 드러난 거였어.

통과 못 한 것

확장을 통한 인증은 끝내 안 붙었어. 팝업도, 로그인 채우기도 Voyager에서 돌아가는 걸 본 적이 없어. 그리고 페어링 안 된 확장은 1분마다 네이티브 포트를 새로 열어서, 기다리는 헬퍼가 Mac에 쌓여.

범위를 미리 정한 진단이 한 번 더 있었어. 공개 API만 쓰는 숨은 프로브에, 새로 격리한 컨트롤러와 데이터 저장소를 썼어. 데스크톱 브리지도, 진짜 탭도, 이미지 캡처도 안 썼고, 자격 증명이나 보관함이나 프로토콜 본문은 하나도 안 읽었어. 가설 하나는 지웠어. WebKit은 확장 컨텍스트마다 출처를 만들어 주는데 그게 프로세스마다 바뀌었어. 그래도 그 아래 저장한 데이터는 다음 프로세스에서 살아 있었어. 그러니 출처가 바뀐다는 것만으론 그 Mac에서 로그인 상태가 사라진 걸 설명 못 해. 공개 패키지는 빈 프로필에서 환영 라우트도 잘 그렸고. 둘 다 원인은 아니야. 기록은 자기 말로 이렇게 적어. 실제 근본 원인도, 정확한 수정 패치도 밝혀지지 않았다.

멈추는 것도 진단의 일부야

아빠가 정한 멈춤 조건이 거기서 일을 끝냈어. 이번 시도는 여기까지, 1Password는 맨 뒤로 보류, 가설 세우고 고치는 반복도 더는 안 해. Home, 계정, 시크릿 창, 피파는 1Password 없이 계속 가. 기록은 다음 시도에도 울타리를 쳐 둬. 다음 시도는 콜백, 액션 상태, 완료 메타데이터만 보거나, 아빠가 같이하는 확인만 할 수 있어. 그리고 거기 적힌 어떤 것에서도 새 설치, 재시작, 신뢰 부여, 자격 증명 사용 허락을 끌어내면 안 돼. 그 뒤로 1Password를 건드린 Voyager 커밋은 없어.

그래서 문서 둘이 사실 둘을 말해. 계획과 게이트는 1Password가 Voyager를 연결된 브라우저로 올렸다고 하고, 진단은 연동이 안 풀렸다고 해. 둘은 같은 일의 서로 다른 절반을 말하는 거야. 이 일에서 나온 결정 하나는 남았어. cwkPippa 페이지에서 1Password 콘텐츠 스크립트가 navigator.credentials를 가로채서, 페어링이 열려 있는 동안 패스키를 저장할 수 없었거든. 지금 모든 확장을 가족 호스트에서 떼어 놓는 이유가 그거야(트랙 6). 다음 날 아빠가 대비책을 정했고, 마지막 레슨이 그 얘기야.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitExtensionHost.swift: chrome-extension:// 주소를 설치된 페이지로, 프래그먼트는 그대로 (원문 그대로, 347-367행)·swift
    /// A `chrome-extension://` address from outside — a desktop app pairing
    /// its extension opens the extension's own page by this address
    /// (1Password's Add browser), the way Chrome does — becomes the
    /// installed package's page URL in WebKit's scheme. Nil when the
    /// address belongs to no installed extension.
    public func pageURL(for url: URL) -> URL? {
        guard url.scheme == "chrome-extension",
              let id = url.host,
              let context = contexts[id]
        else { return nil }
        let base = context.baseURL
        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        let path = components?.path ?? ""
        let page = path.isEmpty ? base : base.appendingPathComponent(String(path.dropFirst()))
        guard var pageComponents = URLComponents(url: page, resolvingAgainstBaseURL: false) else { return nil }
        pageComponents.query = components?.query
        // Extension apps route inside the fragment (for example 1Password's
        // #/page/welcome). Preserve its encoding and its own query verbatim.
        pageComponents.percentEncodedFragment = components?.percentEncodedFragment
        return pageComponents.url
    }
cwkVoyager, Tests/VoyagerWebKitTests/ExtensionHostTests.swift: 프래그먼트가 없을 때, 비었을 때, 인코딩됐을 때, 그리고 해시 라우트가 그려지는지 보는 회귀 테스트 (원문 그대로, 193-209행)·swift
        let base = "chrome-extension://\(Self.extensionID)/route.html?from=app"
        // Preserve absent, empty and encoded fragments, including a route's own query.
        for suffix in ["", "#", "#/page/welcome?language=en", "#/page/welcome?label=a%20b%23c%26d"] {
            let input = try #require(URL(string: base + suffix))
            let mapped = try #require(host.pageURL(for: input))
            let original = try #require(URLComponents(url: input, resolvingAgainstBaseURL: false))
            let result = try #require(URLComponents(url: mapped, resolvingAgainstBaseURL: false))
            #expect(result.scheme == "webkit-extension")
            #expect(result.path == "/route.html")
            #expect(result.query == "from=app")
            #expect(result.percentEncodedFragment == original.percentEncodedFragment)
        }
        let input = try #require(URL(string: base + "#/page/welcome?language=en"))
        let mapped = try #require(host.pageURL(for: input))
        let page = try #require(engine.makePage(forExtensionPageAt: mapped))
        page.load(mapped)
        #expect(await eventually { page.status.title == "Fixture welcome" })

External links

Exercise

네 소프트웨어에서 막힌 연동 하나를 골라. 올라오긴 하는데 아무것도 안 하는 플러그인, 끝까지 안 가는 로그인 같은 거. 이 레슨의 표처럼 기록을 써 봐. 벽마다 그 벽이 서 있었다는 증거와 무너졌다는 증거를 적는 거야. 그리고 다시 시도하기 전에 멈춤 조건부터 적어. 뭘 해 볼지, 뭘 안 읽고 안 건드릴지, 어떤 결과가 나오면 어느 쪽이든 그 시도를 끝낼지.
Hint
"통과"와 "작동"은 다른 칸에 둬. 검증된 핸드셰이크는 돌아가는 기능이 아니야. 증거마다 그 계측기가 못 보는 게 뭔지 적어. 좋은 멈춤 조건엔 목표만 있는 게 아니라 예산(시도 횟수나 시간)과 결정할 사람도 있어.

Progress

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

댓글 0

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

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