콘텐츠로 건너뛰기
Codex

서명 없이 macOS 앱 배포하기

서명 없이 macOS 앱 배포하기

TECH NOTE · DESKTOP AI PET

서명 없이 배포한 macOS 앱이 손상됐다고 나오는 것은 격리 속성 때문입니다. .app이 디렉터리라 표시가 안쪽마다 붙고, xattr에 -r이 필요한 이유를 단계별 실행으로 확인했습니다.

만든 사람 기계에서는 잘 열린다

앞선 세 편에서 창을 만들고, 그릴 것을 준비하고, 시트로 합쳤다. 이번 편은 그렇게 만든 앱을 남에게 주는 이야기다.

애플 개발자 계정 없이 만든 앱을 보내면 받은 사람 화면에 이렇게 뜬다.

"DesktopAIPet"은(는) 손상되었기 때문에 열 수 없습니다.
휴지통으로 이동해야 합니다.

왜 이런 문구가 뜨는지, 어떤 선택지가 있는지는 앞선 글에 적었다. 이번 편은 그 표시가 실제로 어디에 붙어 있는지 직접 보는 쪽이다.

https://github.com/bluecafe/pet-reference/tree/main/macos-unsigned

cargo run을 하면 가짜 번들을 만들어 단계별로 보여준다. 경로를 주면 실제 앱을 검사한다.

문제를 늦게 발견하는 이유

이 문제의 고약한 점은 만드는 사람에게는 안 보인다는 것이다.

직접 빌드한 앱에는 격리 표시가 없다. 인터넷에서 받은 게 아니기 때문이다. 그래서 내 기계에서는 잘 열린다. 압축해서 보내고 나서야, 받은 사람이 “안 열린다”고 알려준다.

예제의 첫 단계가 이것을 보여준다.

1) 갓 빌드한 상태     격리된 항목 0   → 만든 사람 기계에서는 잘 열린다
2) 다운로드된 상태     격리된 항목 2   → 받은 사람 기계에서 막힌다

배포 전에 재현하려면 격리 상태를 직접 만들어야 한다. 예제가 그 일을 한다.

표시는 한 곳이 아니라 여러 곳에 붙는다

이 편에서 실제로 새로 알게 된 부분은 여기다.

.app파일 하나가 아니라 디렉터리다. 안에 Contents/MacOS/실행파일, Contents/Info.plist, 리소스들이 들어 있다.

격리 표시는 그 안의 파일마다 붙는다. 그래서 최상위만 지우면 안 된다.

3) 최상위만 지움      격리된 항목 1   → 여전히 안 열린다
     남음: .../Contents/MacOS/app
4) 재귀로 지움        격리된 항목 0   → 이제 열린다

안내하는 명령이 xattr -cr인데, -r이 재귀라는 뜻이고 그게 없으면 3번 상태에서 멈춘다. 표시가 하나 남아 있으면 앱은 여전히 안 열리고, 사용자는 명령을 쳤는데 왜 안 되냐고 묻는다.

예제를 돌려 보면 이 차이가 눈에 보인다. 가짜 번들을 만들고 표시를 붙였다가 두 방식으로 지워 본다.

격리 속성 안에는 무엇이 들어 있나

값을 뜯어 보면 세미콜론으로 나뉜 네 칸이다.

0081;68a1b2c3;Safari;1A2B3C4D-5E6F-...
플래그  시각   받아온 프로그램   이벤트 ID

파싱하는 코드는 이만큼이다.

pub fn parse_quarantine(value: &[u8]) -> Option<Quarantine> {
    let text = std::str::from_utf8(value).ok()?;
    let mut parts = text.split(';');
    Some(Quarantine {
        flags: parts.next()?.to_string(),
        timestamp_hex: parts.next().unwrap_or_default().to_string(),
        agent: parts.next().unwrap_or_default().to_string(),
        event_id: parts.next().unwrap_or_default().to_string(),
    })
}

첫 칸만 ?로 실패시키고 나머지는 unwrap_or_default()다. 이 차이가 뒤에서 설명할 “관대한 파싱”이다.

세 번째 칸이 흥미롭다. 어떤 프로그램으로 받았는지가 남는다. Safari로 받았으면 Safari, curl로 받았으면 curl이다.

이 형식은 애플이 문서화하지 않았다. 실제 값을 보고 맞춘 것이라 언제든 바뀔 수 있다.

그래서 파싱을 관대하게 만들었다. 칸이 모자라면 빈 문자열로 채우고 계속 간다. 여기서 실패로 처리하면 macOS가 형식을 조금만 바꿔도 도구가 통째로 멈춘다. 이 값을 읽는 목적은 “표시가 있는가”이지 “값이 정확한가”가 아니다.

없는 것을 지우면 실패한다

작은 것이지만 도구를 만들 때 걸린다.

읽기는 없어도 정상이다. 대부분의 파일에는 격리 표시가 원래 없다. 그래서 getxattr가 실패해도 “없음”으로 다룬다.

// 크기를 먼저 물어본다. 0을 넘기면 필요한 바이트 수를 돌려준다.
let size = unsafe {
    sys::getxattr(path_c.as_ptr(), name_c.as_ptr(),
                  std::ptr::null_mut(), 0, 0, sys::XATTR_NOFOLLOW)
};
if size < 0 {
    return Ok(None);   // 오류가 아니다. 원래 없는 것이다
}

let mut buffer = vec![0u8; size as usize];
let read = unsafe {
    sys::getxattr(path_c.as_ptr(), name_c.as_ptr(),
                  buffer.as_mut_ptr().cast(), buffer.len(), 0, sys::XATTR_NOFOLLOW)
};

호출이 두 번인 것도 이 API의 특징이다. 값의 크기를 모르니 버퍼를 미리 잡을 수 없어서, 처음에는 크기만 물어보고 그만큼 잡아 다시 부른다.

지우기는 없으면 실패한다. 그런데 이건 사용자 입장에서 실패가 아니다. 지우려는 표시가 이미 없다는 뜻이니 원하는 상태다.

그래서 도구는 이 오류를 “이미 깨끗함”으로 해석해야 한다. 그대로 사용자에게 던지면 “지우기 실패”라는 메시지를 보고 뭔가 잘못됐다고 생각한다.

같은 시스템 호출인데 방향에 따라 오류의 의미가 다르다.

심볼릭 링크는 따라가지 않는다

번들 안에는 링크가 흔하다. Contents/Frameworks 아래에 버전 폴더를 가리키는 링크가 여러 개 있다.

재귀로 훑을 때 링크를 따라가면 같은 파일을 여러 번 보거나, 링크가 밖을 가리키면 번들 밖으로 나간다. 그러면 남의 파일에 손대게 된다.

그래서 링크는 그 자체로만 보고 따라가지 않는다.

fn visit(path: &Path, action: &mut impl FnMut(&Path)) -> io::Result<()> {
    action(path);

    let metadata = std::fs::symlink_metadata(path)?;
    if !metadata.is_dir() {
        return Ok(());
    }

    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        visit(&entry.path(), action)?;
    }
    Ok(())
}

metadata가 아니라 symlink_metadata다. 앞의 것은 링크가 가리키는 대상을 보고, 뒤의 것은 링크 자체를 본다. 한 글자 차이로 번들 밖까지 훑게 된다.

시스템 호출에도 XATTR_NOFOLLOW를 넘겨 같은 원칙을 지킨다.

읽기와 고치기를 나눴다

예제에 경로를 주면 검사만 한다. 지우지 않는다.

남의 파일을 말없이 고치는 도구는 위험하다. xattr -cr은 되돌릴 수 없고, 대상을 잘못 지정하면 엉뚱한 디렉터리의 확장 속성이 전부 날아간다.

지우는 함수는 예제 안에 있지만 시연 모드에서 자기가 만든 임시 번들에만 쓴다.

정리

서명 없이 배포하면 받는 쪽에서 한 단계를 더 거쳐야 한다. 그 단계를 안내하려면 무엇이 막고 있는지 정확히 알아야 한다.

  • 만든 사람 기계에서는 재현되지 않는다. 격리 표시는 다운로드에 붙는다
  • .app은 디렉터리다. 표시가 안쪽 파일마다 붙는다
  • xattr -cr-r이 없으면 안쪽이 남아 여전히 안 열린다
  • 속성 값 형식은 문서화돼 있지 않다. 관대하게 파싱한다
  • 읽기 실패는 정상이고 지우기 실패는 “이미 깨끗함”이다
  • 심볼릭 링크는 따라가지 않는다
  • 검사와 수정을 나눈다. 되돌릴 수 없는 동작은 말없이 하지 않는다

돌아가는 코드는 pet-reference/macos-unsigned에 있다. 시스템 호출을 직접 선언해 쓰므로 의존성이 없고, cargo run으로 격리 상태를 만들었다 지우는 과정을 그대로 볼 수 있다.

을 받으면 이 상황을 직접 만난다. 다운로드 페이지에 해제 명령을 함께 적어 뒀다.

다음 편은 그렇게 배포하는 파일의 크기다. 번들에서 무엇이 얼마나 차지하는지 재는 방법을 다룬다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다