게임 소개

난수 생성기(RNG) 공정성 공개 문서

본 문서는 아토즈 포커의 카드 결과가 특정 이용자에게 유리하거나 불리하도록 조정되는 로직이 없으며,
카드 결정 과정이 별도의 임의 개입 없이 난수에 기반하여 처리되도록 구현되어 있음을 설명한 문서입니다.

암호 시스템의 설계 원칙 중 하나인 케르크호프스의 원칙(Kerckhoffs’s principle) 은 시스템의 안전성이 알고리즘의 비밀성이 아니라 키의 비밀성에 의존해야 한다는 원칙입니다. 이에 따라 난수 생성 및 카드 셔플의 구조와 알고리즘이 공개된 상태에서도 내부 상태와 키를 알지 못하면 이후의 결과를 예측할 수 없어야 합니다.

이하에 수록된 코드는 난수 생성기와 게임 서버의 실제 소스 코드에서 발췌하였으며, 내용을 수정하거나 재작성하지 않고 원본 그대로 수록하였습니다. 변수명, 함수 구조 및 주석 역시 실제 소스 코드와 동일합니다.

본 문서는 카드 결과 결정과 직접 관련된 난수 생성, 카드 셔플 및 카드 배분 과정의 구조와 구현을 확인하는 데 필요한 코드를 중심으로 구성하였습니다.


요약

항목 내용
난수 생성 구조Fortuna 기반 CSPRNG
블록 암호AES-256 (CTR 방식)
해시 / 키 유도SHA-256 / HMAC-SHA-256
엔트로피 풀32개
입력 소스운영체제 CSPRNG, 스케줄러 지터
카드 셔플C++ 표준 std::shuffle, 난수원으로 FortunaRNG 사용
카드 배분셔플된 덱에서 순서대로 배분, 배분 과정에서 추가 난수 사용 없음
인스턴스 수명 설정최대 24시간 또는 리시드 1,000,000회
배포 형태게임 서버에 링크되는 공유 라이브러리 (libfortuna.so)
제3자 평가BMM Australia Pty Ltd, GLI-19 v3.0 적합성 평가 (Fortuna Random Number Generator v1.0.0)

본 구현은 Cryptography Engineering에 기술된 Fortuna의 구조를 기반으로 하나, 일부 구현 방식에는 차이가 있습니다. 해당 차이와 그 영향 범위는 4절에서 별도로 기술합니다.


1. 게임 서버의 난수 사용 구조

난수 생성기는 별도의 서버 형태로 운영되지 않습니다. Go로 작성된 난수 라이브러리를 libfortuna.so 공유 라이브러리로 빌드하고, 각 게임 서버 프로세스에서 직접 링크하여 사용합니다. 따라서 난수 생성기와 게임 서버 사이에 별도의 네트워크 전송 구간이 존재하지 않습니다.

라이브러리가 외부에 제공하는 인터페이스는 다음 세 개입니다.

// C-compatible function declarations from the Go shared library
extern "C" {
  long long InitRNG();
  long long GetRandomBytes(unsigned char* buffer, int length);
  void FreeRNG();
}

게임 서버에서는 해당 인터페이스를 래퍼 클래스를 통해 사용합니다. 다음은 홀덤 서버에서 사용하는 fortunaBridge.h의 전체 코드입니다.

class FortunaRNG {
  public:
    using result_type = uint32_t;
    static constexpr result_type min() { return 0; }
    static constexpr result_type max() { return 0xFFFFFFFF; }
    static FortunaRNG& instance() {
      static FortunaRNG obj;
      return obj;
    }
    FortunaRNG(const FortunaRNG&) = delete;
    FortunaRNG& operator=(const FortunaRNG&) = delete;
  private:
    FortunaRNG() {
      int res = InitRNG();
      if (res != 0) {
        throw std::runtime_error("Failed to initialize Fortuna RNG");
      }
    }
    ~FortunaRNG() {
      FreeRNG();
    }
  public:
    // Get n random bytes
    std::vector<uint8_t> GetBytes(int n) {
      std::vector<uint8_t> buf(n);
      int res = GetRandomBytes(buf.data(), n);
      if (res != 0) {
        throw std::runtime_error("Failed to get random bytes from Fortuna RNG");
      }
      return buf;
    }
    // Helper to get a random uint64
    uint64_t GetUint64() {
      auto bytes = GetBytes(8);
      uint64_t val = 0;
      for (int i = 0; i < 8; ++i) {
        val |= (static_cast<uint64_t>(bytes[i]) << (8 * i));
      }
      return val;
    }
    // Helper to get a random uint32
    uint32_t GetUint32() {
      auto bytes = GetBytes(4);
      uint32_t val = 0;
      for (int i = 0; i < 4; ++i) {
        val |= (static_cast<uint32_t>(bytes[i]) << (8 * i));
      }
      return val;
    }
    result_type operator()() {
      return GetUint32();
    }
};

난수 획득에 실패한 경우 예외를 발생시킵니다. GetRandomBytes()가 정상적으로 처리되지 않으면 예외가 발생하며, 실패한 결과를 0 또는 임의의 기본값으로 대체하지 않습니다.

난수 생성기는 단일 정적 인스턴스를 통해 사용되며 복사가 제한되어 있습니다. instance()는 정적 객체인 FortunaRNG obj의 참조를 반환합니다. 생성자는 private으로 선언되어 있으며, 복사 생성자와 복사 대입 연산자는 = delete로 정의되어 있습니다.

C++ 표준 난수 생성기 인터페이스에 필요한 형식을 제공합니다. operator(), min(), max()를 구현하고 있으며, 카드 셔플 과정에서는 해당 객체를 std::shuffle의 난수원으로 전달합니다. C++ 표준에서 std::shuffle에 전달되는 난수 생성기는 Uniform Random Bit Generator에 관한 요구사항을 충족해야 합니다. (6절 「한 판의 카드 결정 과정」 참조)

난수 생성기 초기화 시 운영체제 CSPRNG에서 64바이트(512비트)의 초기 난수 데이터를 요청합니다. 요청에 성공하면 해당 데이터를 난수 생성기에 추가한 후 리시드를 시도합니다. 이후 추가 데이터를 공급하기 위한 두 개의 백그라운드 루틴을 실행합니다.

//export InitRNG
func InitRNG() int {
	mu.Lock()
	defer mu.Unlock()
	if initialized {
		return 0 // Already initialized
	}
	var err error
	logger, err = logging.NewLogger()
	if err != nil {
		return -1
	}
	prng = fortuna.NewFortuna()
	stopCh = make(chan struct{})
	ctx, cancel = context.WithCancel(context.Background())
	// Start AutoReseedRoutine
	go prng.AutoReseedRoutine(stopCh)
	// Inject initial entropy to ensure immediate availability
	seed := make([]byte, 64)
	if _, err := rand.Read(seed); err == nil {
		prng.AddEntropy(seed)
		prng.TryReseed()
	}
	// Start Scheduling Entropy Injector
	go schedulingEntropyInjector(ctx, prng, logger)
	initialized = true
	//logger.Info("RNG Bridge Initialized")
	return 0
}

2. 설계 파라미터

난수 생성기의 동작을 규정하는 주요 상수는 다음과 같이 정의됩니다.

const (
	NumPools             = 32
	BlockSizeAes         = 16
	KeySizeAes           = 32
	MinReseedInterval    = 100 * time.Millisecond
	MinPool0EntropyBytes = 64
	MaxBytesPerReseed    = 1 << 20
	MaxRunDuration       = 24 * time.Hour
	MaxReseedCount       = 1_000_000
)
상수 의미
NumPools엔트로피 풀의 개수
BlockSizeAesAES 처리에 사용되는 블록 크기(16바이트)
KeySizeAesAES-256에 사용되는 키 크기(32바이트)
MinReseedInterval연속적인 리시드 수행 사이에 적용되는 최소 시간 간격
MinPool0EntropyBytes리시드 조건에 사용되는 풀 0의 최소 누적 입력 데이터 크기
MaxBytesPerReseed현재 키의 누적 출력량과 요청량의 합이 1 MiB를 초과하는 경우 요청 전에 리시드 수행
MaxRunDuration인스턴스 종료 조건에 사용되는 최대 실행 시간
MaxReseedCount인스턴스 종료 조건에 사용되는 최대 리시드 횟수

MinReseedInterval은 짧은 시간 안에 리시드가 연속적으로 수행되는 것을 제한하기 위한 값입니다. 이를 통해 리시드가 반복적으로 호출되면서 엔트로피 풀의 누적 데이터가 연속적으로 소비되는 빈도를 제한합니다.

새로운 인스턴스는 최초 리시드가 수행되기 전까지 난수 생성이 허용되지 않습니다. reseedCount == 0인 경우 난수 생성 요청은 오류를 반환합니다. (5절 「난수 생성」 참조)


3. 엔트로피 수집

본 문서에서는 운영체제 CSPRNG의 출력과 스케줄러 지터 값을 통틀어 난수 생성기에 입력되는 엔트로피 입력 데이터로 표현합니다. 개별 입력값이 갖는 실제 엔트로피의 정보량을 바이트 수와 동일한 것으로 간주하지 않습니다.

입력 데이터는 32개의 풀에 순차적으로 분배됩니다. poolIndex는 데이터가 추가될 때마다 증가하며, NumPools에 도달하면 다시 0으로 순환합니다.

// AddEntropy: feed external random data
func (f *Fortuna) AddEntropy(data []byte) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.closed {
		return
	}
	f.pools[f.poolIndex].addEntropy(data)
	if f.poolIndex == 0 {
		f.pool0ByteCount += len(data)
	}
	f.poolIndex = (f.poolIndex + 1) % NumPools
}

AddEntropy()에서는 선택된 풀의 addEntropy()를 호출하여 입력 데이터를 전달합니다. 풀 내부의 addEntropy() 및 이후 사용되는 extractAndReset()의 함수 본문은 본 문서에 수록된 발췌 범위에는 포함되어 있지 않습니다.

엔트로피 입력에는 다음 두 소스가 사용됩니다.

(1) 운영체제 CSPRNG

200밀리초 주기로 crypto/rand에서 32바이트의 난수 데이터를 읽어 엔트로피 풀에 추가한 후 리시드를 시도합니다.

// AutoReseedRoutine: periodically read some system random data and try reseed
func (f *Fortuna) AutoReseedRoutine(stopCh <-chan struct{}) {
	ticker := time.NewTicker(200 * time.Millisecond)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			e := make([]byte, 32)
			rand.Read(e)
			f.AddEntropy(e)
			f.TryReseed()
		case <-stopCh:
			return
		}
	}
}

(2) 스케줄러 지터

100밀리초 주기의 타이머를 기준으로 실제 실행 간격과 기준 간격의 차이를 측정하여 입력 데이터로 추가합니다.

코드에서는 이전 실행 시각과 현재 실행 시각의 차이에서 100밀리초를 차감하고, 해당 값의 절댓값을 나노초 단위의 8바이트 데이터로 변환하여 엔트로피 풀에 전달합니다.

func schedulingEntropyInjector(ctx context.Context, prng *fortuna.Fortuna, logger *zap.Logger) {
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()
	var lastTime time.Time
	for {
		select {
		case <-ctx.Done():
			return
		case t := <-ticker.C:
			if !lastTime.IsZero() {
				expected := 100 * time.Millisecond
				actual := t.Sub(lastTime)
				jitter := actual - expected
				ns := jitter.Nanoseconds()
				if ns < 0 {
					ns = -ns
				}
				entropyBytes := make([]byte, 8)
				binary.LittleEndian.PutUint64(entropyBytes, uint64(ns))
				prng.AddEntropy(entropyBytes)
			}
			lastTime = t
		}
	}
}

스케줄러 지터는 단독 난수 생성기로 사용되지 않으며, 운영체제 CSPRNG에서 수집된 값과 함께 엔트로피 풀에 입력됩니다.

본 구현에서는 두 입력 경로 모두 동일한 AddEntropy()를 호출하며, 하나의 poolIndex를 공유하여 32개 풀에 순차적으로 데이터를 분배합니다.

이는 Fortuna 원 설계와 차이가 있습니다. Fortuna 원 설계에서는 각 엔트로피 소스가 독립적으로 풀을 순환하도록 정의되어 있습니다. 이 차이가 갖는 의미는 4절에서 함께 설명합니다.


4. 리시드

리시드는 다음 두 조건을 모두 충족하는 경우 수행됩니다.

  1. 풀 0에 최소 64바이트의 입력 데이터가 누적되어 있어야 합니다.
  2. 최초 리시드 이후에는 직전 리시드로부터 최소 100밀리초가 경과해야 합니다.
func (f *Fortuna) tryReseedLocked() error {
	if f.closed {
		return nil
	}
	now := time.Now()
	if f.pool0ByteCount < MinPool0EntropyBytes {
		return nil
	}
	if f.reseedCount > 0 && now.Sub(f.lastReseed) < MinReseedInterval {
		return nil
	}
	return f.forceReseedLocked()
}

조건이 충족되면 forceReseedLocked()를 통해 리시드를 수행합니다.

func (f *Fortuna) forceReseedLocked() error {
	f.reseedCount++
	var material []byte
	for i := 0; i < NumPools; i++ {
		mask := uint64(1) << i
		if (f.reseedCount & mask) != 0 {
			sum := f.pools[i].extractAndReset()
			material = append(material, sum...)
			if i == 0 {
				f.pool0ByteCount = 0
			}
		}
	}
	newKey := hmacSha256(f.generatorKey, material)
	copy(f.generatorKey, newKey)
	block, err := aes.NewCipher(f.generatorKey)
	if err != nil {
		return err
	}
	f.blockCipher = block
	for i := range f.generatorCtr {
		f.generatorCtr[i] = 0
	}
	f.lastReseed = time.Now()
	f.generatedBytes = 0
	f.checkIfShouldCloseLocked()
	return nil
}

4.1 풀 선택 방식

reseedCount & (1 << i) 연산은 reseedCounti번째 비트가 1인지 확인합니다. 해당 비트가 1인 경우 풀 i의 값을 추출하여 리시드 재료(material)에 포함하고, 사용된 풀을 초기화합니다.

따라서 현재 구현에서는 풀 0이 홀수 번째 리시드에서 선택되고, 나머지 풀 역시 reseedCount의 각 비트 상태에 따라 선택됩니다.

이는 Fortuna 원 설계의 풀 선택 방식과 다릅니다. 원 설계에서는 리시드 번호를 r이라고 할 때 2^ir의 약수인 경우 풀 Pi를 사용합니다. 이에 따라 원 설계에서는 P0가 모든 리시드에 사용되고, P1은 두 번마다, P2는 네 번마다 사용됩니다.

현재 구현에서는 비트 상태에 따라 일정 구간 동안 특정 풀이 연속해서 선택되고, 다음 구간에서는 선택되지 않는 패턴이 발생합니다.

Fortuna 원 설계의 상태 복구(self-healing) 특성에 관한 논증은 원 설계의 풀 선택 주기를 전제로 합니다. 따라서 해당 논증을 본 구현에 그대로 적용할 수 없으며, 본 문서에서는 변경된 풀 선택 방식에 대한 별도의 암호학적 안전성 증명을 제시하지 않습니다.

4.2 엔트로피 소스의 풀 분배 방식

Fortuna 원 설계에서는 각 엔트로피 소스가 독립적으로 엔트로피 풀을 순환합니다.

본 구현에서는 운영체제 CSPRNG와 스케줄러 지터가 동일한 AddEntropy()와 하나의 poolIndex를 공유합니다.

따라서 원 설계에서 개별 엔트로피 소스의 독립적인 풀 순환을 전제로 제시된 분석을 본 구현에 동일하게 적용할 수 없습니다. 본 문서에서는 이 변경에 대한 별도의 암호학적 안전성 증명을 제시하지 않습니다.

4.3 생성기 키 갱신

선택된 풀의 출력은 material에 결합됩니다.

이후 기존 generatorKey를 HMAC 키로 사용하고 material을 입력값으로 하여 HMAC-SHA-256을 계산한 뒤, 결과를 새로운 생성기 키로 설정합니다.

새로운 키를 기준으로 AES 블록 암호 객체를 다시 생성하고, 카운터(generatorCtr)와 누적 출력량(generatedBytes)을 0으로 초기화합니다.

Fortuna 원 설계에서는 리시드 과정의 키 갱신 및 카운터 처리 방식이 본 구현과 다릅니다. 본 구현에서는 매 리시드마다 새로운 키를 유도한 후 카운터를 0부터 다시 시작합니다.

CTR 방식에서는 동일한 키와 동일한 카운터의 조합을 반복하여 사용하는 것을 방지해야 합니다. 본 구현에서는 리시드 시 생성기 키를 변경한 뒤 카운터를 초기화하는 구조를 사용합니다.

4.4 난수 요청 완료 후 키 교체

Fortuna 원 설계에서는 각 난수 생성 요청이 완료된 후 추가 출력을 생성하여 생성기 키를 다시 교체합니다. 이 절차는 이후 생성기 내부 상태가 노출되더라도 이전 요청에서 반환된 출력의 재구성을 제한하기 위한 것입니다.

본 구현에는 요청 단위의 키 교체 단계가 없으며, 생성기 키는 리시드 시점에 갱신됩니다.

따라서 생성기 키와 현재 카운터 등 내부 상태가 노출되는 경우, 마지막 리시드 이후 현재 시점까지 생성된 출력 구간을 재구성할 수 있는 가능성이 존재합니다.

새로운 리시드가 수행되면 새로운 입력 데이터를 기반으로 생성기 키가 다시 유도되므로, 해당 리시드 이전의 생성기 키를 알지 못하는 상태에서 그 이전 구간까지 동일한 방식으로 계산할 수 있다는 의미는 아닙니다.

이 차이는 카드 결과의 확률 분포를 특정 방향으로 조정하는 기능을 의미하지 않습니다. 생성기 내부 상태가 외부에 노출되는 상황에서 과거 출력에 대한 보호 범위가 Fortuna 원 설계와 다르다는 의미입니다.

4.5 관측된 리시드 동작

공개된 AutoReseedRoutine과 스케줄러 지터 입력 루틴을 동일한 구성으로 실행하고, reseedCount의 변화를 5밀리초 간격으로 120초 동안 관측한 결과는 다음과 같습니다.

조건 리시드 횟수 최소 간격 중앙값 평균 최대 간격
무부하28회 / 120초195 ms205 ms4.27초10.61초
200 핸드/초 부하23회 / 120초194 ms6.20초5.06초12.96초

현재 풀 선택 코드에서는 비트 0이 1인 경우에만 풀 0의 pool0ByteCount가 0으로 초기화됩니다. 따라서 짝수 번째 리시드에서는 풀 0이 선택되지 않으며, 해당 시점의 pool0ByteCount가 유지될 수 있습니다.

이 경우 이후 TryReseed()가 다시 호출되고 MinReseedInterval 조건을 충족하면 추가 리시드가 비교적 짧은 간격으로 발생할 수 있습니다. 위 측정에서 약 200밀리초 수준의 최소 리시드 간격이 관측된 것은 이러한 코드 구조와 일치합니다.

위 측정 결과는 해당 시험 구성에서의 관측값이며, 실제 운영 환경 전체의 리시드 빈도나 장기간의 풀 사용 범위를 보장하는 값으로 사용하지 않습니다.


5. 난수 생성

난수 출력은 AES-256 기반의 CTR 방식을 사용하여 생성됩니다. 현재 생성기 키로 16바이트 카운터 블록을 암호화하고, 생성된 값을 출력 데이터로 사용합니다. 각 블록을 처리한 후에는 카운터 값을 증가시킵니다.

// GenerateRandomBytes: produce n random bytes from AES-CTR
func (f *Fortuna) GenerateRandomBytes(n int) ([]byte, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.closed {
		return nil, errors.New("fortuna instance closed")
	}
	if f.reseedCount == 0 {
		return nil, errors.New("fortuna not yet seeded")
	}
	// if we exceed the per-reseed limit
	if f.generatedBytes+uint64(n) > MaxBytesPerReseed {
		if err := f.forceReseedLocked(); err != nil {
			return nil, err
		}
		if f.closed {
			return nil, errors.New("fortuna closed during reseed")
		}
	}
	out := make([]byte, n)
	buf := make([]byte, BlockSizeAes)
	for i := 0; i < n; i += BlockSizeAes {
		f.blockCipher.Encrypt(buf, f.generatorCtr)
		incrementCtr(f.generatorCtr)
		chunkSize := BlockSizeAes
		if i+chunkSize > n {
			chunkSize = n - i
		}
		copy(out[i:i+chunkSize], buf[:chunkSize])
	}
	f.generatedBytes += uint64(n)
	f.checkIfShouldCloseLocked()
	return out, nil
}

함수 실행 시 먼저 인스턴스의 상태와 시드 여부를 확인합니다. 인스턴스가 종료된 상태이거나 최초 리시드가 수행되지 않은 상태(reseedCount == 0)에서는 난수를 생성하지 않고 오류를 반환합니다.

게임 서버의 래퍼 클래스는 GetRandomBytes()에서 반환된 오류를 예외로 처리하도록 구성되어 있습니다. (1절 「게임 서버의 난수 사용 구조」 참조)

이후 현재 키에서 생성된 누적 출력량과 이번 요청의 출력량을 합산하여 MaxBytesPerReseed와 비교합니다.

합산된 출력량이 MaxBytesPerReseed(1 MiB)를 초과하는 경우, 요청을 처리하기 전에 forceReseedLocked()를 호출하여 리시드를 수행하고 생성기 키를 갱신합니다. (4절 「리시드」 참조)

이 조건은 요청 단위의 최대 출력량을 제한하는 방식은 아닙니다. 하나의 요청 자체가 1 MiB를 초과하더라도 요청 전에 한 차례 리시드를 수행한 후 해당 요청량을 생성합니다.

난수는 BlockSizeAes에 정의된 16바이트 단위로 생성됩니다. 각 반복에서 현재 카운터 값을 AES 블록 암호에 입력하고, 암호화 결과를 출력 버퍼에 복사한 후 incrementCtr()를 호출합니다.

incrementCtr()의 함수 본문은 본 문서의 발췌 범위에는 포함되어 있지 않습니다. 따라서 본 문서에서 확인 가능한 것은 각 블록 처리 후 해당 함수가 호출된다는 사실까지입니다.

마지막 블록의 필요한 출력량이 16바이트보다 작은 경우에는 요청된 길이에 해당하는 부분만 복사합니다.

난수 생성이 완료되면 실제 생성된 바이트 수를 generatedBytes에 누적하고, 인스턴스의 종료 조건을 확인한 후 결과를 반환합니다.

리시드 과정의 키 유도에는 HMAC-SHA-256을 사용합니다. 다음 함수는 Go 표준 라이브러리의 crypto/hmaccrypto/sha256을 이용하여 HMAC-SHA-256 값을 생성합니다.

func hmacSha256(key, data []byte) []byte {
	hm := hmac.New(sha256.New, key)
	hm.Write(data)
	return hm.Sum(nil)
}

6. 한 판의 카드 결정 과정

앞 절에서는 난수 생성기의 구성과 난수 생성 과정을 설명하였습니다. 본 절에서는 생성된 난수가 홀덤 게임 서버에서 카드 셔플과 배분에 적용되는 과정을 단계별로 설명합니다.

카드 결정 과정은 다음 네 단계로 구성됩니다.

1단계 — 난수 생성기 초기화

FortunaRNG::instance()가 최초로 호출되면 FortunaRNG 인스턴스가 생성되고 난수 생성기가 초기화됩니다. 이후 동일한 정적 인스턴스를 참조하여 난수를 요청합니다. (1절 「게임 서버의 난수 사용 구조」 참조)

2단계 — 52장 카드 생성

매 판 시작 시 Deck::reset()은 기존 cards 데이터를 초기화한 후 52장의 카드를 다시 생성합니다.

카드 생성 순서는 무늬(s) 4개와 숫자(r) 2부터 14까지의 이중 반복문에 의해 결정되므로, 셔플 수행 전의 카드 배열은 매 판 동일한 규칙에 따라 구성됩니다.

bool Deck::reset(int64_t ante, const std::vector<int64_t> uids)
{
  cards.clear();
  // 52장 카드 생성
  for (int s = 0; s < 4; ++s) {
    for (int r = 2; r <= 14; ++r)
      cards.emplace_back(static_cast<Suit>(s), static_cast<Rank>(r));
  }
  if (!shuffle())
    return false;
  communityCards.clear();
  uidsToCards.clear();
  return true;
}

이 단계에서는 난수를 사용하지 않습니다. 카드 배열에 대한 무작위화는 이후 호출되는 shuffle()에서 수행됩니다.

3단계 — 카드 셔플

생성된 52장의 카드는 Deck::shuffle()을 통해 셔플됩니다.

bool Deck::shuffle()
{
  try {
    //static std::random_device rd;
    //static std::mt19937 g(rd());
    std::shuffle(cards.begin(), cards.end(), FortunaRNG::instance());
    return true;
  } catch (const std::exception& e) {
    loge("Deck::shuffle exception: " << e.what());
    return false;
  }
}

카드 셔플에는 C++ 표준 라이브러리의 std::shuffle을 사용하며, 난수원으로 FortunaRNG::instance()를 전달합니다. 따라서 카드 순서를 결정하는 난수는 앞 절에서 설명한 난수 생성기를 통해 제공됩니다. (5절 「난수 생성」 참조)

주석 처리된 std::random_devicestd::mt19937 관련 코드는 실행되지 않으며, 현재 구현에서는 FortunaRNG::instance()std::shuffle의 난수원으로 사용됩니다.

C++ 표준에서 std::shuffle은 주어진 범위의 각 가능한 순열이 동일한 확률로 나타나도록 재배열할 것을 요구하며, 전달된 난수 생성기를 사용하도록 규정합니다. 표준은 이를 달성하기 위한 특정 셔플 알고리즘 자체를 지정하지 않습니다.

본 문서에서는 실제 배포 환경의 C++ 표준 라이브러리 내부 구현이나 해당 환경에서의 셔플 결과 분포에 대한 별도의 통계적 검증 결과를 제시하지 않습니다. (9절 「문서의 검증 범위와 한계」 참조)

shuffle() 수행 중 예외가 발생하면 false를 반환하며, 이에 따라 Deck::reset() 역시 실패를 반환합니다. 정상적으로 셔플이 완료된 경우에만 이후 카드 배분 과정으로 진행됩니다.

4단계 — 카드 배분

셔플이 완료된 후에는 dealCard()를 통해 카드가 배분됩니다. 다음은 해당 함수의 전체 코드입니다.

Card Deck::dealCard(int64_t uid)
{
  if (uid== 0 && !communityCards.empty()) { // 커뮤니티 카드 덱에서 뽑음
    Card card= communityCards.back();
    communityCards.pop_back();
    return card;
  }
  else if (auto it= uidsToCards.find(uid); it!= uidsToCards.end() && !it->second.empty()) { // 먼저 유저별 카드 덱에서 뽑음
    Card card= it->second.back();
    it->second.pop_back();
    return card;
  }
  if (!cards.empty()) { // 유저별 카드 덱이 모자르면 전체 카드 덱에서 뽑음
    Card card= cards.back();
    cards.pop_back();
    return card;
  }
  throw std::runtime_error("Deck is empty");
}

dealCard()communityCards, uidsToCards, cards의 순서로 사용 가능한 카드가 존재하는지 확인합니다. 앞의 두 구조가 비어 있는 경우 셔플이 완료된 cards에서 카드를 반환합니다.

테스트용 데이터 구조

communityCardsuidsToCards는 개발 및 QA 환경에서 특정 카드 조합을 재현하기 위한 데이터 구조입니다. 두 구조는 Deck 클래스의 private 멤버로 선언되어 있으며, 클래스 외부에서 직접 접근할 수 없습니다.

class Deck {
  private:
    std::vector<Card> cards;
    std::vector<Card> communityCards; // 커뮤니티 카드 덱(탄 때문에 필요)
    std::map<int64_t, std::vector<Card>> uidsToCards; // 유저별 카드 덱(탄 때문에 필요)
    bool loadDbCards(boost::mysql::field_view field, std::vector<Card> &targetCards);
  public:
    Deck();
    ~Deck() = default;
    bool shuffle();
    Card dealCard(int64_t uid= 0);
    bool reset(int64_t ante, const std::vector<int64_t>& uids); // 덱을 초기화하고 섞음
    boost::asio::awaitable<void> loadTestCards(int64_t ante, const std::vector<int64_t> uids); // 테스트 모드용 탄 카드를 DB에서 비동기로 로드
};

Deck이 외부에 제공하는 함수는 위 네 개이며, 각 함수가 두 구조에 대해 수행하는 처리는 다음과 같습니다.

함수 두 구조에 대한 처리
shuffle()사용하지 않음
dealCard()pop_back() — 제거만 수행
reset()clear() — 초기화만 수행
loadTestCards()loadDbCards()를 통해 값을 입력

앞의 세 함수의 코드는 본 문서에 수록되어 있습니다. 두 구조에 카드를 입력하는 처리는 private 함수인 loadDbCards()에서 수행되며, 이를 호출하는 함수는 loadTestCards() 하나입니다.

loadTestCards()에는 다음 조건이 포함되어 있습니다.

boost::asio::awaitable<void> Deck::loadTestCards(int64_t ante, const std::vector<int64_t> uids)
{
  if (global::serverMode== SM_LIVE|| ante<= 0) // 라이브에서는 사용하지 않음
    co_return;

serverMode의 초기값은 다음과 같이 SM_LIVE로 정의되어 있습니다.

    inline static SERVER_MODE serverMode= SM_LIVE;           // 서버 모드

설정 파일의 mode 값은 다음과 같이 처리됩니다.

  string mode= (const char*)config::instance().lookup("mode");
  if (mode== "dev")
    serverMode= SM_DEV;
  else if (mode== "qa")
    serverMode= SM_QA;
  else if (mode== "stage")
    serverMode= SM_STAGE;
  else
    serverMode= SM_LIVE;

mode 값이 dev, qa, stage 중 하나가 아닌 경우 serverModeSM_LIVE가 됩니다.

또한 매 판 시작 시 Deck::reset()에서 communityCards.clear()uidsToCards.clear()가 실행되어 두 구조가 초기화됩니다.

따라서 serverModeSM_LIVE인 환경에서는 두 구조에 카드가 입력되지 않으며, dealCard()는 셔플이 완료된 cards에서 카드를 반환합니다.

셔플된 덱에서의 배분

cards에서 카드를 배분할 때는 back()으로 배열의 마지막 카드를 가져온 후 pop_back()으로 해당 카드를 배열에서 제거합니다. 따라서 한 번 배분된 카드는 이후 cards에 남아 있지 않습니다.

dealCard() 자체에서는 추가적인 난수 생성이나 셔플을 수행하지 않습니다. 셔플이 완료된 cards의 순서에 따라 카드를 한 장씩 제거하여 반환합니다. 이에 따라 카드 배열에 대한 무작위화는 shuffle()에서 수행되며, 이후 배분 과정에서는 해당 배열을 순차적으로 사용합니다.

홀카드는 딜러 다음 좌석부터 시계 방향으로 한 장씩 두 차례 배분됩니다. 이후 플랍 3장, 턴 1장, 리버 1장의 커뮤니티 카드 역시 동일한 덱에서 순차적으로 배분됩니다. 현재 구현에서는 버닝 카드를 사용하지 않습니다.

처리 과정 요약

단계 처리 내용 난수 사용
1난수 생성기 초기화 및 인스턴스 참조
252장의 카드를 정해진 규칙에 따라 생성아니오
3FortunaRNG를 난수원으로 사용하여 카드 배열 셔플
4셔플된 카드 배열에서 순차적으로 배분아니오

한 판의 카드 배열을 무작위화하는 처리는 3단계의 shuffle()에서 수행됩니다. 2단계에서 생성된 카드 배열은 이 단계에서 재배열되며, 이후 4단계에서는 추가적인 난수 생성 없이 셔플된 배열을 기준으로 카드가 배분됩니다.


7. 인스턴스 수명과 키 소거

설계 파라미터에는 최대 실행 시간 MaxRunDuration = 24 * time.Hour와 최대 리시드 횟수 MaxReseedCount = 1_000_000이 정의되어 있습니다. (2절 「설계 파라미터」 참조)

난수 생성 및 리시드 과정에서는 checkIfShouldCloseLocked()가 호출됩니다. 다만 해당 함수의 본문은 현재 본 문서에 수록된 코드 범위에는 포함되어 있지 않습니다.

따라서 본 문서에서는 설정값과 해당 검사 함수의 호출 위치를 확인할 수 있으나, 종료 조건을 판정하는 내부 구현 전체는 확인할 수 없습니다.

인스턴스 종료 시 사용되는 closeLocked()는 다음과 같습니다.

func (f *Fortuna) closeLocked() {
	zeroBytes(f.generatorKey)
	zeroBytes(f.generatorCtr)
	f.blockCipher = nil
	for i := range f.pools {
		f.pools[i].h.Reset()
		f.pools[i].byteCount = 0
	}
	f.closed = true
}

func zeroBytes(b []byte) {
	for i := range b {
		b[i] = 0
	}
}

closeLocked()는 생성기 키(generatorKey)와 카운터(generatorCtr)의 값을 0으로 덮어쓰고, AES 블록 암호 객체에 대한 참조를 제거합니다.

또한 32개 엔트로피 풀의 해시 상태와 누적 바이트 수를 초기화한 후 closed 상태를 true로 설정합니다.

zeroBytes()는 전달된 바이트 배열의 각 요소를 0으로 설정합니다.

종료된 인스턴스는 GenerateRandomBytes()에서 f.closed 검사에 의해 난수 생성 요청에 대해 오류를 반환합니다. (5절 「난수 생성」 참조)

종료 이후의 동작 범위

현재 본 문서에 수록된 코드만으로는 인스턴스가 종료된 이후 동일한 서버 프로세스에서 새로운 난수 생성기 인스턴스를 생성하는 절차나 서버 프로세스 재시작 절차를 확인할 수 없습니다.

특히 공개된 InitRNG()에서는 initialized가 이미 true인 경우 즉시 반환하도록 구성되어 있습니다.

따라서 24시간 또는 최대 리시드 횟수 도달 이후 실제 운영 환경에서 난수 생성기가 어떤 절차를 통해 재초기화되거나 서버가 재기동되는지는 본 문서의 현재 검증 범위에 포함되지 않습니다.

해당 운영 절차를 본 문서의 검증 대상으로 포함하려면 관련 재초기화 또는 서버 수명주기 관리 코드를 추가로 공개해야 합니다.


8. 제3자 시험기관 적합성 평가

Fortuna Random Number Generator v1.0.0은 BMM Australia Pty Ltd(BMM Testlabs)로부터 GLI-19 기준에 따른 적합성 평가를 받았습니다.

항목 내용
평가 대상 제품Fortuna Random Number Generator v1.0.0
시험기관BMM Australia Pty Ltd (BMM Testlabs)
적용 기준GLI-19: Standards for Interactive Gaming Systems, Version 3.0 (2020-07-17)
프로젝트 번호CAPY.1001
평가일2025-08-15
증명서 발행일2025-10-16

평가 범위

해당 적합성 평가는 Fortuna Random Number Generator v1.0.0을 대상으로 수행되었습니다.

게임 서버에서 난수 생성기를 호출하는 브리지 계층, C++ 표준 라이브러리의 std::shuffle, 카드 배분 로직 및 현재 운영 중인 서버 바이너리와 본 문서에 공개된 소스 코드의 일치 여부는 위 난수 생성기 적합성 평가의 범위에 포함되지 않습니다.

시험기관이 발행한 증명서의 공식 명칭과 적합성 표현은 실제 증명서 원문을 기준으로 적용합니다.


9. 문서의 검증 범위와 한계

본 문서는 공개된 소스 코드의 구조와 동작을 확인하기 위한 자료이며, 실제 배포 환경에서 실행 중인 서버 바이너리가 본 문서에 수록된 코드와 동일하다는 사실까지 증명하지는 않습니다.

소스 코드 공개를 통해 공개된 구현의 내용과 동작 구조를 확인할 수 있으나, 운영 중인 서버에 동일한 코드가 배포되어 실행되고 있는지는 별도의 검증 대상입니다.

난수 생성기에 대한 제3자 평가의 범위는 8절 「제3자 시험기관 적합성 평가」에서 별도로 명시하였습니다.

검증 가능한 범위

  • 본 문서에 수록된 코드는 난수 생성기 및 게임 서버의 실제 소스 코드에서 수정이나 재작성 없이 발췌되었습니다.
  • 공개된 코드를 기준으로 난수 생성기의 주요 동작 구조와 카드 셔플 및 배분 과정의 구현 방식을 분석할 수 있습니다.
  • Fortuna 원 설계의 구조와 안전성에 관한 이론적 근거는 참고 문헌의 Cryptography Engineering Chapter 9, “Generating Randomness”에서 확인할 수 있습니다.

본 문서만으로 검증할 수 없는 범위

  • 실제 운영 서버에서 실행 중인 바이너리와 본 문서에 수록된 소스 코드의 일치 여부
  • incrementCtr(), checkIfShouldCloseLocked(), 풀 내부의 addEntropy()extractAndReset() 등 본문에 함수 구현이 수록되지 않은 부분의 세부 동작
  • 실제 배포 환경의 C++ 표준 라이브러리에서 수행되는 std::shuffle 내부 구현 및 그 결과 분포에 대한 별도 통계 검증

따라서 본 문서의 검증 범위는 공개된 코드에서 직접 확인 가능한 난수 생성, 카드 셔플 및 카드 배분 구조로 한정됩니다.


10. 이용 조건

본 문서에 수록된 코드는 열람 및 검증 목적으로만 제공됩니다. 별도의 허가 없이 복제, 수정, 재배포하거나 상업적으로 이용할 수 없습니다.


참고 문헌

  • Niels Ferguson, Bruce Schneier, Tadayoshi Kohno, Cryptography Engineering, Wiley, 2010, Chapter 9, “Generating Randomness.”
  • National Institute of Standards and Technology (NIST), FIPS 197: Advanced Encryption Standard (AES).
  • National Institute of Standards and Technology (NIST), FIPS 198-1: The Keyed-Hash Message Authentication Code (HMAC).
  • National Institute of Standards and Technology (NIST), SP 800-38A: Recommendation for Block Cipher Modes of Operation — Methods and Techniques.
  • The Go Project, crypto/rand package documentation.
  • ISO/IEC 14882, Programming Languages — C++.