From 7cd569799177bb8b7df1f2c7a039972d0e6cbda0 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 9 Aug 2026 17:46:31 +0400 Subject: [PATCH 1/3] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B4=D0=B8=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20Keychain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 49 +++- README.md | 15 +- Sources/ClaudeWeekApp/SettingsView.swift | 14 ++ Sources/ClaudeWeekCore/UpdateInstaller.swift | 60 +++++ Sources/ClaudeWeekTests/UpdaterTests.swift | 27 +++ docs/ARCHITECTURE.md | 19 +- docs/ROADMAP.md | 6 + docs/USAGE.md | 54 ++++- scripts/install.sh | 10 + scripts/make-app.sh | 23 +- scripts/signing-cert.sh | 236 +++++++++++++++++++ 11 files changed, 502 insertions(+), 11 deletions(-) create mode 100755 scripts/signing-cert.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2821d8f..8668cdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,41 @@ jobs: - name: Проверки run: swift run ClaudeWeekTests + # Постоянный сертификат подписи: без него образ подписывается ad-hoc, а + # разрешение на чтение токена Claude Code macOS привязывает к designated + # requirement — у ad-hoc это хеш бинаря, свой у каждой сборки, и человек + # получает вопрос про доступ к Keychain после каждого обновления. + # Секретов нет — шаг пропускается, и всё идёт как прежде. + # + # Ключ здесь только за стабильность requirement: сертификат + # самоподписанный, подлинности сборки он не доказывает (для этого нужен + # Developer ID и нотаризация) и Gatekeeper им не обмануть. + # Как получить пару: ./scripts/signing-cert.sh --export cert.p12, + # затем base64 -i cert.p12 → секрет SIGNING_CERT_P12, пароль → + # SIGNING_CERT_PASSWORD. + - name: Сертификат подписи + if: ${{ secrets.SIGNING_CERT_P12 != '' }} + env: + P12_BASE64: ${{ secrets.SIGNING_CERT_P12 }} + P12_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }} + run: | + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASSWORD="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 3600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + # Связка должна попасть в список поиска, иначе codesign её не увидит; + # прежние оставляем на месте — из них раннер берёт всё остальное. + security list-keychains -d user -s "$KEYCHAIN" \ + $(security list-keychains -d user | tr -d '"') + printf '%s' "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + # На раннере sudo без пароля, поэтому доверие ставим на всю машину: + # самоподписанную identity codesign иначе не берёт. + KEYCHAIN_PASSWORD="$KEYCHAIN_PASSWORD" P12_PASSWORD="$P12_PASSWORD" \ + ./scripts/signing-cert.sh --import "$RUNNER_TEMP/cert.p12" \ + --keychain "$KEYCHAIN" --trust system + rm -f "$RUNNER_TEMP/cert.p12" + - name: Образ — Apple Silicon (arm64) run: ARCH=arm64 ./scripts/make-dmg.sh @@ -78,9 +113,17 @@ jobs: echo 'xattr -dr com.apple.quarantine /Applications/ClaudeWeek.app' echo '```' echo - echo 'Сборка подписана ad-hoc, без Apple Developer ID, поэтому Gatekeeper' - echo 'просит подтверждения. Кому это не нравится — собирайте из исходников:' - echo '`./scripts/install.sh`.' + if codesign -d -r- dist/ClaudeWeek.app 2>&1 | grep -q 'certificate leaf'; then + echo 'Сборка подписана постоянным сертификатом проекта. Apple Developer ID' + echo 'у неё всё равно нет, поэтому Gatekeeper просит подтверждения — зато' + echo 'доступ к токену Claude Code macOS спросит один раз: requirement' + echo 'подписи от версии к версии не меняется, и разрешение переживает' + echo 'обновление.' + else + echo 'Сборка подписана ad-hoc, без Apple Developer ID, поэтому Gatekeeper' + echo 'просит подтверждения. Кому это не нравится — собирайте из исходников:' + echo '`./scripts/install.sh`.' + fi echo echo 'Требуется macOS 14+ на Apple Silicon (M1 и новее). Готового образа' echo 'под Intel нет — там сборка из исходников тем же `install.sh`.' diff --git a/README.md b/README.md index a93b5eb..2e6f74d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,19 @@ cd ClaudeWeek Claude Code из Keychain — только читает, наружу не отдаёт, на диск не пишет. Разбор — [docs/USAGE.md](docs/USAGE.md#авторизация-и-доступ). +Один раз стоит сделать вот что: + +```bash +./scripts/signing-cert.sh # постоянный сертификат подписи +``` + +Без него macOS спрашивает доступ к записи Keychain с токеном после каждой +пересборки: разрешение привязано к подписи, а ad-hoc подпись — это хеш бинаря, +свой у каждой сборки. С сертификатом подпись перестаёт меняться, и «Всегда +разрешать» нажимается в последний раз. Apple ID и Developer ID для этого не +нужны; Gatekeeper он не отменяет. Подробности — +[docs/USAGE.md](docs/USAGE.md#чтобы-разрешение-спросили-один-раз). + --- ## Что показывает @@ -126,7 +139,7 @@ Claude Code из Keychain — только читает, наружу не от ```bash swift build # обе цели -swift run ClaudeWeekTests # 336 проверок: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 371 проверка: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index 7a76144..af755da 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -549,6 +549,20 @@ private struct AccessSettings: View { .foregroundStyle(.secondary) } + Section("Если macOS снова спросила доступ") { + Text(""" + Разрешение привязано к подписи приложения, а не к его пути: у \ + сборки без постоянного сертификата подпись меняется при каждой \ + пересборке, и «Всегда разрешать» перестаёт действовать. \ + Лечится один раз — ./scripts/signing-cert.sh в каталоге \ + исходников, затем ./scripts/install.sh. Дальше подпись не \ + меняется, и вопрос больше не возвращается: обновления \ + переподписываются тем же сертификатом. + """) + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Проверка") { HStack { Button(model.isChecking ? "Проверяю…" : "Проверить сейчас", action: model.checkNow) diff --git a/Sources/ClaudeWeekCore/UpdateInstaller.swift b/Sources/ClaudeWeekCore/UpdateInstaller.swift index 1415b0f..79f5bae 100644 --- a/Sources/ClaudeWeekCore/UpdateInstaller.swift +++ b/Sources/ClaudeWeekCore/UpdateInstaller.swift @@ -161,6 +161,7 @@ public actor UpdateInstaller { let staged = staging.appendingPathComponent(source.lastPathComponent) try manager.copyItem(at: source, to: staged) try accept(staged, expecting: release.version) + resign(staged) // Единственное необратимое движение за всю установку — и оно // атомарное: либо на месте старый бандл, либо целиком новый. @@ -225,6 +226,65 @@ public actor UpdateInstaller { } } + /// Имя постоянного сертификата подписи — того, что заводит + /// `scripts/signing-cert.sh`. + public static let signingIdentityName = "ClaudeWeek Signing" + + /// Переподписывает новый бандл тем же сертификатом, что стоит на машине. + /// + /// Разрешение читать запись Keychain «Claude Code-credentials» macOS + /// привязывает к designated requirement приложения, а образ из релиза + /// подписан ad-hoc — requirement там cdhash, свой у каждой сборки. Не + /// переподписав, мы получили бы после каждого обновления вопрос про доступ + /// к токену заново; с постоянным сертификатом requirement сходится с тем, + /// на который разрешение уже выдано. + /// + /// Неудача обновление не роняет: хуже вернувшегося диалога только + /// не поставленная версия. В лог она попадает — иначе вопрос «почему опять + /// спрашивает» разбирать будет нечем. + private func resign(_ app: URL) { + guard let identity = Self.signingIdentity() else { + Log.debug("постоянного сертификата нет — оставляю подпись образа") + return + } + let result = Self.run("/usr/bin/codesign", [ + "--force", "--sign", identity, app.path, + ]) + guard result.code == 0 else { + Log.warn(""" + не переподписал обновление (\(result.output)) — macOS спросит доступ \ + к записи Keychain заново + """) + return + } + Log.info("переподписал обновление сертификатом \(identity)") + } + + /// Хеш identity, которой подписываемся, или nil — сертификата в связке нет. + private static func signingIdentity() -> String? { + let listing = Self.run("/usr/bin/security", ["find-identity", "-v", "-p", "codesigning"]) + guard listing.code == 0 else { return nil } + return identity(named: signingIdentityName, in: listing.output) + } + + /// Разбирает вывод `security find-identity`: строки вида + /// ` 1) 8F19…0724 "ClaudeWeek Signing"`. Берём хеш, а не имя: тёзок в + /// связке может оказаться два (старый сертификат забыли удалить), и тогда + /// подпись по имени падает на неоднозначности. + public static func identity(named name: String, in listing: String) -> String? { + for line in listing.split(separator: "\n") { + guard line.contains("\"\(name)\"") else { continue } + let fields = line.split(separator: " ", omittingEmptySubsequences: true) + guard fields.count >= 2 else { continue } + let hash = String(fields[1]) + // SHA-1 в hex — ровно 40 знаков; всё прочее значит, что формат + // вывода изменился, и угадывать по позиции больше нельзя. + guard hash.count == 40, hash.allSatisfy(\.isHexDigit) else { continue } + return hash + } + return nil + } + /// Запускает утилиту и отдаёт код возврата с выводом. Вывод нужен целиком: /// он идёт человеку в текст ошибки, и «не получилось» без причины /// разбирать потом будет нечем. diff --git a/Sources/ClaudeWeekTests/UpdaterTests.swift b/Sources/ClaudeWeekTests/UpdaterTests.swift index c11ff62..14f1122 100644 --- a/Sources/ClaudeWeekTests/UpdaterTests.swift +++ b/Sources/ClaudeWeekTests/UpdaterTests.swift @@ -200,6 +200,33 @@ func runUpdaterTests(_ t: Harness) async { } } + t.suite("подпись: разбор find-identity") { + // Вывод `security find-identity -v -p codesigning` в том виде, в каком + // он приходит: отступы, нумерация, кавычки вокруг имени. + let listing = """ + 1) 8F19A7106BFAC7B25F11654C2E3994901C3A0724 "ClaudeWeek Signing" + 2) 1AF3C2D4E5B6978012345678901234567890ABCD "Apple Development: кто-то (TEAMID)" + 2 valid identities found + """ + t.equal( + UpdateInstaller.identity(named: "ClaudeWeek Signing", in: listing), + "8F19A7106BFAC7B25F11654C2E3994901C3A0724", + "хеш взят у нужного сертификата, а не у первой строки" + ) + t.equal( + UpdateInstaller.identity(named: "ClaudeWeek Signing", in: " 0 valid identities found"), + nil, + "пустая связка — nil, а не пустая строка" + ) + // Формат вывода — не контракт: сместится колонка, и подписывать надо + // не тем, что оказалось вторым словом, а ничем. + t.equal( + UpdateInstaller.identity(named: "ClaudeWeek Signing", in: " 1) короткий \"ClaudeWeek Signing\""), + nil, + "на месте хеша не хеш — не подписываем" + ) + } + await t.suite("проверка: сети нет") { let updater = Updater( transport: FakeGitHub(failure: URLError(.notConnectedToInternet)), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6b3a14..1d9b510 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -324,8 +324,9 @@ swift scripts/probe-panel.swift --behaviors # прежде чем менять ```bash swift build # оба таргета swift build -Xswiftc -warnings-as-errors # так же, как в CI: предупреждение = падение -swift run ClaudeWeekTests # 336 проверок, без сети и без UI +swift run ClaudeWeekTests # 371 проверка, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) +./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app ./scripts/make-dmg.sh # упаковать бандл в dist/ClaudeWeek-<версия>[-арх].dmg ./scripts/install.sh # пересобрать, снести старую версию, поставить и запустить @@ -334,6 +335,16 @@ swift run ClaudeWeekApp # запустить из исходников ( ./scripts/uninstall.sh # снять агент и удалить приложение ``` +`signing-cert.sh` заводит самоподписанный сертификат «ClaudeWeek Signing» в +связке ключей и делает его доверенным для подписи кода (без доверия `codesign` +identity не берёт вовсе — «no identity found» при живом ключе). Дальше +`make-app.sh` подписывает бандл им, а не ad-hoc, и designated requirement +перестаёт меняться от сборки к сборке — вместе с ним перестаёт слетать +разрешение на чтение токена Claude Code. Скрипт идемпотентен: с готовым +сертификатом он ничего не меняет, только печатает его. `--export` снимает копию +ключа (единственный момент, когда её можно снять), `--import` ставит её на +другую машину или в CI, `--show` печатает, что есть сейчас. + `install.sh` печатает, что именно собирает: каталог, ветку, коммит и дату, — и предупреждает, если в репозитории есть ветка свежее собираемой. Без этого легко поставить не то: каталогов с исходниками у репозитория несколько (рабочая копия @@ -380,7 +391,11 @@ Intel — собирает у себя, `install.sh` соберёт нативн ### Грабли - **Keychain спрашивает доступ после каждой пересборки** — разрешение - привязано к подписи, а она ad-hoc и меняется. Это не баг. + привязано к designated requirement, а у ad-hoc подписи это хеш бинаря. + Лечится постоянным сертификатом: `./scripts/signing-cert.sh` один раз, дальше + `make-app.sh` подписывает им, а `UpdateInstaller.resign` переподписывает + бандл из релиза, чтобы обновление не сбрасывало выданное разрешение. Пока + сертификата нет, сборка честно печатает свой requirement и что делать. - **`swift run ClaudeWeekApp` даёт вторую иконку** рядом с установленной копией. Для проверки живьём лучше `./scripts/install.sh`. - **«На каком я столе» окно отвечает, только если живёт на одном столе.** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1cfbd93..267b3ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -213,6 +213,12 @@ XCTest без Xcode недоступен, поэтому проверки нап нотаризации: образ подписан ad-hoc, и первый запуск скачанного приложения упирается в Gatekeeper, пока не снимешь карантин руками. +Постоянная подпись при этом уже есть, но другая и для другого: +`scripts/signing-cert.sh` заводит самоподписанный сертификат, чтобы designated +requirement не менялся и разрешение Keychain на токен выдавалось один раз +([USAGE](USAGE.md#чтобы-разрешение-спросили-один-раз)). Gatekeeper она не +касается: тот верит только Developer ID с нотаризацией. + *Что делать:* ничего, пока нет Apple Developer Program — из РФ её штатно не оплатить (та же причина, что у виджета Центра уведомлений). Появится сертификат — `codesign --sign "Developer ID Application"` и `notarytool submit diff --git a/docs/USAGE.md b/docs/USAGE.md index b3c4b5d..48c3320 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -32,9 +32,8 @@ Claude Code из Keychain (запись `Claude Code-credentials`) и потом - **только чтение.** Токен обновляет сам Claude Code; ClaudeWeek в неё не пишет никогда — два процесса, наперегонки правящие одну запись, теряют токен. - **разрешение спрашивает macOS.** При первом запуске всплывает системный - диалог доступа к записи, и его можно отклонить. После пересборки он - появляется снова: разрешение привязано к подписи бинаря, а ad-hoc подпись - меняется при каждой сборке. + диалог доступа к записи, и его можно отклонить. Нажатое «Всегда разрешать» + держится, пока не меняется подпись приложения, — про это ниже. - **токен никуда не уходит.** Не пишется на диск, не попадает в лог, не показывается в интерфейсе. Единственный сетевой адресат — `api.anthropic.com`; в кеш ложатся проценты, границы окна и метка времени. @@ -53,6 +52,55 @@ Claude Code из Keychain (запись `Claude Code-credentials`) и потом локальная оценка») к Keychain не обращается совсем: расход считается по вашим же транскриптам в `~/.claude/projects`. Цена отказа — знак `≈` перед процентом. +### Чтобы разрешение спросили один раз + +Разрешение macOS привязывает не к приложению как файлу, а к его *designated +requirement* — правилу, по которому система узнаёт «то самое» приложение. +У ad-hoc подписи (`codesign --sign -`) это правило звучит как «ровно вот этот +хеш бинаря»: + +``` +# designated => cdhash H"c86938354fee1e4e2448b30a93c957a5389dc4c3" +``` + +Любая пересборка меняет хеш, разрешение перестаёт к чему-либо относиться — +и диалог возвращается. Лечится один раз, постоянным сертификатом: + +```bash +./scripts/signing-cert.sh # завести сертификат (спросит пароль от связки ключей) +./scripts/install.sh # переустановить — бандл подпишется им +``` + +Requirement становится таким — и остаётся одинаковым у всех будущих сборок: + +``` +# designated => identifier "com.greem4.claudeweek" and certificate leaf H"…" +``` + +Дальше приложение спросит доступ к записи Keychain последний раз: «Всегда +разрешать» — и вопрос закрыт, включая обновления через кнопку в «О программе» +(новый бандл переподписывается тем же сертификатом, `UpdateInstaller.resign`). + +Сертификат самоподписанный: ни Apple ID, ни Developer ID для него не нужно, но +и Gatekeeper он не убеждает — карантин со скачанного образа по-прежнему +снимается руками. Его дело — стабильность requirement, и больше ничего. + +**Сертификат — это и есть выданное разрешение.** Удалите его из связки ключей +(или переустановите систему без него) — следующая сборка снова будет спрашивать +про токен. Копия для другой машины и для CI снимается при создании: + +```bash +./scripts/signing-cert.sh --export ~/claudeweek-signing.p12 # напечатает пароль +P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 +``` + +Что сейчас в связке — `./scripts/signing-cert.sh --show`; чем подписан +установленный бандл — `codesign -d -r- ~/Applications/ClaudeWeek.app`. + +Релизные образы подписываются тем же способом, если в репозитории заданы +секреты `SIGNING_CERT_P12` (файл `.p12` в base64) и `SIGNING_CERT_PASSWORD`; +без них релиз собирается ad-hoc, как раньше. + ## Калибровка Руками ничего подбирать не нужно. На каждом успешном официальном ответе diff --git a/scripts/install.sh b/scripts/install.sh index eab09db..fa9b9f6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -195,6 +195,16 @@ launchctl kickstart -k "gui/$UID/$LABEL" VERSION="$(defaults read "$APP_DEST/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "?")" echo +# Ad-hoc подпись означает, что разрешение на чтение токена Claude Code не +# переживёт следующую пересборку: macOS привязывает его к designated +# requirement, а у ad-hoc это хеш бинаря. Сказать об этом надо здесь — вопрос +# про доступ к Keychain человек увидит через несколько секунд. +if ! codesign -d -r- "$APP_DEST" 2>&1 | grep -q "certificate leaf"; then + echo "подпись ad-hoc: доступ к токену Claude Code macOS спросит снова после" + echo "каждой пересборки. Чтобы спросила последний раз — постоянный сертификат:" + echo " ./scripts/signing-cert.sh && ./scripts/install.sh" + echo +fi echo "готово: ClaudeWeek $VERSION в строке меню" echo " собрано из: $SOURCE_NOTE" echo " лог — $LOG" diff --git a/scripts/make-app.sh b/scripts/make-app.sh index bb47ea7..e9c4bb2 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -86,7 +86,26 @@ else echo " иконку сделать не вышло — бандл без иконки" >&2 fi -echo "==> ad-hoc подпись" -codesign --force --sign - "$APP" +echo "==> подпись" +# Разрешение Keychain на запись «Claude Code-credentials» macOS привязывает к +# designated requirement бандла. У ad-hoc подписи requirement — cdhash, свой у +# каждой сборки, и разрешение слетает при каждой пересборке. Постоянный +# сертификат даёт requirement по identifier и сертификату: один и тот же всегда. +IDENTITY="${CLAUDEWEEK_SIGN_IDENTITY:-ClaudeWeek Signing}" +HASH="$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -v name="\"$IDENTITY\"" 'index($0, name) { print $2; exit }')" +if [ -n "$HASH" ]; then + codesign --force --sign "$HASH" "$APP" + echo " сертификатом «${IDENTITY}»" +else + codesign --force --sign - "$APP" + echo " ad-hoc: постоянного сертификата «${IDENTITY}» в связке нет" + echo " из-за этого macOS снова спросит доступ к токену Claude Code —" + echo " заводится один раз: ./scripts/signing-cert.sh" +fi + +# Requirement печатаем всегда: это ровно то, к чему привязано разрешение на +# токен, и заметить его смену на сборке дешевле, чем по вернувшемуся диалогу. +codesign -d -r- "$APP" 2>&1 | sed -n 's/^# designated => / requirement: /p' echo "готово: $APP" diff --git a/scripts/signing-cert.sh b/scripts/signing-cert.sh new file mode 100755 index 0000000..d730bf9 --- /dev/null +++ b/scripts/signing-cert.sh @@ -0,0 +1,236 @@ +#!/bin/bash +# Заводит постоянный сертификат, которым подписывается ClaudeWeek. +# +# ./scripts/signing-cert.sh создать (или показать готовый) +# ./scripts/signing-cert.sh --export copy.p12 создать и сохранить копию ключа +# ./scripts/signing-cert.sh --import copy.p12 поставить готовый ключ (CI, вторая машина) +# ./scripts/signing-cert.sh --show что сейчас в связке ключей +# +# Зачем. Разрешение на чтение записи Keychain «Claude Code-credentials» macOS +# привязывает к designated requirement приложения. У ad-hoc подписи requirement +# это cdhash — хеш конкретного бинаря, свой у каждой сборки; поэтому нажатое +# «Всегда разрешать» переставало действовать после каждой пересборки, и диалог +# доступа к токену возвращался. Подпись своим сертификатом даёт requirement вида +# `identifier "com.greem4.claudeweek" and certificate leaf H"…"` — он одинаков +# для всех будущих сборок, и разрешение выдаётся один раз. +# +# Apple ID и Developer ID для этого не нужны: сертификат самоподписанный, и +# Gatekeeper он не убеждает — карантин со скачанного образа снимать по-прежнему +# руками. Он нужен ровно для стабильности requirement. +# +# Сертификат — единственное, что связывает разрешение с приложением: удалите его +# из связки ключей, и следующая сборка снова спросит доступ. Поэтому `--export` +# и хранение копии в надёжном месте — не лишняя предосторожность, а способ +# пережить переустановку системы. +set -euo pipefail + +NAME="${CLAUDEWEEK_SIGN_IDENTITY:-ClaudeWeek Signing}" +KEYCHAIN="${CLAUDEWEEK_SIGN_KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}" +# 20 лет: срок жизни сертификата — это срок жизни выданного разрешения, и +# продлевать его человеку, который поставил виджет и забыл, будет нечем. +DAYS=7300 + +MODE="create" +EXPORT_TO="" +IMPORT_FROM="" +# Доверять сертификату обязательно: codesign отказывается брать identity, чья +# цепочка не сходится к доверенному корню — «no identity found» при вполне +# живом ключе в связке. user — доверие только для вашей учётной записи, +# system — для всей машины (нужен sudo; так делает CI на своём раннере). +TRUST="user" + +while [ $# -gt 0 ]; do + case "$1" in + --export) + EXPORT_TO="${2:-}" + [ -n "$EXPORT_TO" ] || { echo "--export без пути" >&2; exit 2; } + shift 2 + ;; + --export=*) EXPORT_TO="${1#--export=}"; shift ;; + --import) + IMPORT_FROM="${2:-}" + [ -n "$IMPORT_FROM" ] || { echo "--import без пути" >&2; exit 2; } + MODE="import" + shift 2 + ;; + --import=*) IMPORT_FROM="${1#--import=}"; MODE="import"; shift ;; + --keychain) + KEYCHAIN="${2:-}" + [ -n "$KEYCHAIN" ] || { echo "--keychain без пути" >&2; exit 2; } + shift 2 + ;; + --keychain=*) KEYCHAIN="${1#--keychain=}"; shift ;; + --trust) + TRUST="${2:-}" + case "$TRUST" in + user|system|none) ;; + *) echo "--trust принимает user, system или none" >&2; exit 2 ;; + esac + shift 2 + ;; + --trust=*) TRUST="${1#--trust=}"; shift ;; + --show) MODE="show"; shift ;; + -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "не знаю такого флага: $1" >&2; exit 2 ;; + esac +done + +# Хеш identity (SHA-1 сертификата) — то, чем подписывают. Имён-тёзок в связке +# может оказаться два, хеш один. Связку указываем явно: в CI она своя и в +# списке поиска может ещё не стоять. +identity_hash() { + security find-identity -v -p codesigning "$KEYCHAIN" 2>/dev/null \ + | awk -v name="\"$NAME\"" 'index($0, name) { print $2; exit }' +} + +# Есть ключ, но identity не считается валидной — почти всегда это недостающее +# доверие, и различать эти два случая важно: во втором пересоздавать нечего. +untrusted_hash() { + security find-identity -p codesigning "$KEYCHAIN" 2>/dev/null \ + | awk -v name="\"$NAME\"" 'index($0, name) { print $2; exit }' +} + +report() { + local hash="$1" + echo "==> сертификат «${NAME}»" + echo " хеш identity: $hash" + echo " связка: $KEYCHAIN" + echo + echo "Дальше: ./scripts/install.sh — бандл подпишется этим сертификатом." + echo "При первом запуске macOS ещё раз спросит доступ к записи Keychain" + echo "«Claude Code-credentials» — нажмите «Всегда разрешать». Больше этот" + echo "вопрос не вернётся: requirement подписи с этого момента не меняется." +} + +if [ "$MODE" = "show" ]; then + HASH="$(identity_hash || true)" + if [ -n "$HASH" ]; then + report "$HASH" + exit 0 + fi + UNTRUSTED="$(untrusted_hash || true)" + if [ -n "$UNTRUSTED" ]; then + echo "сертификат «${NAME}» в связке есть ($UNTRUSTED), но не доверен —" >&2 + echo "codesign такую identity не возьмёт; починить: $0 --trust $TRUST" >&2 + exit 1 + fi + echo "сертификата «${NAME}» нет; создать: $0" >&2 + exit 1 +fi + +HASH="$(identity_hash || true)" +if [ -n "$HASH" ] && [ "$MODE" = "create" ]; then + echo "==> сертификат уже есть — ничего не меняю" + [ -n "$EXPORT_TO" ] && echo "!! копию ключа отдаёт только создание;" \ + "выгрузить существующий — Keychain Access → Export" + report "$HASH" + exit 0 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/claudeweek-cert.XXXXXX")" +# Приватный ключ живёт в этом каталоге ровно до конца скрипта: дальше он в +# связке ключей, а файлу на диске место только там, куда попросили (--export). +trap 'rm -rf "$WORK"' EXIT + +P12="$WORK/cert.p12" +CRT="$WORK/cert.crt" +P12_PASSWORD="${P12_PASSWORD:-$(uuidgen)}" + +if [ "$MODE" = "import" ]; then + [ -f "$IMPORT_FROM" ] || { echo "не нашёл файл: $IMPORT_FROM" >&2; exit 1; } + cp "$IMPORT_FROM" "$P12" + echo "==> беру ключ из $IMPORT_FROM" +else + echo "==> создаю самоподписанный сертификат «${NAME}»" + # Системный LibreSSL, а не openssl из PATH: у OpenSSL 3 из Homebrew + # pkcs12 по умолчанию пишет шифрование, которого Security.framework не + # понимает, — импорт падает на «MAC verification failed». + cat > "$WORK/req.cnf" </dev/null + /usr/bin/openssl pkcs12 -export -inkey "$WORK/cert.key" -in "$CRT" \ + -name "$NAME" -out "$P12" -passout pass:"$P12_PASSWORD" \ + -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 +fi + +if [ ! -f "$CRT" ]; then + /usr/bin/openssl pkcs12 -in "$P12" -passin pass:"$P12_PASSWORD" \ + -clcerts -nokeys -out "$CRT" 2>/dev/null \ + || { echo "не разобрал $IMPORT_FROM — не тот пароль? (P12_PASSWORD)" >&2; exit 1; } +fi + +if [ -n "$EXPORT_TO" ]; then + cp "$P12" "$EXPORT_TO" + chmod 600 "$EXPORT_TO" + echo "==> копия ключа: $EXPORT_TO" + echo " пароль: $P12_PASSWORD" + echo " этой парой ставится тот же сертификат на другую машину и в CI:" + echo " P12_PASSWORD=… ./scripts/signing-cert.sh --import <файл>" +fi + +echo "==> кладу в связку ключей" +# -T /usr/bin/codesign, а не -A: доступ к ключу получает подписывающая утилита, +# а не любая программа, которой захочется им подписаться. +security import "$P12" -k "$KEYCHAIN" -P "$P12_PASSWORD" -T /usr/bin/codesign > /dev/null + +# Разрешение на использование ключа. С паролем связки выдаётся сразу и молча — +# так работает CI; без пароля первое обращение codesign macOS сопроводит +# диалогом, где нужно нажать «Всегда разрешать» (тоже один раз). +if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null + echo " доступ codesign к ключу выдан" +else + echo " при первой подписи macOS спросит про доступ к ключу —" + echo " там «Всегда разрешать» (или задайте KEYCHAIN_PASSWORD и повторите)" +fi + +# Отказ в диалоге доверия — не сбой скрипта, а решение человека, и звучать он +# должен по-человечески: `set -e` уронил бы всё на невнятном коде возврата. +case "$TRUST" in + user) + echo "==> доверяю сертификату для подписи кода (ваша учётная запись)" + echo " macOS спросит пароль — это он, ваш обычный пароль входа" + if ! security add-trusted-cert -r trustRoot -p codeSign -k "$KEYCHAIN" "$CRT"; then + echo >&2 + echo "доверие не выдано — без него codesign сертификат не возьмёт." >&2 + echo "Повторить: $0; на всю машину: $0 --trust system" >&2 + exit 1 + fi + ;; + system) + echo "==> доверяю сертификату для подписи кода (вся машина, через sudo)" + if ! sudo security add-trusted-cert -d -r trustRoot -p codeSign \ + -k /Library/Keychains/System.keychain "$CRT"; then + echo "доверие на всю машину не выдано (sudo?)" >&2 + exit 1 + fi + ;; + none) + echo "==> доверие не настраиваю (--trust none)" + ;; +esac + +HASH="$(identity_hash || true)" +if [ -z "$HASH" ]; then + echo >&2 + echo "ключ в связке есть, но codesign его пока не берёт: доверие не встало." >&2 + echo "Ручной путь — Keychain Access: найдите «${NAME}», Get Info →" >&2 + echo "Trust → Code Signing → Always Trust. Или на всю машину:" >&2 + echo " $0 --trust system" >&2 + exit 1 +fi + +report "$HASH" From bb56b64eb0cf03614fe37401f8c67af7b452847e Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 9 Aug 2026 17:50:22 +0400 Subject: [PATCH 2/3] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BD=D1=8F=D0=BB=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20=D0=B4=D0=BE=200.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ClaudeWeekCore/Version.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ClaudeWeekCore/Version.swift b/Sources/ClaudeWeekCore/Version.swift index c299d99..80d05a1 100644 --- a/Sources/ClaudeWeekCore/Version.swift +++ b/Sources/ClaudeWeekCore/Version.swift @@ -1,7 +1,7 @@ import Foundation public enum ClaudeWeek { - public static let version = "0.1.3" + public static let version = "0.1.4" public static let bundleIdentifier = "com.greem4.claudeweek" /// Откуда приходят обновления: `владелец/репозиторий` на GitHub. Тот же /// репозиторий выпускает образы, поэтому имя одно на проверку версии и на From b24e4d5de3cf120f3ca6b4ef8e05f2103f804b22 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Sun, 9 Aug 2026 17:53:35 +0400 Subject: [PATCH 3/3] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D1=8C=20requirement=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B5=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/make-app.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/make-app.sh b/scripts/make-app.sh index e9c4bb2..1fd3125 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -106,6 +106,8 @@ fi # Requirement печатаем всегда: это ровно то, к чему привязано разрешение на # токен, и заметить его смену на сборке дешевле, чем по вернувшемуся диалогу. -codesign -d -r- "$APP" 2>&1 | sed -n 's/^# designated => / requirement: /p' +# Решётку перед `designated` codesign ставит только у ad-hoc подписи — у +# подписанной сертификатом строка идёт без неё, и точный шаблон молчал. +codesign -d -r- "$APP" 2>&1 | sed -n 's/^#\{0,1\} *designated => / requirement: /p' echo "готово: $APP"