콘텐츠로 이동

설계 결정 기록

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, MySQL BIGINT 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. 벡터 계약이 있는 모노레포

이 레포의 제품은 비트 단위로 정확한 매핑이므로, 주된 실패 모드는 구현 간 드리프트입니다. 방어선(중요도 순):

  1. testvectors/v1.json — Python 레퍼런스가 생성하고 모든 구현의 테스트가 소비. 모든 프리셋, 스테이지 경계, 키 파생 경로, 정규화, 그리고 반드시 거부해야 하는 코드까지 포함.
  2. testvectors/ff1_nist.json — dealcode 레이어와 무관하게 FF1 코어를 NIST 공식 샘플에 고정.
  3. 레포가 하나라서 스펙 변경과 일곱 구현의 변경이 리뷰 가능한 커밋 하나로 함께 들어옵니다.

매핑 출력을 바꾸는 스펙 변경은 v1 수정이 아니라 새 포맷 버전(새 tweak 접두사)입니다.

9. 이름

dealcode — 섞인 덱에서 카드를 나눠주기(dealing). 모든 카드는 정확히 한 번, 순서는 랜덤처럼, 딜러는 개수만 기억. 이 비유는 장식이 아니라 설계 전체를 한 문장으로 설명하는 하중을 집니다.