ACYTEC.Security.Hmac.Client
1.0.2
dotnet add package ACYTEC.Security.Hmac.Client --version 1.0.2
NuGet\Install-Package ACYTEC.Security.Hmac.Client -Version 1.0.2
<PackageReference Include="ACYTEC.Security.Hmac.Client" Version="1.0.2" />
<PackageVersion Include="ACYTEC.Security.Hmac.Client" Version="1.0.2" />
<PackageReference Include="ACYTEC.Security.Hmac.Client" />
paket add ACYTEC.Security.Hmac.Client --version 1.0.2
#r "nuget: ACYTEC.Security.Hmac.Client, 1.0.2"
#:package ACYTEC.Security.Hmac.Client@1.0.2
#addin nuget:?package=ACYTEC.Security.Hmac.Client&version=1.0.2
#tool nuget:?package=ACYTEC.Security.Hmac.Client&version=1.0.2
ACYTEC.Security.Hmac.Client
The calling-side counterpart to ACYTEC.Security.Hmac.AspNetCore.
Register it once and get a ready to use HttpClient that transparently signs
the token request, caches and refreshes the bearer token, and encrypts and
decrypts AES-GCM payloads, matching the host package's protocol exactly.
Works the same way in ASP.NET Core MVC apps, Web APIs, and Azure Functions
backends — this package has no ASP.NET Core hosting dependency.
If you're new to this codebase: the host package
(ACYTEC.Security.Hmac.AspNetCore, in the sibling ACYTECEndpointSecurity
repository) protects an API with HMAC-signed token issuance, short lived
JWTs, origin validation, and AES-GCM payload encryption. This package is what
a calling app installs to talk to that API without hand-rolling any of
that. The wire protocol both sides implement is documented in PROTOCOL.md
in the host repository, including a worked test vector.
Install
dotnet add package ACYTEC.Security.Hmac.Client
How it works
AddACYTECHmacClientregisters twoHttpClients: an internal one used only to call the host's token endpoint, and a named one (ACYTECHmacClientOptions.HttpClientName) that your code uses for everything else.- The first time you send a request through the named client,
ACYTECHmacDelegatingHandlerasksITokenProviderfor an access token.CachingTokenProviderdoesn't have one yet, so it signs a request to the host's token endpoint (HmacRequestSigner), decrypts the response envelope, and caches the token in memory. - The handler attaches that token as
Authorization: Bearer ...and the configuredX-App-Originheader, encrypts your request body if it has one, sends the request, and decrypts the response body if the call succeeded. - Subsequent calls reuse the cached token until it's within
TokenRefreshBufferof expiry, at which point the next call transparently fetches a new one.
None of this is visible to your code. You inject IHttpClientFactory, create
the named client, and call it like any other HttpClient.
Quick start
// Program.cs — ASP.NET Core Web API / MVC, Azure Functions isolated worker,
// or any generic host (Host.CreateApplicationBuilder)
using ACYTEC.Security.Hmac.Client.Extensions;
builder.Services.AddACYTECHmacClient(builder.Configuration);
// Anywhere you'd normally inject IHttpClientFactory
using ACYTEC.Security.Hmac.Client.Options;
public sealed class UserSearchService(IHttpClientFactory httpClientFactory)
{
public async Task<string> SearchAsync(string query, CancellationToken ct)
{
var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);
var response = await client.GetAsync($"/users/search?query={Uri.EscapeDataString(query)}", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
}
No manual HMAC signing, no manual token handling, no manual encryption.
Call AddACYTECHmacClient once at startup. Calling it twice registers the
bearer token and encryption handling twice on the same named client.
Usage examples
GET with a typed response
System.Net.Http.Json works normally — the handler decrypts the response
body before your code ever sees it, so ReadFromJsonAsync deserializes the
plain JSON underneath the envelope, not the envelope itself.
using System.Net.Http.Json;
using ACYTEC.Security.Hmac.Client.Options;
public sealed record UserSearchResult(string Query, string[] Results);
public sealed class UserSearchClient(IHttpClientFactory httpClientFactory)
{
public async Task<UserSearchResult?> SearchAsync(string query, CancellationToken ct)
{
var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);
return await client.GetFromJsonAsync<UserSearchResult>(
$"/users/search?query={Uri.EscapeDataString(query)}", ct);
}
}
POST with a JSON body
Set request.Content however you normally would; the handler reads it,
replaces it with an encrypted envelope, and sends that instead. Your code
never constructs the envelope itself.
using System.Net.Http.Json;
using ACYTEC.Security.Hmac.Client.Options;
public sealed record CreateOrderRequest(string Sku, int Quantity);
public sealed record CreateOrderResponse(Guid OrderId);
public sealed class OrdersClient(IHttpClientFactory httpClientFactory)
{
public async Task<CreateOrderResponse?> CreateAsync(CreateOrderRequest order, CancellationToken ct)
{
var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);
var response = await client.PostAsJsonAsync("/orders", order, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<CreateOrderResponse>(cancellationToken: ct);
}
}
Handling errors
Error responses, 401 from a rejected token request, 400 from a decryption
failure on the host, 404, 5xx, and so on, are plain application/problem+json
and pass through the handler untouched, they are never treated as encrypted
envelopes. Use the response status code and body exactly as you would with
any other HttpClient call:
var response = await client.GetAsync("/users/search?query=lovelace", ct);
if (!response.IsSuccessStatusCode)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(cancellationToken: ct);
logger.LogWarning("Host rejected the call: {Status} {Title}", response.StatusCode, problem?.Title);
return null;
}
Azure Functions (isolated worker)
Registration is identical to any other generic host, since this package has no ASP.NET Core dependency:
// Program.cs
using ACYTEC.Security.Hmac.Client.Extensions;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services.AddACYTECHmacClient(builder.Configuration);
builder.Build().Run();
public sealed class SearchUsersFunction(IHttpClientFactory httpClientFactory)
{
[Function("SearchUsers")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequestData req)
{
var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);
var response = await client.GetAsync("/users/search?query=lovelace");
var result = req.CreateResponse(response.StatusCode);
await result.WriteStringAsync(await response.Content.ReadAsStringAsync());
return result;
}
}
Console app / worker service
using ACYTEC.Security.Hmac.Client.Extensions;
using ACYTEC.Security.Hmac.Client.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddACYTECHmacClient(builder.Configuration);
using var host = builder.Build();
var httpClientFactory = host.Services.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName);
var response = await client.GetAsync("/users/search?query=lovelace");
Console.WriteLine(await response.Content.ReadAsStringAsync());
See samples/SampleClientApp in this repository for a runnable version of
this, wired up against samples/SampleHostApi in the host repository.
What's on the wire
If you point a proxy or Wireshark at traffic from this client, here's what
you'll actually see. The point of the package is that none of this is
readable without the client's Secret.
Token request (POST /auth/token) carries no body, just the signed
headers:
POST /auth/token HTTP/1.1
Host: userapi.acytec.lk
X-Client-Id: evcrm
X-Timestamp: 1756195200
X-Nonce: 9f1c2b7a4e1d4c3e8a2f6b0d5c7e1a3b
X-Signature: kR3xVv9m1sQpZ2f8Lw6Yt4Nc0Xj7Bh2Ea5Dg1Fi3Ck=
Content-Length: 0
Token response, and every other 2xx response and non-empty request body,
is an EncryptedEnvelope, never the plain JSON you sent or expect back:
{
"payload": "vN2vQmYVX3ke9dW/3s0PGqjXtq7DrIw2PZ8Q1QpTwjRfvHZTthCUOxvUuY7wynHwvpF3jP6Xnkq/6h4=",
"nonce": "u7CqzT2n8YB4p9kL",
"tag": "R7dJcXwYVpNQGkq8h4v9zg=="
}
payload is the AES-256-GCM ciphertext, nonce is the 12 byte value used
for that encryption, tag is the 16 byte authentication tag, all base64.
Decrypted, the token response looks like
{"accessToken":"eyJhbGciOi...","expiresAt":"2026-08-26T13:15:00Z"}, but an
interceptor never gets that far without the shared secret.
A normal call, e.g. POST /orders from the example above, looks the same on
the wire:
POST /orders HTTP/1.1
Host: userapi.acytec.lk
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-App-Origin: https://evcrm.acytec.lk
Content-Type: application/json
Content-Length: 132
{"payload":"h3Yq9pLz2mXwK7vNc4tRj0dQ8sYfB1oEuI6aG5CxTz+VkP...","nonce":"3xQpZ8LwYtNc0Xj7","tag":"kR3xVv9m1sQpZ2f8Lw6Yt4="}
Your CreateOrderRequest/CreateOrderResponse JSON never appears in
cleartext; the delegating handler swaps request.Content for the envelope
before it reaches the socket, and reverses it on the response before your
code sees it.
Error responses are the one exception, and are deliberately not encrypted, see "Handling errors" above:
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid or expired token."}
What an observer on the wire gets: ClientId, timestamp, nonce, signature,
the opaque bearer JWT, AppOrigin, and the shape/size of each envelope. What
they don't get: the Secret, the HKDF-derived AES key, or any plaintext
request/response body, including the token response itself.
Configuration
Bind under the ACYTECHmacClient section. Every value here must match what
the host has registered for this client id.
{
"ACYTECHmacClient": {
"BaseAddress": "https://userapi.acytec.lk",
"ClientId": "evcrm",
"Secret": "<from key vault, at least 32 bytes, generated by a CSPRNG>",
"AppOrigin": "https://evcrm.acytec.lk",
"TokenEndpointPath": "/auth/token",
"EnablePayloadEncryption": true
}
}
| Option | Type | Default | Description |
|---|---|---|---|
BaseAddress |
string |
(required) | Base address of the host API. |
ClientId |
string |
(required) | This client's id, as registered on the host. |
Secret |
string |
(required) | This client's shared secret. See "Client secret requirements" below. Source this from Key Vault, never commit a real value. |
AppOrigin |
string |
(required) | Sent as X-App-Origin on every request. Must match the AllowedOrigin the host registered for this client id. |
TokenEndpointPath |
string |
/auth/token |
Must match the host's TokenEndpointPath. |
EnablePayloadEncryption |
bool |
true |
Must match the host's EnablePayloadEncryption for this client. Covers the token response too: with it false, /auth/token returns plain { "accessToken": "...", "expiresAt": "..." } and this client reads it as-is instead of attempting to decrypt an envelope. |
TokenRefreshBuffer |
TimeSpan |
00:00:30 |
How long before actual expiry the client proactively refreshes the token. |
ClientIdHeader / TimestampHeader / NonceHeader / SignatureHeader |
string |
X-Client-Id / X-Timestamp / X-Nonce / X-Signature |
Must match the host's HmacOptions header names. |
AppOriginHeader |
string |
X-App-Origin |
Must match the host's declared-origin header name. |
Source Secret from the Key Vault configuration provider so it never lives
in appsettings.json.
Client secret requirements
Secret must be at least 32 bytes, generated by a CSPRNG, not a human
chosen phrase. It does two jobs on the wire: it's the HMAC-SHA256 key that
signs the token request, and it's the HKDF input keying material the
AES-256 payload encryption key is derived from, so a weak secret weakens
both.
This isn't just a recommendation, the host enforces it: ACYTEC.Security.Hmac.AspNetCore
refuses to start if any registered client's secret is under strength, and
separately rejects it at request time as if the client were unregistered.
A Secret that doesn't meet this bar will authenticate against neither.
Generate one the same way you'd generate Tokens.SigningKey on the host:
$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)
Not Get-Random, it isn't cryptographically secure.
What it doesn't do
- It doesn't retry failed calls or apply resiliency policies. Chain your own
Polly handler onto the
ACYTECHmacClientOptions.HttpClientNameclient viaIHttpClientBuilderif you need that. - It doesn't expose a typed client (
AddHttpClient<TClient, TImplementation>style). UseIHttpClientFactory.CreateClient(ACYTECHmacClientOptions.HttpClientName)directly. - It doesn't inspect or transform error responses. A non-2xx response is returned exactly as the host sent it, see "Handling errors" above.
See PROTOCOL.md (in the ACYTEC.Security.Hmac.AspNetCore repository) for
the full wire level signing and encryption schemes this package implements,
including a worked test vector you can use to verify a from-scratch
reimplementation, e.g. in JavaScript.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.