Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정 피하기
· 업데이트: · 小村 豪 · Windows 개발, 보안, DPAPI, C# / .NET, Win32
수정 이력(1건, 최종 수정 2026년 09월 01일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635144)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635143)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정 피하기」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635143 https://comcomponent.com/ko/blog/2026/03/16/000-windows-app-secret-storage-best-practices-dpapi/
- DOI(최신 버전)
- 10.5281/zenodo.21635143
- DOI(이 버전)
- 10.5281/zenodo.22217442
이전 글 「Windows 앱 개발에서 최소한의 보안을 지키기 위한 체크리스트」에서는
「비밀 정보를 소스 코드나 평문 설정에 두지 않는다」「Win32 / .NET이라면 DPAPI / ProtectedData를 쓴다」라는 최소한의 선을 썼습니다.
이번에는 그중에서도 「DPAPI를 써서 최소한 평문보다는 낫게 한다」를 좀 더 파고듭니다.
대상은 다음과 같은 Windows 앱입니다.
- WPF / WinForms / WinUI 데스크톱 앱
- C# / .NET의 Windows 클라이언트
- 로컬 설정 파일에 접속처 자격 증명이나 API 토큰을 저장하고 싶어지는 앱
여기서 다루는 것은 「로컬에 저장할 수밖에 없는 비밀을, 적어도 appsettings.json의 평문 그대로 방치하지 않기」위한 현실적인 설계입니다.
「어떤 공격자에게도 이기는 완전 방어」에 관한 이야기가 아닙니다. 이야기를 과하게 부풀리면 현실적인 보안 논의에서 멀어집니다.
1. 먼저 결론
실무에서는 이 순서로 생각하는 것이 이해하기 쉽습니다.
- 애초에 클라이언트에 장기 비밀을 두지 않는다
- Windows 인증, 통합 인증, 사용자의 대화형 로그인, 서버 측 비밀 관리를 우선한다
- 어쩔 수 없이 로컬 저장이 필요하다면 평문으로 두지 않는다
- Windows라면 먼저 DPAPI /
ProtectedData를 제1 후보로 한다
- Windows라면 먼저 DPAPI /
- 일반 데스크톱 앱은
DataProtectionScope.CurrentUser를 기본으로 한다LocalMachine은 용도가 꽤 제한적이다
- DPAPI는 「단말이 완전히 침해된 상황」까지 지키는 것은 아니다
- 같은 사용자 권한으로 도는 코드는 기본적으로 그 사용자가 복호화할 수 있는 것을 복호화할 수 있다
그리고 이 글에서 가장 중요한 논점은 여기입니다.
「어차피 비밀 키는 어딘가에 저장해야 하니까, 평문이든 DPAPI든 보안상으로는 같은 것 아닌가?」
이것은 절반은 맞고, 결론은 틀립니다.
- 자체 AES + 키를 같은 앱이나 같은 설정에 두는 경우라면 꽤 평문에 가깝습니다
- 하지만 DPAPI는 키 관리를 OS에 맡기고, 복호화할 수 있는 주체를 “그 Windows 사용자” 또는 “그 컴퓨터”에 묶습니다
- 그 결과 설정 파일 단체의 유출, 다른 PC로의 반출, 오송, 백업 유출, 리포지토리 혼입 같은 사고에 대한 내성이 크게 달라집니다
즉, “키가 어딘가에 있다”는 추상론만 보면 같아 보여도, “누가·어떤 맥락에서·얼마나 쉽게 쓸 수 있는가”가 전혀 다르다는 이야기입니다.
현관 매트 밑에 열쇠를 두는 것과 관리실에서 본인 확인을 거쳐 열쇠를 내주는 것을 같다고 단언하는 것은 다소 거칩니다.
flowchart TB
accTitle: "키는 어딘가에 있다"만으로는 같아지지 않는다
accDescr: 키가 어딘가에 있다는 추상론에서는 평문도 DPAPI도 같아 보이지만, 누가 어떤 맥락에서 얼마나 쉽게 쓸 수 있는지는 전혀 다르다는, 이 글의 중심 논점을 보여주는 그림.
abs1["키가 어딘가에 있음(추상론)"] -.->|"여기만 보면"| same1["같아 보임"]
who1["누가·어떤 맥락에서·얼마나 쉽게 쓸 수 있는가"] -->|"여기를 보면"| diff1["전혀 다름"]
그림 1: 추상론에서는 같아 보여도, “누가 어떤 맥락에서 쓸 수 있는가”로 평문과 DPAPI는 갈립니다.
이 글의 지식 맵
이 글은 DPAPI(ProtectedData)를 사용해 Windows 데스크톱 앱의 비밀 정보(접속 문자열이나 API 토큰 등)를 평문 설정보다 안전하게 저장하는 방법을 다룹니다. 평문 저장이나 키를 암호문과 같은 곳에 두는 자체 암호화는 설정 파일 단체의 유출만으로 그대로 비밀이 새어 나가지만, DPAPI는 복호화 주체를 Windows 사용자 또는 컴퓨터에 묶음으로써 이를 완화합니다. 일반적인 데스크톱 앱은 CurrentUser 스코프를 기본으로 하고, LocalMachine은 신뢰된 단일 용도의 Windows 서비스 등으로 용도를 좁혀야 합니다. 다만 비밀번호 재설정이나 프로필 재생성, 암호문만 다른 PC로 복사하는 것은 복호화 실패의 원인이 되며, 반대로 로밍 프로필이라면 키 자료도 함께 이동하기 때문에 다른 PC에서도 복호화할 수 있습니다. 사용자명과 비밀번호 조합을 저장하고 싶은 경우나 여러 머신에서 비밀을 공유해야 하는 경우에는 DPAPI보다 Credential Locker나 Credential Manager 쪽이 적합합니다.
flowchart LR
accTitle: DPAPI를 통한 Windows 앱 비밀 정보 보호 지식 맵
accDescr: DPAPI가 CurrentUser와 LocalMachine 스코프로 복호화 주체를 전환할 수 있다는 것, 평문 저장이나 자체 암호화가 초래하는 유출 위험을 DPAPI가 완화한다는 것, Credential Locker나 Credential Manager와의 구분 사용, 복호화 실패를 부르는 운용상의 함정 사이의 관계를 보여주는 그림
dpapi["DPAPI"]
app_secrets["앱 비밀 정보"]
plaintext_secret_storage["비밀 정보의 평문 저장"]
secret_config_file_leak["설정 파일 유출로 인한 비밀 정보 노출"]
colocated_key_encryption["암호문과 같은 곳에 키를 두는 자체 암호화"]
common_client_secret["전체 클라이언트 공용 장기 비밀"]
dpapi_currentuser_scope["DataProtectionScope.CurrentUser"]
dpapi_localmachine_scope["DataProtectionScope.LocalMachine"]
desktop_app["대화형 사용자의 데스크톱 앱"]
windows_service["Windows 서비스"]
dpapi_decryption_failure["DPAPI 복호화 실패"]
credential_pair["사용자 이름과 암호 쌍"]
credential_locker["Credential Locker(PasswordVault)"]
credential_manager_api["Credential Manager(CredWrite/CredRead)"]
microsoft_account["Microsoft 계정"]
cross_machine_secret_sharing["여러 머신·여러 사용자 간 비밀 공유"]
admin_password_reset["관리자에 의한 비밀번호 재설정"]
profile_recreation["프로필 재생성"]
ciphertext_cross_pc_copy["암호문만 다른 PC로 복사"]
roaming_user_profile["로밍 프로필"]
plaintext_secret_storage -->|"원인이 될 수 있다"| secret_config_file_leak
dpapi -.->|"완화한다"| secret_config_file_leak
colocated_key_encryption -->|"원인이 될 수 있다"| secret_config_file_leak
dpapi -->|"사용은 비권장"| common_client_secret
dpapi -->|"에서 구성할 수 있다"| dpapi_currentuser_scope
dpapi -->|"에서 구성할 수 있다"| dpapi_localmachine_scope
dpapi_currentuser_scope -->|"권장되는 대응"| desktop_app
dpapi_localmachine_scope -->|"권장되는 대응"| windows_service
dpapi_localmachine_scope -->|"사용은 비권장"| desktop_app
windows_service -.->|"원인이 될 수 있다"| dpapi_decryption_failure
dpapi -->|"권장되는 대응"| app_secrets
dpapi -->|"사용은 비권장"| credential_pair
credential_locker -->|"권장되는 대응"| credential_pair
credential_manager_api -->|"권장되는 대응"| credential_pair
credential_locker -.->|"전제로 한다"| microsoft_account
dpapi -.->|"양립하지 않는다"| cross_machine_secret_sharing
credential_locker -.->|"권장되는 대응"| cross_machine_secret_sharing
admin_password_reset -.->|"원인이 될 수 있다"| dpapi_decryption_failure
profile_recreation -->|"원인이 될 수 있다"| dpapi_decryption_failure
ciphertext_cross_pc_copy -->|"원인이 될 수 있다"| dpapi_decryption_failure
roaming_user_profile -.->|"방지한다"| dpapi_decryption_failure
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 왜 평문 설정이 위험한가
평문 저장이 위험한 이유는 암호 이론보다 훨씬 일상적인 이야기입니다. 실무에서는 대체로 다음과 같은 경로로 샙니다.
- 설정 파일을 그대로 Git에 넣어 버린다
- 장애 조사용 ZIP에 설정 파일이 통째로 들어간다
- 지원 문의에 설정 파일을 첨부하게 한다
- 백업이나 파일 공유로 제삼자가 읽을 수 있다
- 로그에 접속 문자열이나 토큰이 그대로 찍힌다
- 퇴사자나 다른 사용자가 같은 단말의 파일을 읽을 수 있다
평문은 제삼자에게 읽힌 시점에 비밀성을 잃습니다.
- 파일을 열면 끝
- 복사되면 끝
- 메일에 첨부되면 끝
- 리포지토리에 남으면 반영구적으로 뒷감당해야 한다
공격자가 고도의 기술을 가질 필요조차 없습니다. 텍스트 에디터로 열 수 있다는 것만으로 이미 꽤 약한 상태입니다.
flowchart TB
accTitle: 평문은 읽힌 시점에 비밀성을 잃는다
accDescr: Git 혼입이나 조사용 ZIP, 백업 유출, 첨부 같은 일상적인 경로로 설정 파일이 제삼자에게 읽히면 평문은 비밀성을 잃는다는, 이 글의 중심 논점을 보여주는 그림.
rt1["Git 혼입·조사용 ZIP·첨부·백업"] --> read1["설정 파일이 읽힘"]
read1 --> end1["제삼자에게 읽힌 시점에 비밀성을 잃음"]
end1 -.-> low1["공격자가 고도의 기술을 가질 필요조차 없음"]
그림 2: 평문의 약점은 일상적인 사고 경로로 제삼자에게 읽힌 시점에 비밀성을 잃는다는 것입니다.
3. 「비밀 키는 어딘가에 저장되는 거니까 같은 것 아닌가?」에 대한 답
이 의문은 타당합니다. 그리고 여기를 대충 답하면 보안 글이 단숨에 흐릿해집니다.
답을 먼저 말하면, 「어딘가에 키가 필요하다」는 의미에서는 yes, 그러니까 같다는 의미에서는 no입니다.
3.1. 무엇이 같고 무엇이 다른가
확실히 암호화에는 최종적으로 어떤 root of trust가 필요합니다. 즉, 암호화에서는 최종적으로 어떤 신뢰의 기점이 필요합니다.
다만 보안상의 차이는 다음 3가지로 결정됩니다.
- 키를 앱이 직접 가지고 있는가
- 키가 어떤 주체에 묶여 있는가
- 파일만 도둑맞았을 때 복호화할 수 있는가
이 차이를 대략 표로 정리하면 다음과 같습니다.
| 방식 | 설정 파일이 읽혔을 때 | 파일만 다른 PC로 반출됐을 때 | 같은 PC의 다른 사용자에게 읽혔을 때 | 같은 사용자 권한으로 도는 코드 |
|---|---|---|---|---|
| 평문 | 그 자리에서 샘 | 그대로 샘 | 그대로 샘 | 당연히 읽힘 |
| 자체 암호화 + 키를 같은 설정 / 바이너리에 둠 | 꽤 샘 | 꽤 샘 | 꽤 샘 | 당연히 복호화됨 |
DPAPI + CurrentUser |
파일 단체로는 바로 안 읽힘 | 통상 복호화하기 어려움 | 통상 복호화하기 어려움 | 복호화 가능 |
DPAPI + LocalMachine |
파일 단체로는 바로 안 읽힘 | 그 PC 이외에서는 통상 복호화하기 어려움 | 같은 PC라면 폭넓게 복호화 가능 | 복호화 가능 |
여기서 중요한 것은 DPAPI가 「파일을 읽을 수 있다」는 것과 「비밀을 쓸 수 있다」는 것을 분리한다는 점입니다.
flowchart TB
CT["암호문<br/>설정 파일 / DB의 열 / 조사용 ZIP에 들어감<br/>= 반출될 수 있는 것"]
subgraph WIN["복호화에 필요한 것 ── OS 쪽에 남고, 암호문에는 포함되지 않음"]
UMK["사용자의 마스터 키<br/>CurrentUser로 보호했을 때"]
MMK["컴퓨터의 마스터 키<br/>LocalMachine으로 보호했을 때"]
end
CT -->|"CurrentUser로 보호"| UMK
CT -->|"LocalMachine으로 보호"| MMK
UMK --> A1["그 사용자로 도는 코드<br/>→ 복호화 가능"]
UMK --> A2["같은 PC의 다른 사용자<br/>→ 복호화 불가"]
MMK --> B1["같은 PC 위의 코드<br/>→ 다른 사용자라도 복호화 가능"]
UMK --> C1["암호문만 다른 PC로 복사<br/>→ 복호화 불가"]
MMK --> C1
UMK --> C2["로밍 프로필째로 이동<br/>→ 키 자료도 함께 이동하므로 복호화 가능(6.5절)"]
그림 3: 암호문만 반출될 수 있는 쪽에 있고, 복호화에 필요한 마스터 키는 OS 쪽에 남습니다. 다만 LocalMachine이 미치는 범위는 그 PC 전체이며, 같은 PC의 다른 사용자를 막을 수는 없습니다.
평문에서는 이 둘이 같습니다. 파일을 읽을 수 있으면 비밀도 읽을 수 있습니다.
하지만 DPAPI에서는 적어도 CurrentUser라면
- 그 Windows 사용자로서
- 그 Windows의 맥락에서
- OS의 보호 기구를 거쳐서
복호화해야 합니다.
이 차이는 사고 현장에서는 상당히 큽니다.
3.2. 「그래도 같은 사용자라면 복호화되지 않나?」는 맞는 말입니다
여기는 얼버무리지 않고 써야 할 지점입니다.
같은 사용자 권한으로 실행되는 코드는 그 사용자가 복호화할 수 있는 것을 기본적으로 복호화할 수 있습니다.
즉, DPAPI는 다음과 같은 상황을 주된 목적으로 삼지 않습니다.
- 이미 그 단말이 악성코드에 침해되어 있다
- 공격자가 그 사용자로서 코드를 실행할 수 있다
- 단말 관리자 레벨에서 완전히 탈취되어 있다
이 상황에서는 앱 자신도 복호화할 수 있으므로, 공격자의 코드도 복호화할 수 있어 버립니다. 여기서 「그래도 암호화되어 있습니다」는 별로 든든하지 않습니다.
DPAPI가 효과를 발휘하는 것은 주로 “파일 유출·잘못된 배치·오프라인 반출·다른 사용자로부터의 참조” 쪽입니다.
여기를 혼동하면
- 지킬 수 있는 것을 과소평가해서 쓰지 않는다
- 지킬 수 없는 것을 과대평가해서 안심한다
두 가지가 모두 일어납니다. 둘 다 은근히 위험합니다.
flowchart TB
accTitle: DPAPI가 효과를 발휘하는 쪽과 발휘하지 못하는 쪽
accDescr: DPAPI가 효과를 발휘하는 것은 파일 유출이나 잘못된 배치, 오프라인 반출, 다른 사용자로부터의 참조 쪽이며, 같은 사용자 권한으로 도는 공격 코드나 이미 침해된 단말에는 효과가 없다는 경계를 보여주는 그림.
dp2["DPAPI의 수비 범위"] -->|"효과가 있는 쪽"| eff1["유출·잘못된 배치·다른 사용자"]
dp2 -.->|"효과가 없는 쪽"| noef1["동일 사용자 권한의 코드"]
dp2 -.-> mis1["혼동하면 평가를 그르침"]
그림 4: 효과가 있는 쪽과 없는 쪽의 경계를 모르면, 쓰지 않는 과소평가이거나 지나치게 안심하는 과대평가가 됩니다.
3.3. 그래서 무엇이 좋은가
DPAPI의 이점을 한마디로 말하면
「비밀 그 자체를 설정 파일의 가독성에서 분리할 수 있다」
는 것입니다.
예를 들어 다음과 같은 사고에서는 평문과 DPAPI의 차이가 드러납니다.
- 이용자가 설정 파일을 지원 부서로 보내 버렸다
- 조사용 ZIP에 설정 파일이 들어갔다
- 백업에서 설정 파일만 유출됐다
- 공유 폴더에 복사됐다
- 개발자가 암호문만 보고 내용을 읽을 수 없는 상태로 만들어 두었다
이것은 꽤 현실적인 이점입니다. 공격자를 영화 속 초인으로 만들지 않아도 일상적인 사고의 반경을 줄일 수 있습니다.
flowchart TB
accTitle: 사고 반경이 작아지는 구조
accDescr: 지원 부서로의 오송이나 조사용 ZIP, 백업 유출 같은 일상적인 사고로 파일이 밖으로 나가도 나가는 것은 암호문뿐이므로 비밀 유출로 이어지지 않고 사고 반경이 작아진다는 것을 보여주는 그림.
ac1["오송·조사 ZIP·백업 유출"] --> out3["밖으로 나가는 것은 암호문뿐"]
out3 --> sm2["그대로 비밀 유출로 이어지지 않음"]
sm2 --> rad1["일상적인 사고 반경이 작아짐"]
그림 5: 파일이 밖으로 나가는 사고는 막을 수 없어도, 나가는 것을 암호문으로 바꿀 수 있습니다.
4. DPAPI가 딱 알맞은 이유
Windows에서 로컬 저장 비밀을 다룰 때 DPAPI가 실무에 딱 맞는 이유는 다음과 같습니다.
4.1. 키 관리를 OS에 맡길 수 있다
직접 AES 키를 생성하고 저장하고 권한을 부여하고 로테이션하고 유출 시 영향을 고려하고 변조 탐지까지 넣는다. 이것은 생각보다 무겁습니다. 게다가 대충 하면 대개 키를 같은 곳에 두는 것으로 끝나 버립니다.
DPAPI를 쓰면 「암호 키를 어떻게 만들고 어디에 둘 것인가」라는 문제를 앱 구현에서 떼어낼 수 있습니다.
그런 의미에서 DPAPI는 「암호화 알고리즘을 고르는 API」가 아니라 「키 관리를 OS에 위임하는 API」 로 보는 편이 본질에 가깝습니다.
flowchart TB
accTitle: DPAPI를 보는 본질적인 관점
accDescr: DPAPI는 암호화 알고리즘을 고르는 API가 아니라, 키를 어떻게 만들고 어디에 둘 것인가 하는 키 관리 문제를 OS에 위임하는 API로 보는 것이 본질에 가깝다는 것을 보여주는 그림.
v1["알고리즘을 고르는 API"] -.->|"이 관점이 아님"| dpv1["DPAPI"]
v2["키 관리를 OS에 위임하는 API"] -->|"이 관점이 본질에 가까움"| dpv1
dpv1 --> off1["키 생성·보관 문제를 구현에서 떼어낼 수 있음"]
그림 6: DPAPI를 키 관리의 위임처로 보면 앱에서 무엇을 떼어낼 수 있는지 알 수 있습니다.
4.2. 복호화 주체를 Windows 사용자 또는 컴퓨터에 묶을 수 있다
일반 데스크톱 앱이라면 CurrentUser를 고르면 되는 경우가 많습니다.
- 그 사용자가 로그온해 있을 것
- 그 사용자 맥락에서 처리가 돌아갈 것
을 전제로 복호화할 수 있습니다.
그 덕분에 암호문만 다른 PC로 복사해도 그대로는 쓰기 어렵다는 성질을 얻을 수 있습니다.
4.3. 변조 탐지까지 포함하기 쉽다
자체 암호화에서 흔히 있는 것은 “AES로 암호화했으니 끝”이라며 변조 탐지를 잊는 것입니다.
DPAPI는 암호화 데이터에 대한 무결성 보호도 갖고 있으므로 암호문이 함부로 수정됐을 때의 탐지까지 OS 쪽 구조에 태우기 쉽다는 실무상의 이점이 있습니다.
flowchart TB
accTitle: 변조 탐지의 차이
accDescr: 자체 암호화에서는 AES로 암호화했으니 끝이라며 변조 탐지를 잊기 쉽지만, DPAPI는 암호화 데이터에 무결성 보호를 갖추고 있어 암호문 수정 탐지까지 OS 쪽 구조에 태우기 쉽다는 것을 보여주는 그림.
diy1["자체 암호화"] -.-> forget1["변조 탐지를 잊기 쉬움"]
dpi1["DPAPI"] --> integ1["무결성 보호를 갖춤"]
integ1 --> det2["수정 탐지까지 OS 쪽에 얹힘"]
그림 7: 암호화뿐 아니라 변조 탐지까지 OS 쪽 구조에 태울 수 있는 것이 DPAPI의 이점입니다.
4.4. C# / .NET에서 그대로 쓸 수 있다
C#이라면 System.Security.Cryptography.ProtectedData를 그대로 쓸 수 있습니다.
불필요한 라이브러리를 추가하지 않아도 되는 점도 Windows 전용 앱에서는 상당히 도움이 됩니다.
5. DPAPI로 지킬 수 있는 것과 지킬 수 없는 것
여기는 확실히 나눠 두는 편이 안전합니다.
5.1. 지키기 쉬워지는 것
DPAPI는 적어도 다음과 같은 상황에서는 유효합니다.
- 설정 파일의 평문 유출
- 다른 PC로의 파일 반출
- 같은 PC의 다른 사용자로부터의 참조(
CurrentUser전제) - 백업이나 첨부 파일로서의 유출
- 개발·유지보수 현장에서의 「무심코 읽혀 버리는」 상태
5.2. 지킬 수 없거나 지키는 힘이 약한 것
한편 다음과 같은 상황에서는 과신하지 않는 것이 좋습니다.
- 같은 사용자 권한으로 실행되는 공격 코드
- 단말 자체의 완전한 침해
- 관리자 권한에서의 탈취
- 앱이 복호화한 뒤 메모리상의 평문
- 모든 클라이언트에 공통으로 배포되는 장기 비밀
마지막의 「전 클라이언트 공통 장기 비밀」은 특히 중요합니다.
예를 들어
- 모든 고객에게 같은 API 키를 심는다
- 모든 단말에서 같은 공유 패스워드를 갖는다
- 클라이언트만으로 완결되는 고정 복호화 키를 배포한다
같은 설계는 어느 1대에서 빠져나가는 순간 전체로 번지기 쉽습니다. 이유는 단순합니다. 어느 1대에서든 앱이 복호화할 수 있다면, 그 비밀은 꺼낼 수 있기 때문입니다.
DPAPI는 「그 저장 위치를 평문보다 낫게 만드는」 데는 유효하지만 애초에 클라이언트에 두어서는 안 되는 비밀을 정당화하는 것은 아닙니다.
flowchart TB
accTitle: 공통 장기 비밀이 번지는 구조
accDescr: 모든 클라이언트에 공통 장기 비밀을 배포하면 어느 1대에서든 앱이 복호화할 수 있는 이상 그 비밀을 꺼낼 수 있으므로, 1대에서 빠져나간 시점에 전체로 번지기 쉽다는 것을 보여주는 그림.
com1["전 클라이언트 공통 장기 비밀"] --> one4["어느 1대에서든 앱이 복호화 가능"]
one4 --> ext1["그 1대에서 비밀을 꺼낼 수 있음"]
ext1 --> all1["전체로 번짐"]
com1 -.-> np2["DPAPI로 저장해도 근본 해결이 안 됨"]
그림 8: 공통 비밀은 1대의 함락이 전체로 번지므로, 저장 방법이 아니라 두는 곳부터 다시 검토해야 합니다.
이런 종류의 비밀은 저장 방법을 궁리하기보다 다음 방향으로 옮기는 것이 본질적인 해법입니다.
- 서버 쪽에 둔다
- 클라이언트는 토큰만 가진다
- 사용자별 자격 증명으로 한다
- 기한부 토큰으로 한다
6. CurrentUser와 LocalMachine의 구분
여기는 꽤 중요합니다. 대충 고르면 의미가 달라집니다.
6.1. 기본은 CurrentUser
일반 Windows 데스크톱 앱에서는 먼저 CurrentUser를 기본으로 생각합니다.
적합한 예:
- WPF / WinForms / WinUI의 사용자용 데스크톱 앱
- 사용자별로 설정이나 자격 증명을 갖는 앱
%LocalAppData%나%AppData%아래에 설정을 두는 앱
이 경우 「그 Windows 사용자의 비밀」로서 다루기 쉬워집니다.
6.2. LocalMachine은 용도가 꽤 제한적이다
LocalMachine은 편리해 보이지만 일반 데스크톱 앱에서는 너무 넓습니다.
적합한 것은 예를 들어 다음과 같은 경우입니다.
- 신뢰된 단일 용도 머신 위의 Windows 서비스
- 그 머신 위의 특정 프로세스에서만 쓰는 기밀
- 로그온 사용자를 넘나들며 같은 단말에서 써야 하는 경우
다만 주의점은 무겁습니다.
- 그 PC 위에서 도는 프로세스로부터 폭넓게 복호화 가능
- 공용 단말, RDS, 배스천 단말, 여러 사용자가 있는 환경에서는 위험해지기 쉽다
- 「일단 모두가 쓸 수 있어서 편하니까」로 고르면 대개 나중에 곤란해진다
그리고 LocalMachine을 고르고 싶어지는 동기는 대개 다음 3가지입니다.
- 사용자를 전환해도 읽힌다
- 서비스에서도 읽힌다
- 돌아가기만 하면 편리하다
어느 쪽이든 「편함」이지 「지켜지고 있음」은 아닙니다.
일반 데스크톱 앱에서 LocalMachine을 고르면 그 PC 위의 다른 프로세스에도 복호화 가능성을 넓히는 셈이 되므로 의미가 상당히 달라집니다.
flowchart TB
accTitle: LocalMachine을 편해서 고르면 어떻게 되는가
accDescr: 사용자를 넘나들며 읽을 수 있다, 서비스에서도 읽을 수 있다 같은 편함으로 LocalMachine을 고르면 그 PC 위의 다른 프로세스에도 복호화 가능성이 넓어져, 지켜지고 있는 것과는 다른 상태가 된다는 것을 보여주는 그림.
ease1["사용자 넘나듦·서비스 대응으로 편함"] --> pick4["LocalMachine을 고름"]
pick4 --> wide1["PC 위의 다른 프로세스에도 복호화 가능성이 넓어짐"]
wide1 -.-> not1["편함이지 지켜지고 있음은 아님"]
그림 9: 편하다는 이유로 LocalMachine을 고르면 복호화할 수 있는 범위가 그 PC 전체로 넓어져 버립니다.
6.3. 망설여지면 이렇게 생각한다
- 일반 UI 앱 ->
CurrentUser - 정말로 머신 단위로 지키고 싶은 특수 케이스 ->
LocalMachine - 어떤 사용자라도 복호화할 수 있어야 하는데 단말에 다른 사용자도 있다 -> 대개 설계부터 다시 보는 편이 좋다
6.4. 서비스나 impersonation에서는 주의가 조금 더 필요하다
Windows 서비스나 impersonation이 얽히면 CurrentUser의 의미가 조금 무거워집니다.
- 실행 계정이 누구인가
- 그 계정의 프로필이 로드되어 있는가
- 복호화 시점이 어느 맥락인가
이 부분이 어긋나면 「암호화는 됐는데 복호화가 안 된다」가 되기 쉽습니다.
서비스 용도는 「일단 CurrentUser」로 끝나지 않는 경우가 있습니다.
impersonation의 경우 Microsoft Learn에도 명시된 전형적인 실패가 「Key not valid for use in specified state.」입니다. DPAPI는 키 데이터를 사용자 프로필에 보관하므로 프로필이 로드되어 있지 않으면 복호화할 수 없습니다. impersonate하기 전에 대상 사용자의 프로필을 로드해 두어야 합니다.
flowchart TB
accTitle: impersonation에서의 전형적인 실패
accDescr: DPAPI는 키 데이터를 사용자 프로필에 보관하므로 프로필이 로드되지 않은 상태로 impersonate해서 복호화하면 전형적인 오류가 나며, 미리 대상 사용자의 프로필을 로드해 두어야 한다는 것을 보여주는 그림.
imp1["로드하지 않고 impersonate"] --> ferr1["복호화가 오류로 실패"]
ld1["먼저 프로필을 로드"] --> okp1["impersonate 후에도 복호화 가능"]
imp1 -.-> whyp1["키는 프로필에 있음"]
그림 10: 키는 프로필 쪽에 있으므로 impersonate하기 전에 프로필 로드가 필요합니다.
6.5. 운영에서 「복호화할 수 없게 되는」 경우를 알아 둔다
암호화 그 자체보다 복호화할 수 없게 되는 사고 쪽이 실무에서는 더 아픕니다. DPAPI는 복호화할 수 있는 주체를 Windows 사용자 / 컴퓨터에 묶는 구조이므로, 그 연결이 끊기면 읽을 수 없게 됩니다.
미리 알아 두어야 할 것은 다음 5가지입니다.
| 경우 | 무슨 일이 일어나는가 | 대비하는 법 |
|---|---|---|
| 관리자에 의한 패스워드 재설정 | 사용자의 패스워드에 연결된 보호가 풀려 DPAPI로 보호한 데이터에 접근할 수 없게 되는 일이 있습니다. Microsoft의 지원 정보에도 관리자가 패스워드를 재설정한 뒤 DPAPI 데이터에 접근할 수 없게 되는 현상으로 기재되어 있습니다 | 비밀을 「다시 얻을 수 있는」 설계로 해 둔다. 복호화 실패 시 재입력으로 유도한다 |
| 프로필 재작성 | 새 프로필은 다른 키 자료를 가지므로 이전 암호문은 복호화할 수 없습니다 | 설정 파일의 버전을 두고, 복호화 실패를 비정상 종료로 처리하지 않는다 |
| 암호문만 다른 PC로 복사 | 복호화에 필요한 키 자료는 사용자 프로필 쪽에 있으므로 CurrentUser의 암호문만 가져가도 읽을 수 없습니다(이것은 3.1.에서 든 「강점」의 이면입니다) |
단말마다 다시 저장한다는 전제로 설계한다 |
| 로밍 프로필 | 이쪽은 읽을 수 있습니다. 프로필과 함께 키 자료도 이동하므로 Microsoft Learn도 「로밍 프로필을 가진 사용자는 네트워크상의 다른 컴퓨터에서 복호화할 수 있다」고 명시하고 있습니다. 앞 행과 같이 취급하면 이관 절차에서 불필요하게 자격 증명을 다시 만들게 됩니다 | 「다른 PC니까 읽을 수 없다」고 단정하지 않는다. 로밍 여부를 확인한 뒤 이관 절차를 정한다 |
| 서비스의 실행 계정 변경 | 보호했을 때와 복호화할 때 실행 계정이 바뀌면 CurrentUser로는 읽을 수 없습니다 |
계정 변경 시 다시 보호하는 절차를 운영에 넣는다 |
요컨대 ProtectedData.Unprotect는 실패할 수 있다는 전제로 코드를 작성합니다. 복호화에 실패하면 CryptographicException이 발생하므로 그것을 잡아서 재입력으로 유도합니다.
using System;
using System.Security.Cryptography;
using System.Text;
// protectedBase64: 설정 파일에서 읽은 암호문 (Base64)
// entropy: 보호할 때와 같은 값을 전달. null도 가능
static bool TryUnprotect(string protectedBase64, byte[]? entropy, out string plaintext)
{
plaintext = string.Empty;
try
{
byte[] plainBytes = ProtectedData.Unprotect(
Convert.FromBase64String(protectedBase64),
optionalEntropy: entropy,
scope: DataProtectionScope.CurrentUser);
plaintext = Encoding.UTF8.GetString(plainBytes);
return true;
}
catch (CryptographicException)
{
// 복호화할 수 없다 = 환경이 바뀌었을 가능성이 높다.
// 여기서 죽이지 말고, 호출 쪽에서 재입력 흐름으로 유도한다
return false;
}
catch (FormatException)
{
// Base64로서 손상된 경우
return false;
}
}
「암호화했는데 복호화가 안 된다」는 문의가 들어오는 것은 대개 이 표 중 하나입니다.
flowchart TB
accTitle: 복호화 실패를 전제로 한 코드의 흐름
accDescr: ProtectedData.Unprotect는 환경 변화로 실패할 수 있다는 전제로 코드를 작성하고, CryptographicException을 잡아서 비정상 종료시키지 않고 재입력 흐름으로 유도한다는 흐름을 보여주는 그림.
upx1["Unprotect를 시도"] -->|"성공"| use2["비밀을 사용"]
upx1 -->|"예외로 실패"| ctc1["예외를 잡아서 죽이지 않음"]
ctc1 --> rein1["재입력 흐름으로 유도"]
upx1 -.-> why2["연결이 끊기면 실패할 수 있음"]
그림 11: 복호화는 실패할 수 있다는 전제로 작성하고, 실패를 비정상 종료가 아니라 재입력으로 연결합니다.
7. 구현의 최소한의 지침
Windows 앱에서 「설정 파일의 평문을 그만둔다」 정도라면 설계를 그렇게까지 복잡하게 할 필요는 없습니다. 다만 놓치고 싶지 않은 포인트가 몇 가지 있습니다.
7.1. 비밀만 보호한다
설정 전체를 통째로 암호화하는 것보다 먼저 비밀 항목만을 보호하는 편이 다루기 쉽습니다.
예를 들어 다음처럼 나눕니다.
- 서버 URL
- 사용자명
- DB명
- 기능 플래그
는 평문 그대로 두어도 되는 경우가 많습니다.
한편
- 패스워드
- API 토큰
- 리프레시 토큰
- 공유 폴더 자격 증명
은 보호 대상입니다.
이렇게 나누면
- 설정 편집이 쉽다
- 차이 확인이 쉽다
- 어디가 비밀인지 명확하다
- 전체 운영이 단순하다
라는 효과를 얻습니다.
flowchart TB
accTitle: 비밀 항목만 보호하는 구분법
accDescr: 설정 전체를 통째로 암호화하는 것이 아니라 URL이나 사용자명 등은 평문 그대로 두고, 패스워드나 토큰 등 비밀 항목만 보호하면 편집하기 쉽고 어디가 비밀인지도 명확해진다는 것을 보여주는 그림.
cfg2["설정 파일"] -->|"평문 그대로"| pl1["URL·사용자명·플래그 등"]
cfg2 -->|"보호함"| sc1["패스워드·토큰 등"]
sc1 --> mr1["어디가 비밀인지 명확하고 운영도 단순"]
그림 12: 통째로 암호화하는 대신 비밀 항목만 보호하면 다루기 쉬움과 안전을 함께 얻을 수 있습니다.
7.2. 저장 위치는 per-user를 기본으로 한다
일반 데스크톱 앱이라면 저장 위치는 per-user 위치를 기본으로 합니다.
%LocalAppData%\Vendor\App\settings.json%AppData%\Vendor\App\settings.json
적어도 설치 폴더 아래나 공유되기 쉬운 위치에 대충 두지 않는 편이 좋습니다.
DPAPI로 보호하고 있어도 저장 위치의 ACL이 허술하면 「암호문은 읽힌다」「설정 구조는 보인다」「운영 실수는 일어난다」 라는 이야기가 됩니다. 방어는 한 겹이 아니라 여러 겹으로 쌓는 편이 효과적입니다.
7.3. optionalEntropy는 만능의 제2 키가 아니다
ProtectedData에는 optionalEntropy를 넘길 수 있습니다.
편리하지만 “이것을 바이너리에 심으면 안전해지는 마법의 제2 키”는 아닙니다.
- 같은 파일에 두면 비밀이 되지 않습니다
- 바이너리에 고정값으로 심어도 강한 비밀이라고 할 수 없습니다
- 그래도 용도 식별이나 오용 방지에는 도움이 됩니다
실무에서는
- 앱 이름
- 용도 이름
- 버전 식별자
를 고정 바이트열로 전달해서 「다른 용도의 암호문을 실수로 받아들이지 않도록」하는 데 쓰는 정도가 딱 알맞습니다.
flowchart TB
accTitle: optionalEntropy의 올바른 위치
accDescr: optionalEntropy는 바이너리에 심으면 안전해지는 마법의 제2 키가 아니라, 앱 이름이나 용도 이름을 고정 바이트열로 전달해 다른 용도의 암호문을 실수로 받아들이지 않기 위한 용도 식별에 쓰는 것이 딱 알맞다는 것을 보여주는 그림.
ent1["optionalEntropy"] -.->|"이런 기대는 하지 않음"| key2["마법의 제2 키"]
ent1 -->|"이 쓰임이 딱 알맞음"| tag1["앱 이름·용도 이름의 식별자"]
tag1 --> guard1["다른 용도의 암호문을 실수로 받아들이지 않음"]
그림 13: entropy는 비밀 키가 아니라 용도 식별과 오용 방지를 위한 태그로 사용합니다.
7.4. 암호문을 Git에 넣어도 된다, 는 아니다
여기도 은근히 중요합니다.
DPAPI의 암호문은 평문보다 훨씬 낫지만 그렇다고 설정 파일째로 리포지토리에 넣어도 된다는 것은 아닙니다.
이유는 단순합니다.
- 암호문은 오래 남는다
- 언젠가 같은 단말이나 같은 맥락이 재현될지도 모른다
- 파일에는 비밀 이외의 정보도 들어 있다
- 「보호되어 있으니 대충 다뤄도 된다」는 문화가 생긴다
는 것입니다.
「평문보다 낫다」와 「어디에 두어도 안전하다」 는 완전히 다른 이야기입니다.
flowchart TB
accTitle: 암호문이라도 Git에 넣지 않는 이유
accDescr: DPAPI의 암호문은 평문보다 낫지만, 리포지토리에 넣으면 오래 남고 비밀 이외의 정보도 포함되며 보호되고 있으니 대충 다뤄도 된다는 문화가 생기므로, 어디에 두어도 안전한 것과는 별개의 이야기라는 것을 보여주는 그림.
enc1["DPAPI의 암호문"] -->|"평문보다 나음"| bet2["사고에 강해짐"]
enc1 -.->|"그래도"| git1["리포지토리에는 넣지 않음"]
git1 --> rs1["오래 남음·다른 정보도 포함·문화가 느슨해짐"]
그림 14: 「평문보다 낫다」는 것이 두는 곳을 대충 해도 되는 이유는 아닙니다.
7.5. 로그에 출력하지 않는다
의외로 흔한 것이 복호화한 뒤 로그에 출력해서 전부 헛수고가 되는 패턴입니다.
- 접속 실패 시 접속 문자열을 통째로 출력한다
- API 401 시 Authorization 헤더를 남긴다
- 예외 메시지에 비밀을 섞는다
이런 것을 하면 설정 파일의 평문을 그만둬도 결국 로그가 평문 창고가 됩니다. 슬프지만 상당히 현실적인 실무입니다.
8. C# / .NET 최소 구현 예
8.1. 먼저 참조 추가가 필요합니다
ProtectedData는 BCL에 들어 있는 것처럼 보이지만 대상 프레임워크에 따라 「어디서 오는가」가 다릅니다. 여기서 걸려 넘어지면 ProtectedData라는 타입 이름을 해석할 수 없습니다.
| 대상 | 필요한 작업 | 제공처 |
|---|---|---|
| .NET Framework | 프로젝트에 System.Security 어셈블리 참조를 추가한다 |
System.Security.dll |
| .NET Core / .NET 5 이후(.NET 6 / 8 포함) | NuGet의 System.Security.Cryptography.ProtectedData 패키지를 추가한다 |
System.Security.Cryptography.ProtectedData.dll |
이 패키지는 .NET Core / .NET 5 이후의 어떤 공유 프레임워크에도 포함되어 있지 않습니다. net8.0-windows 같은 Windows용 대상이라도 명시적으로 참조가 필요합니다.
dotnet add package System.Security.Cryptography.ProtectedData
한 가지 더, 구현보다 먼저 알아 두고 싶은 것이 있습니다.
ProtectedData는 Windows 전용입니다. DPAPI에 의존하기 때문에 Windows 이외의 플랫폼에서 .NET으로 호출하면 PlatformNotSupportedException이 발생합니다. 크로스 플랫폼을 전제로 한 코드베이스라면 10.1.과 같이 처음부터 다른 설계로 갑니다.
flowchart TB
accTitle: ProtectedData를 쓰기 전 확인
accDescr: ProtectedData는 대상에 맞는 참조 추가가 필요하며 추가하지 않으면 타입 이름을 해석할 수 없고, 또 Windows 전용이므로 Windows 이외에서 호출하면 예외가 된다는, 구현 전에 확인해야 할 2가지를 보여주는 그림.
use3["ProtectedData를 쓰고 싶음"] --> ref1["대상에 맞는 참조를 추가"]
ref1 -.->|"추가하지 않으면"| unres1["타입 이름을 해석할 수 없음"]
use3 --> winonly1["Windows 전용이라고 이해해 둠"]
winonly1 -.->|"Windows 이외에서 호출하면"| pnse1["실행 시 예외가 됨"]
그림 15: 구현 전에 참조 추가와 Windows 전용이라는 2가지 전제를 짚어 둡니다.
8.2. 최소 구현
다음은 설정 파일에 저장할 문자열을 CurrentUser로 보호하는 최소 예제입니다.
용도 식별을 위해 고정 optionalEntropy를 넣고 있지만, 이것을 비밀 키라고 생각하지 마세요.
using System;
using System.Security.Cryptography;
using System.Text;
public static class DpapiSecretProtector
{
// 용도 식별용. 제2의 비밀 키가 아님.
private static readonly byte[] Entropy =
Encoding.UTF8.GetBytes("ComComponent:DesktopApp:SettingsSecret:v1");
public static string ProtectToBase64(string plaintext)
{
ArgumentNullException.ThrowIfNull(plaintext);
byte[] plainBytes = Encoding.UTF8.GetBytes(plaintext);
byte[] protectedBytes = Array.Empty<byte>();
try
{
protectedBytes = ProtectedData.Protect(
plainBytes,
optionalEntropy: Entropy,
scope: DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
finally
{
Array.Clear(plainBytes, 0, plainBytes.Length);
if (protectedBytes.Length > 0)
{
Array.Clear(protectedBytes, 0, protectedBytes.Length);
}
}
}
public static string UnprotectFromBase64(string protectedBase64)
{
ArgumentNullException.ThrowIfNull(protectedBase64);
byte[] protectedBytes = Convert.FromBase64String(protectedBase64);
byte[] plainBytes = Array.Empty<byte>();
try
{
plainBytes = ProtectedData.Unprotect(
protectedBytes,
optionalEntropy: Entropy,
scope: DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(plainBytes);
}
finally
{
Array.Clear(protectedBytes, 0, protectedBytes.Length);
if (plainBytes.Length > 0)
{
Array.Clear(plainBytes, 0, plainBytes.Length);
}
}
}
}
사용법은 단순합니다.
string protectedPassword = DpapiSecretProtector.ProtectToBase64(password);
// JSON 등에 저장
// settings.DbPasswordProtected = protectedPassword;
string password = DpapiSecretProtector.UnprotectFromBase64(settings.DbPasswordProtected);
설정 파일은 예를 들어 이런 형태로 만들 수 있습니다.
{
"ApiBaseUrl": "https://api.example.com/",
"UserName": "app-user",
"PasswordProtected": "AQAAANCMnd8BFdERjHoAwE..."
}
이 형태의 좋은 점은
- URL이나 사용자명은 평범하게 편집할 수 있다
- 패스워드만 보호할 수 있다
- 설정 구조가 보기 쉽다
- 평문 그대로 두는 것보다 사고가 덜 난다
는 것입니다.
9. 그래도 위험한 설계
DPAPI를 쓰고 있어도 다음 설계는 여전히 위험합니다.
9.1. 복호화한 값을 오래 갖고 다닌다
복호화한 값을
- 로그에 출력한다
- 화면에 표시한다
- 예외에 포함시킨다
- 장수명 객체에 계속 실어 둔다
는 것은 피하고 싶습니다.
「저장 시에는 암호화」와 「사용 중에도 안전」 은 별개의 문제입니다.
9.2. 전체 설치에 공통 비밀을 갖게 한다
전 사용자에게 같은 API 키를 갖게 하는 설계는 DPAPI로 저장해도 근본 해결이 되지 않습니다. 이유와 대신 어디로 옮길지는 5.2.에 정리되어 있습니다.
9.3. LocalMachine을 「편해서」 고른다
이것도 정말 흔합니다. 다만 「편함」이지 「지켜지고 있음」은 아닙니다. 고르고 싶어지는 동기와 그때 무엇이 넓어지는지는 6.2.와 같습니다. 판단이 망설여지면 6.3.의 3줄을 보십시오.
9.4. 자체 암호화를 더해서 안심한다
DPAPI 대신
- AES 키를 소스 코드에 심는다
- AES 키를 설정 파일의 다른 항목에 둔다
- 「살짝 난독화한 문자열」을 키로 취급한다
같은 구현을 넣는 것은 대개 효과가 미미합니다.
“평문이 아니다”와 “안전하다” 사이에는 상당히 큰 도랑이 있습니다.
flowchart TB
accTitle: 자체 암호화로 안심하는 위험함
accDescr: AES 키를 소스 코드나 설정 파일의 다른 항목에 두거나 난독화한 문자열을 키로 취급하는 자체 암호화는 효과가 미미하며, 평문이 아닌 것과 안전한 것 사이에는 큰 도랑이 있다는 것을 보여주는 그림.
hm1["키를 코드나 설정에 심는 자체 암호화"] --> npl1["평문이 아닌 상태"]
npl1 -.->|"사이에 큰 도랑"| sfe1["안전한 상태"]
hm1 -.-> thin1["효과는 대개 미미함"]
그림 16: 키를 같은 곳에 두는 자체 암호화는 「평문이 아니다」에 그칠 뿐 「안전」에는 이르지 못합니다.
10. DPAPI로 충분하지 않은 경우
DPAPI는 편리하지만 만능은 아닙니다. 다음과 같은 경우에는 다른 선택지를 생각하는 편이 좋습니다.
10.1. Windows 이외에서도 돌리고 싶다
DPAPI / ProtectedData는 Windows 대상입니다.
크로스 플랫폼 앱이라면 그 전제로는 구성할 수 없습니다.
10.2. 여러 머신·여러 사용자에서 같은 비밀을 다루고 싶다
같은 암호문을 여러 PC에서 복호화하고 싶다, 여러 사용자가 함께 쓰고 싶다는 요건은 「그 단말·그 사용자에 묶는」 DPAPI의 특기 분야에서 벗어납니다.
이 경우에는
- 서버 쪽의 비밀 관리
- 자격 증명 기반
- Windows 인증 / 통합 인증
- 앱용 자격 증명 저장소
등 요건에 맞는 다른 설계를 생각해야 합니다.
10.3. 저장 대상이 사용자 자격 증명 그 자체인 경우
저장하고 싶은 것이 명확히
- 사용자명
- 패스워드
의 조합이라면, DPAPI로 자신의 파일에 쓰는 것보다 Windows가 제공하는 자격 증명 저장소 쪽이 더 자연스럽습니다. 여기는 실무에서 자주 망설이는 부분이므로 비교표를 둡니다.
| 관점 | DPAPI (ProtectedData) |
Credential Locker (PasswordVault) |
Credential Manager (CredWrite / CredRead) |
|---|---|---|---|
| 저장 위치 | 직접 정한 파일(암호문을 어떻게 둘지는 앱 나름) | Windows가 관리하는 자격 증명 저장소 | Windows가 관리하는 자격 증명 저장소 |
| 저장할 수 있는 것 | 임의의 바이트열(접속 문자열, 토큰, 설정의 일부라도 무방) | 사용자명 + 패스워드의 조합 | 자격 증명(종류별 구조체) |
| API | System.Security.Cryptography |
WinRT의 Windows.Security.Credentials |
Win32 (wincred.h / Advapi32.dll) |
| 데스크톱 앱에서 쓸 수 있는가 | 그대로 쓸 수 있음 | WinUI뿐 아니라 WPF / WinForms에서도 쓸 수 있음(WinRT API 호출 설정은 필요) | 그대로 쓸 수 있음 |
| 동기화 | 없음 | Microsoft 계정으로 단말 간 로밍됨 | 없음(로컬 사용자 자격 증명 세트) |
| 제한 | 실질적으로 없음 | 앱당 20건까지. 대용량 데이터용은 아님 | 현재 토큰의 로그온 세션에 종속 |
| 관리 주체 | 앱(저장 위치도 ACL도 직접 정함) | OS(저장 위치를 설계하지 않아도 됨) | OS(제어판의 자격 증명 관리자에서 관리 가능) |
구분해서 쓰는 기준은 다음과 같습니다.
- 저장 대상이 사용자명 + 패스워드의 조합이고 건수도 적다 -> Credential Locker / Credential Manager가 제1 후보입니다. 저장 위치 설계도 ACL도 직접 갖지 않아도 됩니다
- 저장 대상이 「사용자명 + 패스워드」의 형태가 아니다 -> 접속 문자열, API 토큰, 리프레시 토큰, 설정 파일의 일부 같은 것은 DPAPI 쪽이 자연스럽습니다. 이 글이 다루는 것은 이쪽입니다
- 단말 간 인계가 필요하다 -> Credential Locker의 로밍이 효과를 발휘합니다. DPAPI의
CurrentUser는 반대로 「인계되지 않는다」는 것이 장점이므로 여기서는 목적이 정반대입니다 - 건수가 많다 / 크기가 크다 -> Credential Locker의 20건 제한에 걸립니다. DPAPI로 자체 파일에 맡깁니다
또한 자격 증명 저장소도 내부적으로는 OS의 보호 기구를 타고 있으므로 「DPAPI보다 안전하다」「DPAPI가 뒤떨어진다」는 서열이 아닙니다. 저장하고 싶은 것의 형태와 로밍 필요 여부로 고르는 것이 실무적입니다.
그리고 어느 쪽을 고르든 신규 앱이라면 Windows Hello / 패스키 같은 패스워드리스를 먼저 검토할 가치가 있습니다. 애초에 장기 패스워드를 갖지 않아도 된다면 그것이 가장 강력합니다.
이 글의 중심은 어디까지나 「Windows 클라이언트에서 설정 파일 평문을 그만두기」위한 DPAPI의 실무 라인입니다.
11. 실무에서의 추천 우선순위
마지막으로 실무에서 망설여지면 이 순서로 생각하면 정리하기 쉽습니다. 위에서부터 순서대로 검토하고, 조건을 만족하지 않을 때만 아래로 내려갑니다.
flowchart TD
Q1{"장기 비밀을 단말에<br/>두지 않아도 되는가"}
Q1 -->|"된다"| A1["우선 1: 갖지 않는다<br/>(Windows 인증·단명 토큰)"]
Q1 -->|"안 된다"| Q2{"비밀을 이용자별로<br/>나눌 수 있는가"}
Q2 -->|"나눌 수 없다"| A2["공통 키가 되어 있지 않은지<br/>설계부터 다시 본다"]
Q2 -->|"나눌 수 있다"| QF{"저장하고 싶은 것의 형태는<br/>사용자명 + 패스워드의 조합인가(10.3절)"}
QF -->|"그 조합이고 건수도 적다"| QR1{"단말 간 인계가 필요한가"}
QR1 -->|"필요하다"| QA{"Microsoft 계정으로<br/>동기화되는 단말인가(10.3절)"}
QA -->|"그렇다"| CL2["Credential Locker의<br/>로밍을 사용한다"]
QA -->|"도메인 / 로컬 계정"| SRV
QR1 -->|"필요 없다"| CL["Credential Locker /<br/>Credential Manager"]
QF -->|"토큰 등 다른 형태"| QR2{"단말 간 인계가 필요한가"}
QR2 -->|"필요하다"| SRV["DPAPI로는 인계할 수 없음.<br/>서버 쪽에서 관리한다(10.2절)"]
QR2 -->|"필요 없다"| Q3{"같은 비밀을 복호화하는<br/>계정은 몇 개인가"}
Q3 -->|"하나면 된다(이용자 본인,<br/>또는 전용 서비스 계정)"| A3["우선 3: DPAPI + CurrentUser<br/>무인 실행이라면 프로필<br/>로드를 확인한다(6.4절)"]
Q3 -->|"여러 계정에서<br/>복호화해야 한다"| Q4{"다른 이용자는 들어오지 않는다고<br/>단언할 수 있는가"}
Q4 -->|"단언할 수 있다"| A4["우선 4: DPAPI + LocalMachine<br/>예외 취급으로 근거를 남긴다"]
Q4 -->|"단언할 수 없다"| A5["다른 사용자도 복호화할 수 있게 됨.<br/>인증 방식 쪽을 다시 본다"]
그림 17: 저장 방식의 선정 순서입니다. 비밀의 형태와 로밍 필요 여부를 먼저 본 다음 DPAPI로 들어갑니다. LocalMachine은 「편해서」가 아니라 다른 선택지가 성립하지 않을 때의 예외로 고릅니다.
우선 1: 애초에 갖지 않는다
- Windows 인증
- 통합 인증
- 대화형 로그인
- 서버 쪽에서 비밀 보유
- 단명 토큰
우선 2: 사용자별 비밀에 맞춘다
- 공통 비밀보다 per-user
- 장기 고정 자격 증명보다 갱신 가능한 토큰
- 전 클라이언트 공통 키를 피한다
우선 3: 로컬 저장이 필요하면 DPAPI
- 기본은
CurrentUser - 저장 위치는 per-user
- 비밀 항목만 보호
- 로그에 출력하지 않는다
우선 4: LocalMachine은 예외 취급
- 정말로 머신 단위여야 하는가
- 그 단말에 다른 사용자가 들어오지 않는가
- 서비스 설계로서 타당한가
12. 정리
Windows 앱에서 설정 파일에 기밀 정보를 저장해야 할 때 평문 그대로 두는 것은 피하고 싶습니다.
그리고
「어차피 키는 어딘가에 저장되는 거니까 같은 것 아닌가?」
라는 의문에는 이렇게 답하는 것이 실무적입니다.
- 자체 암호화로 키를 같은 곳에 둔다면 꽤 같습니다
- DPAPI는 같지 않습니다
- 키 관리를 OS에 맡길 수 있습니다
- 복호화 주체를 Windows 사용자 / 컴퓨터에 묶을 수 있습니다
- 파일 단체의 유출을 그대로 비밀 유출로 만들지 않아도 됩니다
- 다만
- 같은 사용자 권한으로 도는 코드
- 완전히 침해된 단말
- 클라이언트에 두어서는 안 되는 장기 공통 비밀
까지는 해결하지 못합니다
요컨대 DPAPI는 만능의 성벽이 아닙니다. 그러나 설정 파일 평문이라는 훤히 뚫린 유리창을, 최소한 제대로 된 창으로 바꾸는 정도의 효과는 있습니다.
Windows 클라이언트의 실무에서는 이 차이가 상당히 큽니다. 먼저 여기를 놓치지 않는 것부터 시작하는 것이 가장 현실적입니다.
flowchart TB
accTitle: DPAPI의 현실적인 자리매김
accDescr: DPAPI는 만능의 성벽은 아니지만 설정 파일 평문이라는 훤히 뚫린 유리창을 최소한 제대로 된 창으로 바꾸는 효과가 있으며, 먼저 거기서부터 시작하는 것이 현실적이라는 것을 보여주는 그림.
glass1["평문 설정 = 훤히 뚫린 유리창"] -->|"DPAPI로 바꿈"| win2["최소한 제대로 된 창"]
win2 -.-> notwall1["만능의 성벽은 아님"]
win2 --> first1["먼저 여기서부터 시작하는 것이 현실적"]
그림 18: DPAPI는 성벽이 아니라 창문 교체이지만, 실무에서는 그 차이가 가장 크게 작용합니다.
13. 참고 자료
- 이전 글: /ko/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Microsoft Learn:
CryptProtectDatahttps://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata - Microsoft Learn:
ProtectedDatahttps://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata?view=windowsdesktop-10.0 - Microsoft Learn:
DataProtectionScopehttps://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotectionscope?view=windowsdesktop-10.0 - Microsoft Learn: How to: Use Data Protection https://learn.microsoft.com/en-us/dotnet/standard/security/how-to-use-data-protection
- Microsoft Learn: Credential locker for Windows apps https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker
- Microsoft Learn:
CredWrite(Windows Credential Manager의 Win32 API) https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew - NuGet: System.Security.Cryptography.ProtectedData https://www.nuget.org/packages/System.Security.Cryptography.ProtectedData
- Microsoft 지원: 관리자가 패스워드를 재설정한 뒤 DPAPI 데이터에 접근할 수 없게 되는 현상 https://support.microsoft.com/en-us/topic/you-cannot-access-dpapi-data-after-an-administrator-resets-your-password-on-a-windows-server-2012-based-domain-controller-4aa890cd-12b5-fe5c-9e68-06244e70673d
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 앱에서 「관리자 권한이 필요한 처리만」을 분리하는 구체적인 방법
Windows 앱에서 UI는 asInvoker로 두고 관리자 처리만 helper EXE로 분리하는 broker 설계를 runas 기동, 명명 파이프 ACL, 클라이언트 PID 검증, 고정 operation allowlist까지 .NET 8 코드로...
Windows 앱 개발에서 최저한의 보안을 지키기 위한 체크리스트
WPF / WinForms / WinUI / C++ / C# 업무 앱에서 권한, 서명, 업데이트, 비밀 정보, HTTPS, 입력 검증, DLL 읽기, 로그의 기본을 체크리스트 형태로 정리합니다.
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
Windows I/O의 심층(제6회・최종회) ── 필터 드라이버와 미니필터: Procmon과 백신 검사가 I/O에 개입할 수 있는 이유
Windows의 필터 드라이버와 미니필터를 그림으로 설명하는 연재의 최종회입니다. 필터 매니저와 알티튜드, pre/post 콜백, Procmon과 백신이 모든 I/O를 검사할 수 있는 구조, '그 환경에서만 느리다'의 조사 절차까지 정리합니다.
PowerShell에서 자격 증명을 안전하게 다루기 ── 평문 비밀번호를 스크립트에서 추방한다
PowerShell 스크립트의 평문 비밀번호를 안전한 보관으로 옮기는 절차를 정리합니다. SecureString의 실체와 한계, Export-Clixml에 의한 DPAPI 저장의 구조, SecretManagement/SecretStore를 언제 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- DPAPI란 무엇입니까?
- DPAPI(Data Protection API)는 Windows가 제공하는 데이터 보호 메커니즘으로, 암호 키 관리를 OS에 위임하고 복호화할 수 있는 주체를 그 Windows 사용자 또는 그 컴퓨터에 묶습니다. C# / .NET에서는 System.Security.Cryptography.ProtectedData 클래스를 통해 별도 라이브러리를 추가하지 않고 쓸 수 있습니다. 암호화 알고리즘을 고르는 API라기보다는 키 관리를 OS에 위임하는 API로 보는 편이 본질에 가깝습니다. 설정 파일에 저장하는 패스워드나 API 토큰을 평문 그대로 두지 않기 위한 현실적인 선택지입니다.
- 키는 어딘가에 저장되는 거니까 평문이든 DPAPI든 같은 것 아닌가요?
- 같지 않습니다. 자체 AES 암호화로 키를 같은 앱이나 설정 파일에 두는 경우는 꽤 평문에 가깝지만, DPAPI는 키 관리를 OS에 맡기고 복호화할 수 있는 주체를 Windows 사용자 또는 컴퓨터에 묶습니다. 그 결과 설정 파일 단체의 유출, 다른 PC로의 반출, 오송, 백업 유출, 리포지토리 혼입 같은 사고에 대한 내성이 크게 달라집니다. DPAPI는 「파일을 읽을 수 있다」는 것과 「비밀을 쓸 수 있다」는 것을 분리할 수 있다는 점이 평문과의 결정적인 차이입니다.
- DPAPI로 지킬 수 없는 것은 무엇인가요?
- 같은 사용자 권한으로 실행되는 코드는 그 사용자가 복호화할 수 있는 것을 기본적으로 복호화할 수 있습니다. 그래서 단말이 악성코드에 침해된 상황이나 관리자 권한에서 탈취된 상황, 복호화 후 메모리상의 평문까지는 지키지 못합니다. 또 모든 클라이언트에 공통으로 배포하는 장기 비밀은 어느 1대에서 빠지는 순간 전체로 번지기 쉬우므로, DPAPI로 저장해도 근본 해결이 되지 않습니다. DPAPI가 효과를 발휘하는 것은 주로 파일 유출·잘못된 배치·오프라인 반출·다른 사용자로부터의 참조 쪽입니다.
- DataProtectionScope의 CurrentUser와 LocalMachine 중 어느 것을 써야 합니까?
- 일반 Windows 데스크톱 앱에서는 CurrentUser가 기본입니다. 그 사용자의 비밀로 다룰 수 있고, 암호문만 다른 PC로 복사해도 그대로는 쓰기 어려운 성질을 얻습니다. LocalMachine은 그 PC 위에서 도는 프로세스로부터 폭넓게 복호화할 수 있으므로 공용 단말이나 여러 사용자가 있는 환경에서는 위험해지기 쉽고, 신뢰된 단일 용도 머신 위의 Windows 서비스 등 용도가 꽤 제한적입니다. 「모두가 쓸 수 있어서 편하니까」로 LocalMachine을 고르면 대개 나중에 곤란해집니다.