Skip to content

Commit 4eaaa75

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 4eaaa75

File tree

9 files changed

+280
-6
lines changed

9 files changed

+280
-6
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 AuthenticationOptions:
19+
"""Options for authenticating to the endpoint."""
20+
21+
api_key: str
22+
"""The API key to authenticate with."""
23+
24+
25+
# There is an issue in gRPC that causes the type to be unspecifieable correctly here.
26+
class AuthenticationInterceptor(UnaryUnaryClientInterceptor): # type: ignore[type-arg]
27+
"""An Interceptor that adds HMAC authentication of the metadata fields to a gRPC call."""
28+
29+
def __init__(self, options: AuthenticationOptions):
30+
"""Create an instance of the interceptor.
31+
32+
Args:
33+
options: The options for authenticating to the endpoint.
34+
"""
35+
self._key = options.api_key
36+
37+
async def intercept_unary_unary(
38+
self,
39+
continuation: Callable[
40+
[ClientCallDetails, object], UnaryUnaryCall[object, object]
41+
],
42+
client_call_details: ClientCallDetails,
43+
request: object,
44+
) -> object:
45+
"""Intercept the call to add HMAC authentication to the metadata fields.
46+
47+
This is a known method from the base class that is overridden.
48+
49+
Args:
50+
continuation: The next interceptor in the chain.
51+
client_call_details: The call details.
52+
request: The request object.
53+
54+
Returns:
55+
The response object (this implementation does not modify the response).
56+
"""
57+
self.add_auth_header(
58+
client_call_details,
59+
)
60+
return await continuation(client_call_details, request)
61+
62+
def add_auth_header(
63+
self,
64+
client_call_details: ClientCallDetails,
65+
) -> None:
66+
"""Add the API key as a metadata field to the call.
67+
68+
The API key is used by the later sign interceptor to calculate the HMAC.
69+
In addition it is used as a first layer of authentication by the server.
70+
71+
Args:
72+
client_call_details: The call details.
73+
"""
74+
if client_call_details.metadata is None:
75+
client_call_details.metadata = Metadata()
76+
77+
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 .authentication import AuthenticationInterceptor, AuthenticationOptions
21+
from .signing import SigningInterceptor, SigningOptions
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: SigningOptions | None = None
81+
"""Signing options for the channel."""
82+
83+
auth: AuthenticationOptions | 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+
AuthenticationInterceptor(options=defaults.auth) # type: ignore[arg-type]
198+
)
199+
200+
if defaults.sign is not None:
201+
interceptors.append(
202+
SigningInterceptor(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: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 SigningOptions:
25+
"""Options for message signing of messages."""
26+
27+
secret: str
28+
"""The secret to sign the message with."""
29+
30+
31+
# There is an issue in gRPC that causes the type to be unspecifieable correctly here.
32+
class SigningInterceptor(UnaryUnaryClientInterceptor): # type: ignore[type-arg]
33+
"""An Interceptor that adds HMAC authentication of the metadata fields to a gRPC call."""
34+
35+
def __init__(self, options: SigningOptions):
36+
"""Create an instance of the interceptor.
37+
38+
Args:
39+
options: The options for signing the message.
40+
"""
41+
self._secret = options.secret.encode()
42+
43+
async def intercept_unary_unary(
44+
self,
45+
continuation: Callable[
46+
[ClientCallDetails, object], UnaryUnaryCall[object, object]
47+
],
48+
client_call_details: ClientCallDetails,
49+
request: object,
50+
) -> object:
51+
"""Intercept the call to add HMAC authentication to the metadata fields.
52+
53+
This is a known method from the base class that is overridden.
54+
55+
Args:
56+
continuation: The next interceptor in the chain.
57+
client_call_details: The call details.
58+
request: The request object.
59+
60+
Returns:
61+
The response object (this implementation does not modify the response).
62+
"""
63+
self.add_hmac(
64+
client_call_details,
65+
int(time.time()).to_bytes(8, "big"),
66+
secrets.token_bytes(16),
67+
)
68+
return await continuation(client_call_details, request)
69+
70+
def add_hmac(
71+
self, client_call_details: ClientCallDetails, ts: bytes, nonce: bytes
72+
) -> None:
73+
"""Add the HMAC authentication to the metadata fields of the call details.
74+
75+
The extra headers are directly added to the client_call details.
76+
77+
Args:
78+
client_call_details: The call details.
79+
ts: The timestamp to use for the HMAC.
80+
nonce: The nonce to use for the HMAC.
81+
"""
82+
if client_call_details.metadata is None:
83+
_logger.error(
84+
"No metadata found, cannot extract an api key. Therefore, cannot sign the request."
85+
)
86+
return
87+
88+
key: Any = client_call_details.metadata.get("x-key")
89+
if key is None:
90+
_logger.error("No key found in metadata, cannot sign the request.")
91+
return
92+
hmac_obj = hmac.new(self._secret, digestmod="sha256")
93+
hmac_obj.update(key.encode())
94+
hmac_obj.update(ts)
95+
hmac_obj.update(nonce)
96+
97+
hmac_obj.update(client_call_details.method.encode())
98+
99+
client_call_details.metadata["x-ts"] = ts
100+
client_call_details.metadata["x-nonce"] = nonce
101+
client_call_details.metadata["x-hmac"] = urlsafe_b64encode(hmac_obj.digest())

tests/test_authentication.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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.authentication import (
9+
AuthenticationInterceptor,
10+
AuthenticationOptions,
11+
)
12+
13+
14+
async def test_auth_interceptor() -> None:
15+
"""Test that the Auth Interceptor adds the correct header."""
16+
auth: AuthenticationOptions = AuthenticationOptions(api_key="my_key")
17+
auth_interceptor: AuthenticationInterceptor = AuthenticationInterceptor(
18+
options=auth
19+
)
20+
21+
metadata: dict[str, str] = {}
22+
23+
client_call_details = mock.MagicMock(method="my_rpc")
24+
client_call_details.metadata = metadata
25+
26+
auth_interceptor.add_auth_header(client_call_details)
27+
28+
assert metadata["x-key"] == "my_key"

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: 5 additions & 1 deletion
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

tests/test_signing.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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.signing import (
9+
SigningInterceptor,
10+
SigningOptions,
11+
)
12+
13+
14+
async def test_sign_interceptor() -> None:
15+
"""Test that the HMAC is calculated correctly so that it will match the value of the server."""
16+
sign: SigningOptions = SigningOptions(secret="my_secret")
17+
sign_interceptor: SigningInterceptor = SigningInterceptor(options=sign)
18+
19+
metadata: dict[str, str | bytes] = {"x-key": "my_key"}
20+
21+
client_call_details = mock.MagicMock(method="my_rpc")
22+
client_call_details.metadata = metadata
23+
24+
sign_interceptor.add_hmac(client_call_details, b"1634567890", b"123456789")
25+
26+
assert metadata["x-hmac"] == "NJDvrkRZhOPekn5AvPiaJsYTJYCgnLzA-LQFC2D7GNE=".encode(
27+
"utf-8"
28+
)

0 commit comments

Comments
 (0)