암호문을 제자리에 묶어 두기 — AES-256-GCM 과 AAD, 키 버전
Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 열 번째 글입니다. 이번 요구는 외부 서비스의 비밀 키를 데이터베이스에 보관하되, 응답이나 로그로 새지 않고, 다른 자리로 옮겨져도 쓸 수 없게 해야 한다는 것입니다.
여러 가게가 각자 자기 계정으로 외부 서비스에 연결하는 기능을 생각해 보겠습니다. 서버는 가게마다 그 서비스의 비밀 키를 저장해 두었다가 대신 요청을 보냅니다. 이 키가 새면 그 가게의 계정을 남이 쓸 수 있게 됩니다.
암호화는 두 가지를 지켜야 합니다. 못 읽게, 그리고 못 바꾸게
저장할 때 암호화를 한다고 하면 보통 "남이 못 읽게 한다"만 떠올립니다. 그런데 하나가 더 필요합니다. 누군가 암호문을 조작하거나 다른 자리로 옮겼을 때, 그것을 알아차려야 합니다.
이 두 가지를 함께 해 주는 방식을 인증 암호(AEAD)라고 부릅니다. 내용을 암호화하면서 함께 "봉인 스티커"에 해당하는 인증 태그를 만들고, 풀 때 그 스티커가 온전한지 확인합니다. 대표적인 것이 AES-256-GCM 입니다. AES-256 은 256비트 키를 쓰는 암호 알고리즘이고, GCM 은 그 위에 인증 태그를 붙이는 운용 방식입니다.
봉투에는 세 가지를 함께 넣었습니다
저장하는 값은 편지 봉투처럼 만들었습니다. 봉투 하나에 풀 때 필요한 것을 모두 담습니다.
v1 . [ IV 12바이트 | 인증 태그 16바이트 | 암호문 ]
│ └─ 이 세 덩어리를 이어 붙여 base64 로 적는다
└─ 어느 키로 잠갔는지(키 버전)
- IV 는 암호화할 때마다 새로 뽑는 무작위 값입니다. 같은 키로 같은 내용을 잠가도 매번 다른 암호문이 나오게 합니다. NIST 의 GCM 권고(SP 800-38D)는 IV 길이를 96비트, 곧 12바이트로 두기를 권합니다.
- 인증 태그는 봉인 스티커입니다. 암호문이 한 바이트만 달라도 풀 때 확인에 실패합니다.
- 키 버전은 어느 열쇠로 잠갔는지 적어 둔 꼬리표입니다. 열쇠를 바꾸는 날을 위해 둡니다.
AAD 는 암호문을 제자리에 묶어 둡니다
AEAD 에는 잘 알려지지 않은 입력이 하나 더 있습니다. AAD(추가 인증 데이터)입니다. NIST 문서의 정의대로 "인증은 하되 암호화하지는 않는 데이터"입니다. 암호문 안에 들어가지는 않지만, 풀 때 똑같은 AAD 를 주지 않으면 인증에 실패합니다.
저는 AAD 에 "어느 계정의 어느 칸인가"를 넣었습니다. 예를 들어 41번 계정의 비밀 키라면 account:41:apiSecret 입니다. 그러면 누군가 데이터베이스에서 41번 계정의 암호문을 복사해 42번 계정 칸에 붙여 넣어도, 42번의 맥락으로는 풀리지 않습니다.
import crypto from 'node:crypto';
function seal(plaintext: string, aad: string, key: Buffer, version: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
cipher.setAAD(Buffer.from(aad));
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return `${version}.${Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64')}`;
}
function open(envelope: string, aad: string, keys: Record<string, Buffer>): string {
const [version, body] = envelope.split('.');
const raw = Buffer.from(body, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', keys[version], raw.subarray(0, 12));
decipher.setAAD(Buffer.from(aad));
decipher.setAuthTag(raw.subarray(12, 28));
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
}
세 가지 상황을 직접 돌려 봤습니다.
행 A 를 행 A 맥락으로 열기 -> 성공: secret-for-A
행 A 암호문을 행 B 에 복사해 열기 -> 실패: Unsupported state or unable to authenticate data
암호문을 한 바이트 바꿔서 열기 -> 실패: Unsupported state or unable to authenticate data
Node.js 공식 문서도 인증 태그가 없거나 암호문이 조작됐으면 마지막 단계(decipher.final())에서 에러를 던지고, 그 암호문은 버려야 한다고 적고 있습니다. 조용히 틀린 값을 돌려주지 않고 멈춘다는 점이 중요했습니다.
열쇠를 바꾸는 날을 위해 봉투에 버전을 적어 둡니다
암호화 열쇠도 언젠가 바꿔야 합니다. 새어 나갔을 가능성이 생겼을 수도 있고, 정해진 주기가 돌아왔을 수도 있습니다. 이때 저장된 값을 한꺼번에 다시 잠그는 동안에도 서비스는 계속 돌아야 합니다.
새로 잠근 값의 앞머리 = v2. 옛 값의 앞머리 = v1.
v1 봉투를 그대로 열기 -> 성공
다시 잠근 뒤 행 A 의 앞머리 = v2. → 이제 v1 열쇠를 폐기할 수 있다
봉투에 적힌 버전 덕분에, 새 값은 새 열쇠로 잠그고 옛 값은 옛 열쇠로 여는 기간을 둘 수 있습니다. 옛 값을 모두 열어 새 열쇠로 다시 잠그고 나면, 그때 옛 열쇠를 버립니다.
가장 흔한 누출은 암호가 아니라 응답에서 일어납니다
암호화를 아무리 잘해도, 풀어 둔 값을 응답에 실어 보내면 소용이 없습니다. 실험하면서 가장 조심스러웠던 것은 오히려 이쪽이었습니다. 두 가지 방식을 비교했습니다.
펼친 뒤 비밀 칸만 지우기:
{"id":42,"name":"가게 B","apiSecretBackup":"v1.V2L2zPa..."} ← 새로 생긴 칸이 그대로 샌다
필요한 칸만 골라 담기:
{"id":42,"name":"가게 B","hasApiSecret":true}
첫 번째 방식은 데이터를 통째로 펼친 뒤 알고 있는 비밀 칸만 지웁니다. 나중에 누군가 비슷한 이름의 칸을 하나 더 만들면, 그 칸은 지울 목록에 없어서 그대로 나갑니다. 두 번째 방식은 보낼 칸을 하나하나 골라 담습니다. 새 칸이 생겨도 누군가 일부러 고르기 전에는 나가지 않습니다.
그래서 규칙을 하나 세웠습니다. 비밀 값은 응답 타입에 아예 칸을 두지 않습니다. 대신 "키가 등록돼 있는지"만 참·거짓으로 알려 줍니다. 칸이 있으면 언젠가는 실린다는 생각이 들었습니다.
정리하며
- 못 읽게와 못 바꾸게를 함께 지킵니다. AES-256-GCM 같은 인증 암호를 쓰고, 인증에 실패하면 멈춥니다.
- AAD 로 암호문을 제자리에 묶습니다. 어느 계정의 어느 칸인지를 AAD 에 넣어, 옮겨 붙인 암호문이 풀리지 않게 합니다.
- 봉투에 키 버전을 적고, 응답에는 비밀 칸을 두지 않습니다. 열쇠 교체를 대비하고, 누출은 구조로 막습니다.
암호화 알고리즘은 이미 잘 만들어져 있어서, 제가 할 일은 대부분 "어디에 무엇을 넣고 무엇을 빼느냐"였습니다. 결국 보안은 알고리즘보다 그 주변의 약속에서 갈린다는 생각이 들었습니다.
실험 환경: Node.js v26.5.0(node:crypto) · 2026-09-29. 키와 비밀 값은 실험을 위해 매번 새로 만든 무작위 값이며, 실제 서비스의 값이 아닙니다.
관련 글
노트북을 잃어버리면 무엇이 사라지는가 (2) — 전부 무료로 짠 3계층 백업 아키텍처
계층마다 단일 실패점이 다른 곳에 있도록 설계한 3계층 시크릿 백업입니다. 부트스트랩 열쇠는 무료 클라우드 패스워드 매니저에, 본체는 SOPS와 age로 암호화해 private git에, 운영 환경변수는 스냅샷으로. 전부 무료 티어로 짰습니다.
같은 요청이 두 번 와도 한 번만 — 멱등 키(Idempotency-Key) 구현하기
응답이 사라지면 재시도는 피할 수 없습니다. 그래서 같은 요청이 두 번 와도 한 번만 처리하고, 두 번째에도 처음과 같은 응답을 돌려주는 멱등 키를 MySQL 로 직접 구현했습니다. 다섯 요청이 동시에 와도 확정은 한 번이었던 실험을 함께 정리했습니다.
쿠키 없이 24시간 고유 방문자를 추정하는 방법 (3편)
쿠키도 localStorage도 쓰지 않고 하루 안에서만 같은 독자를 알아보는 방법을 정리했습니다. Plausible의 daily salt 해시를 HMAC 기반 deterministic 방식으로 재구성하면서, 서버 재시작 안전성과 cross-day 추적 불가능성을 어떻게 확보했는지 기록했습니다.