설계 결정 기록¶
English | 한국어
format v1 뒤에 있는 선택들과, 기각된 대안이 왜 기각됐는지의 기록입니다. 문제 정의는 philosophy.ko.md를 먼저 읽으세요. 규범 문서는 SPEC.md입니다.
1. 카운터 + 순열이지, 랜덤 + 재시도가 아니다¶
코드 체계는 "지금까지 발급된 것들"에서 코드로 가는 전단사 함수여야 하고, 아니면 충돌이 가능합니다. 랜덤 생성은 전단사를 확률적으로 흉내 내고 그 대가를 영원히 치릅니다(유일성 조회, 재시도 루프, 커지는 실패율 — 생일 경계에 의해 첫 중복 기대 시점은 ~√전체공간). DB 시퀀스는 이미 그 자체로 충돌 없는 열거자입니다. 여기에 키 기반 순열을 합성하면 그 성질은 보존되고 눈에 보이는 순서만 파괴됩니다. 유일성이 확률이 아니라 정리(theorem)가 됩니다.
2. 순열로 FF1을 고른 이유¶
[0, r^d) 위의 순열 후보들:
| 후보 | 판정 |
|---|---|
| full-cycle LCG (Hull–Dobell) | 키가 없음 — 값 하나와 파라미터 추정으로 나머지가 전부 예측됨. 하위 자릿수가 짧은 주기로 순환 |
| Quadratic residue (Preshing) | 사실상 키 없음("키"가 작은 오프셋 수준), 도메인 제약(p ≡ 3 mod 4), 섞임이 약함 |
| 자작 Feistel + cycle walking | 접근은 건전하나 라운드 수/PRF를 손으로 정하면 미묘한 상호운용·보안 버그를 부름 |
| Fisher–Yates 테이블 | 진짜 랜덤 순열이지만 셔플 테이블 전체를 저장하고 서비스 간 동기화해야 함 |
| FF1 (NIST SP 800-38G) | 표준화됨, AES 기반, tweak 지원, 임의 radix·길이에서 네이티브로 동작, 공식 테스트 벡터 존재 |
FF1이 이기는 근거 세 가지: 공표된 표준이고 샘플 벡터가 있어 일곱 개 구현이 동일함을 증명할 수 있고, AES로 키가 걸려 있어 예측 불가능성이 AES로 환원되며, radix/길이 유연성이 dealcode의 알파벳/스테이징 모델에 cycle-walking 없이 1:1로 대응됩니다.
FF3-1은 기각: FF1의 보안 이력이 더 튼튼하고(FF3는 2017년 Durak–Vaudenay 공격 후 개정이 필요했음), FF1의 자유 길이 tweak이 도메인 문자열 설계에 맞습니다. FF3-1은 tweak이 56비트로 고정입니다.
의도적으로 수용한 한계: 작은 도메인 FPE에는 AES 보안 수준보다 훨씬 낮은 구별 공격이 존재합니다. dealcode의 위협 모델은 "외부인이 시퀀스를 읽거나 열거하지 못하게 한다"이지 "비밀을 암호화한다"가 아니며, 그 모델에는 FF1이 넉넉히 충분합니다. 더 강한 무언가를 써도 작은 코드 공간의 온라인 열거는 남습니다(그건 암호가 아니라 rate limit으로 막는 것).
3. 길이 스테이징 (코드가 자랄 수 있는 이유)¶
고정 길이 체계는 첫날에 평생 물량을 찍어야 합니다. 짧게 잡으면 나중에
고통스러운 마이그레이션, 길게 잡으면 모든 코드가 영원히 군살을 답니다.
스테이징은 그 추측을 제거합니다. 스테이지 d는 카운터 [r^(d−1), r^d)를
덮고(첫 스테이지는 0부터) 코드는 현재 물량이 허용하는 만큼만 짧으며
길이가 다르면 충돌 자체가 불가능합니다. 스테이지 용량이 r^d 전체가 아니라
"그 자릿수의 나머지 범위"인 덕분에 카운터→길이 함수가 자명해지고(d = 자릿수,
최소 min_length) 빈틈이 없습니다.
대안 — 하나의 거대한 고정 도메인 + cycle walking — 은 첫 발급부터 최대 길이 코드를 강제하므로 기각했습니다.
4. Tweak과 도메인¶
FF1의 tweak 입력은 키를 늘리지 않고 순열을 분리해 줍니다. v1은
"dealcode/v1/" + domain을 바인딩합니다.
- 상수 접두사는 같은 키의 다른 FF1 용도와 dealcode를, 그리고 미래의 v2와 format v1을 분리합니다.
domain은 키 하나 아래 무제한의 독립 네임스페이스(orders vs coupons)를 주며 네임스페이스마다 키를 파는 것보다 운영이 훨씬 쌉니다.
radix와 길이는 tweak에 넣을 필요가 없습니다 — FF1이 라운드 함수 유도(P
블록)에 이미 둘 다 섞습니다.
5. 2^63 카운터 경계¶
카운터는 스펙에 의해 [0, min(r^max_length, 2^63))에 삽니다. 이유:
- 현실의 모든 카운터 소스(Postgres
bigserial, MySQLBIGINT AUTO_INCREMENT, Snowflake의 시퀀스 파트)가 부호 있는 64비트 정수라서 도달 가능한 값은 아무것도 배제되지 않습니다. - 경계를 구현 재량이 아니라 스펙에 박으면 Java(
long), Go(int64), C(uint64_t+ 명시적 검사), Python(무한int)이 정확히 같은 입력을 받고 정확히 같은 입력을 거부합니다. - 코드 공간이 카운터 공간보다 큰 구성은 여전히 허용되어
(
r^max_length ≤ 2^128) 고정 길이·미학적 길이 형식이 동작합니다. 도달 불가능한 영역은 잘못된 길이나 문자셋과 같은 방식으로, 범위 위반으로 decode가 거부합니다.
6. 키 재료 처리¶
"정확히 16/24/32바이트"를 요구하면 모든 사용자가 자기만의 hex 디코드/해시
글루 코드를 짜게 되고 — 그게 서비스 간 불일치의 고전적 원천입니다. v1은
규칙 하나를 스펙에 고정합니다. AES 크기의 바이트는 그대로 통과, 그 외
전부(모든 문자열 포함 — hex처럼 보여도 추측하지 않음)는
SHA-256("dealcode/v1/kdf" ‖ material)로 AES-256 키를 파생합니다. 입력이
저엔트로피 비밀번호가 아니라 키 재료이므로 고정 접두사 SHA-256이면
충분합니다(접두사는 도메인 분리). HKDF는 이 상황에서 보안을 바꾸지 않는
의식(ceremony)만 추가합니다.
7. 암호 의존성¶
규칙: FF1은 NIST 원문에서 직접 구현하고, AES는 절대 직접 구현하지
않는다. 각 언어는 플랫폼 표준의 검증된 AES를 씁니다. PyCA
cryptography, node:crypto, Go 표준 라이브러리, JCE, RustCrypto
aes/sha2, OpenSSL libcrypto. 그 외 런타임 의존성은 어디에도 없습니다 —
공급망 표면이 플랫폼 암호 라이브러리로 끝납니다. 스펙에서 구현한 FF1은 모든
언어에서 NIST 공식 샘플 벡터로 검증되며 이는 서드파티 FF1 패키지에
의존하는 것보다 강한 보증입니다(존재하는 것도 적고, 유지되는 건 더 적고,
일곱 언어에 고르게 있는 건 없습니다).
8. 벡터 계약이 있는 모노레포¶
이 레포의 제품은 비트 단위로 정확한 매핑이므로, 주된 실패 모드는 구현 간 드리프트입니다. 방어선(중요도 순):
testvectors/v1.json— Python 레퍼런스가 생성하고 모든 구현의 테스트가 소비. 모든 프리셋, 스테이지 경계, 키 파생 경로, 정규화, 그리고 반드시 거부해야 하는 코드까지 포함.testvectors/ff1_nist.json— dealcode 레이어와 무관하게 FF1 코어를 NIST 공식 샘플에 고정.- 레포가 하나라서 스펙 변경과 일곱 구현의 변경이 리뷰 가능한 커밋 하나로 함께 들어옵니다.
매핑 출력을 바꾸는 스펙 변경은 v1 수정이 아니라 새 포맷 버전(새 tweak 접두사)입니다.
9. 이름¶
dealcode — 섞인 덱에서 카드를 나눠주기(dealing). 모든 카드는 정확히 한 번,
순서는 랜덤처럼, 딜러는 개수만 기억. 이 비유는 장식이 아니라 설계 전체를 한
문장으로 설명하는 하중을 집니다.