feat: 해시 기반 간이 DRBG, 그에 기반한 RandUtil (#208)

기존의 Util 내의 함수는 mt_rand에 기반하고 있어서 일반적으론 충분하지만,
게임 내부에서 랜덤 값을 정교하게 제어하고 싶은 경우에는 적절하지 않았음.

따라서 직접 seed를 제어할 수 있는 난수생성기를 추가.
SHA512(seed || blockIdx) 의 형태로 동작하는 DRBG이며, FIPS 표준을 따르는 RNG는 아니지만 암호학적으로 안전한 형태로 구현함.

PHP, TS 두가지 형태로 구현.

Reviewed-on: https://storage.hided.net/gitea/devsam/core/pulls/208
Co-authored-by: hide_d <hided62@gmail.com>
Co-committed-by: hide_d <hided62@gmail.com>
This commit is contained in:
2022-03-12 23:53:16 +09:00
parent c220b3d511
commit 0ab485f122
15 changed files with 1620 additions and 4 deletions
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace sammo;
//NOTE: JavaScript 버전과 일치
const MAX_RNG_SUPPORT_BIT = 53;
if (PHP_INT_SIZE * 8 < MAX_RNG_SUPPORT_BIT) {
throw new \RangeException("PHP not support {$MAX_RNG_SUPPORT_BIT} bit integer");
}
/**
* Reseed를 하지 않는 단순한 형태의 sha512 drbg
* float: bit를 강제로 채움
**/
class LiteHashDRBG implements RNG
{
const MAX_INT = (1 << MAX_RNG_SUPPORT_BIT) - 1;
const BUFFER_BYTE_SIZE = 512 / 8; //SHA512
protected string $buffer;
protected int $bufferIdx;
public function __construct(protected string $seed, protected int $stateIdx = 0, int $bufferIdx = 0)
{
if($bufferIdx < 0){
throw new \InvalidArgumentException("bufferIdx {$bufferIdx} < 0");
}
if($bufferIdx >= self::BUFFER_BYTE_SIZE){
throw new \InvalidArgumentException("bufferIdx {$bufferIdx} >= ".self::BUFFER_BYTE_SIZE);
}
if($stateIdx < 0){
throw new \InvalidArgumentException("stateIdx {$stateIdx} < 0");
}
$this->genNextBlock();
$this->bufferIdx = $bufferIdx;
}
protected function genNextBlock(): void
{
$hq = $this->seed . pack('V', $this->stateIdx);
$this->buffer = hash('sha512', $hq, true);
$this->bufferIdx = 0;
$this->stateIdx += 1;
}
static function getMaxInt(): int
{
return self::MAX_INT;
}
const INT_BIT_MASK_MAP = [
0x1 => 1,
0x3 => 2,
0x7 => 3,
0xf => 4,
0x1f => 5,
0x3f => 6,
0x7f => 7,
0xff => 8,
0x1ff => 9,
0x3ff => 10,
0x7ff => 11,
0xfff => 12,
0x1fff => 13,
0x3fff => 14,
0x7fff => 15,
0xffff => 16,
0x1ffff => 17,
0x3ffff => 18,
0x7ffff => 19,
0xfffff => 20,
0x1fffff => 21,
0x3fffff => 22,
0x7fffff => 23,
0xffffff => 24,
0x1ffffff => 25,
0x3ffffff => 26,
0x7ffffff => 27,
0xfffffff => 28,
0x1fffffff => 29,
0x3fffffff => 30,
0x7fffffff => 31,
0xffffffff => 32,
0x1ffffffff => 33,
0x3ffffffff => 34,
0x7ffffffff => 35,
0xfffffffff => 36,
0x1fffffffff => 37,
0x3fffffffff => 38,
0x7fffffffff => 39,
0xffffffffff => 40,
0x1ffffffffff => 41,
0x3ffffffffff => 42,
0x7ffffffffff => 43,
0xfffffffffff => 44,
0x1fffffffffff => 45,
0x3fffffffffff => 46,
0x7fffffffffff => 47,
0xffffffffffff => 48,
0x1ffffffffffff => 49,
0x3ffffffffffff => 50,
0x7ffffffffffff => 51,
0xfffffffffffff => 52,
0x1fffffffffffff => 53,
];
private static function calcBitMask($n)
{
$n |= $n >> 1;
$n |= $n >> 2;
$n |= $n >> 4;
$n |= $n >> 8;
$n |= $n >> 16;
$n |= $n >> 32;
return $n;
}
public function nextBytes(int $bytes): string
{
if ($bytes <= 0) {
throw new \InvalidArgumentException("{$bytes} <= 0");
}
if ($this->bufferIdx + $bytes <= self::BUFFER_BYTE_SIZE) {
$buffer = substr($this->buffer, $this->bufferIdx, $bytes);
$this->bufferIdx += $bytes;
if ($this->bufferIdx == self::BUFFER_BYTE_SIZE) {
$this->genNextBlock();
}
return $buffer;
}
$result = [substr($this->buffer, $this->bufferIdx)];
$remain = $bytes - (self::BUFFER_BYTE_SIZE - $this->bufferIdx);
while ($remain > self::BUFFER_BYTE_SIZE) {
$this->genNextBlock();
$result[] = $this->buffer;
$remain -= self::BUFFER_BYTE_SIZE;
}
$this->genNextBlock();
if ($remain == 0) {
return join("", $result);
}
$result[] = substr($this->buffer, 0, $remain);
$this->bufferIdx = $remain;
return join("", $result);
}
public function nextBits(int $bits): string
{
$bytes = ($bits + 7) >> 3;
$headBits = $bits & 0x7;
$buffer = $this->nextBytes($bytes);
if ($headBits === 0) {
return $buffer;
}
$buffer[$bytes - 1] = chr(ord($buffer[$bytes - 1]) & (0xff >> (8 - $headBits)));
return $buffer;
}
static private function parseU64(string $value): int
{
return unpack('P', $value)[1];
}
private function _nextInt(int $bits): int
{
$buffer = $this->nextBits($bits) . "\x00\x00\x00\x00\x00\x00\x00";
return self::parseU64($buffer);
}
public function nextInt(?int $max = null): int
{
if ($max === null || $max === self::MAX_INT) {
$buffer = $this->nextBits(MAX_RNG_SUPPORT_BIT) . "\x00";
return self::parseU64($buffer);
}
if ($max > self::MAX_INT) {
throw new \InvalidArgumentException('Over Max Int');
} else if ($max === 0) {
return 0;
} else if ($max < 0) {
return -$this->nextInt(-$max);
}
$mask = self::calcBitMask($max);
$bits = self::INT_BIT_MASK_MAP[$mask];
$n = $this->_nextInt($bits);
while ($n > $max) {
$n = $this->_nextInt($bits);
}
return $n;
}
public function nextFloat1(): float
{
$max = 1 << MAX_RNG_SUPPORT_BIT;
while (true) {
$buffer = $this->nextBits(MAX_RNG_SUPPORT_BIT + 1) . "\x00";
$nInt = self::parseU64($buffer);
if ($nInt <= $max) {
break;
}
}
return $nInt / $max;
}
static function build(string $seed, int $idx = 0): self
{
return new LiteHashDRBG($seed, $idx);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace sammo;
interface RNG
{
/**
* @return int nextInt()가 반환 가능한 최대값
*/
public static function getMaxInt(): int;
/**
*
* @param int $bytes
* @return string Little Endian 형태로 채워진 binary 값
*/
public function nextBytes(int $bytes): string;
public function nextBits(int $bits): string;
/**
* @param ?int $max 최대치(해당 값 포함)
* @return int 0과 최대치 사이의 임의의 정수
*/
public function nextInt(?int $max = null): int;
public function nextFloat1(): float;
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace sammo;
class RandUtil
{
public function __construct(protected RNG $rng)
{
}
public function nextFloat1(): float
{
return $this->rng->nextFloat1();
}
public function nextRange(int|float $min, int|float $max): float
{
$range = $max - $min;
return $this->nextFloat1() * $range + $min;
}
public function nextRangeInt(int $min, int $max): int
{
$range = $max - $min;
if ($range > $this->rng->getMaxInt()) {
throw new \InvalidArgumentException("Invalid random int range");
}
return $this->rng->nextInt($range) + $min;
}
public function nextInt(?int $max = null): int{
return $this->rng->nextInt($max);
}
public function nextBit(): bool
{
return $this->rng->nextBits(1) !== "\0";
}
public function nextBool(int|float $prob = 0.5): bool
{
if ($prob >= 1) {
return true;
}
return $this->nextFloat1() < $prob;
}
public function shuffle(array $srcArray): array
{
if(!$srcArray){
return $srcArray;
}
$cnt = count($srcArray);
if ($cnt > $this->rng->getMaxInt()) {
throw new \InvalidArgumentException("Invalid random int range");
}
$result = [];
foreach ($srcArray as $val) {
$result[] = $val;
}
//PHP의 range는 max 포함
foreach (range(0, $cnt - 1) as $srcIdx) {
$destIdx = $this->rng->nextInt($cnt - $srcIdx - 1) + $srcIdx;
if($srcIdx === $destIdx){
continue;
}
$tmpVal = $result[$srcIdx];
$result[$srcIdx] = $result[$destIdx];
$result[$destIdx] = $tmpVal;
}
return $result;
}
public function shuffleAssoc(array $srcArray): array
{
if(!$srcArray){
return $srcArray;
}
$result = [];
foreach ($this->shuffle(array_keys($srcArray)) as $key) {
$result[$key] = $srcArray[$key];
}
return $result;
}
public function choice(array $items)
{
$keys = array_keys($items);
$keyIdx = $this->rng->nextInt(count($keys) - 1);
return $items[$keys[$keyIdx]];
}
public function choiceUsingWeight(array $items)
{
$sum = 0;
foreach ($items as $value) {
if ($value <= 0) {
continue;
}
$sum += $value;
}
$rd = $this->nextFloat1() * $sum;
foreach ($items as $item => $value) {
if ($value <= 0) {
$value = 0;
}
if ($rd <= $value) {
return $item;
}
$rd -= $value;
}
//fallback. 이곳으로 빠지지 않음
end($items);
return $items[key($items)][0];
}
public function choiceUsingWeightPair(array $items)
{
$sum = 0;
foreach ($items as [$item, $value]) {
if ($value <= 0) {
continue;
}
$sum += $value;
}
$rd = $this->nextFloat1() * $sum;
foreach ($items as [$item, $value]) {
if ($value <= 0) {
$value = 0;
}
if ($rd <= $value) {
return $item;
}
$rd -= $value;
}
//fallback. 이곳으로 빠지지 않음
end($items);
return $items[key($items)][0];
}
}
-1
View File
@@ -809,5 +809,4 @@ class Util extends \utilphp\util
}
}
}
};