macOS 샌드박스 서명 대장정 (또는 v1.4.0부터 v1.4.5까지가 존재하는 이유)
오늘 변경 이력을 보고 하루에 패치 릴리스가 여섯 번 올라온 것을 발견했다면 무슨 일이 있었는지 궁금할 것입니다. 짧게 답하면, macOS 코드 서명과 App Sandbox에 우리가 거의 무너질 뻔했습니다. 전말은 이렇습니다.
MailVault의 내부 구조
MailVault는 Tauri v2 데스크톱 앱입니다. 프런트엔드는 네이티브 웹뷰에서 도는 React이고, 백엔드는 Bun으로 네이티브 바이너리로 컴파일한 Node.js Express 서버입니다. "사이드카"라고 부르는 이 바이너리가 본체 앱과 나란히 돌면서 IMAP/SMTP 통신을 전부 맡습니다.
macOS로 배포하려면 Apple은 앱이 Developer ID 인증서로 서명되고 공증되기를 요구합니다. 우리는 App Sandbox도 켭니다 — 앱이 시스템에서 무엇에 접근할 수 있는지 제한하는 Apple의 보안 계층입니다. Mac App Store에는 필수이고, 어떤 데스크톱 앱에서든 좋은 관행입니다.
문제
v1.4.0을 내놓은 뒤, 릴리스 빌드에서 사이드카 서버가 시작되지 않는다는 것을 발견했습니다. 앱은 잘 실행되었지만 백엔드가 종료 코드 133(SIGTRAP)으로 조용히 죽고 있었습니다. 개발 환경에서는 완벽하게 동작했고 — 서명되고 샌드박스가 적용된 릴리스 빌드에서만 문제가 생겼습니다.
층을 하나씩 벗겨 내는 데 여러 번의 시도가 필요했습니다:
- v1.4.0 — 서버는
0.0.0.0에 바인딩했는데 프런트엔드는localhost로 접속해 IPv4/IPv6 불일치가 났습니다.127.0.0.1에 명시적으로 바인딩해 해결했습니다. - v1.4.1 — "Add Account" 모달이 API 헬퍼 대신
fetch('/api/test-connection')을 그대로 썼습니다. Tauri 릴리스 빌드에서 웹뷰 오리진은http://localhost가 아니라tauri://localhost입니다 — 그래서 상대 URL이 엉뚱한 곳으로 풀립니다. - v1.4.2 —
com.apple.security.network.server권한이 빠져 있었습니다. 샌드박스가 사이드카의 포트 수신을 막았습니다. - v1.4.3 — App Sandbox 안에서는 막히는 셸 기반 프로세스 정리(
lsof,kill)를 쓰고 있었습니다. Tauri의CommandChild핸들로 바꿨습니다. - v1.4.4 — 진짜 범인. Bun으로 컴파일한 바이너리에는 JIT(Just-In-Time 컴파일)가 필요합니다. JIT 컴파일러는 실행 시점에 실행 가능한 메모리를 할당합니다. macOS에는 이를 허용하는 권한(
com.apple.security.cs.allow-jit)이 있지만, Tauri는--deep을 써서 앱 번들 안의 모든 바이너리를 같은 권한으로 서명합니다. 그 결과 사이드카에 App Sandbox 권한이 붙었고, allow-jit 플래그가 있어도 JIT가 막혔습니다. - v1.4.5 — 서명 파이프라인을 고쳤습니다. 이제 사이드카는 자체 권한(App Sandbox 없음, JIT 허용)으로 따로 서명합니다. 본체 앱은 App Sandbox를 유지합니다. 서명 순서가 중요합니다. 사이드카를 먼저, 그다음 본체 앱 번들을
--deep없이 서명해야 사이드카의 권한이 보존됩니다. 또한 CI 파이프라인이 제대로 서명한 DMG 대신 Tauri가 자동 생성한 서명 없는 DMG를 올리고 있었다는 것도 이때 잡았습니다.
해결
해법은 두 부분입니다:
- 권한 파일 분리 — 사이드카는
entitlements-sidecar.plist(JIT + 네트워크, 샌드박스 없음)를 받습니다. 본체 앱은entitlements.plist(샌드박스 + JIT + 네트워크)를 받습니다. - 올바른 서명 순서 — 사이드카를 자체 권한으로 먼저 서명하고, 그다음 본체
.app번들을--deep없이 서명하십시오.--deep을 쓰면 번들 안의 모든 것을 재귀적으로 다시 서명하면서 사이드카의 권한을 덮어씁니다.
서명을 직접 확인하는 방법
MailVault가 제대로 서명되고 공증되었는지 직접 확인하고 싶다면 터미널을 열고 다음 명령을 실행하십시오:
1. 앱 서명 확인:
codesign --verify --verbose /Applications/MailVault.app
다음과 같이 나와야 합니다:
/Applications/MailVault.app: valid on disk /Applications/MailVault.app: satisfies its Designated Requirement
2. 메인 앱의 엔타이틀먼트 확인:
codesign -d --entitlements - /Applications/MailVault.app
com.apple.security.app-sandbox가 true로 되어 있는지 확인하십시오 — 앱이 샌드박스에서 돈다는 뜻입니다.
3. 사이드카의 엔타이틀먼트 확인:
codesign -d --entitlements - /Applications/MailVault.app/Contents/MacOS/mailvault-server
사이드카에는 com.apple.security.cs.allow-jit이 있어야 하고, com.apple.security.app-sandbox는 없어야 합니다.
4. 공증 확인:
spctl --assess --type execute -v /Applications/MailVault.app
다음과 같이 나와야 합니다:
/Applications/MailVault.app: accepted source=Notarized Developer ID
5. DMG 공증 확인(설치 전):
spctl --assess --type open --context context:primary-signature -v ~/Downloads/MailVault-v1.4.5.dmg
배운 것
- 릴리스 빌드를 일찍 시험하십시오. 개발 빌드는 코드 서명과 샌드박싱을 아예 건너뜁니다. "개발에서는 된다"와 "릴리스에서도 된다" 사이의 간격은 대단히 클 수 있습니다.
- 사이드카는 따로 다뤄야 합니다. Tauri 앱 안에 Bun/Node/Deno로 컴파일한 바이너리를 넣는다면 그 바이너리에는 자체 권한이 필요합니다. Tauri는 바이너리별 권한을 기본으로 지원하지 않습니다 — 빌드가 끝난 뒤 직접 다시 서명해야 합니다.
--deep은 위험합니다. 편리하지만 중첩된 서명을 전부 같은 권한으로 덮어씁니다. 바이너리마다 다른 권한이 필요하다면 하나씩 따로 서명하십시오.- 로컬과 CI가 같은 빌드 스크립트를 쓰게 하십시오. 로컬 빌드와 CI에 서명 로직이 따로 있었던 탓에 CI가 엉뚱한(서명되지 않은) DMG를 올렸습니다. 지금은 둘 다 같은 스크립트를 씁니다.
망가진 릴리스로 불편을 겪으셨다면 사과드립니다. v1.4.5가 제대로 서명된 안정 릴리스입니다. 메일이 위험했던 적은 없습니다 — 문제는 오로지 앱 바이너리가 백엔드 서버를 띄우지 못한 데 있었습니다.