- 2048비트 RSA 키에 필요한 두 개의 약 1024비트 소수를 Rust로 직접 생성하며, 외부 의존성 없이 난수 생성부터 큰 정수 연산까지 구현한 실험임
- 단순한 trial division은 16비트에서는 약 40ms로 충분했지만, 64비트에서도 최적화 후 6.4초가 걸려 1024비트로는 확장하기 어려웠음
- Fermat 테스트는 빠르지만 pseudoprime을 걸러내지 못할 수 있어, 최종 판정에는 k=10의 Miller-Rabin 테스트를 사용함
- 기본 정수형 한계를 넘기 위해 직접 BigInt를 만들었고, bool 배열에서 byte 배열, u64 청크 구조로 바꾸며 1024비트 소수 생성 시간이 32분대에서 60~90초 수준으로 줄어듦
- 최종 구현은 u64 청크 BigInt, 빠른 나눗셈, 작은 소수 trial division, 후보값
+2증가, 16개 스레드 병렬 실행을 결합해 평균 약 40ms에 1024비트 소수를 찾았지만, 검증된 암호 라이브러리는 아님
RSA용 1024비트 소수를 직접 만들기
- 목표는 RSA 키 생성에 쓸 수 있는 소수를 직접 생성하는 것이었음
- 2048비트 RSA 키는 두 소수의 곱으로 만들어지므로, 각각 약 1024비트 크기의 소수가 필요함
- 도전 과제는 자연스럽게 1024비트 소수 생성으로 좁혀짐
- 실험에는 세 가지 제약을 둠
- 코드는 처음부터 작성하고 외부 의존성을 쓰지 않음
- 외부 하드웨어나 클라우드 없이 AMD Ryzen 7 CPU와 16GB RAM이 있는 노트북을 사용함
- “합리적인 시간” 안에 소수를 생성해야 함
- 언어는 최근 배우고 있던 Rust를 선택함
- 저수준 개념을 다루기에 충분히 가깝고, 코드 조각을 이해하기에는 충분히 높은 수준이라고 봄
16비트와 64비트에서 드러난 trial division의 한계
- 기본 흐름은 N비트 난수를 반복 생성하고, 소수성 검사를 통과하면 종료하는 방식임
- 난수는 Rust의
randcrate 대신 Linux의/dev/urandom을 직접 읽어 만듦/dev/urandom은 Linux 커널의 CSPRNG에 접근하는 의사 장치 파일임- 커널은 사용자 환경에서 엔트로피를 수집하고 ChaCha20 기반 결정적 스트림 암호를 주기적으로 시드함
- 16비트 난수는 첫 비트와 마지막 비트를
1로 설정함- 마지막 비트
1은 홀수 보장용임 - 첫 비트
1은 필요한 비트 범위 전체를 쓰기 위한 장치임
- 마지막 비트
- 16비트에서는
3부터sqrt(num)까지 나눠보는 trial division만으로도 약 40ms에 소수를 찾음- 예시 실행은
Prime found: 44809, 전체 시간은 약 0.038초였음
- 예시 실행은
- 64비트로 확장하자 단순 trial division은 약 30초가 걸림
- 이후
6k±1형태의 후보만 검사하고, 작은 소수 목록으로 먼저 나눠보는 방식으로 개선함 - 개선 후 64비트 소수 생성 시간은 약 6.414초였음
- 이후
- 64비트에서도 6초가 걸리면서, 이 방식으로는 1024비트 소수 생성에 도달하기 어렵다는 한계가 분명해짐
확률적 소수 판정으로 전환
- 결정적 알고리듬 중 APR-CL과 ECPP를 찾아봤지만, 수학적으로 복잡하고 접근 가능한 설명이 부족해 구현 대상으로 삼기 어려웠음
- OpenSSL 소스 코드와 NIST 권고를 살펴본 뒤, RSA를 포함한 실제 사용 사례에서 확률적 소수 판정이 널리 쓰인다는 점을 확인함
- 이후 알고리듬은 수가 “소수임을 증명”하기보다, 특정 정확도로 probable prime이라고 판정하는 방식으로 바뀜
-
Fermat 테스트
- Fermat의 작은 정리는
p가 소수이고a가p로 나누어떨어지지 않으면a^(p-1) = 1 mod p가 성립한다는 관계를 사용함 - 단순 거듭제곱은
u128에서 오버플로가 발생하므로, 모듈러 거듭제곱을 구현함 pow()는 지수를u32로 받으며,u128을 더 큰 지수로 올리면 오버플로가 발생할 수 있음- 곱셈 자체도
u128범위를 넘을 수 있어, 임시로 64비트 수를u128안에 저장하는 방식으로 진행함 - Fermat 테스트는 빠르지만 Fermat pseudoprime 때문에 합성수를 소수로 잘못 판정할 수 있음
- 이런 합성수는 드물어도 충분히 많아, Fermat 테스트만으로는 신뢰하기 어렵다고 봄
- Fermat의 작은 정리는
-
Miller-Rabin 테스트
- Miller-Rabin은 Fermat 테스트와 같은 원리에 기반하지만 더 강한 확률적 소수 판정 알고리듬으로 쓰임
- 구현은
n-1 = 2^s × d형태로 2의 거듭제곱을 분리한 뒤 여러 조건을 검사함 a^d = 1 mod n- 또는 어떤
0 <= r < s에 대해a^(2^r × d) = n - 1 mod n - 128비트 실험에서는 Fermat 테스트와 비슷하게 약 0.042초에 소수를 찾음
- Miller-Rabin의 최악 오류 한계는
4^-k, 큰n에서 평균적으로는8^-k수준임 k=10일 때 평균 오류 확률 계산은0.000000000931323%였음- 이는 동전 30번을 연속으로 던져 모두 앞면이 나올 확률인
2^-30과 같다고 비교함 - 실제 암호용에서는 랜덤 base 선택과 적대적 조건을 더 조심해야 함
직접 BigInt 만들기
- Rust 기본 정수형만으로는 64비트를 넘어 충분히 큰 수를 다루기 어려워 임의 정밀도 정수(BigInt) 구현이 필요해짐
- 외부 bigint crate를 쓰지 않는 제약 때문에 BigInt도 직접 구현함
-
시도 1: 숫자 자릿수 배열
- 처음에는 큰 수를 10진수 자릿수 배열로 저장하는 방식을 시도함
- 덧셈과 곱셈은 손계산 방식처럼 구현할 수 있었지만, 나눗셈 구현에서 막혀 포기함
-
시도 2: bool 기반 이진 배열
- 두 번째 방식은 수를 0과 1의 배열로 저장하는 구조였음
BigInt는[bool; 2048]배열을 사용함- 1024비트 수끼리 곱하면 최대 2048비트 공간이 필요해 2048비트를 잡음
- 덧셈과 뺄셈은 full adder 방식으로 구현함
- 곱셈은 이진수 특성을 이용해 shift-and-add 방식으로 처리함
- 나눗셈은 이진 long division으로 구현함
- 이 구현으로 첫 1024비트 소수를 찾는 데 성공했지만, 실행 시간은 약 32분 44.90초였음
- 기술적으로 목표는 달성했지만, “합리적인 시간”이라는 제약에는 맞지 않았음
-
시도 3: byte 청크
- bool 배열의 각
bool이 1비트가 아니라 1바이트를 차지한다는 점을 확인함 [bool; 2048]은 2048비트가 아니라 2048바이트를 사용함- 이후 2048비트를 256바이트 배열에 저장하는 방식으로 바꿈
- 덧셈, 뺄셈, 곱셈은 큰 변경 없이 작동했고, 나눗셈은 byte 청크를 비트 목록처럼 다루도록 조정함
- 이 방식으로 1024비트 소수 생성 시간은 4분 43초까지 줄어듦
- bool 배열의 각
-
시도 4: u64 청크
- byte 청크 방식은 사실상 높은 기수의 자릿수를 쓰는 digit 기반 BigInt였음
- 다음 단계에서는 2048비트를
u64청크 32개로 저장함 - 각 청크는 하나의 “자릿수”처럼 동작함
- 두
u64청크를 곱한 결과를 담기 위해u128을 사용함 - 이 구조에서는 1024비트 수를 10진수 309자리 대신
u64청크 16개로 표현할 수 있음 - 1024비트 소수 생성 시간은 60~90초까지 개선됨
병목 최적화
- 간단한 벤치마크에서 binary 구현과 u64 청크 구현의 차이가 뚜렷했음
a + b와a - b: 5537.35ns → 123.57nsa * b: 1292283.14ns → 842.32nsa / b와a % b: 733446.76ns → 44440.12nsa < b와a > b: 2506.02ns → 58.91ns
- 이후 최적화는 주로 나눗셈, 곱셈, Miller-Rabin 내부 연산, 후보 생성 로직에 집중함
-
나눗셈
- 가장 큰 병목은 나눗셈이었음
- u64 청크 구조에서도 기존 나눗셈은 여전히 한 비트씩 long division을 수행함
- Handbook of Applied Cryptography의 598쪽 알고리듬을 참고해 radix 기반 long division을 구현함
- dividend의 앞 3개 “자릿수”와 divisor의 앞 2개 “자릿수”로 현재 quotient “자릿수”를 추정하는 방식임
- 이 구현은 나눗셈 1회당 약 40,000ns를 절약함
- divisor가 단일
u64청크이면u128을 사용해 더 직접적인 long division을 수행하도록 특수 처리함 - Miller-Rabin에서 이런 경우가 자주 나타남
-
곱셈
- 곱셈은 중간 결과 저장용 BigInt를 제거하도록 루프를 재배치해 약 2배 빨라짐
- 점유된 청크 개수를 계산해 0이 아닌 청크에 대해서만 루프를 돌도록 바꿈
- BigInt는 대부분 1024비트 이하 수를 저장하므로 2048비트 공간의 절반이 비어 있는 경우가 많음
- Karatsuba나 FFT 기반 곱셈도 검토했지만, 직접 구현하기엔 복잡했고 현재 곱셈이 충분히 빨라졌다고 판단함
-
Miller-Rabin 내부 최적화
- Miller-Rabin 구현에서는 비용이 큰 연산을 줄이는 데 집중함
x = mod_exp(x, 2, n)대신x = (x * x) % n을 직접 수행함- 첫
mod_exp()는 단순화한 인라인 버전으로 바꿔 함수 호출 오버헤드를 줄임 - 짝수 검사에
num.is_even()을 추가해% 2계산을 피함 d / 2는d >>= 1로 바꿈+= 1,-= 1은increase()와decrease()로 특수 처리함- 특히
is_even()과d >>= 1은 각각 약 70,000ns 이득을 냄 - 최종 벤치마크에서 u64 청크 최적화 버전은 크게 빨라짐
a * b: 842.32ns → 295.04nsa / b와a % b: 44440.12ns → 831.77nsa / 2: 75121.58ns → 60.89nsa % 2 == 0: 78400.87ns → 21.65nsa - 1: 103.15ns → 67.54ns
최종 1024비트 소수 생성기
- 최종 함수는 먼저
/dev/urandom에서 1024비트 난수를 읽음- 최상위 비트를 켜 1024비트 크기를 보장함
- 최하위 비트를 켜 홀수를 보장함
- 이후 새 난수를 매번 다시 읽지 않고, 후보값에
2를 더해 다음 홀수 후보로 이동함increase_by_2()는 대부분u64청크 하나의 덧셈만 수행함
- Miller-Rabin 전에 작은 소수 목록으로 먼저 trial division을 수행함
- 최종 코드에서는 첫 1000개 작은 소수를 사용함
- 작은 소수는 단일
u64청크에 들어가므로, 빠른 단일 청크 나눗셈 특수 처리를 활용할 수 있음
- 이 문제는 공유 메모리나 스레드 간 동기화가 필요 없는 embarrassingly parallel 형태로 다룰 수 있음
- 16개 CPU 스레드가 각각 소수를 찾고, 가장 먼저 결과를 보내는 스레드의 값을 사용함
- 최종 실행 예시는 약 0.086초 elapsed time을 기록함
- CPU 사용률은 690%로 표시됨
- 100회 실행 평균은
0.04109 ± 0.00307초였음- 평균적으로 약 40ms에 1024비트 소수를 찾음
- 개별
prime_1024bit()호출은 무작위성 때문에 약 8ms부터 약 800ms까지 변동할 수 있음 - 병렬 실행으로 가장 빠른 결과를 선택해 변동을 완화함
코드와 한계
- 전체 코드와 저장소는 github에 공개됨
- 토론 링크는 hackernews와 reddit에 있음
- 이 구현은 실제 암호학적으로 안전하다고 보기 어렵고, 목적도 암호용 라이브러리 제작이 아니라 학습과 구현 실험에 가까움