|
23 | 23 | from __future__ import annotations |
24 | 24 |
|
25 | 25 | import struct |
| 26 | +from abc import ABC, abstractmethod |
26 | 27 | from collections.abc import MutableSequence, Sequence |
27 | 28 | from typing import Any, TypeVar |
28 | 29 |
|
|
32 | 33 | T = TypeVar("T") |
33 | 34 |
|
34 | 35 |
|
35 | | -class EntropyTap: |
| 36 | +class BaseTap(ABC): |
36 | 37 | """ |
37 | | - Extracts and formats random values from an entropy pool. |
38 | | -
|
39 | | - The tap is responsible for converting raw entropy bytes into |
40 | | - various formats like floats, integers, and booleans. It ensures |
41 | | - that all generated values are uniformly distributed. |
| 38 | + Abstract base class for entropy taps. |
42 | 39 |
|
43 | | - Example: |
44 | | - >>> pool = EntropyPool() |
45 | | - >>> tap = EntropyTap(pool) |
46 | | - >>> value = tap.random() |
47 | | - >>> print(f"Random: {value}") |
| 40 | + Provides common utility methods and higher-level distributions |
| 41 | + dependent on the core random primitives. |
48 | 42 | """ |
49 | 43 |
|
50 | | - # ------------------------------------------------------------------------- |
51 | | - # Initialization |
52 | | - # ------------------------------------------------------------------------- |
53 | | - |
54 | | - def __init__(self, pool: EntropyPool) -> None: |
55 | | - """ |
56 | | - Initialize the tap with an entropy pool. |
57 | | -
|
58 | | - Args: |
59 | | - pool: The EntropyPool instance to extract entropy from |
60 | | - """ |
61 | | - self._pool = pool |
62 | | - |
63 | | - # ------------------------------------------------------------------------- |
64 | | - # Random Value Generation |
65 | | - # ------------------------------------------------------------------------- |
66 | | - |
| 44 | + @abstractmethod |
67 | 45 | def random(self) -> float: |
68 | | - """ |
69 | | - Generate a random float in the range [0.0, 1.0). |
70 | | -
|
71 | | - Uses 64 bits of entropy to generate a uniformly distributed |
72 | | - floating-point number. The result is always less than 1.0. |
73 | | -
|
74 | | - Returns: |
75 | | - A float value where 0.0 <= value < 1.0 |
76 | | -
|
77 | | - How it works: |
78 | | - 1. Extract 8 bytes (64 bits) from the pool |
79 | | - 2. Interpret as unsigned 64-bit integer |
80 | | - 3. Divide by 2^64 to get value in [0, 1) |
81 | | - """ |
82 | | - # Extract 8 bytes of entropy |
83 | | - raw_bytes = self._pool.extract(8) |
84 | | - |
85 | | - # Unpack as unsigned 64-bit integer (big-endian) |
86 | | - # We use big-endian for consistency across platforms |
87 | | - value = struct.unpack("!Q", raw_bytes)[0] |
88 | | - |
89 | | - # Convert to float in range [0.0, 1.0) |
90 | | - # 2^64 = 18446744073709551616 |
91 | | - return value / 18446744073709551616.0 |
| 46 | + """Generate a random float in the range [0.0, 1.0).""" |
| 47 | + pass |
92 | 48 |
|
| 49 | + @abstractmethod |
93 | 50 | def randint(self, a: int, b: int) -> int: |
94 | | - """ |
95 | | - Generate a random integer N such that a <= N <= b. |
96 | | -
|
97 | | - Uses rejection sampling to ensure uniform distribution. |
98 | | - This avoids modulo bias that would occur with simple modulo. |
99 | | -
|
100 | | - Args: |
101 | | - a: Lower bound (inclusive) |
102 | | - b: Upper bound (inclusive) |
103 | | -
|
104 | | - Returns: |
105 | | - Random integer in [a, b] |
106 | | -
|
107 | | - Raises: |
108 | | - ValueError: If a > b |
109 | | -
|
110 | | - How it works: |
111 | | - 1. Calculate the range size (b - a + 1) |
112 | | - 2. Find the smallest number of bits needed to represent range |
113 | | - 3. Generate random bits and check if value < range |
114 | | - 4. If not, reject and try again (rejection sampling) |
115 | | - 5. This ensures perfectly uniform distribution |
116 | | - """ |
117 | | - if a > b: |
118 | | - raise ValueError(f"randint: a ({a}) must be <= b ({b})") |
| 51 | + """Generate a random integer N such that a <= N <= b.""" |
| 52 | + pass |
119 | 53 |
|
120 | | - if a == b: |
121 | | - return a # Only one possible value |
122 | | - |
123 | | - # Calculate range size |
124 | | - range_size = b - a + 1 |
125 | | - |
126 | | - # Find number of bits needed to represent range_size |
127 | | - # We need ceil(log2(range_size)) bits |
128 | | - bits_needed = (range_size - 1).bit_length() |
129 | | - bytes_needed = (bits_needed + 7) // 8 # Round up to bytes |
130 | | - |
131 | | - # Mask to extract only the bits we need |
132 | | - # e.g., for range_size=100, bits_needed=7, mask=0x7F (127) |
133 | | - mask = (1 << bits_needed) - 1 |
134 | | - |
135 | | - # Rejection sampling loop |
136 | | - # We keep generating random values until we get one in range |
137 | | - # Expected number of iterations is < 2 on average |
138 | | - while True: |
139 | | - # Extract random bytes |
140 | | - raw_bytes = self._pool.extract(bytes_needed) |
141 | | - |
142 | | - # Pad to 8 bytes for unpacking (big-endian) |
143 | | - padded = raw_bytes.rjust(8, b"\x00") |
144 | | - |
145 | | - # Unpack as unsigned 64-bit integer |
146 | | - value = struct.unpack("!Q", padded)[0] |
147 | | - |
148 | | - # Apply mask to get only needed bits |
149 | | - value = value & mask |
150 | | - |
151 | | - # Check if value is in valid range |
152 | | - if value < range_size: |
153 | | - return a + value |
| 54 | + @abstractmethod |
| 55 | + def randbytes(self, n: int) -> bytes: |
| 56 | + """Generate n random bytes.""" |
| 57 | + pass |
154 | 58 |
|
155 | 59 | def randbool(self) -> bool: |
156 | 60 | """ |
157 | 61 | Generate a random boolean (True or False). |
158 | 62 |
|
159 | | - Each value has exactly 50% probability - a fair coin flip. |
160 | | -
|
161 | | - Returns: |
162 | | - True or False with equal probability |
163 | | -
|
164 | | - How it works: |
165 | | - 1. Extract 1 byte from the pool |
166 | | - 2. Check the least significant bit |
167 | | - 3. Return True if bit is 1, False if 0 |
| 63 | + Default implementation uses random(). Can be overridden for efficiency. |
168 | 64 | """ |
169 | | - # Extract 1 byte of entropy |
170 | | - raw_byte = self._pool.extract(1) |
171 | | - |
172 | | - # Check least significant bit |
173 | | - return (raw_byte[0] & 1) == 1 |
174 | | - |
175 | | - def randbytes(self, n: int) -> bytes: |
176 | | - """ |
177 | | - Generate n random bytes. |
178 | | -
|
179 | | - Args: |
180 | | - n: Number of bytes to generate (must be positive) |
181 | | -
|
182 | | - Returns: |
183 | | - A bytes object of length n |
184 | | -
|
185 | | - Raises: |
186 | | - ValueError: If n is not positive |
187 | | - """ |
188 | | - if n <= 0: |
189 | | - raise ValueError(f"randbytes: n ({n}) must be positive") |
190 | | - |
191 | | - return self._pool.extract(n) |
| 65 | + return self.random() < 0.5 |
192 | 66 |
|
193 | 67 | def choice(self, seq: Sequence[T]) -> T: |
194 | 68 | """ |
@@ -586,6 +460,165 @@ def random_password( |
586 | 460 | # Generate password by choosing random characters |
587 | 461 | return "".join(self.choice(chars) for _ in range(length)) |
588 | 462 |
|
| 463 | + |
| 464 | +class EntropyTap(BaseTap): |
| 465 | + """ |
| 466 | + Extracts and formats random values from an entropy pool. |
| 467 | +
|
| 468 | + The tap is responsible for converting raw entropy bytes into |
| 469 | + various formats like floats, integers, and booleans. It ensures |
| 470 | + that all generated values are uniformly distributed. |
| 471 | +
|
| 472 | + Example: |
| 473 | + >>> pool = EntropyPool() |
| 474 | + >>> tap = EntropyTap(pool) |
| 475 | + >>> value = tap.random() |
| 476 | + >>> print(f"Random: {value}") |
| 477 | + """ |
| 478 | + |
| 479 | + # ------------------------------------------------------------------------- |
| 480 | + # Initialization |
| 481 | + # ------------------------------------------------------------------------- |
| 482 | + |
| 483 | + def __init__(self, pool: EntropyPool) -> None: |
| 484 | + """ |
| 485 | + Initialize the tap with an entropy pool. |
| 486 | +
|
| 487 | + Args: |
| 488 | + pool: The EntropyPool instance to extract entropy from |
| 489 | + """ |
| 490 | + self._pool = pool |
| 491 | + |
| 492 | + # ------------------------------------------------------------------------- |
| 493 | + # Random Value Generation |
| 494 | + # ------------------------------------------------------------------------- |
| 495 | + |
| 496 | + def random(self) -> float: |
| 497 | + """ |
| 498 | + Generate a random float in the range [0.0, 1.0). |
| 499 | +
|
| 500 | + Uses 64 bits of entropy to generate a uniformly distributed |
| 501 | + floating-point number. The result is always less than 1.0. |
| 502 | +
|
| 503 | + Returns: |
| 504 | + A float value where 0.0 <= value < 1.0 |
| 505 | +
|
| 506 | + How it works: |
| 507 | + 1. Extract 8 bytes (64 bits) from the pool |
| 508 | + 2. Interpret as unsigned 64-bit integer |
| 509 | + 3. Divide by 2^64 to get value in [0, 1) |
| 510 | + """ |
| 511 | + # Extract 8 bytes of entropy |
| 512 | + raw_bytes = self._pool.extract(8) |
| 513 | + |
| 514 | + # Unpack as unsigned 64-bit integer (big-endian) |
| 515 | + # We use big-endian for consistency across platforms |
| 516 | + value = struct.unpack("!Q", raw_bytes)[0] |
| 517 | + |
| 518 | + # Convert to float in range [0.0, 1.0) |
| 519 | + # 2^64 = 18446744073709551616 |
| 520 | + return value / 18446744073709551616.0 |
| 521 | + |
| 522 | + def randint(self, a: int, b: int) -> int: |
| 523 | + """ |
| 524 | + Generate a random integer N such that a <= N <= b. |
| 525 | +
|
| 526 | + Uses rejection sampling to ensure uniform distribution. |
| 527 | + This avoids modulo bias that would occur with simple modulo. |
| 528 | +
|
| 529 | + Args: |
| 530 | + a: Lower bound (inclusive) |
| 531 | + b: Upper bound (inclusive) |
| 532 | +
|
| 533 | + Returns: |
| 534 | + Random integer in [a, b] |
| 535 | +
|
| 536 | + Raises: |
| 537 | + ValueError: If a > b |
| 538 | +
|
| 539 | + How it works: |
| 540 | + 1. Calculate the range size (b - a + 1) |
| 541 | + 2. Find the smallest number of bits needed to represent range |
| 542 | + 3. Generate random bits and check if value < range |
| 543 | + 4. If not, reject and try again (rejection sampling) |
| 544 | + 5. This ensures perfectly uniform distribution |
| 545 | + """ |
| 546 | + if a > b: |
| 547 | + raise ValueError(f"randint: a ({a}) must be <= b ({b})") |
| 548 | + |
| 549 | + if a == b: |
| 550 | + return a # Only one possible value |
| 551 | + |
| 552 | + # Calculate range size |
| 553 | + range_size = b - a + 1 |
| 554 | + |
| 555 | + # Find number of bits needed to represent range_size |
| 556 | + # We need ceil(log2(range_size)) bits |
| 557 | + bits_needed = (range_size - 1).bit_length() |
| 558 | + bytes_needed = (bits_needed + 7) // 8 # Round up to bytes |
| 559 | + |
| 560 | + # Mask to extract only the bits we need |
| 561 | + # e.g., for range_size=100, bits_needed=7, mask=0x7F (127) |
| 562 | + mask = (1 << bits_needed) - 1 |
| 563 | + |
| 564 | + # Rejection sampling loop |
| 565 | + # We keep generating random values until we get one in range |
| 566 | + # Expected number of iterations is < 2 on average |
| 567 | + while True: |
| 568 | + # Extract random bytes |
| 569 | + raw_bytes = self._pool.extract(bytes_needed) |
| 570 | + |
| 571 | + # Pad to 8 bytes for unpacking (big-endian) |
| 572 | + padded = raw_bytes.rjust(8, b"\x00") |
| 573 | + |
| 574 | + # Unpack as unsigned 64-bit integer |
| 575 | + value = struct.unpack("!Q", padded)[0] |
| 576 | + |
| 577 | + # Apply mask to get only needed bits |
| 578 | + value = value & mask |
| 579 | + |
| 580 | + # Check if value is in valid range |
| 581 | + if value < range_size: |
| 582 | + return a + value |
| 583 | + |
| 584 | + def randbool(self) -> bool: |
| 585 | + """ |
| 586 | + Generate a random boolean (True or False). |
| 587 | +
|
| 588 | + Each value has exactly 50% probability - a fair coin flip. |
| 589 | +
|
| 590 | + Returns: |
| 591 | + True or False with equal probability |
| 592 | +
|
| 593 | + How it works: |
| 594 | + 1. Extract 1 byte from the pool |
| 595 | + 2. Check the least significant bit |
| 596 | + 3. Return True if bit is 1, False if 0 |
| 597 | + """ |
| 598 | + # Extract 1 byte of entropy |
| 599 | + raw_byte = self._pool.extract(1) |
| 600 | + |
| 601 | + # Check least significant bit |
| 602 | + return (raw_byte[0] & 1) == 1 |
| 603 | + |
| 604 | + def randbytes(self, n: int) -> bytes: |
| 605 | + """ |
| 606 | + Generate n random bytes. |
| 607 | +
|
| 608 | + Args: |
| 609 | + n: Number of bytes to generate (must be positive) |
| 610 | +
|
| 611 | + Returns: |
| 612 | + A bytes object of length n |
| 613 | +
|
| 614 | + Raises: |
| 615 | + ValueError: If n is not positive |
| 616 | + """ |
| 617 | + if n <= 0: |
| 618 | + raise ValueError(f"randbytes: n ({n}) must be positive") |
| 619 | + |
| 620 | + return self._pool.extract(n) |
| 621 | + |
589 | 622 | # ------------------------------------------------------------------------- |
590 | 623 | # String Representation |
591 | 624 | # ------------------------------------------------------------------------- |
|
0 commit comments