Skip to content

Commit 74e441d

Browse files
authored
Merge pull request #311 from minvws/key-id
User can now specify a key-id to their public key so we can
2 parents 417d0d4 + 27c35bb commit 74e441d

13 files changed

Lines changed: 76 additions & 12 deletions

app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def parse_space_separated(cls, v: Any) -> list[str] | None:
111111
return v.split() or None
112112
return v if v is None or isinstance(v, list) else None
113113

114+
114115
class Config(BaseModel):
115116
app: ConfigApp
116117
database: ConfigDatabase

app/db/entities/organization_key.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class OrganizationKey(Base):
2828
"scope", JSONB, nullable=False, server_default="{}"
2929
)
3030
key_data: Mapped[str] = mapped_column("key_data", Text, nullable=False)
31+
key_id: Mapped[str | None] = mapped_column("key_id", Text, nullable=True)
3132

3233
organization = relationship("Organization", back_populates="keys")
3334

@@ -37,4 +38,5 @@ def to_dict(self) -> dict[str, Any]:
3738
# We omit organization_id since this is an internal detail.
3839
"scope": self.scope,
3940
"key_data": self.key_data,
41+
"key_id": self.key_id or "",
4042
}

app/db/repositories/org_key_repository.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def get_by_org(self, org_id: uuid.UUID) -> Optional[Sequence[OrganizationKey]]:
4646
return self.db_session.execute(query).scalars().all()
4747

4848
def create(
49-
self, org_id: uuid.UUID, scope: list[str], key_data: str
49+
self, org_id: uuid.UUID, scope: list[str], key_data: str, key_id: Optional[str]
5050
) -> OrganizationKey:
5151
"""
5252
Creates a new key entry.
@@ -55,6 +55,7 @@ def create(
5555
organization_id=org_id,
5656
scope=scope,
5757
key_data=key_data,
58+
key_id=key_id,
5859
)
5960
self.db_session.add(entry)
6061
self.db_session.flush()

app/models/requests.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import base64
22
import logging
33
from datetime import datetime
4-
from typing import Any, Literal, List
4+
from typing import Any, Literal, List, Optional
55

66
from pydantic import BaseModel, ConfigDict, model_validator, Field, field_validator
77

@@ -16,6 +16,7 @@
1616

1717
class RegisterRequest(BaseModel):
1818
scope: List[str]
19+
key_id: Optional[str]
1920

2021

2122
class OrgRequest(BaseModel):

app/routers/key.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def post_key(
3434

3535
# Create the key entry
3636
try:
37-
key_resolver.create(org.id, req.scope, mtls_pub_key)
37+
key_resolver.create(org.id, req.scope, req.key_id, mtls_pub_key)
3838
except AlreadyExistsError:
3939
logger.warning("key already exists for org_id=%s scope=%r", org.id, req.scope)
4040
raise HTTPException(

app/services/key_resolver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,15 @@ def resolve(self, org_id: uuid.UUID, scope: str) -> Optional[jwk.JWK]:
8989
return jwk.JWK.from_pem(entry.key_data.encode("ascii"))
9090

9191
def create(
92-
self, org_id: uuid.UUID, scope: list[str], key_data: str
92+
self, org_id: uuid.UUID, scope: list[str], key_id: Optional[str], key_data: str
9393
) -> OrganizationKey:
9494
scope = _normalize_scope(scope)
9595
key_data = key_data.strip()
9696

9797
with self.db.get_db_session() as session:
9898
try:
9999
entry = session.get_repository(OrganizationKeyRepository).create(
100-
org_id, scope, key_data
100+
org_id, scope, key_data, key_id
101101
)
102102
except Exception as e:
103103
logger.exception(

sql/007-key-id.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Add key ID to be returned in the JWE kid header
2+
3+
ALTER TABLE organization_key ADD COLUMN key_id TEXT;

tests/test_hsm_key_version_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def test_new_key_version_is_added_to_jwe(
9999
oin=OIN, name=f"Org {OIN}", max_key_usage=RidUsage.ReversiblePseudonym
100100
)
101101
private_key_pem, public_key_pem = _generate_rsa_keypair()
102-
key_resolver.create(org.id, [SCOPE], public_key_pem)
102+
key_resolver.create(org.id, [SCOPE], None, public_key_pem)
103103

104104
# Route OPRF evaluation through a (mocked) HSM that reads its active key
105105
# versions from the same database the endpoint writes to.

tests/test_key_resolver.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_resolver_create_and_resolve_roundtrip(
1616
key_resolver: KeyResolver, org_service: OrgService
1717
) -> None:
1818
org = org_service.create(
19-
oin="ura:00000099000000001000",
19+
oin="oin:00000099000000001000",
2020
name="test org",
2121
max_key_usage=RidUsage.ReversiblePseudonym,
2222
)
@@ -27,7 +27,7 @@ def test_resolver_create_and_resolve_roundtrip(
2727
scope=["NVI", " lmr "],
2828
pub_key=TEST_PUBKEY,
2929
)
30-
entry = key_resolver.create(org.id, req.scope, req.pub_key)
30+
entry = key_resolver.create(org.id, req.scope, "my-key-id", req.pub_key)
3131

3232
assert entry.organization_id == org.id
3333
assert sorted(entry.scope) == ["lmr", "nvi"]
@@ -46,7 +46,7 @@ def test_resolver_get_and_delete(
4646
max_key_usage=RidUsage.ReversiblePseudonym,
4747
)
4848

49-
e = key_resolver.create(org.id, ["*"], TEST_PUBKEY)
49+
e = key_resolver.create(org.id, ["*"], "my-key-id", TEST_PUBKEY)
5050

5151
items = key_resolver.get_by_org(org.id) or []
5252
assert len(items) == 1
@@ -60,3 +60,37 @@ def test_resolver_get_and_delete(
6060

6161
items2 = key_resolver.get_by_org(org.id)
6262
assert items2 == []
63+
64+
65+
def test_resolver_create_persists_key_id(
66+
key_resolver: KeyResolver, org_service: OrgService
67+
) -> None:
68+
org = org_service.create(
69+
oin="oin:00000099000000001000", name="test org", max_key_usage=RidUsage.ReversiblePseudonym
70+
)
71+
72+
entry = key_resolver.create(org.id, ["nvi"], "kid-2024", TEST_PUBKEY)
73+
assert entry.key_id == "kid-2024"
74+
75+
# key_id survives a round-trip through the database
76+
stored = key_resolver.get_by_id(entry.id)
77+
assert stored is not None
78+
assert stored.key_id == "kid-2024"
79+
assert stored.to_dict()["key_id"] == "kid-2024"
80+
81+
82+
def test_resolver_create_without_key_id(
83+
key_resolver: KeyResolver, org_service: OrgService
84+
) -> None:
85+
org = org_service.create(
86+
oin="oin:00000099000000001000", name="test org", max_key_usage=RidUsage.ReversiblePseudonym
87+
)
88+
89+
entry = key_resolver.create(org.id, ["nvi"], None, TEST_PUBKEY)
90+
assert entry.key_id is None
91+
92+
stored = key_resolver.get_by_id(entry.id)
93+
assert stored is not None
94+
assert stored.key_id is None
95+
# to_dict() represents a missing key_id as an empty string
96+
assert stored.to_dict()["key_id"] == ""

tests/test_oprf_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def setup_org_and_key(
5656
max_key_usage=RidUsage.ReversiblePseudonym,
5757
)
5858
private_key_pem, public_key_pem = generate_rsa_keypair()
59-
key_resolver.create(org.id, [scope], public_key_pem)
59+
key_resolver.create(org.id, [scope], None, public_key_pem)
6060

6161
return private_key_pem
6262

0 commit comments

Comments
 (0)