Skip to content

Commit c09c158

Browse files
committed
feat(api): change key format to lh_pat_ with 16-char prefix
API key format changed from `lh_<entropy>` to `lh_pat_<entropy>` so that secret-scanning tools (gitleaks, GitGuardian) can detect leaked tokens via the unambiguous `pat_` discriminator. - key_generator: plaintext = f"lh_pat_{body}", prefix = plaintext[:16] - dependencies: prefix extraction 12 → 16 chars, min-length guard 12 → 16 - alembic 20260517: bootstrap key regenerated with lh_pat_ format - alembic 20260518: new migration extends key_prefix to VARCHAR(16) - all tests updated to lh_pat_ plaintext strings and [:16] prefix slices Refs #22
1 parent c4f050f commit c09c158

15 files changed

Lines changed: 120 additions & 68 deletions

backend/alembic/versions/20260517_phase7c_api_keys.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424

2525
def _generate_bootstrap_key() -> tuple[str, str, str]:
2626
body = secrets.token_urlsafe(32)
27-
plaintext = f"lh_{body}"
28-
prefix = plaintext[:12]
27+
plaintext = f"lh_pat_{body}"
28+
prefix = plaintext[:16]
2929
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=12)).decode()
3030
return plaintext, prefix, hashed
3131

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Phase 7c — update key_prefix to VARCHAR(16) for lh_pat_ format.
2+
3+
Revision ID: 20260518_phase7c_pat_prefix
4+
Revises: 20260517_phase7c_api_keys
5+
Create Date: 2026-05-18
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision = "20260518_phase7c_pat_prefix"
14+
down_revision = "20260517_phase7c_api_keys"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# SQLite via batch_alter_table supports column type changes.
21+
# For PostgreSQL the String type without length is unlimited, so this
22+
# migration is a no-op in production but makes the intent explicit.
23+
with op.batch_alter_table("api_keys") as batch_op:
24+
batch_op.alter_column(
25+
"key_prefix",
26+
existing_type=sa.String(),
27+
type_=sa.String(16),
28+
nullable=False,
29+
)
30+
31+
32+
def downgrade() -> None:
33+
with op.batch_alter_table("api_keys") as batch_op:
34+
batch_op.alter_column(
35+
"key_prefix",
36+
existing_type=sa.String(16),
37+
type_=sa.String(12),
38+
nullable=False,
39+
)

backend/app/auth/dependencies.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ async def _validate_api_key(
122122
) -> AuthContext:
123123
"""Validate the X-Label-Hub-Key header.
124124
125-
1. Extract prefix (first 12 chars) to look up the key row.
125+
1. Extract prefix (first 16 chars) to look up the key row.
126126
2. bcrypt-verify the full plaintext against the stored hash.
127127
3. Check the key is enabled and not expired.
128128
4. Check the key's scopes satisfy ``required_scope``.
@@ -132,13 +132,13 @@ async def _validate_api_key(
132132
HTTPException 401: key not found / bcrypt mismatch / disabled
133133
HTTPException 403: key valid but insufficient scope
134134
"""
135-
if len(key_header) < 12:
135+
if len(key_header) < 16:
136136
raise HTTPException(
137137
status_code=status.HTTP_401_UNAUTHORIZED,
138138
detail={"error_code": "invalid_key_format", "error_message": "Invalid key format"},
139139
)
140140

141-
prefix = key_header[:12]
141+
prefix = key_header[:16]
142142
key_row = await api_keys_repo.get_by_prefix(session, prefix)
143143

144144
if key_row is None:

backend/app/auth/key_generator.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""API key generation for Phase 7c — generates bcrypt-hashed keys with prefix.
22
3-
Key format: ``lh_<43-char-urlsafe-base64>``
4-
- ``lh_`` — Label Hub prefix, distinguishes from other token formats
3+
Key format: ``lh_pat_<43-char-urlsafe-base64>``
4+
- ``lh_pat_`` — Label Hub Personal Access Token infix, unambiguously
5+
identifies token type for both humans and secret-scanning tools
56
- 43-char body — secrets.token_urlsafe(32) produces ~43 URL-safe chars
67
from 256 bits of entropy (no padding)
78
89
The plaintext is returned only at generation time and must be shown to the
9-
user ONCE. Only the bcrypt hash and the 12-char prefix are persisted.
10+
user ONCE. Only the bcrypt hash and the 16-char prefix are persisted.
1011
"""
1112

1213
from __future__ import annotations
@@ -26,11 +27,11 @@ def generate_api_key() -> tuple[str, str, str]:
2627
Returns:
2728
(plaintext, prefix, bcrypt_hash) where:
2829
- plaintext — the full key, shown to the user ONCE, never persisted
29-
- prefix — first 12 chars (e.g. "lh_ab12cd34X"), stored for UI display
30+
- prefix — first 16 chars (e.g. "lh_pat_ab12cd34X"), stored for UI display
3031
- bcrypt_hash — stored in the DB, used for verify_api_key()
3132
"""
3233
body = secrets.token_urlsafe(32) # 256 bits of entropy, URL-safe charset
33-
plaintext = f"lh_{body}"
34-
prefix = plaintext[:12]
34+
plaintext = f"lh_pat_{body}"
35+
prefix = plaintext[:16]
3536
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)).decode()
3637
return plaintext, prefix, hashed

backend/tests/db/test_api_keys_repo.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def _make_key(
1414
*,
1515
name="test-key",
1616
key_hash=r"\$2b\$12\$fake",
17-
key_prefix="lh_ab12cd34",
17+
key_prefix="lh_pat_ab12cd34",
1818
scopes=None,
1919
allowed_printer_ids=None,
2020
rate_limit_per_minute=60,
@@ -45,18 +45,18 @@ async def test_create_inserts_and_returns_key(session):
4545

4646
@pytest.mark.asyncio
4747
async def test_create_multiple_keys(session):
48-
k1 = await repo.create(session, _make_key(name="key1", key_prefix="lh_aaaaaaaaaa"))
49-
k2 = await repo.create(session, _make_key(name="key2", key_prefix="lh_bbbbbbbbbb"))
48+
k1 = await repo.create(session, _make_key(name="key1", key_prefix="lh_pat_aaaaaaa"))
49+
k2 = await repo.create(session, _make_key(name="key2", key_prefix="lh_pat_bbbbbbb"))
5050
assert k1.id != k2.id
5151

5252

5353
@pytest.mark.asyncio
5454
async def test_get_by_prefix_returns_matching_key(session):
55-
key = _make_key(key_prefix="lh_ab12cd34XX")
55+
key = _make_key(key_prefix="lh_pat_ab12cd3X")
5656
await repo.create(session, key)
57-
found = await repo.get_by_prefix(session, "lh_ab12cd34XX")
57+
found = await repo.get_by_prefix(session, "lh_pat_ab12cd3X")
5858
assert found is not None
59-
assert found.key_prefix == "lh_ab12cd34XX"
59+
assert found.key_prefix == "lh_pat_ab12cd3X"
6060

6161

6262
@pytest.mark.asyncio
@@ -67,17 +67,17 @@ async def test_get_by_prefix_returns_none_for_unknown(session):
6767

6868
@pytest.mark.asyncio
6969
async def test_list_active_returns_only_enabled_non_expired(session):
70-
enabled = _make_key(name="enabled", key_prefix="lh_aaaaaaaaaa", enabled=True)
71-
disabled = _make_key(name="disabled", key_prefix="lh_bbbbbbbbbb", enabled=False)
70+
enabled = _make_key(name="enabled", key_prefix="lh_pat_aaaaaaa", enabled=True)
71+
disabled = _make_key(name="disabled", key_prefix="lh_pat_bbbbbbb", enabled=False)
7272
expired = _make_key(
7373
name="expired",
74-
key_prefix="lh_cccccccccc",
74+
key_prefix="lh_pat_ccccccc",
7575
enabled=True,
7676
expires_at=datetime.now(UTC) - timedelta(hours=1),
7777
)
7878
future = _make_key(
7979
name="future-expiry",
80-
key_prefix="lh_dddddddddd",
80+
key_prefix="lh_pat_ddddddd",
8181
enabled=True,
8282
expires_at=datetime.now(UTC) + timedelta(days=30),
8383
)

backend/tests/integration/api/test_audit_trail.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717

1818

1919
async def _insert_print_key(factory):
20-
plaintext = f"lh_audit_trail_test_{uuid4().hex[:16]}"
21-
prefix = plaintext[:12]
20+
plaintext = f"lh_pat_audit_trail_{uuid4().hex[:16]}"
21+
prefix = plaintext[:16]
2222
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
2323
key_id = uuid4()
2424
async with factory() as s:

backend/tests/integration/api/test_auth_wiring.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020
async def _make_print_key(factory):
2121
"""Insert an api-key with print scope and return (plaintext, ApiKey)."""
22-
plaintext = "lh_print_integ_wiring_test_step4_001"
23-
prefix = plaintext[:12]
22+
plaintext = "lh_pat_print_integ_wiring_test_step4_001"
23+
prefix = plaintext[:16]
2424
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
2525
async with factory() as s:
2626
key = ApiKey(
@@ -37,8 +37,8 @@ async def _make_print_key(factory):
3737

3838

3939
async def _make_read_key(factory):
40-
plaintext = "lh_read_integ_wiring_test_step4_002"
41-
prefix = plaintext[:12]
40+
plaintext = "lh_pat_read_integ_wiring_test_step4_002"
41+
prefix = plaintext[:16]
4242
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
4343
async with factory() as s:
4444
key = ApiKey(
@@ -55,8 +55,8 @@ async def _make_read_key(factory):
5555

5656

5757
async def _make_admin_key(factory):
58-
plaintext = "lh_admin_integ_wiring_test_step4_003"
59-
prefix = plaintext[:12]
58+
plaintext = "lh_pat_admin_integ_wiring_test_step4_003"
59+
prefix = plaintext[:16]
6060
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
6161
async with factory() as s:
6262
key = ApiKey(

backend/tests/integration/api/test_printer_acl.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616
async def _insert_restricted_key(factory, *, allowed_printer_ids: list[str], scopes=None):
1717
"""Insert a key restricted to specific printer IDs."""
18-
plaintext = f"lh_acl_test_{uuid4().hex[:20]}"
19-
prefix = plaintext[:12]
18+
plaintext = f"lh_pat_acl_t_{uuid4().hex[:16]}"
19+
prefix = plaintext[:16]
2020
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
2121
async with factory() as s:
2222
key = ApiKey(

backend/tests/integration/api/test_rate_limit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020
async def _insert_key(factory, *, rate_limit: int = 3, scopes=None):
2121
"""Insert an API key with the given rate limit and return plaintext."""
22-
plaintext = f"lh_ratelimit_test_{uuid4().hex[:20]}"
23-
prefix = plaintext[:12]
22+
plaintext = f"lh_pat_rlt_{uuid4().hex[:16]}"
23+
prefix = plaintext[:16]
2424
hashed = bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=4)).decode()
2525
async with factory() as s:
2626
key = ApiKey(

backend/tests/unit/api/test_admin_api_keys_routes.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async def test_list_api_keys_returns_existing_keys(session):
6666
key = ApiKey(
6767
name="existing-key",
6868
key_hash="fakehash",
69-
key_prefix="lh_existing",
69+
key_prefix="lh_pat_existg",
7070
scopes=["read"],
7171
allowed_printer_ids=[],
7272
enabled=True,
@@ -102,7 +102,7 @@ async def test_create_api_key_returns_plaintext_once(session):
102102
assert resp.status_code == 201
103103
body = resp.json()
104104
assert "plaintext" in body, "plaintext must be returned ONCE on creation"
105-
assert body["plaintext"].startswith("lh_")
105+
assert body["plaintext"].startswith("lh_pat_")
106106
assert "prefix" in body
107107
assert "key_id" in body
108108

@@ -139,7 +139,7 @@ async def test_get_api_key_detail_returns_metadata(session):
139139
key = ApiKey(
140140
name="detail-key",
141141
key_hash="fakehash",
142-
key_prefix="lh_detail",
142+
key_prefix="lh_pat_detail",
143143
scopes=["print"],
144144
allowed_printer_ids=[],
145145
enabled=True,
@@ -170,7 +170,7 @@ async def test_patch_api_key_updates_fields(session):
170170
key = ApiKey(
171171
name="to-patch",
172172
key_hash="fakehash",
173-
key_prefix="lh_topatch",
173+
key_prefix="lh_pat_topatch",
174174
scopes=["read"],
175175
allowed_printer_ids=[],
176176
enabled=True,
@@ -201,7 +201,7 @@ async def test_delete_api_key_revokes_it(session):
201201
key = ApiKey(
202202
name="to-delete",
203203
key_hash="fakehash",
204-
key_prefix="lh_todelete",
204+
key_prefix="lh_pat_tdel",
205205
scopes=["read"],
206206
allowed_printer_ids=[],
207207
enabled=True,

0 commit comments

Comments
 (0)