Skip to content
C.W.K.
Lesson 05 of 05 · published

A Password Store of Its Own

~15 min · passwords, keychain, privacy, consent

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

The fallback Dad ruled

다른 브라우져들처럼 자체 비밀번호 저장소를 만들어줘. 한번 기억한 id/pass 쌍은 다시 입력 안 해도 되게. 1password 연동 안 되면 이걸로 fallback.

Dad, 2026-10-04: "Make it a password store of its own, like other browsers have. Once it remembers an id and password pair, I shouldn't have to type it again. If the 1Password connection doesn't work, this is the fallback." It came the day after 1Password was parked. The word that matters is fallback: the daily friction had to go whether or not 1Password ever connects.

The index and the secret

The store splits every login in two. logins.json in Voyager's folder is the index: for each login an id, the origin, the username, a realm for HTTP authentication, and two dates, plus the sites Dad said never to ask about. The password is never there. It goes to the Keychain as one generic-password item per login, under the service com.cwk.voyager.logins, filed by the login's random UUID, readable only while the Mac is unlocked. A locked Keychain is its own error, so "cannot read it now" is never mistaken for "was never saved".

The vault is a protocol with two implementations: the Keychain in the installed app, and memory in the tests and the verification build, so a check leaves nothing behind in Dad's Keychain. The test theIndexOnDiskHoldsNoPassword reads the file back and looks. And the save writes in one order on purpose (first code block): the vault first, then the index. If the Keychain refuses, no row is written, so the index never lists a login it cannot fill.

Watching a sign-in, once

A small page script, loginWatcher, runs in the main frame of every page, in Voyager's own content world where the page's scripts cannot reach it. It tells the parent two things. Whether a login form is on the page, once per change. And, at the moment Dad submits one (a form submit, Enter in a password field, a click on a button), the name and the password he sent. For a sign-in in two steps it remembers the last name-like field he typed in, for ten minutes. A frame from another site inside the page is not the page's login, so only the main frame counts.

This is the one script in Voyager that reads a password field's value, and only at submission. Pippa's page-reading scripts name a password field by its label and never read what is in it; pippasReadingStillLeavesThePasswordOut holds that. The house sign adds a rule for whoever touches this code next: never log a login-submit message.

Offer and fill

After a submission the parent decides what to offer. Nothing, when the site is on the never-save list or the same name is already saved with the same password. "Update" for a known name with a new password. Otherwise "Save", with "Never for This Site" beside it. A private window offers nothing and saves nothing, and Settings can turn offering off.

Filling is the parent's decision too, never the page's. When a login form appears and exactly one login is saved for the site, and nothing was filled at this address yet, Voyager fills it. With several, a key appears in the address field and Dad chooses. A login belongs to a site when the scheme and port match and the host matches apart from a leading www., so a login saved over https is never offered to a plain-http page. HTTP authentication is remembered by realm and answered without the sheet, unless the server already refused it once. Settings → Passwords shows a password only after Touch ID or the Mac's password, the way Safari and Chrome ask.

Importing once, with the OS's consent

Dad's next question was whether the passwords Comet already held could come along. They can, once, through a consent chain where each link belongs to someone:

  1. Dad starts it, from Settings or the Bookmarks menu. A verification run refuses before anything is read.
  2. Voyager says what will happen before it happens (second code block).
  3. The key that protects the other browser's saved passwords is that browser's own Keychain item, so macOS, not Voyager, asks Dad whether Voyager may use it.
  4. Off the main thread, Voyager reads a copy of the profile's login databases once. Each password is decrypted in memory and goes only into the Keychain, through the same store.
  5. A login Voyager already has for that site and name stays as it is, a never-save site stays unsaved, and nothing of the other browser's is changed. The result is reported as counts: new, already here, never-save, not decrypted, not stored.

The importer opens two login databases alongside the bookmarks and history files (Login Data and Login Data For Account; the file's header comment still says three files in all), and never cookies, autofill or preferences. Nothing reaches the network. This lesson shows the consent's shape and leaves the cipher out. The tests take their vectors from Python's hashlib and openssl, never from this code, so the code is not graded against itself.

Passkeys, and the entitlement Dad declined

Measured on 2026-10-04: WebKit exposes PublicKeyCredential to a web view without Apple's web-browser entitlement, but isUserVerifyingPlatformAuthenticatorAvailable() answers false, so no page gets a Touch ID passkey. On iPhone and iPad that entitlement waits behind a public TestFlight link and external Beta App Review. Dad's answer:

아니, 근데 이거 다 디폴트 브라우져 자격 얻는데 필요한 깐깐한 심사 아니야? 디폴트로 안 쓰면 그만이잖아? 이걸 외부 베타 테스트 심사까지 받는다고? 그건 하면 안 돼.

"Isn't all this the strict review for becoming a default browser? If I don't use it as the default, that's that. Going through external beta review for this? We must not." The cost is written as current fact: cwkPippa's passkey sign-in shows no button in Voyager, and the family PIN signs in (Solo Auth Quest teaches that PIN). Track 9 tells the delivery side of the same decision.

Code

cwkVoyager, Sources/VoyagerCore/Passwords.swift: the password goes to the vault before the index row is written (verbatim, lines 334-355)·swift
    /// Saves a login, replacing the password of one already saved for the
    /// same site, name and realm. The password goes to the vault first: an
    /// index row without a password is never written.
    @discardableResult
    public func save(origin: PageOrigin, username: String, password: String, realm: String?) throws -> SavedLogin {
        let time = now()
        var next = book
        let login: SavedLogin
        if let index = next.logins.firstIndex(where: {
            $0.username == username && $0.realm == realm && (origin.scheme == "auth" || origin.scheme == "proxy-auth" ? $0.origin == origin : LoginBook.sameSite($0.origin, origin))
        }) {
            next.logins[index].lastUsed = time
            login = next.logins[index]
        } else {
            login = SavedLogin(origin: origin, username: username, realm: realm, created: time)
            next.logins.append(login)
        }
        try vault.store(password, for: login.account)
        apply(next)
        if let saveProblem { throw PasswordStoreError.indexNotWritable(saveProblem) }
        return login
    }
cwkVoyager, Sources/VoyagerApp/AppDelegate.swift: the import refuses a verification run and explains the macOS prompt before it asks (verbatim, lines 660-677)·swift
    func importPasswords(from profile: ChromiumProfile) {
        guard let services else { return }
        let say: (String) -> Void = { [weak self] text in (self?.frontController ?? self?.controllers.first)?.post(text) }
        // A check never reads another browser's Keychain item.
        guard !VoyagerRuntime.isolation.isPermitted else {
            say("A verification run does not read another browser's passwords.")
            return
        }
        let alert = NSAlert()
        alert.messageText = "Import the passwords saved in \(profile.name)?"
        alert.informativeText = """
            Voyager reads \(profile.name)'s saved logins once and keeps each password in this Mac's Keychain, \
            the way it keeps its own. macOS then asks whether Voyager may use “\(profile.safeStorageService)”: \
            enter your login password and choose Allow. A login Voyager already has stays as it is, \
            and nothing of \(profile.name)'s is changed.
            """
        alert.addButton(withTitle: "Import")
        alert.addButton(withTitle: "Cancel")

External links

Exercise

Build a small credential store for an app of your own with two parts: a JSON index (site, username, dates, a random id) and a vault protocol with a Keychain implementation and an in-memory one. Write three tests against the in-memory vault: the index file on disk never contains the password; a vault that throws on store leaves no new index row; and a login saved over https is not returned for the same host over http.
Hint
Make the vault a protocol the store receives at init, so the tests never touch the real Keychain. For the second test, give the store a vault whose store method always throws and compare the index before and after. For the third, define what "the same site" means in one static function and test it directly.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.