← 제작기

번호 하나 올리려다

요청은 한 줄이었습니다. versionCode 22로 올리고 릴리스 빌드해 줘.

새 버전을 내려면 당연히 해야 하는 일이고, 5분이면 끝날 것 같았습니다. 결과적으로는 앱의 서명 키를 통째로 바꾸고, 쓰는 사람들에게 "앱을 지웠다가 다시 설치해 달라"고 부탁하는 데까지 갔습니다.

그사이에 알게 된 것들은 전부 지금까지 잘 되고 있다고 믿었던 것들이었습니다.

1. 릴리스 키스토어가 없었다

릴리스 빌드를 돌리자 바로 멈췄습니다.

Execution failed for task ':app:validateSigningRelease'.
> Keystore file not set for signing config release

README 에는 키스토어를 만드는 절차가 적혀 있었습니다. keytool -genkey 로 smsalarm-release.jks 를 만들라는, 제가 직접 적어 둔 문단이었습니다. 그 절차는 한 번도 실행된 적이 없었습니다. 맥 전체를 뒤져도 그런 파일은 없었습니다.

그렇다면 지금까지 배포한 APK 들은 무엇으로 서명됐을까요.

2. 디버그 키로 서명하고 있었다

배포본에서 인증서를 직접 뽑아 봤습니다.

Signer #1 certificate DN: C=US, O=Android, CN=Android Debug
Signer #1 certificate SHA-256: 896b9c51…ea917c19

CN=Android Debug, 안드로이드 스튜디오가 개발할 때 쓰라고 자동으로 만들어 주는 바로 그 키였습니다. 지문이 ~/.android/debug.keystore 와 정확히 일치했습니다.

서명 키를 잃어버린 것이 아니라 처음부터 만든 적이 없었습니다. 안드로이드 스튜디오가 알아서 서명해 주니 아무 문제 없이 굴러갔고, 그래서 아무도 이상하다고 느끼지 않았습니다.

3. 디버그 빌드를 배포하고 있었다

같은 APK 의 매니페스트를 열어 보고 더 놀랐습니다.

android:debuggable="true"

assembleDebug 산출물을 그대로 배포하고 있었던 것입니다. 키스토어가 없어서 릴리스 빌드가 되지 않으니 자연스럽게 그렇게 된 것으로 보입니다.

이 앱에서는 가볍게 넘길 문제가 아닙니다. Fab Alert 는 기기에 오는 모든 문자와 메시징 앱 알림을 읽습니다. debuggable 빌드는 USB 디버깅이 켜진 상태에서 다른 프로세스가 디버거를 붙여 앱 메모리와 Room DB 를 들여다볼 수 있습니다. 제 휴대폰뿐 아니라 다른 사람의 휴대폰에도 설치된 앱이라 더 그렇습니다.

릴리스 빌드로 바꾸자 debuggable 이 사라지고 크기도 20MB 에서 14MB 로 줄었습니다. 여기까지가 v1.21 이었습니다.

4. 이번에는 설치가 막혔다

앱에서 업데이트를 받아 설치를 누르자 이런 화면이 떴습니다.

악성 앱으로 의심
이 앱은 악성으로 신고되었거나 그와 유사하여 피싱과 같은 범죄에 사용될 수 있으므로 설치할 수 없습니다. 휴대전화와 데이터 보호를 위해 이 제한은 끌 수 없습니다.

처음에는 기기의 자동 차단 기능이라고 봤습니다. 로그에 UnknownSourceAppBlockActivity 라는 삼성 구성 요소가 찍혀 있었기 때문입니다. 기기가 Android 16 / One UI 8 이라 운영체제 업데이트로 이 기능이 기본으로 켜졌을 것이라고 짐작했습니다.

틀린 짐작이었습니다. 자동 차단을 끄고 다시 시도했는데도 같은 화면이 떴습니다. 설정을 바꾼 시각(02:00:49)과 설치를 시도한 시각(02:01:06)이 로그에 남아 있어서, 확실히 끈 뒤에 시도했다는 것을 확인할 수 있었습니다.

화면에 적힌 글을 직접 읽기

여기서 방향을 바꿔, 짐작하는 대신 대화상자의 실제 문구를 그대로 뽑아냈습니다. uiautomator dump 로 화면 구조를 받아 텍스트만 꺼냈습니다.

그렇게 얻은 문장에 답이 있었습니다. "이 제한은 끌 수 없습니다." 끌 수 있는 자동 차단과는 다른 것이었고, 계속 자동 차단을 의심했다면 설정만 뒤지다 시간을 다 썼을 것입니다.

5. 여기서 크게 헛짚었다

다음 단서는 adb install 로는 설치가 된다는 점이었습니다. APK 자체가 거부되는 것이 아니라 사용자가 설치하는 화면에서만 막히는, 경험 규칙에 따른 판정이었습니다.

저는 Fab Alert 의 권한 조합이 스미싱 악성 앱과 사실상 같기 때문이라고 추론했습니다.

  • RECEIVE_SMS 와 SEND_SMS 로 문자를 가로채 다른 번호로 보냅니다
  • 알림 접근 권한으로 모든 앱의 알림을 읽습니다
  • REQUEST_INSTALL_PACKAGES 로 스스로 APK 를 설치합니다

"기능은 정당하지만 바깥에서 보이는 행동으로는 구분이 안 된다"고 적었고, 서명을 바꿔도 풀리지 않을 가능성이 크다고 했습니다.

그 예상은 틀렸습니다. 실명 키로 서명을 바꾸자 차단이 그냥 사라졌습니다. 권한은 하나도 건드리지 않았습니다.

지금 보면 납득이 됩니다. CN=Android Debug 는 모든 개발자의 기기에 똑같이 있는 공용 값입니다. 정상적으로 배포되는 앱이라면 절대 달고 있을 수 없는 서명입니다. 신원이 없는 앱과 신원이 있는 앱의 차이였습니다.
뒷이야기: 이 결론도 불완전했습니다. 사흘 뒤 같은 화면이 같은 실명 키로 서명한 새 버전에서 다시 떴습니다. 변인을 하나씩 통제해 실험해 보니 진짜 변수는 서명이 아니라 "누가 설치를 시작했는가"였습니다. 같은 파일이라도 앱이 스스로 설치하면 차단되고, 파일 관리자로 열면 설치됩니다. 키를 바꿀 때 설치 방법도 수동으로 함께 바뀌었는데, 그것을 보지 못했던 것입니다. 자세한 이야기는 범인은 서명이 아니었다에 있습니다.

6. 실명 키로 옮기기

먼저 짚어 둘 것이 있습니다. 안드로이드 앱 인증서는 전부 자체 서명입니다. 인증 기관이 발급하지 않습니다. 여기서 "실명"은 인증서 소유자 정보에 본인이 들어간 고유한 키라는 뜻이고, 어디선가 인증을 받았다는 뜻은 아닙니다.

RSA 4096비트, 2053년까지 유효한 키를 만들었습니다. CN=Android Debug 가 CN=고유진 이 됐습니다.

대가도 따릅니다. 서명이 다르면 기존 앱 위에 덮어 설치할 수 없습니다. 쓰는 사람들이 한 번은 앱을 지우고 다시 설치해야 하고, 등록해 둔 키워드와 시간대와 연락처가 사라집니다. 자동 업데이트로는 이 전환 자체를 배포할 수 없어서 첫 한 번은 수동으로 해야 합니다.

옛 디버그 키도 버리지 않고 보관합니다. 아직 구버전을 쓰는 사람에게 "지우고 다시 설치해 주세요"라는 안내를 담은 마지막 업데이트를 보내려면 그 키로 서명해야 하기 때문입니다.

하나 더: 백업이 백업이 아니었던 일

키가 생겼으니 백업을 해야 했습니다. 암호화된 디스크 이미지를 만드는 스크립트를 썼고, 실행하니 파일이 만들어졌습니다. 그것으로 끝난 줄 알았습니다.

두 번째 실행에서 "파일이 이미 있음" 오류가 났고, 그 김에 파일을 열어 봤습니다.

00000000: 0000 0000 0000 0000 0000 0000 0000 0000

헤더가 전부 0 이었고, 유효한 디스크 이미지가 아니었습니다. 첫 실행이 빈 껍데기만 만들고 실패했는데, 파일이 생겼다는 이유만으로 성공한 줄 알았던 것입니다. 게다가 실패했을 때 정리하지 않도록 짜여 있어서, 암호화되지 않은 키 사본이 임시 폴더에 8개나 남아 있었습니다.

방식을 openssl 로 바꾸고, 이번에는 스크립트가 만든 직후 스스로 복호화해서 원본과 대조하게 했습니다. 맞지 않으면 백업 파일을 지웁니다.

백업은 복구해 보기 전까지 백업이 아닙니다.

파일 이름 하나 때문에 생긴 일

키가 디버그 키와 실명 키 두 개가 되니 안내문도 두 개가 됐습니다. 각각 FabAlert-서명키-안내.txt 와 fabalert-서명키-안내.txt 였습니다.

맥의 파일 시스템은 대소문자를 구분하지 않습니다. 두 번째 파일을 만드는 순간 첫 번째 파일이 조용히 사라졌습니다. 나중에 드라이브에 올리고 나서야, 두 안내문이 같은 키를 가리키고 있는 것을 보고 알았습니다.

하나 더: 코드 리뷰에서 나온 것들

배포를 마치고 코드를 다시 읽었더니 문제가 여섯 개 나왔습니다. 그중 둘은 심각했습니다.

업데이트 대화상자가 영원히 잠길 수 있었습니다. 다운로드 상태를 주기적으로 확인하는 반복문이, 사용자가 알림에서 다운로드를 취소해 기록이 사라지면 끝날 조건을 영영 만나지 못했습니다. 그동안 대화상자는 두 버튼이 모두 비활성이고 바깥을 눌러도 닫히지 않는 상태였습니다. 코루틴이 새는 것보다, 사용자가 화면에 갇히는 것이 문제였습니다.

알람이 꺼지지 않을 수 있었습니다. MediaPlayer 필드를 메인 스레드와 IO 코루틴이 동기화 없이 함께 쓰고 있었습니다. "중지"를 눌러 필드를 비운 직후, 아직 준비 중이던 코루틴이 새로 만든 재생 중 플레이어를 그 자리에 덮어씁니다. 화면은 사라졌는데 소리는 계속 나고, 그 소리를 멈출 참조는 어디에도 없습니다. "반드시 울려야 하는 앱"에서 정반대 방향으로 위험한 실패였습니다.

나머지는 중복을 거르는 자료 구조의 경쟁 상태, 제가 넣은 릴리스 검사를 우회하는 경로, 시작 시각과 종료 시각이 같을 때 조용히 24시간이 되는 문제, 숫자가 없는 전화번호가 그 키워드를 영영 걸리지 않게 만드는 문제였습니다.


남는 것

  • "되고 있다"와 "맞다"는 다릅니다. 디버그 키로 서명한 디버그 빌드가 몇 주 동안 아무 불평 없이 잘 돌아갔습니다. 플랫폼이 규칙을 조인 뒤에야 드러났을 뿐입니다.
  • 문서에 절차를 적어 두는 것과 그 절차를 실행한 것은 다릅니다. README 에는 키스토어를 만드는 절차가 멀쩡히 적혀 있었지만, 한 번도 실행되지 않았습니다.
  • 막혔을 때는 화면에 실제로 무엇이 적혀 있는지부터 읽습니다. "자동 차단이겠지"라는 짐작이 한참을 잡아먹었습니다. 문구를 그대로 뽑아내자 "끌 수 없습니다" 한 줄이 방향을 바로잡아 줬습니다.
  • 우회 경로로 성공한 것은 성공이 아닙니다. adb install 은 잘 됐지만, 사용자는 그렇게 설치하지 않습니다. (같은 이유로, 나중에 알람 시험을 adb 로 하다가 서비스가 exported="false" 라 아예 시작조차 되지 않은 것을 모르고 "통과"로 읽은 적도 있습니다.)
  • 틀린 예측은 기록해 둘 가치가 있습니다. 권한 조합이 원인일 것이라고 봤지만 서명이었습니다. 그 예측을 믿고 배포 방식부터 통째로 바꿨다면, 훨씬 큰 일을 벌이고도 5분이면 될 문제를 고치지 못했을 것입니다.

버전 번호 하나 올리는 일이었습니다.

The request was one line. "Bump versionCode to 22 and build a release."

It is the obvious thing to do when shipping a new version, and it looked like five minutes of work. It ended with the app's signing key replaced outright and a note to everyone using it asking them to uninstall and reinstall.

Everything I learned along the way was something I had believed was already working fine.

1. There was no release keystore

The release build stopped immediately.

Execution failed for task ':app:validateSigningRelease'.
> Keystore file not set for signing config release

The README documented how to create the keystore. A paragraph I had written myself, telling me to run keytool -genkey and produce smsalarm-release.jks. But that procedure had never once been run. Searching the entire Mac turned up no such file.

So what had every APK shipped so far been signed with?

2. It was being signed with the debug key

I pulled the certificate straight out of a shipped build.

Signer #1 certificate DN: C=US, O=Android, CN=Android Debug
Signer #1 certificate SHA-256: 896b9c51…ea917c19

CN=Android Debug — the key Android Studio generates for you to use during development. The fingerprint matched ~/.android/debug.keystore exactly.

So the signing key had not been lost. It had never been created. Android Studio signed everything automatically, so nothing ever broke, and nobody had reason to think anything was wrong.

3. And a debug build was being distributed

Opening the manifest of that same APK was worse.

android:debuggable="true"

The assembleDebug output was being shipped as-is. Since the release build did not work (no keystore), it had presumably drifted there on its own.

For this app in particular that is not a small thing. Fab Alert reads every text message and messaging notification that reaches the device. A debuggable build lets another process attach a debugger, with USB debugging on, and read the app's memory and Room database. And this app is not only on my phone — it is on other people's.

Switching to a release build removed debuggable and dropped the size from 20MB to 14MB. That was v1.21.

4. Then the install was blocked

Downloading the update in-app and pressing install produced this screen.

Suspected malicious app
This app has been reported as malicious, or resembles one, and may be used in crimes such as phishing, so it cannot be installed. To protect your phone and data, this restriction cannot be turned off.

My first read was Samsung's Auto Blocker. The log showed a Samsung component called UnknownSourceAppBlockActivity. The device runs Android 16 / One UI 8, so I assumed an OS update had turned it on by default.

Wrong. I turned Auto Blocker off, retried, and got the same screen. The log had both timestamps — the setting changed at 02:00:49, the install attempted at 02:01:06 — so the retry definitely came after it was off.

Reading what the screen actually says

This is where I changed direction. Instead of guessing, I extracted the dialog's actual wording. A uiautomator dump gave me the view tree, and I pulled the text out of it.

The answer was in that sentence — "this restriction cannot be turned off." Auto Blocker can be turned off, so this was something else. Had I kept suspecting Auto Blocker, I would have spent the rest of the night in the settings app.

5. And here I was badly wrong

The next clue was that adb install succeeded. So the APK itself was not being rejected — it was being blocked only in the user-facing install flow, by a heuristic.

From that I reasoned: Fab Alert's permission set is effectively indistinguishable from an SMS-phishing app.

  • RECEIVE_SMS + SEND_SMS — intercept texts and send them to another number
  • Notification access — read every app's notifications
  • REQUEST_INSTALL_PACKAGES — install APKs by itself

I wrote that it was "functionally legitimate but externally indistinguishable," and said changing the signature probably would not fix it.

That prediction was wrong. Signing with a real-identity key made the block simply disappear. Not one permission was touched.

In hindsight it makes sense. CN=Android Debug is a shared value present on every developer's machine. No legitimately distributed app could ever carry that signature. The difference was between an app with no identity and an app with one.
Since then: this conclusion was incomplete too. Three days later the same screen appeared for a new version signed with the same real-identity key. A controlled experiment, one variable at a time, showed the real variable was not the signature but who initiates the install — the same file is blocked when the app installs it itself, and installs fine when opened through a file manager. When the key changed, the install method had quietly changed to manual along with it, and I never separated the two. The full story is in It Wasn't the Signature.

6. Moving to a real-identity key

One thing worth stating first: every Android app certificate is self-signed. No CA issues them. "Real identity" here means a unique key whose certificate subject names me — not that anything is certified by anyone.

I generated an RSA 4096-bit key valid through 2053. CN=Android Debug became CN=고유진.

There is a cost. A different signature cannot be installed over the existing app. Everyone using it has to uninstall and reinstall once, losing the keywords, schedules, and contacts they had registered. The transition itself cannot be delivered through the auto-updater, so the first hop is manual.

Which is why the old debug key is kept rather than discarded. To send a final update to anyone still on an old version — one that says "please uninstall and reinstall" — that update has to be signed with that key.

Aside: the backup that was not a backup

With a key in hand, it needed backing up. I wrote a script that creates an encrypted disk image, ran it, and a file appeared. I thought that was that.

Then a second run failed with "file exists," and while I was there I opened the file.

00000000: 0000 0000 0000 0000 0000 0000 0000 0000

All-zero header. Not a valid disk image. The first run had failed after creating an empty shell, and I had read "a file exists" as success. Worse, the script had no cleanup on failure, so eight plaintext copies of the key were sitting in a temp folder.

I switched to openssl, and this time the script decrypts what it just wrote and compares it against the original. If they do not match, it deletes the backup.

A backup is not a backup until you have restored from it.

And then, one filename

Two keys (debug and real) meant two instruction notes. They were named FabAlert-서명키-안내.txt and fabalert-서명키-안내.txt.

macOS's filesystem is case-insensitive. Creating the second silently destroyed the first. I only noticed after uploading both to Drive and seeing that the two notes described the same key.

Aside 2: what the code review turned up

After shipping, I read the code again and found six issues. Two were serious.

The update dialog could lock forever. The loop polling download status never met its exit condition if the user cancelled the download from the notification and the query row disappeared. Meanwhile the dialog had both buttons disabled and would not dismiss on an outside tap. This was not a leaked coroutine — it was a user trapped on a screen.

The alarm could refuse to stop. The MediaPlayer field was shared between the main thread and an IO coroutine with no synchronization. Right after "Stop" cleared the field, a coroutine still in preparation would write its freshly started player into that same slot. The screen is gone, the sound keeps playing, and no reference to stop it exists anywhere. For an app whose whole job is to ring, that is the failure mode pointing the wrong way.

The rest: a race in the deduplication structure, a bypass around the release guard I had added myself, a start-equals-end time silently meaning 24 hours, and a phone number with no digits in it permanently preventing a keyword from ever matching.


What stays with me

  • "It works" and "it is correct" are different things. A debug build signed with a debug key ran without complaint for weeks. It only surfaced once the platform tightened its rules.
  • Documenting a procedure and running it are different things. The README described keystore creation perfectly well. It had simply never been executed.
  • When you are stuck, start by reading what the screen actually says. The assumption "it must be Auto Blocker" ate a long stretch of time. Extracting the literal wording gave me one line — "cannot be turned off" — that corrected the direction.
  • Succeeding through a side door is not success. adb install worked fine. But that is not how users install. (For the same reason, I later ran alarm tests over adb and read them as "passing" without noticing the service was exported="false" and had never started at all.)
  • Wrong predictions are worth recording. I called the permission set as the cause; it was the signature. Had I trusted that prediction and gone straight to overhauling the distribution model, I would have caused far more work and still not fixed a five-minute problem.

It was one version number.