Skip to content

Commit 633dff6

Browse files
Implement HMAC for metadata
This implements a metadata-based hmac. Note that this implementation only uses the metadata as input for the HMAC. Signed-off-by: Florian Wagner <[email protected]>
1 parent b72e84f commit 633dff6

File tree

8 files changed

+272
-7
lines changed

8 files changed

+272
-7
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Frequenz Client Base Library Release Notes
22

3+
## Features
4+
5+
* Added support for HMAC signing of client messages
6+
37
## Upgrading
48

59
* Updated `protobuf` dependency range: changed from `>=4.21.6, <6` to `>=5.29.2, <7`
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""An Interceptor that adds the API key to a gRPC call."""
5+
6+
import dataclasses
7+
from typing import Callable
8+
9+
from grpc.aio import (
10+
ClientCallDetails,
11+
Metadata,
12+
UnaryUnaryCall,
13+
UnaryUnaryClientInterceptor,
14+
)
15+
16+
17+
@dataclasses.dataclass(frozen=True)
18+
class AuthOptions:
19+
"""Options for authenticating to the endpoint."""
20+
21+
api_key: str
22+
"""The API key to authenticate with."""
23+
24+
25+
class AuthInterceptor(UnaryUnaryClientInterceptor): # type: ignore[type-arg]
26+
"""An Interceptor that adds HMAC authentication of the metadata fields to a gRPC call."""
27+
28+
def __init__(self, *, auth_options: AuthOptions):
29+
"""Create an instance of the interceptor.
30+
31+
Args:
32+
auth_options: The options for authenticating to the endpoint.
33+
"""
34+
self._key = auth_options.api_key
35+
36+
async def intercept_unary_unary(
37+
self,
38+
continuation: Callable[
39+
[ClientCallDetails, object], UnaryUnaryCall[object, object]
40+
],
41+
client_call_details: ClientCallDetails,
42+
request: object,
43+
) -> object:
44+
"""Intercept the call to add HMAC authentication to the metadata fields.
45+
46+
This is a known method from the base class that is overridden.
47+
48+
Args:
49+
continuation: The next interceptor in the chain.
50+
client_call_details: The call details.
51+
request: The request object.
52+
53+
Returns:
54+
The response object (this implementation does not modify the response).
55+
"""
56+
self.add_auth_header(
57+
client_call_details,
58+
)
59+
return await continuation(client_call_details, request)
60+
61+
def add_auth_header(
62+
self,
63+
client_call_details: ClientCallDetails,
64+
) -> None:
65+
"""Add the API key as a metadata field to the call.
66+
67+
The API key is used by the later sign interceptor to calculate the HMAC.
68+
In addition it is used as a first layer of authentication by the server.
69+
70+
Args:
71+
client_call_details: The call details.
72+
"""
73+
if client_call_details.metadata is None:
74+
client_call_details.metadata = Metadata()
75+
76+
client_call_details.metadata["x-key"] = self._key

src/frequenz/client/base/channel.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@
1010
from urllib.parse import parse_qs, urlparse
1111

1212
from grpc import ssl_channel_credentials
13-
from grpc.aio import Channel, insecure_channel, secure_channel
13+
from grpc.aio import (
14+
Channel,
15+
ClientInterceptor,
16+
insecure_channel,
17+
secure_channel,
18+
)
19+
20+
from .auth_interceptor import AuthInterceptor, AuthOptions
21+
from .sign_interceptor import SignInterceptor, SignOptions
1422

1523

1624
@dataclasses.dataclass(frozen=True)
@@ -69,6 +77,12 @@ class ChannelOptions:
6977
keep_alive: KeepAliveOptions = KeepAliveOptions()
7078
"""HTTP2 keep-alive options for the channel."""
7179

80+
sign: SignOptions | None = None
81+
"""Signing options for the channel."""
82+
83+
auth: AuthOptions | None = None
84+
"""Authentication options for the channel."""
85+
7286

7387
def parse_grpc_uri(
7488
uri: str,
@@ -177,6 +191,17 @@ def parse_grpc_uri(
177191
else None
178192
)
179193

194+
interceptors: list[ClientInterceptor] = []
195+
if defaults.auth is not None:
196+
interceptors.append(
197+
AuthInterceptor(auth_options=defaults.auth) # type: ignore[arg-type]
198+
)
199+
200+
if defaults.sign is not None:
201+
interceptors.append(
202+
SignInterceptor(sign_options=defaults.sign) # type: ignore[arg-type]
203+
)
204+
180205
ssl = defaults.ssl.enabled if options.ssl is None else options.ssl
181206
if ssl:
182207
return secure_channel(
@@ -199,8 +224,9 @@ def parse_grpc_uri(
199224
),
200225
),
201226
channel_options,
227+
interceptors=interceptors,
202228
)
203-
return insecure_channel(target, channel_options)
229+
return insecure_channel(target, channel_options, interceptors=interceptors)
204230

205231

206232
def _to_bool(value: str) -> bool:

src/frequenz/client/base/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from collections.abc import Awaitable, Callable
99
from typing import Any, Generic, Self, TypeVar, overload
1010

11-
from grpc.aio import AioRpcError, Channel
11+
from grpc.aio import (
12+
AioRpcError,
13+
Channel,
14+
)
1215

1316
from .channel import ChannelOptions, parse_grpc_uri
1417
from .exception import ApiClientError, ClientNotConnected
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""An Interceptor that adds HMAC signature of the metadata fields to a gRPC call."""
5+
6+
import dataclasses
7+
import hmac
8+
import logging
9+
import secrets
10+
import time
11+
from base64 import urlsafe_b64encode
12+
from typing import Any, Callable
13+
14+
from grpc.aio import (
15+
ClientCallDetails,
16+
UnaryUnaryCall,
17+
UnaryUnaryClientInterceptor,
18+
)
19+
20+
_logger = logging.getLogger(__name__)
21+
22+
23+
@dataclasses.dataclass(frozen=True)
24+
class SignOptions:
25+
"""Options for message signing of messages."""
26+
27+
secret: str
28+
"""The secret to sign the message with."""
29+
30+
31+
class SignInterceptor(UnaryUnaryClientInterceptor): # type: ignore[type-arg]
32+
"""An Interceptor that adds HMAC authentication of the metadata fields to a gRPC call."""
33+
34+
def __init__(self, *, sign_options: SignOptions):
35+
"""Create an instance of the interceptor.
36+
37+
Args:
38+
sign_options: The options for signing the message.
39+
"""
40+
self._secret = sign_options.secret.encode()
41+
42+
async def intercept_unary_unary(
43+
self,
44+
continuation: Callable[
45+
[ClientCallDetails, object], UnaryUnaryCall[object, object]
46+
],
47+
client_call_details: ClientCallDetails,
48+
request: object,
49+
) -> object:
50+
"""Intercept the call to add HMAC authentication to the metadata fields.
51+
52+
This is a known method from the base class that is overridden.
53+
54+
Args:
55+
continuation: The next interceptor in the chain.
56+
client_call_details: The call details.
57+
request: The request object.
58+
59+
Returns:
60+
The response object (this implementation does not modify the response).
61+
"""
62+
self.add_hmac(
63+
client_call_details,
64+
int(time.time()).to_bytes(8, "big"),
65+
secrets.token_bytes(16),
66+
)
67+
return await continuation(client_call_details, request)
68+
69+
def add_hmac(
70+
self, client_call_details: ClientCallDetails, ts: bytes, nonce: bytes
71+
) -> None:
72+
"""Add the HMAC authentication to the metadata fields of the call details.
73+
74+
The extra headers are directly added to the client_call details.
75+
76+
Args:
77+
client_call_details: The call details.
78+
ts: The timestamp to use for the HMAC.
79+
nonce: The nonce to use for the HMAC.
80+
"""
81+
if client_call_details.metadata is None:
82+
_logger.error(
83+
"No metadata found, cannot extract an api key. Therefore, cannot sign the request."
84+
)
85+
return
86+
87+
key: Any = client_call_details.metadata.get("x-key")
88+
if key is None:
89+
_logger.error("No key found in metadata, cannot sign the request.")
90+
return
91+
hmac_obj = hmac.new(self._secret, digestmod="sha256")
92+
hmac_obj.update(key.encode())
93+
hmac_obj.update(ts)
94+
hmac_obj.update(nonce)
95+
96+
hmac_obj.update(client_call_details.method.encode())
97+
98+
client_call_details.metadata["x-ts"] = ts
99+
client_call_details.metadata["x-nonce"] = nonce
100+
client_call_details.metadata["x-hmac"] = urlsafe_b64encode(hmac_obj.digest())

tests/test_channel.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,11 +315,14 @@ def test_parse_uri_ok( # pylint: disable=too-many-locals
315315
certificate_chain=expected_certificate_chain,
316316
)
317317
secure_channel_mock.assert_called_once_with(
318-
expected_target, expected_credentials, expected_channel_options
318+
expected_target,
319+
expected_credentials,
320+
expected_channel_options,
321+
interceptors=[],
319322
)
320323
else:
321324
insecure_channel_mock.assert_called_once_with(
322-
expected_target, expected_channel_options
325+
expected_target, expected_channel_options, interceptors=[]
323326
)
324327

325328

tests/test_client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import pytest_mock
1313

1414
from frequenz.client.base.channel import ChannelOptions, SslOptions
15-
from frequenz.client.base.client import BaseApiClient, StubT, call_stub_method
15+
from frequenz.client.base.client import (
16+
BaseApiClient,
17+
StubT,
18+
call_stub_method,
19+
)
1620
from frequenz.client.base.exception import ClientNotConnected, UnknownError
1721

1822

@@ -108,7 +112,9 @@ def test_base_api_client_init_with_channel_defaults(
108112
channel_defaults = ChannelOptions(ssl=SslOptions(enabled=False))
109113
client, mocks = create_client_with_mocks(mocker, channel_defaults=channel_defaults)
110114
assert client.server_url == _DEFAULT_SERVER_URL
111-
mocks.parse_grpc_uri.assert_called_once_with(client.server_url, channel_defaults)
115+
mocks.parse_grpc_uri.assert_called_once_with(
116+
client.server_url, channel_defaults
117+
)
112118
assert client.channel is mocks.channel
113119
assert client._stub is mocks.stub # pylint: disable=protected-access
114120
assert client.is_connected

tests/test_interceptors.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the Interceptor functionalities."""
5+
6+
from unittest import mock
7+
8+
from frequenz.client.base.auth_interceptor import (
9+
AuthInterceptor,
10+
AuthOptions,
11+
)
12+
from frequenz.client.base.sign_interceptor import (
13+
SignInterceptor,
14+
SignOptions,
15+
)
16+
17+
18+
async def test_hmac_construction() -> None:
19+
"""Test that the HMAC is calculated correctly so that it will match the value of the server."""
20+
sign: SignOptions = SignOptions(secret="my_secret")
21+
sign_interceptor: SignInterceptor = SignInterceptor(sign_options=sign)
22+
23+
metadata: dict[str, str | bytes] = {"x-key": "my_key"}
24+
25+
client_call_details = mock.MagicMock(method="my_rpc")
26+
client_call_details.metadata = metadata
27+
28+
sign_interceptor.add_hmac(client_call_details, b"1634567890", b"123456789")
29+
30+
assert metadata["x-hmac"] == "NJDvrkRZhOPekn5AvPiaJsYTJYCgnLzA-LQFC2D7GNE=".encode(
31+
"utf-8"
32+
)
33+
34+
35+
async def test_auth_interceptor() -> None:
36+
"""Test that the Auth Interceptor adds the correct header."""
37+
auth: AuthOptions = AuthOptions(api_key="my_key")
38+
auth_interceptor: AuthInterceptor = AuthInterceptor(auth_options=auth)
39+
40+
metadata: dict[str, str] = {}
41+
42+
client_call_details = mock.MagicMock(method="my_rpc")
43+
client_call_details.metadata = metadata
44+
45+
auth_interceptor.add_auth_header(client_call_details)
46+
47+
assert metadata["x-key"] == "my_key"

0 commit comments

Comments
 (0)