From 65a2bd12197d5151d1d88e4b4c280506f4b045bc Mon Sep 17 00:00:00 2001 From: corgab Date: Sun, 6 Sep 2026 03:13:03 +0000 Subject: [PATCH 1/2] fix: measure whole bytes of entropy instead of zero-padding the last one generateEntropy() sized the shots for the exact bit count requested, and bitstringToBytes() turned a final chunk shorter than 8 bits into a byte whose high bits were always zero, so generate(12) returned a second byte that could never exceed 15. The driver now rounds the request up to whole bytes before computing shots and requires the device to return that many bits, so every returned byte is fully measured; bitstringToBytes() rejects a bit string that is not a multiple of 8 binary digits instead of padding it silently. Closes #45 --- CLAUDE.md | 1 + README.md | 2 ++ src/Bridge/PythonBridge.php | 16 +++++++-- src/Contracts/PythonExecutor.php | 4 ++- src/Drivers/AbstractQuantumDriver.php | 15 +++++--- src/Entropy/EntropyGenerator.php | 4 +++ tests/Unit/Bridge/PythonBridgeTest.php | 11 ++++++ .../Drivers/AbstractQuantumDriverTest.php | 35 +++++++++++++++++++ 8 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..469d2ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Quantum (Facade) - **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). - **QPU safety:** Drivers with `synchronous_safe: false` throw on `->run()` to prevent HTTP timeouts. - **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo. +- **Entropy is fetched in whole bytes:** `generateEntropy($bits)` rounds the request up to a multiple of 8 before computing shots, and `PythonBridge::bitstringToBytes()` rejects a bit string that is not a multiple of 8, so no byte is ever zero-padded. ## Config diff --git a/README.md b/README.md index a59f1ac..a22df2c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ $hex = $entropy->hex(128); // 32-char hex string $roll = $entropy->integer(1, 6); // unbiased die roll (rejection sampling) ``` +`generate($bits)` returns `ceil($bits / 8)` bytes. A bit count that is not a multiple of 8 is rounded up before the device is asked, so the last byte is measured in full rather than zero-padded: `generate(12)` measures 16 bits and returns 2 fully random bytes. + ### Batch Execution Run several circuits in a single Python process instead of paying the interpreter start-up cost once per circuit. The results come back as a `BatchResult`, ordered like the input, which is arrayable, jsonable, countable and iterable over the individual `CircuitResult` objects. diff --git a/src/Bridge/PythonBridge.php b/src/Bridge/PythonBridge.php index a7a0a0d..20dedbd 100644 --- a/src/Bridge/PythonBridge.php +++ b/src/Bridge/PythonBridge.php @@ -135,15 +135,25 @@ public function scriptsPath(): string /** * Convert a binary digit string (e.g. "10110011") into raw bytes. + * + * The string must hold a whole number of bytes: bindec() would silently + * left-pad a shorter final chunk with zeros, producing a byte whose high + * bits are deterministic rather than measured. + * + * @throws \InvalidArgumentException When the string is not a multiple of 8 binary digits. */ public function bitstringToBytes(string $bitstring): string { + if ($bitstring === '' || preg_match('/^[01]+$/', $bitstring) !== 1 || strlen($bitstring) % 8 !== 0) { + throw new \InvalidArgumentException( + 'Bit string must be a non-empty sequence of 0/1 digits whose length is a multiple of 8, got '.strlen($bitstring).' character(s).' + ); + } + $bytes = ''; foreach (str_split($bitstring, 8) as $chunk) { - // & 0xFF keeps the value in chr()'s 0-255 range (each chunk is at - // most 8 bits, so this is a no-op for valid input). - $bytes .= chr(((int) bindec($chunk)) & 0xFF); + $bytes .= chr((int) bindec($chunk)); } return $bytes; diff --git a/src/Contracts/PythonExecutor.php b/src/Contracts/PythonExecutor.php index 22ff647..3bee581 100644 --- a/src/Contracts/PythonExecutor.php +++ b/src/Contracts/PythonExecutor.php @@ -19,7 +19,9 @@ interface PythonExecutor public function execute(string $script, array $payload, array $driverConfig = []): array; /** - * Convert a binary digit string into raw bytes. + * Convert a binary digit string holding a whole number of bytes into raw bytes. + * + * @throws \InvalidArgumentException When the string is not a multiple of 8 binary digits. */ public function bitstringToBytes(string $bitstring): string; diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..da2d06c 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -344,6 +344,10 @@ protected function pollTask(string $taskArn): TaskSnapshot return TaskSnapshot::fromResponse($response); } + /** + * Returns ceil($bits / 8) bytes, every bit of which was measured: the + * request is rounded up to whole bytes before it reaches the device. + */ public function generateEntropy(int $bits): string { $this->preflight(); @@ -357,7 +361,10 @@ public function generateEntropy(int $bits): string $qubits = 16; } - $shots = (int) ceil($bits / $qubits); + // Fetch whole bytes: a final chunk shorter than 8 bits would be + // zero-padded into a byte whose high bits are never random. + $bitsToFetch = (int) ceil($bits / 8) * 8; + $shots = (int) ceil($bitsToFetch / $qubits); $payload = $this->payload([ 'qubits' => $qubits, @@ -373,14 +380,14 @@ public function generateEntropy(int $bits): string ); } - if (strlen($response['bits']) < $bits) { + if (strlen($response['bits']) < $bitsToFetch) { throw QuantumExecutionException::malformedResponse( 'entropy.py', - "expected at least {$bits} bits in the response, got ".strlen($response['bits']).'.' + "expected at least {$bitsToFetch} bits in the response, got ".strlen($response['bits']).'.' ); } - $bitstring = substr($response['bits'], 0, $bits); + $bitstring = substr($response['bits'], 0, $bitsToFetch); $this->dispatchEvent(new EntropyGenerated($this->driverName(), $bits)); diff --git a/src/Entropy/EntropyGenerator.php b/src/Entropy/EntropyGenerator.php index 5d19e47..7b86bfa 100644 --- a/src/Entropy/EntropyGenerator.php +++ b/src/Entropy/EntropyGenerator.php @@ -29,6 +29,10 @@ public function __construct(private readonly QuantumDevice $device) {} /** * Generate raw entropy bytes. + * + * Returns ceil($bits / 8) bytes. A bit count that is not a multiple of 8 + * is rounded up before it reaches the device, so every returned byte is + * fully measured rather than zero-padded. */ public function generate(int $bits): string { diff --git a/tests/Unit/Bridge/PythonBridgeTest.php b/tests/Unit/Bridge/PythonBridgeTest.php index bcdb1b4..7544ae6 100644 --- a/tests/Unit/Bridge/PythonBridgeTest.php +++ b/tests/Unit/Bridge/PythonBridgeTest.php @@ -180,6 +180,17 @@ function fakePython(string $shBody): string // bitstringToBytes() // ------------------------------------------------------------------------- +it('rejects a bit string that does not hold whole bytes', function (string $bitstring) { + $bridge = new PythonBridge('python3'); + + expect(fn () => $bridge->bitstringToBytes($bitstring)) + ->toThrow(InvalidArgumentException::class, 'multiple of 8'); +})->with([ + 'short final chunk' => ['110011001010'], + 'empty' => [''], + 'non-binary digit' => ['0000000a'], +]); + it('converts a binary digit string into raw bytes', function () { $bridge = new PythonBridge('python3'); diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..d8a3c50 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -233,6 +233,41 @@ protected function beforeExecution(): void $this->driver->generateEntropy(16); })->throws(QuantumExecutionException::class); +it('rounds a bit count up to whole bytes before asking the device', function (int $bits, int $qubits, int $shots, int $fetched) { + $bridge = $this->createMock(PythonExecutor::class); + $bridge->expects($this->once()) + ->method('execute') + ->with('entropy.py', $this->callback(fn (array $p): bool => $p['qubits'] === $qubits && $p['shots'] === $shots), $this->anything()) + ->willReturn(['bits' => str_repeat('1', $shots * $qubits)]); + $bridge->expects($this->once()) + ->method('bitstringToBytes') + ->with(str_repeat('1', $fetched)) + ->willReturn(str_repeat("\xff", intdiv($fetched, 8))); + + $driver = new class($bridge, ['entropy_qubits' => $qubits]) extends AbstractQuantumDriver + { + protected function driverName(): string + { + return 'test'; + } + }; + + expect($driver->generateEntropy($bits))->toBe(str_repeat("\xff", intdiv($fetched, 8))); +})->with([ + '12 bits on 16 qubits' => [12, 16, 1, 16], + '9 bits on 4 qubits' => [9, 4, 4, 16], + '17 bits on 16 qubits' => [17, 16, 2, 24], + '16 bits on 16 qubits' => [16, 16, 1, 16], +]); + +it('requires the device to return the rounded-up bit count', function () { + // 12 requested bits need 16 measured bits; 12 is no longer enough. + $this->bridge->method('execute')->willReturn(['bits' => str_repeat('1', 12)]); + + expect(fn () => $this->driver->generateEntropy(12)) + ->toThrow(QuantumExecutionException::class, 'expected at least 16 bits'); +}); + // ------------------------------------------------------------------------- // entropy_qubits clamping // ------------------------------------------------------------------------- From 6ffb212f7ae16198e69141d394628162b6b26228 Mon Sep 17 00:00:00 2001 From: corgab Date: Sun, 6 Sep 2026 03:17:28 +0000 Subject: [PATCH 2/2] fix: validate entropy.py output in the driver and keep the bridge guard strict Non-binary output from entropy.py is now rejected by the driver as a malformed response, so it surfaces as a QuantumExecutionException with the script name rather than as the bridge's argument error. The bridge guard anchors its pattern with /D so a trailing newline cannot slip through, drops the redundant empty-string clause and keeps the 0xFF mask that types the chr() argument. The QuantumDevice contract now documents the whole-byte guarantee, and the README no longer states a measured count that depends on entropy_qubits. --- README.md | 2 +- src/Bridge/PythonBridge.php | 8 +++++--- src/Contracts/QuantumDevice.php | 5 ++++- src/Drivers/AbstractQuantumDriver.php | 7 +++++++ tests/Unit/Bridge/PythonBridgeTest.php | 1 + tests/Unit/Drivers/AbstractQuantumDriverTest.php | 7 +++++++ 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a22df2c..d31377c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ $hex = $entropy->hex(128); // 32-char hex string $roll = $entropy->integer(1, 6); // unbiased die roll (rejection sampling) ``` -`generate($bits)` returns `ceil($bits / 8)` bytes. A bit count that is not a multiple of 8 is rounded up before the device is asked, so the last byte is measured in full rather than zero-padded: `generate(12)` measures 16 bits and returns 2 fully random bytes. +`generate($bits)` returns `ceil($bits / 8)` bytes. A bit count that is not a multiple of 8 is rounded up before the device is asked, so the last byte is measured in full rather than zero-padded: `generate(12)` fetches at least 16 bits (exactly 16 with the default `entropy_qubits` of 16) and returns 2 fully random bytes. ### Batch Execution diff --git a/src/Bridge/PythonBridge.php b/src/Bridge/PythonBridge.php index 20dedbd..7b4a196 100644 --- a/src/Bridge/PythonBridge.php +++ b/src/Bridge/PythonBridge.php @@ -144,16 +144,18 @@ public function scriptsPath(): string */ public function bitstringToBytes(string $bitstring): string { - if ($bitstring === '' || preg_match('/^[01]+$/', $bitstring) !== 1 || strlen($bitstring) % 8 !== 0) { + if (preg_match('/^[01]+$/D', $bitstring) !== 1 || strlen($bitstring) % 8 !== 0) { throw new \InvalidArgumentException( - 'Bit string must be a non-empty sequence of 0/1 digits whose length is a multiple of 8, got '.strlen($bitstring).' character(s).' + 'Bit string must be a sequence of 0/1 digits whose length is a multiple of 8, got '.strlen($bitstring).' character(s): '.var_export($bitstring, true) ); } $bytes = ''; foreach (str_split($bitstring, 8) as $chunk) { - $bytes .= chr((int) bindec($chunk)); + // The guard above makes every chunk exactly 8 binary digits; the + // mask only narrows the type to chr()'s 0-255 range. + $bytes .= chr(((int) bindec($chunk)) & 0xFF); } return $bytes; diff --git a/src/Contracts/QuantumDevice.php b/src/Contracts/QuantumDevice.php index a95f2c6..03acaf4 100644 --- a/src/Contracts/QuantumDevice.php +++ b/src/Contracts/QuantumDevice.php @@ -18,7 +18,10 @@ interface QuantumDevice public function executeCircuit(CircuitBuilder $circuit): CircuitResult; /** - * Generate a cryptographically strong random bit-string of the requested length. + * Generate cryptographically strong random bytes covering the requested bit count. + * + * Returns ceil($bits / 8) raw bytes; a bit count that is not a multiple of + * 8 is rounded up so every byte is fully random rather than zero-padded. */ public function generateEntropy(int $bits): string; } diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index da2d06c..b495605 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -380,6 +380,13 @@ public function generateEntropy(int $bits): string ); } + if (preg_match('/^[01]*$/D', $response['bits']) !== 1) { + throw QuantumExecutionException::malformedResponse( + 'entropy.py', + 'expected the "bits" value to contain only 0 and 1 digits.' + ); + } + if (strlen($response['bits']) < $bitsToFetch) { throw QuantumExecutionException::malformedResponse( 'entropy.py', diff --git a/tests/Unit/Bridge/PythonBridgeTest.php b/tests/Unit/Bridge/PythonBridgeTest.php index 7544ae6..0fd9a52 100644 --- a/tests/Unit/Bridge/PythonBridgeTest.php +++ b/tests/Unit/Bridge/PythonBridgeTest.php @@ -189,6 +189,7 @@ function fakePython(string $shBody): string 'short final chunk' => ['110011001010'], 'empty' => [''], 'non-binary digit' => ['0000000a'], + 'trailing newline' => ["1111111\n"], ]); it('converts a binary digit string into raw bytes', function () { diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index d8a3c50..d07a6a1 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -260,6 +260,13 @@ protected function driverName(): string '16 bits on 16 qubits' => [16, 16, 1, 16], ]); +it('rejects entropy.py output that is not made of binary digits', function () { + $this->bridge->method('execute')->willReturn(['bits' => 'abcdefgh']); + + expect(fn () => $this->driver->generateEntropy(8)) + ->toThrow(QuantumExecutionException::class, 'only 0 and 1 digits'); +}); + it('requires the device to return the rounded-up bit count', function () { // 12 requested bits need 16 measured bits; 12 is no longer enough. $this->bridge->method('execute')->willReturn(['bits' => str_repeat('1', 12)]);