배경

Mac mini를 헤드리스 서버로 24/7 운영하고 있다. 평균 2주에 1번꼴로 정전이 발생하는 환경(아버지 취미가 전구 교체하기..,,)이라, 전원 복구 후 자동으로 모든 서비스가 올라와야 한다.

복구 체인은 다음과 같다:

flowchart LR
    A["전원 복구"] --> B["pmset autorestart"]
    B --> C["macOS 부팅"]
    C --> D["launchd"]
    D --> E["서비스 복구"]

문제는 서비스 매니저(PM2)를 어디에 등록하느냐에 따라 복구 성공 여부가 갈린다는 점이다. macOS의 launchd에는 두 가지 도메인이 있고, 각각의 특성이 전혀 다르다.

macOS 로그인과 launchd 세션

LaunchDaemon vs LaunchAgent

macOS의 launchd는 두 개의 분리된 세계를 관리한다.

LaunchDaemonLaunchAgent
위치/Library/LaunchDaemons/~/Library/LaunchAgents/
실행 시점부팅 직후, 로그인 불필요GUI 로그인 완료 후
실행 컨텍스트system 도메인 (root)gui/UID 도메인 (유저)
키체인 접근 불가 가능
환경변수최소한의 시스템 환경유저의 전체 환경
대표 예시sshd, tailscaledbrew services, Spotlight

PM2를 LaunchDaemon에 등록하면 부팅 직후 바로 실행되지만, 키체인에 접근할 수 없고 환경변수도 제한된다. 반대로 LaunchAgent에 등록하면 유저 환경을 그대로 사용할 수 있지만, GUI 로그인이 완료되어야만 실행된다.

처음에는 LaunchDaemon으로 PM2를 올렸다. 프로세스 자체는 뜨지만, 앱들이 키체인 접근 실패, 환경변수 누락 등으로 반쯤 죽어있는 상태였다. 하드웨어 UUID 기반 AES-256 키체인 언락 스크립트까지 작성해봤지만, 근본적으로 LaunchDaemon 컨텍스트에서는 한계가 있었다.

결론은 명확했다 - LaunchAgent를 쓰되, 자동 로그인을 확실하게 보장해야 한다.

Aqua 세션 vs Background 세션

LaunchAgent가 실행되려면 단순히 유저 프로세스가 있는 것만으로는 부족하다. macOS는 유저 세션을 두 가지로 구분한다.

  • Aqua 세션: GUI 로그인이 완료된 상태. WindowServer가 활성화되고, 키체인이 자동으로 열리며, LaunchAgent가 전부 로드된다.
  • Background 세션: SSH 접속 등으로 유저 프로세스가 생겼지만 GUI 로그인은 없는 상태. 키체인 잠김, LaunchAgent 일부만 로드.

launchctl print user/501로 확인하면 session 필드에서 구분할 수 있다:

# Aqua 세션 (GUI 로그인 완료)
user/501 = {
   type = user
   session = Aqua
   ...
}
 
# Background 세션 (SSH만 연결된 상태)
user/501 = {
   type = user
   session = Background
   ...
}

헤드리스 서버에서 자동 로그인이 실패하면 Aqua 세션이 생성되지 않는다. loginwindow가 로그인 화면에서 대기하고, console user는 root로 남아있으며, LaunchAgent들은 로드되지 않는다.

즉, 자동 로그인의 신뢰성이 전체 서비스 복구의 단일 실패 지점이 된다.

kcpassword - 전통적인 자동 로그인

macOS의 자동 로그인은 내부적으로 두 가지 구성 요소로 이루어져 있다.

  1. autoLoginUser - 어떤 유저를 자동 로그인할지 지정
# /Library/Preferences/com.apple.loginwindow.plist
sudo defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser -string "username"
  1. /etc/kcpassword - 해당 유저의 비밀번호를 난독화하여 저장

loginwindow는 부팅 시 autoLoginUser를 읽어 대상 유저를 확인하고, /etc/kcpassword에서 비밀번호를 복호화하여 자동으로 인증을 수행한다.

XOR 난독화 메커니즘

kcpassword의 “암호화”는 사실 암호화라고 부르기 어렵다. 고정된 11바이트 키로 XOR 연산을 수행하는 단순한 난독화(obfuscation)다.

XOR 키: 0x7D 0x89 0x52 0x23 0xD2 0xBC 0xDD 0xEA 0xA3 0xB9 0x1F

이 키는 2007년에 처음 리버스 엔지니어링되었고, macOS Sequoia(15)까지도 변경되지 않았다.

인코딩 과정:

  1. 비밀번호의 각 바이트를 11바이트 키와 순서대로 XOR
  2. 비밀번호가 키보다 길면 키를 순환하여 반복 적용
  3. 결과를 12바이트의 배수로 패딩
  4. /etc/kcpassword0600 권한(root만 읽기/쓰기)으로 저장

디코딩은 동일한 키로 다시 XOR하면 된다. XOR의 성질상 A XOR K XOR K = A이므로.

# kcpassword 디코딩 의사코드
KEY = [0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F]
 
for i, byte in enumerate(encrypted):
    decrypted[i] = byte ^ KEY[i % len(KEY)]

고정 키에 단순 XOR이라는 점에서 보안적으로는 취약하지만, /etc/kcpassword 파일 자체가 root만 접근 가능하므로 root 권한이 없는 한 비밀번호가 노출되지는 않는다. root 권한이 있다면 어차피 비밀번호를 변경할 수 있으니, 이 수준의 난독화로 충분하다는 설계 판단인 셈이다.

내부 동작 경로

System Preferences(또는 System Settings)에서 자동 로그인을 설정하면 내부적으로 다음 경로를 거친다:

flowchart TD
    A["System Settings"] --> B["Accounts.prefPane XPC Service<br>(com.apple.preferences.users.remoteservice)"]
    B --> C["cfprefsd"]
    B --> D["login private framework<br>(SACSetAutoLoginPassword)"]
    C --> E["autoLoginUser plist key"]
    D --> F["logind<br>(com.apple.logind)<br>XPC"]
    F --> G["XOR encode + write<br>/etc/kcpassword"]

Accounts.prefPane의 XPC 서비스(com.apple.preferences.users.remoteservice)가 두 가지 작업을 병렬로 수행한다. cfprefsd를 통해 autoLoginUser plist 키를 설정하고, login private framework의 SACSetAutoLoginPassword를 호출하여 logind에 비밀번호를 전달한다. logind의 SA_SetAutologinPassword:reply: 메서드가 XOR 인코딩을 수행하고 /etc/kcpassword 파일을 작성한다.

수동 생성의 한계

많은 관리자들이 이 XOR 키를 이용해 kcpassword를 직접 생성하는 스크립트를 사용해왔다. Perl, Python, Shell 등 다양한 구현체가 존재했고, CI/CD 환경이나 VM 프로비저닝에서 널리 쓰였다.

# 전통적인 수동 설정 방식
sudo defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser -string "username"
# + 별도 스크립트로 /etc/kcpassword 직접 생성

이 방식의 문제:

  • 패딩 불일치: Apple의 공식 구현(SecKeychain.cpp)은 처음부터 12바이트 배수(XOR 키 11 + null terminator 1)로 패딩해왔지만, 커뮤니티 스크립트 대부분이 XOR 키 길이인 11바이트 배수로 잘못 패딩하고 있었다. 이전 macOS는 이를 관대하게 처리했으나, 커뮤니티 보고에 따르면 Catalina(10.15)에서 loginwindow의 read-side 검증이 엄격해져 잘못된 패딩을 거부하기 시작했다. Greg Neagle의 pycreateuserpkg PR #31에서도 동일한 문제를 수정한 기록이 있다.
  • 공식 API 우회: logind를 거치지 않고 파일을 직접 생성하므로, Apple이 추가한 검증 로직을 모두 건너뛴다.
  • FileVault 충돌: FileVault가 활성화되어 있으면 자동 로그인 자체가 불가능하지만, 수동 방식은 이를 검증하지 않고 파일만 생성한다.

sysadminctl -autologin - 공식 CLI

macOS Ventura 13.2.1(2023년 초)에서 Apple은 sysadminctl-autologin 옵션을 추가했다. sysadminctl 자체는 macOS 10.13 High Sierra부터 존재하던 유저 관리 도구지만, 자동 로그인 기능은 이때 처음 도입되었다.

# 자동 로그인 설정 (비밀번호를 인터랙티브로 입력)
sudo sysadminctl -autologin set -userName myuser -password -
 
# 상태 확인
sudo sysadminctl -autologin status
 
# 비활성화
sudo sysadminctl -autologin off

수동 kcpassword와의 차이

sysadminctl -autologin은 내부적으로 System Settings와 동일한 경로를 사용한다:

flowchart TD
    A["sysadminctl<br>-autologin set"] --> B["cfprefsd"]
    A --> C["login private framework<br>(SACSetAutoLoginPassword)"]
    B --> D["autoLoginUser<br>plist key"]
    C --> E["logind XPC"]
    E --> F["XOR encode + write<br>/etc/kcpassword"]

결과물은 같다 - autoLoginUser plist 키와 /etc/kcpassword 파일. 하지만 과정이 다르다:

수동 kcpasswordsysadminctl
API 경로파일 직접 생성logind XPC (공식 경로)
유저 검증없음계정 존재 여부 확인
비밀번호 검증없음실제 인증 시도
패딩/포맷스크립트 구현에 의존logind가 정규 포맷으로 생성
양쪽 설정autoLoginUser와 kcpassword를 각각 설정한 번에 처리
FileVault 확인없음활성 시 경고

핵심 차이는 검증이다. sysadminctl은 유저가 실제로 존재하는지, 비밀번호가 맞는지 확인한 후에야 설정을 진행한다. 수동 방식은 이런 검증 없이 파일만 덮어쓴다.

macOS 26에서 kcpassword 수동 생성이 안 되는 이유

실제 겪은 증상

서버의 상황은 이랬다:

  • autoLoginUser = "myuser" - 설정됨
  • /etc/kcpassword - 존재 (12 bytes)
  • FileVault - OFF
  • macOS 버전 - 26.3.1 (Tahoe)

설정은 완벽해 보였지만, 재부팅하면 로그인 화면에서 비밀번호 입력을 기다리며 멈춰있었다. SSH로 접속해서 확인하면:

$ stat -f '%Su' /dev/console
root  # myuser가 아님!
 
$ launchctl print user/501 | grep session
session = Background  # Aqua가 아님!

자동 로그인 자체가 작동하지 않았다. sysadminctl -autologin status는 설정되어 있다고 보고하지만, 실제 loginwindow는 이를 무시하고 있었다.

sysadminctl로 재설정하면 해결

기존 설정을 끄고 sysadminctl로 다시 설정했더니 바로 해결됐다:

# 기존 설정 제거
sudo sysadminctl -autologin off
 
# 공식 경로로 재설정
sudo sysadminctl -autologin set -userName myuser -password -

재부팅 후:

$ stat -f '%Su' /dev/console
myuser  # 정상!
 
$ launchctl print gui/501 | grep session
session = Aqua  # 정상!

원인 분석

XOR 키 자체는 변경되지 않았다. 그렇다면 왜 수동 생성한 kcpassword가 macOS 26에서 작동하지 않았을까?

확정적인 답은 Apple이 공개하지 않아 알 수 없지만, 유력한 가능성들은 다음과 같다:

  1. kcpassword 파일 포맷 검증 강화: macOS 업데이트를 거치면서 loginwindow(또는 logind)가 kcpassword를 읽을 때 수행하는 검증이 엄격해졌을 수 있다. 패딩 바이트, 파일 크기, 소유권 등에서 기존 스크립트가 생성한 파일이 새로운 검증을 통과하지 못하는 경우다.

  2. 추가 메타데이터: sysadminctl이 kcpassword 외에 다른 시스템 상태도 함께 설정하고 있을 가능성이 있다. 단순히 plist 키 + kcpassword 파일만으로는 부족한 추가 조건이 생겼을 수 있다.

  3. logind 내부 상태: logind가 kcpassword를 생성할 때 자체 내부 상태나 캐시도 함께 업데이트하는데, 파일을 직접 생성하면 이 내부 상태와 불일치가 발생할 수 있다.

어떤 원인이든 결론은 같다 - macOS가 버전을 거듭할수록 수동 kcpassword 생성은 점점 불안정해지고 있으며, sysadminctl이라는 공식 경로를 사용하는 것이 유일하게 안정적인 방법이다.