Skip to content

Commit b4718e6

Browse files
authored
Merge pull request #1 from tonyzorin/claude/security-audit-Vwtzv
Claude/security audit vwtzv
2 parents 4c0d67d + 086386b commit b4718e6

File tree

5 files changed

+222
-50
lines changed

5 files changed

+222
-50
lines changed

.github/workflows/ci.yml

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,55 +12,56 @@ jobs:
1212
strategy:
1313
matrix:
1414
os: [ubuntu-latest, windows-latest, macos-latest]
15-
python-version: ['3.13']
15+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
1616

1717
steps:
1818
- uses: actions/checkout@v4
19-
19+
2020
- name: Set up Python ${{ matrix.python-version }}
21-
uses: actions/setup-python@v4
21+
uses: actions/setup-python@v5
2222
with:
2323
python-version: ${{ matrix.python-version }}
24-
24+
allow-prereleases: true
25+
2526
- name: Install dependencies
2627
run: |
2728
python -m pip install --upgrade pip
2829
pip install pytest pytest-cov
29-
30+
3031
- name: Run tests
3132
run: |
3233
python -m pytest test_ksuid.py -v --cov=. --cov-report=xml
33-
34+
3435
- name: Run benchmarks
3536
run: |
3637
python benchmark.py
37-
38+
3839
- name: Test CLI
3940
run: |
40-
python -m ksuid.cli generate --count 5
41-
python -m ksuid.cli benchmark --count 1000
41+
python cli.py generate --count 5
42+
python cli.py benchmark --count 1000
4243
4344
lint:
4445
runs-on: ubuntu-latest
45-
46+
4647
steps:
4748
- uses: actions/checkout@v4
48-
49+
4950
- name: Set up Python
50-
uses: actions/setup-python@v4
51+
uses: actions/setup-python@v5
5152
with:
5253
python-version: '3.13'
53-
54+
5455
- name: Install dependencies
5556
run: |
5657
python -m pip install --upgrade pip
5758
pip install black flake8 mypy
58-
59+
5960
- name: Run black
6061
run: black --check .
61-
62+
6263
- name: Run flake8
6364
run: flake8 . --max-line-length=88 --extend-ignore=E203,W503
64-
65+
6566
- name: Run mypy
6667
run: mypy . --ignore-missing-imports

__init__.py

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
"""
2121

2222
import os
23+
import secrets
2324
import time
2425
from datetime import datetime, timezone
2526
from typing import Union, Optional
2627

2728
__version__ = "1.0.0"
28-
__all__ = ["KSUID", "generate", "from_string", "from_bytes"]
29+
__all__ = ["KSUID", "generate", "generate_token", "from_string", "from_bytes"]
2930

30-
# KSUID epoch (January 1, 2014 UTC)
31+
# KSUID epoch (May 13, 2014 16:53:20 UTC)
3132
EPOCH = 1400000000
3233

3334
# KSUID components
@@ -43,14 +44,16 @@
4344
class KSUID:
4445
"""
4546
K-Sortable Unique Identifier
46-
47+
4748
A KSUID is a 20-byte identifier consisting of:
4849
- 4-byte timestamp (seconds since KSUID epoch)
4950
- 16-byte random payload
50-
51+
5152
KSUIDs are naturally sortable by creation time and collision-resistant.
5253
"""
53-
54+
55+
__slots__ = ('_timestamp', '_payload', '_bytes')
56+
5457
def __init__(self, timestamp: Optional[int] = None, payload: Optional[bytes] = None):
5558
"""
5659
Create a new KSUID.
@@ -147,7 +150,7 @@ def __repr__(self) -> str:
147150

148151
def __eq__(self, other) -> bool:
149152
if not isinstance(other, KSUID):
150-
return False
153+
return NotImplemented
151154
return self._bytes == other._bytes
152155

153156
def __lt__(self, other) -> bool:
@@ -178,13 +181,10 @@ def _base62_encode(data: bytes) -> str:
178181
"""Encode bytes to base62 string."""
179182
if not data:
180183
return ""
181-
184+
182185
# Convert bytes to integer
183186
num = int.from_bytes(data, 'big')
184-
185-
if num == 0:
186-
return BASE62_ALPHABET[0]
187-
187+
188188
result = []
189189
while num > 0:
190190
num, remainder = divmod(num, BASE62_BASE)
@@ -196,17 +196,27 @@ def _base62_encode(data: bytes) -> str:
196196
return encoded.zfill(27)
197197

198198

199+
_BASE62_LOOKUP = {c: i for i, c in enumerate(BASE62_ALPHABET)}
200+
201+
# Maximum integer value that fits in TOTAL_LENGTH bytes
202+
_MAX_ENCODED = (1 << (TOTAL_LENGTH * 8)) - 1
203+
204+
199205
def _base62_decode(s: str) -> bytes:
200206
"""Decode base62 string to bytes."""
201207
if not s:
202208
return b""
203-
209+
204210
num = 0
205211
for char in s:
206-
if char not in BASE62_ALPHABET:
212+
val = _BASE62_LOOKUP.get(char)
213+
if val is None:
207214
raise ValueError(f"Invalid base62 character: {char}")
208-
num = num * BASE62_BASE + BASE62_ALPHABET.index(char)
209-
215+
num = num * BASE62_BASE + val
216+
217+
if num > _MAX_ENCODED:
218+
raise ValueError("Base62 value exceeds maximum for KSUID")
219+
210220
# Convert to bytes (20 bytes for KSUID)
211221
return num.to_bytes(TOTAL_LENGTH, 'big')
212222

@@ -217,6 +227,20 @@ def generate() -> KSUID:
217227
return KSUID()
218228

219229

230+
def generate_token() -> str:
231+
"""Generate a cryptographically secure opaque token as a base62 string.
232+
233+
Unlike KSUIDs, tokens use 20 bytes (160 bits) of pure random data from
234+
``secrets.token_bytes`` with no embedded timestamp. This makes them
235+
suitable for API keys, session secrets, and other security-sensitive
236+
values where the creation time should not be leaked.
237+
238+
Returns:
239+
A 27-character base62 string with 160 bits of entropy.
240+
"""
241+
return _base62_encode(secrets.token_bytes(TOTAL_LENGTH))
242+
243+
220244
def from_string(ksuid_str: str) -> KSUID:
221245
"""Create a KSUID from its string representation."""
222246
return KSUID.from_string(ksuid_str)

cli.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@
1414
from datetime import datetime
1515

1616

17+
MAX_COUNT = 1_000_000
18+
19+
20+
def _validate_count(value):
21+
"""Validate --count is a positive integer within bounds."""
22+
try:
23+
ivalue = int(value)
24+
except ValueError:
25+
raise argparse.ArgumentTypeError(f"invalid integer value: {value!r}")
26+
if ivalue < 1:
27+
raise argparse.ArgumentTypeError("count must be at least 1")
28+
if ivalue > MAX_COUNT:
29+
raise argparse.ArgumentTypeError(
30+
f"count must be at most {MAX_COUNT:,}"
31+
)
32+
return ivalue
33+
34+
1735
def cmd_generate(args):
1836
"""Generate one or more KSUIDs."""
1937
for _ in range(args.count):
@@ -127,10 +145,10 @@ def main():
127145
# Generate command
128146
gen_parser = subparsers.add_parser('generate', help='Generate KSUIDs')
129147
gen_parser.add_argument(
130-
'-c', '--count',
131-
type=int,
132-
default=1,
133-
help='Number of KSUIDs to generate (default: 1)'
148+
'-c', '--count',
149+
type=_validate_count,
150+
default=1,
151+
help='Number of KSUIDs to generate (default: 1, max: 1,000,000)'
134152
)
135153
gen_parser.add_argument(
136154
'-v', '--verbose',
@@ -158,10 +176,10 @@ def main():
158176
# Benchmark command
159177
bench_parser = subparsers.add_parser('benchmark', help='Run benchmark')
160178
bench_parser.add_argument(
161-
'-c', '--count',
162-
type=int,
163-
default=10000,
164-
help='Number of KSUIDs to generate (default: 10000)'
179+
'-c', '--count',
180+
type=_validate_count,
181+
default=10000,
182+
help='Number of KSUIDs to generate (default: 10000, max: 1,000,000)'
165183
)
166184
bench_parser.set_defaults(func=cmd_benchmark)
167185

prefixed_examples.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import os
1111
sys.path.insert(0, os.path.dirname(__file__))
1212

13-
from __init__ import KSUID, generate, from_string
13+
from __init__ import KSUID, generate, generate_token, from_string
1414
from typing import Dict, Optional, Tuple
1515
import re
1616

@@ -87,9 +87,9 @@ def create(cls, prefix: str) -> str:
8787
if not prefix:
8888
raise ValueError("Prefix cannot be empty")
8989

90-
# Validate prefix format (alphanumeric and underscores only)
91-
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_]*$', prefix):
92-
raise ValueError("Prefix must start with a letter and contain only alphanumeric characters and underscores")
90+
# Validate prefix format (alphanumeric only, no underscores since _ is the delimiter)
91+
if not re.match(r'^[a-zA-Z][a-zA-Z0-9]*$', prefix):
92+
raise ValueError("Prefix must start with a letter and contain only alphanumeric characters")
9393

9494
return f"{prefix}_{generate()}"
9595

@@ -194,12 +194,20 @@ def create_order_id() -> str:
194194
return PrefixedKSUID.create('ord')
195195

196196
def create_api_key() -> str:
197-
"""Create an API key: ak_..."""
198-
return PrefixedKSUID.create('ak')
197+
"""Create a secure API key: ak_...
198+
199+
Uses 160 bits of cryptographically secure random data (no timestamp)
200+
via ``generate_token()``, making it safe for use as a secret key.
201+
"""
202+
return f"ak_{generate_token()}"
199203

200204
def create_session_id() -> str:
201-
"""Create a session ID: sess_..."""
202-
return PrefixedKSUID.create('sess')
205+
"""Create a secure session token: sess_...
206+
207+
Uses 160 bits of cryptographically secure random data (no timestamp)
208+
via ``generate_token()``, making it safe for use as a bearer token.
209+
"""
210+
return f"sess_{generate_token()}"
203211

204212

205213
def demo_basic_usage():

0 commit comments

Comments
 (0)