├── LICENSE.md ├── README.md ├── TODO.md ├── composer.json ├── docker-compose.yml └── src ├── IntToUuid.php └── IntegerId.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 WickedByte 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Integer ID To RFC 4122 UUID Converter 2 | 3 | Bidirectionally encodes a non-negative 64-bit unsigned "id" integer and optional 4 | 32-bit "namespace" integer into a valid 5 | [RFC 4122](https://www.rfc-editor.org/rfc/rfc4122) UUID, with the internet-draft 6 | [Version 8](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis-00#section-5.8) 7 | format. The id and namespace integers are encoded to obscure their value and 8 | produce non-sequential UUIDs, while guaranteeing uniqueness and reproducibility. 9 | 10 | This could be used to present an auto-incrementing integer "database id" as a 11 | UUID (proxy ID) in a public context, where you would not want to expose an 12 | enumerable, sequential value directly tied to your database structure/data. 13 | Since the encoded UUID can be converted back into integer namespace and id 14 | values at runtime, the UUID does not need to be persisted in the database or 15 | otherwise indexed to the ID it represents. 16 | 17 | **Note:** The integer ID and namespace values are only _encoded_ in the UUID, 18 | not _encrypted_, and the value can be recovered by a third party with effort. 19 | This library is intended to support on-demand conversion between an integer and 20 | a UUID, while mitigating basic "user enumeration attacks". Securely encrypting 21 | a 64-bit integer in the 122 bits available in a UUID is currently outside the 22 | scope of this library. 23 | 24 | ## Usage 25 | 26 | #### Encode ID with Default Namespace (0) to UUID 27 | 28 | ```php 29 | $id = \WickedByte\IntToUuid\IntegerId::make(12); 30 | $uuid = \WickedByte\IntToUuid\IntToUuid::encode($id); 31 | echo $uuid->toString(); // 14228ed0-822c-8d5d-b9c3-30d2a75c0e10 32 | ``` 33 | 34 | #### Encode ID with Namespace to UUID 35 | 36 | ```php 37 | $id = \WickedByte\IntToUuid\IntegerId::make(42, 12); 38 | $uuid = \WickedByte\IntToUuid\IntToUuid::encode($id); 39 | echo $uuid->toString(); // 97ed98ee-0994-8f79-b993-bcb7a2905968 40 | ``` 41 | 42 | #### Decode UUID to ID and Namespace Integers 43 | 44 | ```php 45 | $uuid = \Ramsey\Uuid\Uuid::fromString('97ed98ee-0994-8f79-b993-bcb7a2905968'); 46 | $id = \WickedByte\IntToUuid\IntToUuid::decode($uuid); 47 | echo $id->value; // 42 48 | echo $id->namespace; // 12 49 | ``` 50 | 51 | ### Conversion Algorithm 52 | 53 | Encoding an integer uses a deterministic seed based on the xxHash (`xxh3`) hash 54 | of the concatenated binary strings packed from the id and namespace values. The 55 | first 32-bits of the hash are used as the contiguous `time_hi_and_version`, 56 | `clock_seq_hi_and_reserved`, and `clock_seq_low` fields. To comply with the RFC 57 | 4122 Version 8, the seed is multiplexed with the required Version and Variant 58 | bits, leaving 26 bits of deterministic "pseudo-randomness". The encoded id is 59 | the id integer packed as a 64-bit binary string XOR the xxHash hash of the 60 | namespace and seed. The encoded namespace is the namespace integer packed as a 61 | 32-bit binary string XOR the xxHash hash of the seed. The resulting octets are 62 | arranged into a valid UUID and a new `UuidInterface` (from the `ramsey/uuid` 63 | library) is returned. 64 | 65 | Decoding is the reverse of the encoding process: the UUID octets are split into 66 | the encoded id, encoded namespace, and seed binary strings, XOR is applied to 67 | the encoded values and corresponding hashes, and a "checksum" seed is produced 68 | from the decoded binary strings, which are then unpacked into integer values. 69 | If the seed value from the UUID does not match the checksum seed, then UUID does 70 | not encode valid information, and an exception is thrown. An exception is also 71 | thrown if the UUID passed into the decode function is not a valid Version 8 72 | UUID. 73 | 74 | #### RFC 4122 UUID Field Names and Bit Layout 75 | 76 | ``` 77 | 0 1 2 3 78 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 79 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 80 | | time_low | 81 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 82 | | time_mid | time_hi_and_version | 83 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 84 | |clk_seq_hi_res | clk_seq_low | node (0-1) | 85 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 86 | | node (2-5) | 87 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 88 | ``` 89 | 90 | #### Encoded Integer ID Field and Bit Layout 91 | 92 | ``` 93 | 0 1 2 3 94 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 95 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 96 | | namespace | 97 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 98 | | id (0-1) | seed (0-1) | 99 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 100 | | seed (2-3) | id (2-3) | 101 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 102 | | id (4-7) | 103 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 104 | ``` 105 | 106 | ### Why RFC 4122 Version 8? 107 | 108 | The other UUID versions defined by RFC 4122 have distinct generation algorithms 109 | and properties. Versions 1, 2, 6, and 7 are based on the current timestamp. 110 | Version 3 (Name-Based MD5) and Version 5 (Name-Based SHA1) are deterministic 111 | for a string "name" and "namespace" values, but are unidirectional 112 | because they are based on hash functions. Version 4 (Random) comes the closest 113 | to fulfilling our needs: 122 of the 128 bits are randomly/pseudo-randomly 114 | generated. The same algorithm used here _could_ be used to generate encoded 115 | UUIDs that _look_ like Version 4 UUIDs, but they would not be technically 116 | compatible with the RFC definition, or have the expected universal uniqueness 117 | property. 118 | 119 | The proposed Version 8 defines a new, RFC-compatible format for experimental or 120 | vendor-defined UUIDs. The definition allows for both implementation-specific 121 | uniqueness and for the embedding of arbitrary information, both of which are key 122 | to this particular use case. While Version 8 is currently in the IETF review 123 | process, it is expected to be accepted without significant changes. 124 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## To Do List 2 | 3 | - [ ] Add and Configure Dependency: Rector 4 | - [ ] Add Dependency: parallel lint 5 | - [ ] Add CONTRIBUTING.md 6 | - [ ] Add SECURITY.md 7 | - [ ] Clean Up Coding Standards 8 | - [ ] Remove Unnecessary Parts of Dockerfile 9 | 10 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wickedbyte/int-to-uuid", 3 | "description": "Utility for Bidirectional Conversion of Integer Ids to UUIDs", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Andy Snell", 9 | "email": "andy@wickedbyte.com" 10 | } 11 | ], 12 | "config": { 13 | "sort-packages": true, 14 | "allow-plugins": { 15 | "dealerdirect/phpcodesniffer-composer-installer": true, 16 | "phpstan/extension-installer": true 17 | } 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "WickedByte\\IntToUuid\\": "src/" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "WickedByte\\Benchmarks\\IntToUuid\\": "benchmarks/", 27 | "WickedByte\\Tests\\IntToUuid\\": "tests/" 28 | } 29 | }, 30 | "require": { 31 | "php": "^8.3", 32 | "ramsey/uuid": "^4.7.6" 33 | }, 34 | "require-dev": { 35 | "php-parallel-lint/php-parallel-lint": "^1.4", 36 | "phpbench/phpbench": "^1.4.1", 37 | "phpstan/phpstan": "^2.1.17", 38 | "phpunit/phpunit": "^12.1.6", 39 | "rector/rector": "^2.0.16", 40 | "wickedbyte/coding-standard": "^1.0.1" 41 | }, 42 | "scripts": { 43 | "lint": "@php vendor/bin/parallel-lint -j $(nproc --ignore=2) --show-deprecated --exclude vendor --exclude build .", 44 | "phpcbf": "@php vendor/bin/phpcbf --parallel=$(nproc --ignore=2) --report=full", 45 | "phpcs": "@php vendor/bin/phpcs --parallel=$(nproc --ignore=2) --report=full", 46 | "phpstan": "@php vendor/bin/phpstan analyze --memory-limit=-1 --verbose", 47 | "test": [ 48 | "@putenv XDEBUG_MODE=off", 49 | "@php vendor/bin/phpunit" 50 | ], 51 | "test-coverage": [ 52 | "@putenv XDEBUG_MODE=coverage", 53 | "@php vendor/bin/phpunit --coverage-html=build/phpunit" 54 | ], 55 | "rector": "@php vendor/bin/rector process", 56 | "rector-dry-run": "@php vendor/bin/rector process --dry-run", 57 | "ci": [ 58 | "@lint", 59 | "@phpcs", 60 | "@phpstan", 61 | "@test", 62 | "@php vendor/bin/rector process --dry-run --clear-cache" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | php: 3 | build: 4 | context: ./ 5 | dockerfile: Dockerfile 6 | target: development 7 | platform: linux/amd64 8 | volumes: 9 | - ${SOURCE_DIR:-./}:/app 10 | environment: 11 | XDEBUG_MODE: "${XDEBUG_MODE:-debug}" 12 | XDEBUG_CONFIG: "${XDEBUG_CONFIG:-client_host=host.docker.internal start_with_request=trigger idekey=PHPSTORM output_dir=/app/build/xdebug}" 13 | extra_hosts: 14 | - host.docker.internal:host-gateway 15 | -------------------------------------------------------------------------------- /src/IntToUuid.php: -------------------------------------------------------------------------------- 1 | value); 25 | $namespace = \pack(self::INT32_UNSIGNED_BE, $integer_id->namespace); 26 | $seed = self::seed($id, $namespace); 27 | 28 | $id ^= self::hash($namespace . $seed); 29 | $namespace ^= self::hash($seed); 30 | 31 | return Uuid::fromBytes($namespace . \substr($id, 0, 2) . $seed . \substr($id, 2)); 32 | } 33 | 34 | public static function decode(\Stringable|string $uuid): IntegerId 35 | { 36 | $uuid = $uuid instanceof UuidInterface ? $uuid : Uuid::fromString((string)$uuid); 37 | if (!\preg_match(self::VALIDATION_REGEX, $uuid->toString())) { 38 | throw new \UnexpectedValueException('UUID Does Not Match Required RFC4122 v8 Format'); 39 | } 40 | 41 | $bytes = $uuid->getBytes(); 42 | $seed = \substr($bytes, 6, 4); 43 | $namespace = \substr($bytes, 0, 4); 44 | $id = \substr($bytes, 4, 2) . \substr($bytes, 10); 45 | 46 | $namespace ^= self::hash($seed); 47 | $id ^= self::hash($namespace . $seed); 48 | 49 | if (self::seed($id, $namespace) !== $seed) { 50 | throw new \LogicException("UUID Could Not Be Decoded Successfully"); 51 | } 52 | 53 | return IntegerId::make( 54 | self::unpack(self::INT64_UNSIGNED_BE, $id), 55 | self::unpack(self::INT32_UNSIGNED_BE, $namespace), 56 | ); 57 | } 58 | 59 | private static function hash(string $message): string 60 | { 61 | return \hash('xxh3', $message, true); 62 | } 63 | 64 | private static function seed(string $packed_id, string $packed_namespace): string 65 | { 66 | $hash = self::hash($packed_id . $packed_namespace); 67 | $seed = self::unpack(self::INT32_UNSIGNED_BE, \substr($hash, 0, 4)); 68 | return \pack(self::INT32_UNSIGNED_BE, $seed & 0x0FFF3FFF | 0x80008000); 69 | } 70 | 71 | private static function unpack(string $format, string $packed_string,): int 72 | { 73 | $data = \unpack($format, $packed_string) ?: throw new \LogicException('UUID Unpack Error'); 74 | // unpack returns 1-indexed array, so we need to use [1] to get the first (and only) value 75 | $value = $data[1] ?? throw new \LogicException('UUID Unpack Error'); 76 | return \is_int($value) ? $value : throw new \LogicException('UUID Unpack Error'); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/IntegerId.php: -------------------------------------------------------------------------------- 1 | $value 27 | * @phpstan-assert int<0,4294967295> $namespace 28 | */ 29 | public function __construct( 30 | public int $value, 31 | public int $namespace = self::ID_MIN, 32 | ) { 33 | if ($this->value < self::ID_MIN) { 34 | throw new \UnexpectedValueException(\vsprintf(self::ERROR_TEMPLATE, [ 35 | 'Value', 36 | self::ID_MIN, 37 | self::ID_MAX, 38 | $this->value, 39 | ])); 40 | } 41 | 42 | if ($this->namespace < self::NAMESPACE_MIN || $this->namespace > self::NAMESPACE_MAX) { 43 | throw new \UnexpectedValueException(\vsprintf(self::ERROR_TEMPLATE, [ 44 | 'Namespace', 45 | self::NAMESPACE_MIN, 46 | self::NAMESPACE_MAX, 47 | $this->namespace, 48 | ])); 49 | } 50 | } 51 | 52 | public static function make(int $value, int $namespace = self::ID_MIN): self 53 | { 54 | return new self($value, $namespace); 55 | } 56 | } 57 | --------------------------------------------------------------------------------