HMAC mit C# und T-SQL
Neues in SQL Server 2025, Teil 3
In verteilten Systemen ist die Absicherung von Datenintegrität und Absenderauthentizität eine zentrale Anforderung. Häufig werden Daten zwischen Anwendungen, APIs oder Datenbanken im JSON-Format ausgetauscht. Um sicherzustellen, dass diese Daten während der Übertragung nicht verändert wurden und vom erwarteten Absender stammen, wird häufig ein HMAC (Hash-based Message Authentication Code) eingesetzt.
Dieser Artikel beschreibt, wie ein HMAC-SHA256-Hashcode über JSON-Inhalte so berechnet wird, dass C#-Anwendungen und SQL Server (T-SQL) exakt denselben Hash erzeugen. Der Schwerpunkt liegt dabei auf der korrekten Behandlung von JSON, Zeichencodierung und Byte-Konsistenz.
HMAC light
Da HASHBYTES() in SQL Server keinen Initialisierungsvektor vorsieht, kann dieser in der C#-Implementierung auch nicht verwendet werden. Die daraus resultierende Einschränkung ist in der Praxis jedoch in den meisten Fällen vernachlässigbar.
Was ist HMAC – und was nicht?
HMAC kombiniert drei Dinge:
- eine Nachricht (Message)
- ein geheimes Schlüsselmaterial (Secret)
- einen kryptografischen Hash-Algorithmus (zum Beispiel SHA-256)
Das Ergebnis ist ein Prüfwert, der folgende Eigenschaften besitzt:
- Jede Änderung der Nachricht führt zu einem anderen HMAC.
- Ohne Kenntnis des geheimen Schlüssels kann der HMAC nicht reproduziert werden.
HMAC dient nicht der Verschlüsselung, sondern ausschließlich der Integritäts- und Authentizitätsprüfung. Der Inhalt ist also nach wie vor lesbar.
Die zentrale Herausforderung bei JSON
JSON ist textbasiert und semantisch flexibel. Die folgenden JSON-Dokumente (Variante 1 und Variante 2) sind inhaltlich identisch, erzeugen aber unterschiedliche HMAC-Werte. JSON-Variante 1:
{"a":1,"b":2}
JSON-Variante 2:
{
"b": 2,
"a": 1
}
Für kryptografische Verfahren zählt ausschließlich die exakte Bytefolge. Daher ist es zwingend erforderlich, JSON vor der HMAC-Berechnung in eine kanonische, deterministische Form zu überführen.
Definition von Canonical JSON
Für die folgenden Beispiele gilt daher verbindlich:
- UTF-8-Encoding
- Keine Whitespaces oder Einrückungen
- Objekt-Properties lexikografisch sortiert (Ordinal)
- Arrays behalten ihre Reihenfolge
- Strings, Zahlen, Boolean- und Null-Werte bleiben typkonform
- Ausgabe ohne „Pretty Printing“
Diese Regeln müssen auf allen beteiligten Systemen identisch umgesetzt werden. Dazu werden folgende Beispiel-Daten verwendet:
{
"user": "thorsten",
"timestamp": "2025-01-01T10:00:00Z",
"roles": ["dba", "dev"],
"active": true
}
Der geheime Schlüssel soll dabei, völlig willkürlich und vielleicht nicht extrem sicher, „SuperSecretSharedKey123“ sein.
C#-Implementierung
Die C#-Implementierung besteht im Kern aus einer Funktion, um das Canonical JSON sicherzustellen (Listing 1), und einer für die Arbeit mit dem SHA256-Algorithmus sowie Formatierung des Endergebnisses als Hex, da es sich um Bytes und nicht um Zeichen handelt.
Listing 1: CanonicalizeJson()
public static string CanonicalizeJson(string json)
{
using var jsonDoc = JsonDocument.Parse(json);
return CanonicalizeElement(jsonDoc.RootElement);
static string CanonicalizeElement(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
var properties = element.EnumerateObject()
.OrderBy(p => p.Name, StringComparer.Ordinal);
var objBuilder = new StringBuilder();
objBuilder.Append('{');
bool firstProp = true;
foreach (var prop in properties)
{
if (!firstProp) objBuilder.Append(',');
firstProp = false;
objBuilder.Append(JsonSerializer.Serialize(prop.Name));
objBuilder.Append(':');
objBuilder.Append(CanonicalizeElement(prop.Value));
}
objBuilder.Append('}');
return objBuilder.ToString();
case JsonValueKind.Array:
var arrayBuilder = new StringBuilder();
arrayBuilder.Append('[');
bool firstItem = true;
foreach (var item in element.EnumerateArray())
{
if (!firstItem) arrayBuilder.Append(',');
firstItem = false;
arrayBuilder.Append(CanonicalizeElement(item));
}
arrayBuilder.Append(']');
return arrayBuilder.ToString();
default:
JsonSerializerOptions options = new()
{
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
return JsonSerializer.Serialize(element, options);
}
}
}
Zwar verfügt .NET über eine System.Security.Cryptography.HMACSHA256-Klasse, die von Namen her geeignet scheint, die aber im Vergleich zum SQL Server immer abweichende Ergebnis geliefert hat. Als Lösung wird im Beispiel direkt mit der HMACSHA256-Klasse aus dem gleichen Namensraum gearbeitet (Listing 2).
Listing 2: ComputeHmacSha256Hex()
public static string ComputeHmacSha256Hex(string canonicalJson, string secret)
{
using SHA256 hasher = SHA256.Create();
// Convert the input string to a byte array and compute the hash.
string input = secret + ':' + canonicalJson;
byte[] data = hasher.ComputeHash(Encoding.Unicode.GetBytes(input));
// StringBuilder für das Ergebnis
StringBuilder sBuilder = new(input.Length * 2);
// Byteweise in Hex umwandeln
for (int i = 0; i < data.Length; i++)
sBuilder.Append(data[i].ToString("x2"));
// Rückgabe
return sBuilder.ToString();
//byte[] keyBytes = Encoding.Unicode.GetBytes(secret);
//byte[] messageBytes = Encoding.Unicode.GetBytes(canonicalJson);
//using var hmac = new HMACSHA256(keyBytes);
//byte[] hash = hmac.ComputeHash(messageBytes);
//var sb = new StringBuilder(hash.Length * 2);
//foreach (var b in hash)
// sb.Append(b.ToString("x2"));
//return sb.ToString();
}
Zusammen werden dann beide nacheinander aufgerufen (Listing 3). Die so erzeugte Zeichenkette kann dann für den Vergleich verwendet werden.
Listing 3: Gesamtaufruf C#
string canonical = JsonHmacHelper.CanonicalizeJson(json);
// {"active":true,"roles":["dba","dev"],"timestamp":"2025-01-01T10:00:00Z","user":"thorsten"}
string hmacHex = JsonHmacHelper.ComputeHmacSha256Hex(canonical, secret);
// 7586df0441ea83163311043efb0413b4d2e930e52ef627a733d11a62562687b2
SQL-Server-(T-SQL-)Implementierung
Die Implementierung für SQL Server besteht im Kern ebenfalls aus zwei Funktionen. Wieder eine für das das Canonical JSON (Listing 4) und eine zweite für die Arbeit mit dem Hash-Algorithmus (Listing 5).
Listing 4: dbo.CanonicalizeJson() T-SQL
CREATE OR ALTER FUNCTION dbo.CanonicalizeJson (@json NVARCHAR(MAX))
RETURNS NVARCHAR(MAX)
AS
BEGIN
DECLARE @result NVARCHAR(MAX);
-- Primitive? (OPENJSON liefert bei nicht-Objekt/Array NULL)
IF ISJSON(@json) = 0
RETURN @json;
-- Versuche Object
IF EXISTS (SELECT 1 FROM OPENJSON(@json))
BEGIN
-- Entscheiden: Object oder Array?
-- Array: OPENJSON liefert keys 0..n, Object: keys sind Property-Namen
IF EXISTS (SELECT 1 FROM OPENJSON(@json) WHERE TRY_CONVERT(INT, [key]) IS NULL)
BEGIN
-- Object
SELECT @result =
N'{' + STRING_AGG(
-- Property name als JSON-String + ":" + kanonischer value
STRING_ESCAPE([key], 'json')
-- STRING_ESCAPE gibt keinen umschließenden "
, N''
) WITHIN GROUP (ORDER BY [key])
FROM OPENJSON(@json);
-- Oben bauen wir nur die Keys; jetzt korrekt zusammensetzen:
;WITH kv AS
(
SELECT
[key],
value,
type
FROM OPENJSON(@json)
)
SELECT @result =
N'{' +
STRING_AGG(
N'"' + STRING_ESCAPE([key], 'json') + N'":' +
CASE
WHEN type IN (4,5) THEN dbo.CanonicalizeJson(value) -- array/object
WHEN type = 1 THEN N'"' + STRING_ESCAPE(value, 'json') + N'"' -- string
ELSE value -- number, true, false, null
END
, N',') WITHIN GROUP (ORDER BY [key])
+ N'}'
FROM kv;
RETURN @result;
END
ELSE
BEGIN
-- Array
;WITH a AS
(
SELECT
TRY_CONVERT(INT, [key]) AS idx,
value,
type
FROM OPENJSON(@json)
)
SELECT @result =
N'[' +
STRING_AGG(
CASE
WHEN type IN (4,5) THEN dbo.CanonicalizeJson(value)
WHEN type = 1 THEN N'"' + STRING_ESCAPE(value, 'json') + N'"'
ELSE value
END
, N',') WITHIN GROUP (ORDER BY idx)
+ N']'
FROM a;
RETURN @result;
END
END
RETURN @json;
END;
GO
Für die Arbeit mit SHA256 wird die interne Funktion HASHBYTES() verwendet.
Listing 5: dbo.ComputeHmacSha256Hex() T-SQL
CREATE OR ALTER FUNCTION dbo.ComputeHmacSha256Hex
(
@canonicalJson NVARCHAR(MAX),
@secret NVARCHAR(4000)
)
RETURNS VARCHAR(64)
AS
BEGIN
-- in Unicode-Bytefolgen überführen:
DECLARE @input VARBINARY(MAX) = CONVERT(VARBINARY(MAX), @secret + N':' + @canonicalJson);
DECLARE @hash VARBINARY(32) = HASHBYTES('SHA2_256', @input);
RETURN LOWER(CONVERT(VARCHAR(64), @hash, 2));
END;
GO
Listing 8 führt dann den Gesamtaufruf durch und liefert als Ergebnis die gleich hexadezimale Zeichenfolge wie das C# Pendant
Listing 8: Gesamtaufruf C#
DECLARE @canonical NVARCHAR(MAX) = dbo.CanonicalizeJson(CAST(@json AS NVARCHAR(MAX)));
DECLARE @hmac VARCHAR(64) = dbo.ComputeHmacSha256Hex(@canonical, @secret);
SELECT @canonical AS CanonicalJson, @hmac AS HmacHex;
-- {"active":true,"roles":["dba","dev"],"timestamp":"2025-01-01T10:00:00Z","user":"thorsten"}
-- 7586df0441ea83163311043efb0413b4d2e930e52ef627a733d11a62562687b2
Fazit
Vielleicht etwas codeintensiv, aber leistbar. Berechnet werden Signaturen, die sich an dem (kanonischen) Inhalt orientieren und über Systemgrenze hinweg prüfen, ob Inhalt modifiziert wurden. Das Verfahren ist auch ohne SSL/TLS und daher nicht nur für Webhooks nutzbar. Lesbare Tabellen-/Dateiinhalte können so zuverlässig geprüft werden.