From 113bf65ead48360ab74aec9a105dde1c4b1735d3 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 13:38:12 +0000 Subject: [PATCH 01/21] Extended the API to verify Approov message signatures after the token check. Added message-signing configuration and decoding logic (MessageSigningMode, base-secret parsing, header list, max age, nonce control) in servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs (line 3) and Program.cs (line 31). Base secrets now accept base64 or base32. Captured needed token metadata (device ID, expiry, installation public key, raw token) for downstream use in ApproovTokenMiddleware (servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs (line 23) onward). Introduced helpers for HTTP signature parsing and canonical message construction plus signature verification routines (Helpers/HttpSignatureParser.cs (line 6), Helpers/MessageSigningUtilities.cs (line 11)) and the new MessageSigningMiddleware enforcing installation/account signature checks, timestamp freshness, and nonce policy (Middleware/MessageSigningMiddleware.cs (line 9)). Updated configuration defaults (appsettings.json (line 9), appsettings.Development.json (line 9)) and documentation describing setup and testing (README.md (line 61) and README.md (line 112)). Added xUnit coverage for canonical message building, installation/account verification paths, metadata validation, and middleware behaviour in tests/Hello.Tests/UnitTest1.cs (line 11). --- .../token-check/Helpers/AppSettings.cs | 14 +- .../Helpers/ApproovTokenContextKeys.cs | 9 + .../Helpers/HttpSignatureParser.cs | 292 ++++++++++++++++++ .../Helpers/MessageSigningUtilities.cs | 217 +++++++++++++ .../Middleware/ApproovTokenMiddleware.cs | 58 +++- .../Middleware/MessageSigningMiddleware.cs | 239 ++++++++++++++ .../token-check/Program.cs | 106 ++++++- .../token-check/README.md | 31 ++ .../token-check/appsettings.Development.json | 9 + .../token-check/appsettings.json | 11 +- tests/Hello.Tests/Hello.Tests.csproj | 25 ++ tests/Hello.Tests/UnitTest1.cs | 256 +++++++++++++++ 12 files changed, 1262 insertions(+), 5 deletions(-) create mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs create mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs create mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs create mode 100644 servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs create mode 100644 tests/Hello.Tests/Hello.Tests.csproj create mode 100644 tests/Hello.Tests/UnitTest1.cs diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs index ba12e29..71ec1e5 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs @@ -1,6 +1,18 @@ namespace Hello.Helpers; +public enum MessageSigningMode +{ + None, + Installation, + Account +} + public class AppSettings { - public byte[] ?ApproovSecretBytes { get; set; } + public byte[]? ApproovSecretBytes { get; set; } + public MessageSigningMode MessageSigningMode { get; set; } = MessageSigningMode.None; + public byte[]? AccountMessageBaseSecretBytes { get; set; } + public string[] MessageSigningHeaderNames { get; set; } = Array.Empty(); + public int MessageSigningMaxAgeSeconds { get; set; } = 300; + public bool RequireSignatureNonce { get; set; } } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs new file mode 100644 index 0000000..cf251c7 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs @@ -0,0 +1,9 @@ +namespace Hello.Helpers; + +public static class ApproovTokenContextKeys +{ + public const string ApproovToken = "ApproovToken"; + public const string DeviceId = "ApproovDeviceId"; + public const string TokenExpiry = "ApproovTokenExpiry"; + public const string InstallationPublicKey = "ApproovInstallationPublicKey"; +} diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs new file mode 100644 index 0000000..7a97df8 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs @@ -0,0 +1,292 @@ +namespace Hello.Helpers; + +using System.Collections.Generic; +using System.Text; + +public readonly record struct HttpSignatureEntry(string Label, string Signature); + +public record HttpSignatureInput( + string Label, + IReadOnlyList Components, + long? Created, + long? Expires, + string? Nonce); + +public static class HttpSignatureParser +{ + public static bool TryParseSignature(string headerValue, out HttpSignatureEntry entry, out string? error) + { + entry = default; + error = null; + + if (string.IsNullOrWhiteSpace(headerValue)) + { + error = "Signature header is empty."; + return false; + } + + foreach (var segment in SplitTopLevel(headerValue)) + { + var trimmed = segment.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + continue; + } + + var equalsIndex = trimmed.IndexOf('='); + if (equalsIndex < 0) + { + continue; + } + + var label = trimmed[..equalsIndex].Trim(); + var remainder = trimmed[(equalsIndex + 1)..].Trim(); + + if (string.IsNullOrEmpty(label)) + { + continue; + } + + string signatureValue; + + if (remainder.StartsWith(":", StringComparison.Ordinal)) + { + var closingColonIndex = remainder.IndexOf(':', 1); + if (closingColonIndex < 0) + { + error = "Signature header value missing closing colon."; + return false; + } + + signatureValue = remainder.Substring(1, closingColonIndex - 1); + } + else + { + var semicolonIndex = remainder.IndexOf(';'); + if (semicolonIndex >= 0) + { + remainder = remainder[..semicolonIndex]; + } + + signatureValue = remainder.Trim().Trim('"'); + } + + if (string.IsNullOrEmpty(signatureValue)) + { + error = "Signature header missing encoded signature."; + return false; + } + + entry = new HttpSignatureEntry(label, signatureValue); + return true; + } + + error = "Signature header missing expected entry."; + return false; + } + + public static bool TryParseSignatureInput(string headerValue, string expectedLabel, out HttpSignatureInput? signatureInput, out string? error) + { + signatureInput = null; + error = null; + + if (string.IsNullOrWhiteSpace(headerValue)) + { + error = "Signature-Input header is empty."; + return false; + } + + foreach (var segment in SplitTopLevel(headerValue)) + { + var trimmed = segment.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + continue; + } + + var equalsIndex = trimmed.IndexOf('='); + if (equalsIndex < 0) + { + continue; + } + + var label = trimmed[..equalsIndex].Trim(); + if (!string.IsNullOrEmpty(expectedLabel) && !string.Equals(label, expectedLabel, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var remainder = trimmed[(equalsIndex + 1)..].Trim(); + var openParenIndex = remainder.IndexOf('('); + var closeParenIndex = remainder.IndexOf(')', openParenIndex + 1); + + if (openParenIndex < 0 || closeParenIndex < 0 || closeParenIndex <= openParenIndex) + { + error = "Signature-Input header missing component list."; + return false; + } + + var componentsSegment = remainder.Substring(openParenIndex + 1, closeParenIndex - openParenIndex - 1); + var components = ParseComponents(componentsSegment); + + var parametersSegment = remainder[(closeParenIndex + 1)..]; + long? created = null; + long? expires = null; + string? nonce = null; + + if (!string.IsNullOrWhiteSpace(parametersSegment)) + { + var parameterParts = parametersSegment.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var parameter in parameterParts) + { + var separatorIndex = parameter.IndexOf('='); + var key = separatorIndex >= 0 ? parameter[..separatorIndex].Trim() : parameter.Trim(); + var value = separatorIndex >= 0 ? parameter[(separatorIndex + 1)..].Trim() : string.Empty; + value = TrimQuotes(value); + + switch (key) + { + case "created": + if (long.TryParse(value, out var createdValue)) + { + created = createdValue; + } + break; + case "expires": + if (long.TryParse(value, out var expiresValue)) + { + expires = expiresValue; + } + break; + case "nonce": + nonce = value; + break; + } + } + } + + signatureInput = new HttpSignatureInput(label, components, created, expires, nonce); + return true; + } + + error = string.IsNullOrEmpty(expectedLabel) + ? "Signature-Input header missing required entry." + : $"Signature-Input header missing entry for label '{expectedLabel}'."; + return false; + } + + private static IReadOnlyList ParseComponents(string segment) + { + var components = new List(); + var builder = new StringBuilder(); + var insideQuotes = false; + var escaping = false; + + foreach (var ch in segment) + { + if (escaping) + { + builder.Append(ch); + escaping = false; + continue; + } + + if (ch == '\\' && insideQuotes) + { + escaping = true; + continue; + } + + if (ch == '"') + { + if (insideQuotes) + { + components.Add(builder.ToString()); + builder.Clear(); + } + + insideQuotes = !insideQuotes; + continue; + } + + if (insideQuotes) + { + builder.Append(ch); + } + } + + return components; + } + + private static IEnumerable SplitTopLevel(string headerValue) + { + var segments = new List(); + var builder = new StringBuilder(); + var insideQuotes = false; + var escaping = false; + var parenDepth = 0; + + foreach (var ch in headerValue) + { + if (escaping) + { + builder.Append(ch); + escaping = false; + continue; + } + + switch (ch) + { + case '\\': + if (insideQuotes) + { + escaping = true; + continue; + } + break; + case '"': + insideQuotes = !insideQuotes; + break; + case '(': + if (!insideQuotes) + { + parenDepth++; + } + break; + case ')': + if (!insideQuotes && parenDepth > 0) + { + parenDepth--; + } + break; + case ',': + if (!insideQuotes && parenDepth == 0) + { + segments.Add(builder.ToString()); + builder.Clear(); + continue; + } + break; + } + + builder.Append(ch); + } + + if (builder.Length > 0) + { + segments.Add(builder.ToString()); + } + + return segments; + } + + private static string TrimQuotes(string value) + { + if (value.Length >= 2 && value.StartsWith('"') && value.EndsWith('"')) + { + return value[1..^1]; + } + + return value; + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs new file mode 100644 index 0000000..cac6246 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs @@ -0,0 +1,217 @@ +namespace Hello.Helpers; + +using System.Buffers.Binary; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +public static class MessageSigningUtilities +{ + public static async Task BuildCanonicalMessageAsync(HttpRequest request, IEnumerable headerNames) + { + var orderedHeaders = headerNames + .Select(name => name.Trim()) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => name.ToLowerInvariant()) + .Distinct() + .ToList(); + + var parts = new List + { + request.Method.ToUpperInvariant(), + BuildPathAndQuery(request) + }; + + foreach (var headerName in orderedHeaders) + { + if (!request.Headers.TryGetValue(headerName, out var values)) + { + // HeaderDictionary is case-insensitive, but TryGetValue requires the original case. + // If a lowercase lookup fails, attempt using the original casing by enumerating the dictionary. + var match = request.Headers.FirstOrDefault(pair => string.Equals(pair.Key, headerName, StringComparison.OrdinalIgnoreCase)); + values = match.Value; + } + + var canonicalValue = values.Count > 0 + ? string.Join(", ", values.Select(v => v.Trim())) + : string.Empty; + + parts.Add($"{headerName}:{canonicalValue}"); + } + + var bodyHash = await ComputeBodyHashAsync(request).ConfigureAwait(false); + if (bodyHash != null) + { + parts.Add(Convert.ToBase64String(bodyHash)); + } + + var canonical = string.Join('\n', parts); + return Encoding.UTF8.GetBytes(canonical); + } + + public static bool VerifyInstallationSignature(byte[] message, string signature, string publicKeyBase64) + { + if (!TryDecodeBase64(signature, out var signatureBytes)) + { + return false; + } + + byte[] publicKeyBytes; + try + { + publicKeyBytes = Convert.FromBase64String(publicKeyBase64); + } + catch (FormatException) + { + return false; + } + + using var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _); + } + catch (CryptographicException) + { + return false; + } + + if (ecdsa.VerifyData(message, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)) + { + return true; + } + + if (signatureBytes.Length == 64 && + ecdsa.VerifyData(message, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) + { + return true; + } + + return false; + } + + public static byte[] DeriveSecret(byte[] baseSecret, string deviceId, DateTimeOffset tokenExpiry) + { + if (baseSecret.Length == 0) + { + throw new ArgumentException("Base secret must not be empty.", nameof(baseSecret)); + } + + byte[] deviceIdBytes; + try + { + deviceIdBytes = Convert.FromBase64String(deviceId); + } + catch (FormatException) + { + deviceIdBytes = Encoding.UTF8.GetBytes(deviceId); + } + + Span expiryBytes = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(expiryBytes, tokenExpiry.ToUnixTimeSeconds()); + + var payloadLength = deviceIdBytes.Length + expiryBytes.Length; + var payload = new byte[payloadLength]; + Buffer.BlockCopy(deviceIdBytes, 0, payload, 0, deviceIdBytes.Length); + expiryBytes.CopyTo(payload.AsSpan(deviceIdBytes.Length)); + + using var hmac = new HMACSHA256(baseSecret); + return hmac.ComputeHash(payload); + } + + public static bool VerifyAccountSignature(byte[] message, string signature, byte[] derivedSecret) + { + if (!TryDecodeBase64(signature, out var signatureBytes)) + { + return false; + } + + using var hmac = new HMACSHA256(derivedSecret); + var expectedSignature = hmac.ComputeHash(message); + + if (signatureBytes.Length != expectedSignature.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals(signatureBytes, expectedSignature); + } + + private static async Task ComputeBodyHashAsync(HttpRequest request) + { + request.EnableBuffering(); + if (!request.Body.CanRead) + { + return null; + } + + request.Body.Position = 0; + + using var sha256 = SHA256.Create(); + var buffer = new byte[8192]; + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await request.Body.ReadAsync(buffer.AsMemory(0, buffer.Length)).ConfigureAwait(false)) > 0) + { + sha256.TransformBlock(buffer, 0, bytesRead, null, 0); + totalRead += bytesRead; + } + + sha256.TransformFinalBlock(Array.Empty(), 0, 0); + request.Body.Position = 0; + + return totalRead > 0 ? sha256.Hash : null; + } + + private static string BuildPathAndQuery(HttpRequest request) + { + var path = request.Path.HasValue ? request.Path.Value : "/"; + var query = request.QueryString.HasValue ? request.QueryString.Value : string.Empty; + return string.Concat(path, query); + } + + private static bool TryDecodeBase64(string value, out byte[] bytes) + { + bytes = Array.Empty(); + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + + try + { + bytes = Convert.FromBase64String(trimmed); + return true; + } + catch (FormatException) + { + // Fall through and try Base64Url decoding. + } + + var normalized = trimmed.Replace('-', '+').Replace('_', '/'); + switch (normalized.Length % 4) + { + case 2: + normalized += "=="; + break; + case 3: + normalized += "="; + break; + } + + try + { + bytes = Convert.FromBase64String(normalized); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index 6b8e380..60f043c 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs @@ -3,7 +3,6 @@ namespace Hello.Middleware; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; -using System.Text; using Hello.Helpers; public class ApproovTokenMiddleware @@ -23,13 +22,14 @@ public async Task Invoke(HttpContext context) { var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); - if (token == null) { + if (string.IsNullOrWhiteSpace(token)) { _logger.LogInformation("Missing Approov-Token header."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } if (verifyApproovToken(context, token)) { + context.Items[ApproovTokenContextKeys.ApproovToken] = token; await _next(context); return; } @@ -43,6 +43,11 @@ private bool verifyApproovToken(HttpContext context, string token) try { var tokenHandler = new JwtSecurityTokenHandler(); + if (_appSettings.ApproovSecretBytes == null || _appSettings.ApproovSecretBytes.Length == 0) + { + _logger.LogError("Approov secret bytes not configured."); + return false; + } tokenHandler.ValidateToken(token, new TokenValidationParameters { @@ -54,6 +59,9 @@ private bool verifyApproovToken(HttpContext context, string token) ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); + var jwtToken = (JwtSecurityToken)validatedToken; + captureApproovTokenMetadata(context, jwtToken); + return true; } catch (SecurityTokenException exception) { _logger.LogInformation(exception.Message); @@ -63,4 +71,50 @@ private bool verifyApproovToken(HttpContext context, string token) return false; } } + + private static void captureApproovTokenMetadata(HttpContext context, JwtSecurityToken jwtToken) + { + var deviceId = jwtToken.Claims.FirstOrDefault(claim => + string.Equals(claim.Type, "did", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "device_id", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "device", StringComparison.OrdinalIgnoreCase)) + ?.Value; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; + } + + var expiryUnixSeconds = jwtToken.Payload.Exp; + if (expiryUnixSeconds.HasValue) + { + try + { + context.Items[ApproovTokenContextKeys.TokenExpiry] = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds.Value); + } + catch (ArgumentOutOfRangeException) + { + // Leave unset if the exp claim contains an invalid value. + } + } + else + { + var expClaim = jwtToken.Claims.FirstOrDefault(claim => string.Equals(claim.Type, JwtRegisteredClaimNames.Exp, StringComparison.OrdinalIgnoreCase))?.Value; + if (expClaim != null && long.TryParse(expClaim, out var parsedExp)) + { + context.Items[ApproovTokenContextKeys.TokenExpiry] = DateTimeOffset.FromUnixTimeSeconds(parsedExp); + } + } + + var installationPubKey = jwtToken.Claims.FirstOrDefault(claim => + string.Equals(claim.Type, "ipk", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "installation_pubkey", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "installation_public_key", StringComparison.OrdinalIgnoreCase)) + ?.Value; + + if (!string.IsNullOrWhiteSpace(installationPubKey)) + { + context.Items[ApproovTokenContextKeys.InstallationPublicKey] = installationPubKey; + } + } } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs new file mode 100644 index 0000000..b1cf47a --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -0,0 +1,239 @@ +namespace Hello.Middleware; + +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using Hello.Helpers; +using Microsoft.Extensions.Options; + +public class MessageSigningMiddleware +{ + private static readonly TimeSpan FutureTimestampSkewTolerance = TimeSpan.FromSeconds(30); + + private readonly RequestDelegate _next; + private readonly AppSettings _settings; + private readonly ILogger _logger; + + public MessageSigningMiddleware(RequestDelegate next, IOptions settings, ILogger logger) + { + _next = next; + _settings = settings.Value; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_settings.MessageSigningMode == MessageSigningMode.None) + { + await _next(context); + return; + } + + if (!context.Items.TryGetValue(ApproovTokenContextKeys.ApproovToken, out var tokenObject) || tokenObject is not string approovToken) + { + _logger.LogWarning("Approov token not found in context items. Message signing verification aborted."); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var signatureHeader = context.Request.Headers["Signature"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(signatureHeader)) + { + _logger.LogInformation("Missing Signature header."); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + if (!HttpSignatureParser.TryParseSignature(signatureHeader, out var signatureEntry, out var signatureError)) + { + _logger.LogInformation("Invalid Signature header: {Error}", signatureError); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + HttpSignatureInput? signatureInput = null; + var signatureInputHeader = context.Request.Headers["Signature-Input"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(signatureInputHeader)) + { + if (!HttpSignatureParser.TryParseSignatureInput(signatureInputHeader, signatureEntry.Label, out signatureInput, out var inputError)) + { + _logger.LogInformation("Invalid Signature-Input header: {Error}", inputError); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + else if (_settings.MessageSigningMaxAgeSeconds > 0 || _settings.RequireSignatureNonce) + { + _logger.LogInformation("Missing Signature-Input header required for metadata validation."); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + if (!ValidateMetadata(signatureInput, out var metadataError)) + { + _logger.LogInformation("Message signature metadata validation failed: {Error}", metadataError); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var headerNames = DetermineHeaderNames(signatureInput?.Components); + var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); + + var verified = _settings.MessageSigningMode switch + { + MessageSigningMode.Installation => VerifyInstallationSignature(context, canonicalMessage, signatureEntry.Signature), + MessageSigningMode.Account => VerifyAccountSignature(context, canonicalMessage, signatureEntry.Signature), + _ => false + }; + + if (!verified) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + _logger.LogDebug("Approov message signature verified using {Mode} mode.", _settings.MessageSigningMode); + await _next(context); + } + + private bool VerifyInstallationSignature(HttpContext context, byte[] canonicalMessage, string encodedSignature) + { + if (!context.Items.TryGetValue(ApproovTokenContextKeys.InstallationPublicKey, out var publicKeyObj) || + publicKeyObj is not string installationPublicKey) + { + _logger.LogInformation("Installation public key not found in Approov token."); + return false; + } + + if (!MessageSigningUtilities.VerifyInstallationSignature(canonicalMessage, encodedSignature, installationPublicKey)) + { + _logger.LogInformation("Installation message signature verification failed."); + return false; + } + + return true; + } + + private bool VerifyAccountSignature(HttpContext context, byte[] canonicalMessage, string encodedSignature) + { + if (_settings.AccountMessageBaseSecretBytes is null || _settings.AccountMessageBaseSecretBytes.Length == 0) + { + _logger.LogError("Account message signing base secret not configured."); + return false; + } + + if (!context.Items.TryGetValue(ApproovTokenContextKeys.DeviceId, out var deviceIdObj) || + deviceIdObj is not string deviceId || string.IsNullOrWhiteSpace(deviceId)) + { + _logger.LogInformation("Device ID not present in Approov token."); + return false; + } + + if (!context.Items.TryGetValue(ApproovTokenContextKeys.TokenExpiry, out var expiryObj) || + expiryObj is not DateTimeOffset tokenExpiry) + { + _logger.LogInformation("Token expiry missing from Approov token context."); + return false; + } + + byte[] derivedSecret; + try + { + derivedSecret = MessageSigningUtilities.DeriveSecret(_settings.AccountMessageBaseSecretBytes, deviceId, tokenExpiry); + } + catch (Exception ex) when (ex is ArgumentException or FormatException or CryptographicException) + { + _logger.LogInformation("Failed to derive account message signing secret: {Message}", ex.Message); + return false; + } + + if (!MessageSigningUtilities.VerifyAccountSignature(canonicalMessage, encodedSignature, derivedSecret)) + { + _logger.LogInformation("Account message signature verification failed."); + return false; + } + + return true; + } + + private bool ValidateMetadata(HttpSignatureInput? signatureInput, out string? error) + { + error = null; + + if (signatureInput is null) + { + return true; + } + + if (_settings.RequireSignatureNonce && string.IsNullOrWhiteSpace(signatureInput.Nonce)) + { + error = "Signature nonce required but not provided."; + return false; + } + + if (_settings.MessageSigningMaxAgeSeconds > 0) + { + if (!signatureInput.Created.HasValue) + { + error = "Signature-Input missing 'created' parameter."; + return false; + } + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(signatureInput.Created.Value); + var now = DateTimeOffset.UtcNow; + + if (now - createdAt > TimeSpan.FromSeconds(_settings.MessageSigningMaxAgeSeconds)) + { + error = "Signature creation time outside allowable age."; + return false; + } + + if (createdAt - now > FutureTimestampSkewTolerance) + { + error = "Signature creation time is in the future."; + return false; + } + } + + if (signatureInput.Expires.HasValue) + { + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(signatureInput.Expires.Value); + if (DateTimeOffset.UtcNow > expiresAt) + { + error = "Signature has expired."; + return false; + } + } + + return true; + } + + private List DetermineHeaderNames(IReadOnlyList? components) + { + var headers = new List(); + + if (components is not null && components.Count > 0) + { + foreach (var component in components) + { + if (string.IsNullOrWhiteSpace(component) || component.StartsWith("@", StringComparison.Ordinal)) + { + continue; + } + + headers.Add(component); + } + } + else if (_settings.MessageSigningHeaderNames.Length > 0) + { + headers.AddRange(_settings.MessageSigningHeaderNames); + } + + if (!headers.Any(header => string.Equals(header, "Approov-Token", StringComparison.OrdinalIgnoreCase))) + { + headers.Insert(0, "Approov-Token"); + } + + return headers; + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Program.cs b/servers/hello/src/approov-protected-server/token-check/Program.cs index 20326ef..1256ade 100644 --- a/servers/hello/src/approov-protected-server/token-check/Program.cs +++ b/servers/hello/src/approov-protected-server/token-check/Program.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; using Hello.Helpers; ////////////////////////// @@ -26,8 +28,37 @@ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.Configure(appSettings => { +var approovSection = builder.Configuration.GetSection("Approov"); +var messageSigningModeValue = approovSection["MessageSigningMode"] ?? nameof(MessageSigningMode.None); +if (!Enum.TryParse(messageSigningModeValue, true, out var messageSigningMode)) +{ + throw new InvalidOperationException($"Unsupported Approov message signing mode '{messageSigningModeValue}'."); +} + +var accountMessageBaseSecretRaw = approovSection["AccountMessageBaseSecret"]; +byte[]? accountMessageBaseSecretBytes = null; +if (!string.IsNullOrWhiteSpace(accountMessageBaseSecretRaw)) +{ + accountMessageBaseSecretBytes = DecodeAccountMessageBaseSecret(accountMessageBaseSecretRaw); +} + +if (messageSigningMode == MessageSigningMode.Account && accountMessageBaseSecretBytes == null) +{ + throw new InvalidOperationException("Message signing mode 'Account' requires Approov:AccountMessageBaseSecret to be configured."); +} + +var configuredSignedHeaders = approovSection.GetSection("SignedHeaders").Get() ?? Array.Empty(); +var messageSigningMaxAgeSeconds = approovSection.GetValue("MessageSigningMaxAgeSeconds") ?? 300; +var requireSignatureNonce = approovSection.GetValue("RequireSignatureNonce") ?? false; + +builder.Services.Configure(appSettings => +{ appSettings.ApproovSecretBytes = approovSecretBytes; + appSettings.MessageSigningMode = messageSigningMode; + appSettings.AccountMessageBaseSecretBytes = accountMessageBaseSecretBytes; + appSettings.MessageSigningHeaderNames = configuredSignedHeaders; + appSettings.MessageSigningMaxAgeSeconds = messageSigningMaxAgeSeconds; + appSettings.RequireSignatureNonce = requireSignatureNonce; }); var app = builder.Build(); @@ -47,9 +78,82 @@ // app.UseHttpsRedirection(); app.UseMiddleware(); +app.UseMiddleware(); app.UseAuthorization(); app.MapControllers(); app.Run(); + +static byte[] DecodeAccountMessageBaseSecret(string encodedSecret) +{ + var sanitized = encodedSecret.Trim(); + if (string.IsNullOrWhiteSpace(sanitized)) + { + throw new FormatException("Account message signing base secret cannot be empty."); + } + + try + { + return Convert.FromBase64String(sanitized); + } + catch (FormatException) + { + // Intentionally fall through to try Base32 decoding. + } + + return DecodeBase32(sanitized); +} + +static byte[] DecodeBase32(string encodedSecret) +{ + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + var cleaned = encodedSecret + .Trim() + .Replace("-", string.Empty) + .Replace(" ", string.Empty) + .TrimEnd('=') + .ToUpperInvariant(); + + if (cleaned.Length == 0) + { + throw new FormatException("Account message signing base secret cannot be empty."); + } + + var output = new List(); + var buffer = 0; + var bitsLeft = 0; + + foreach (var ch in cleaned) + { + var index = alphabet.IndexOf(ch); + if (index < 0) + { + throw new FormatException($"Invalid Base32 character '{ch}'."); + } + + buffer = (buffer << 5) | index; + bitsLeft += 5; + + if (bitsLeft >= 8) + { + bitsLeft -= 8; + var value = (byte)((buffer >> bitsLeft) & 0xFF); + output.Add(value); + buffer &= (1 << bitsLeft) - 1; + } + } + + if (bitsLeft > 0 && (buffer & ((1 << bitsLeft) - 1)) != 0) + { + throw new FormatException("Invalid Base32 padding in account message signing base secret."); + } + + if (output.Count == 0) + { + throw new FormatException("Account message signing base secret decoding produced no data."); + } + + return output.ToArray(); +} diff --git a/servers/hello/src/approov-protected-server/token-check/README.md b/servers/hello/src/approov-protected-server/token-check/README.md index 3280f61..936d049 100644 --- a/servers/hello/src/approov-protected-server/token-check/README.md +++ b/servers/hello/src/approov-protected-server/token-check/README.md @@ -58,6 +58,31 @@ Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to [TOC](#toc---table-of-contents) +## Message Signing Configuration + +After the Approov token is validated the API can optionally verify an [Approov Message Signing](https://approov.io/docs/latest/approov-usage-documentation/#installation-message-signing) signature. Configure the behaviour in `appsettings.json`: + +```jsonc +"Approov": { + "MessageSigningMode": "Installation", // None | Installation | Account + "AccountMessageBaseSecret": "", // Required when Account mode is selected + "MessageSigningMaxAgeSeconds": 300, // Reject signatures older than this age + "RequireSignatureNonce": false, // Enforce nonce presence if you track replay protection + "SignedHeaders": [ + "Approov-Token", + "Content-Type" + ] +} +``` + +* **None** — message signing checks are skipped and only the token is validated. +* **Installation** — expects an `ipk` claim in the Approov token. The middleware extracts the Elliptic Curve public key and verifies an ECDSA P-256/SHA-256 signature over the canonical request representation (HTTP method, path + query, configured headers and body hash when present). +* **Account** — derives a per-token HMAC key using the configured base secret, the device ID (`did` claim) and token expiry. The base secret can be provided in base64 or base32 form exactly as exported by `approov secret -messageSigningKey`. + +The canonical message always includes the `Approov-Token` header to prevent replay. If the client supplies a [`Signature-Input`](https://www.rfc-editor.org/rfc/rfc9421) header its declared components are honoured; otherwise the server falls back to the `SignedHeaders` list. Set `MessageSigningMaxAgeSeconds` and `RequireSignatureNonce` to mirror your policy for timestamp freshness and nonce enforcement. + +[TOC](#toc---table-of-contents) + ## Try the Approov Integration Example @@ -86,6 +111,12 @@ The reason you got a `401` is because the Approoov token isn't provided in the h Finally, you can test that the Approov integration example works as expected with this [Postman collection](/TESTING.md#testing-with-postman) or with some cURL requests [examples](/TESTING.md#testing-with-curl). +Run the automated unit tests with: + +```bash +dotnet test ../../../../tests/Hello.Tests/Hello.Tests.csproj +``` + [TOC](#toc---table-of-contents) diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json index 0c208ae..bd777b1 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json @@ -4,5 +4,14 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Approov": { + "MessageSigningMode": "None", + "AccountMessageBaseSecret": "", + "MessageSigningMaxAgeSeconds": 300, + "RequireSignatureNonce": false, + "SignedHeaders": [ + "Approov-Token" + ] } } diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.json b/servers/hello/src/approov-protected-server/token-check/appsettings.json index 10f68b8..e499876 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.json @@ -5,5 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Approov": { + "MessageSigningMode": "None", + "AccountMessageBaseSecret": "", + "MessageSigningMaxAgeSeconds": 300, + "RequireSignatureNonce": false, + "SignedHeaders": [ + "Approov-Token" + ] + } } diff --git a/tests/Hello.Tests/Hello.Tests.csproj b/tests/Hello.Tests/Hello.Tests.csproj new file mode 100644 index 0000000..762166c --- /dev/null +++ b/tests/Hello.Tests/Hello.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Hello.Tests/UnitTest1.cs b/tests/Hello.Tests/UnitTest1.cs new file mode 100644 index 0000000..ef4bbd2 --- /dev/null +++ b/tests/Hello.Tests/UnitTest1.cs @@ -0,0 +1,256 @@ +namespace Hello.Tests; + +using System.Security.Cryptography; +using System.Text; +using Hello.Helpers; +using Hello.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +public class MessageSigningUtilitiesTests +{ + [Fact] + public async Task BuildCanonicalMessageAsync_ComposesExpectedString() + { + var context = new DefaultHttpContext(); + context.Request.Method = "POST"; + context.Request.Path = "/api/test"; + context.Request.QueryString = new QueryString("?q=1"); + context.Request.Headers["Approov-Token"] = "token123"; + context.Request.Headers["Custom-Header"] = "SomeValue"; + context.Request.Headers["Content-Type"] = "application/json"; + + var bodyBytes = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}"); + context.Request.Body = new MemoryStream(bodyBytes); + context.Request.ContentLength = bodyBytes.Length; + + var canonicalBytes = await MessageSigningUtilities.BuildCanonicalMessageAsync( + context.Request, + new[] { "Approov-Token", "Custom-Header" }); + + var canonical = Encoding.UTF8.GetString(canonicalBytes); + var bodyHash = Convert.ToBase64String(SHA256.HashData(bodyBytes)); + var expected = string.Join('\n', new[] + { + "POST", + "/api/test?q=1", + "approov-token:token123", + "custom-header:SomeValue", + bodyHash + }); + + Assert.Equal(expected, canonical); + } + + [Fact] + public void VerifyInstallationSignature_ReturnsTrueForValidSignature() + { + var message = Encoding.UTF8.GetBytes("installation-message"); + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var signature = Convert.ToBase64String(ecdsa.SignData(message, HashAlgorithmName.SHA256)); + var publicKey = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()); + + Assert.True(MessageSigningUtilities.VerifyInstallationSignature(message, signature, publicKey)); + Assert.False(MessageSigningUtilities.VerifyInstallationSignature(Encoding.UTF8.GetBytes("tampered"), signature, publicKey)); + } + + [Fact] + public void VerifyAccountSignature_ReturnsTrueForValidSignature() + { + var baseSecret = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); + var deviceIdBytes = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray(); + var deviceId = Convert.ToBase64String(deviceIdBytes); + var tokenExpiry = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000); + + var derivedSecret = MessageSigningUtilities.DeriveSecret(baseSecret, deviceId, tokenExpiry); + var messageBytes = Encoding.UTF8.GetBytes("account-message"); + using var hmac = new HMACSHA256(derivedSecret); + var signature = Convert.ToBase64String(hmac.ComputeHash(messageBytes)); + + Assert.True(MessageSigningUtilities.VerifyAccountSignature(messageBytes, signature, derivedSecret)); + + var tamperedSignature = signature[..^1] + (signature[^1] == 'A' ? "B" : "A"); + Assert.False(MessageSigningUtilities.VerifyAccountSignature(messageBytes, tamperedSignature, derivedSecret)); + } +} + +public class MessageSigningMiddlewareTests +{ + [Fact] + public async Task NoneMode_BypassesVerification() + { + var settings = Options.Create(new AppSettings + { + MessageSigningMode = MessageSigningMode.None + }); + + var invoked = false; + RequestDelegate next = _ => + { + invoked = true; + return Task.CompletedTask; + }; + + var middleware = new MessageSigningMiddleware(next, settings, NullLogger.Instance); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.True(invoked); + } + + [Fact] + public async Task InstallationMode_ValidSignature_AllowsRequest() + { + var settings = Options.Create(new AppSettings + { + MessageSigningMode = MessageSigningMode.Installation, + MessageSigningHeaderNames = new[] { "Approov-Token", "Content-Type" }, + MessageSigningMaxAgeSeconds = 300 + }); + + var called = false; + RequestDelegate next = _ => + { + called = true; + return Task.CompletedTask; + }; + + var middleware = new MessageSigningMiddleware(next, settings, NullLogger.Instance); + var context = BuildHttpContext(); + + var headerNames = new[] { "Approov-Token", "Content-Type" }; + var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var signature = Convert.ToBase64String(ecdsa.SignData(canonicalMessage, HashAlgorithmName.SHA256)); + var publicKey = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()); + + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\" \"content-type\");created=" + created; + context.Request.Headers["Signature"] = "sig1=:" + signature + ":"; + context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); + context.Items[ApproovTokenContextKeys.InstallationPublicKey] = publicKey; + + await middleware.InvokeAsync(context); + + Assert.True(called); + Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InstallationMode_InvalidSignature_DeniesRequest() + { + var settings = Options.Create(new AppSettings + { + MessageSigningMode = MessageSigningMode.Installation, + MessageSigningHeaderNames = new[] { "Approov-Token" }, + MessageSigningMaxAgeSeconds = 300 + }); + + var middleware = new MessageSigningMiddleware(_ => Task.CompletedTask, settings, NullLogger.Instance); + var context = BuildHttpContext(); + + context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\");created=" + DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + context.Request.Headers["Signature"] = "sig1=:invalid:"; + context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); + context.Items[ApproovTokenContextKeys.InstallationPublicKey] = Convert.ToBase64String(ECDsa.Create(ECCurve.NamedCurves.nistP256).ExportSubjectPublicKeyInfo()); + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task AccountMode_ValidSignature_AllowsRequest() + { + var baseSecret = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); + var deviceIdBytes = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray(); + var deviceId = Convert.ToBase64String(deviceIdBytes); + var tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(2); + + var settings = Options.Create(new AppSettings + { + MessageSigningMode = MessageSigningMode.Account, + AccountMessageBaseSecretBytes = baseSecret, + MessageSigningHeaderNames = new[] { "Approov-Token", "Content-Type" }, + MessageSigningMaxAgeSeconds = 300 + }); + + var called = false; + RequestDelegate next = _ => + { + called = true; + return Task.CompletedTask; + }; + + var middleware = new MessageSigningMiddleware(next, settings, NullLogger.Instance); + var context = BuildHttpContext(); + + var headerNames = new[] { "Approov-Token", "Content-Type" }; + var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); + var derivedSecret = MessageSigningUtilities.DeriveSecret(baseSecret, deviceId, tokenExpiry); + using var hmac = new HMACSHA256(derivedSecret); + var signature = Convert.ToBase64String(hmac.ComputeHash(canonicalMessage)); + + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\" \"content-type\");created=" + created; + context.Request.Headers["Signature"] = "sig1=:" + signature + ":"; + context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); + context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; + context.Items[ApproovTokenContextKeys.TokenExpiry] = tokenExpiry; + + await middleware.InvokeAsync(context); + + Assert.True(called); + Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task AccountMode_ExpiredSignature_DeniesRequest() + { + var baseSecret = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); + var deviceId = Convert.ToBase64String(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray()); + + var settings = Options.Create(new AppSettings + { + MessageSigningMode = MessageSigningMode.Account, + AccountMessageBaseSecretBytes = baseSecret, + MessageSigningHeaderNames = new[] { "Approov-Token" }, + MessageSigningMaxAgeSeconds = 60 + }); + + var middleware = new MessageSigningMiddleware(_ => Task.CompletedTask, settings, NullLogger.Instance); + var context = BuildHttpContext(); + + var created = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds(); + context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\");created=" + created; + context.Request.Headers["Signature"] = "sig1=:invalid:"; + context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); + context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; + context.Items[ApproovTokenContextKeys.TokenExpiry] = DateTimeOffset.UtcNow; + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + private static DefaultHttpContext BuildHttpContext() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Method = "POST"; + context.Request.Path = "/resource"; + context.Request.QueryString = new QueryString("?id=123"); + context.Request.Headers["Approov-Token"] = "token-value"; + context.Request.Headers["Content-Type"] = "application/json"; + + var body = Encoding.UTF8.GetBytes("{\"key\":\"value\"}"); + context.Request.Body = new MemoryStream(body); + context.Request.ContentLength = body.Length; + + return context; + } +} From 8e7c2d6dc5265b06d5eed4bdada1ebdf71327503 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 17:13:59 +0000 Subject: [PATCH 02/21] development checkpoint --- scripts/run-local.sh | 122 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100755 scripts/run-local.sh diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..e08b1af --- /dev/null +++ b/scripts/run-local.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HELLO_ROOT="${ROOT_DIR}/servers/hello/src" + +print_usage() { + cat <<'EOF' +Usage: scripts/run-local.sh [unprotected|token-check|token-binding|all] + +Runs the sample APIs directly with the local dotnet SDK, avoiding Docker. +For Approov-protected apps the script ensures a .env file exists by copying +from .env.example when necessary. +EOF +} + +project_dir_for() { + case "$1" in + unprotected) printf '%s\n' "${HELLO_ROOT}/unprotected-server" ;; + token-check) printf '%s\n' "${HELLO_ROOT}/approov-protected-server/token-check" ;; + token-binding) printf '%s\n' "${HELLO_ROOT}/approov-protected-server/token-binding-check" ;; + *) return 1 ;; + esac +} + +project_port_for() { + case "$1" in + unprotected) printf '8001\n' ;; + token-check) printf '8002\n' ;; + token-binding) printf '8003\n' ;; + *) return 1 ;; + esac +} + +ensure_env_file() { + local project_dir="$1" + local env_example="${project_dir}/.env.example" + local env_file="${project_dir}/.env" + + if [[ -f "${env_example}" && ! -f "${env_file}" ]]; then + echo ">> Copying ${env_example##*/} to ${env_file##*/}" + cp "${env_example}" "${env_file}" + fi +} + +run_project() { + local key="$1" + local project_dir + local port + + if ! project_dir="$(project_dir_for "${key}")"; then + echo "Unknown project key '${key}'" >&2 + exit 1 + fi + + if ! port="$(project_port_for "${key}")"; then + echo "Unknown project key '${key}'" >&2 + exit 1 + fi + + ensure_env_file "${project_dir}" + + echo ">> Starting ${key} at http://localhost:${port}" + (cd "${project_dir}" && exec dotnet run --urls "http://0.0.0.0:${port}") +} + +run_all() { + local pids=() + local key + local project_dir + local port + + trap 'echo "Stopping services..."; for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done; wait || true' INT TERM + + for key in unprotected token-check token-binding; do + project_dir="$(project_dir_for "${key}")" || { + echo "Unknown project key '${key}'" >&2 + exit 1 + } + port="$(project_port_for "${key}")" || { + echo "Unknown project key '${key}'" >&2 + exit 1 + } + ensure_env_file "${project_dir}" + ( + cd "${project_dir}" + exec dotnet run --urls "http://0.0.0.0:${port}" + ) & + local pid=$! + pids+=("${pid}") + echo ">> ${key} listening on http://localhost:${port} (PID ${pid})" + done + + echo "All services are running. Press Ctrl+C to stop." + wait -n || true +} + +main() { + if [[ $# -ne 1 ]]; then + print_usage + exit 1 + fi + + case "$1" in + unprotected|token-check|token-binding) + run_project "$1" + ;; + all) + run_all + ;; + -h|--help) + print_usage + ;; + *) + print_usage + exit 1 + ;; + esac +} + +main "$@" From ae9f6da97283c2ae484caf51f9ec6dc45eabf52d Mon Sep 17 00:00:00 2001 From: adriantuk Date: Tue, 4 Nov 2025 17:14:14 +0000 Subject: [PATCH 03/21] checkpoint before refactor --- .gitignore | 1 + README.md | 10 + docker-compose.yml | 39 ++-- ...oov-dotnet-example.postman_collection.json | 64 ++++++- .../Helpers/MessageSigningUtilities.cs | 31 +++- .../Middleware/ApproovTokenMiddleware.cs | 36 +++- .../Middleware/MessageSigningMiddleware.cs | 173 ++++++++++++++++-- .../token-check/Program.cs | 17 +- tests/Hello.Tests/UnitTest1.cs | 13 +- 9 files changed, 322 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index a1a35c5..91b3f12 100644 --- a/.gitignore +++ b/.gitignore @@ -264,3 +264,4 @@ __pycache__/ *.pyc .env +.DS_Store diff --git a/README.md b/README.md index 4b80baf..e0f620e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ The quickstart was tested with the following Operating Systems: * MacOS Big Sur * Windows 10 WSL2 - Ubuntu 20.04 +### Running the sample APIs without Docker + +The repository now ships with a helper script that launches the demo servers directly with the local .NET SDK. From the repository root run: + +```bash +./scripts/run-local.sh all +``` + +This starts the unprotected, token-check and token-binding examples on ports `8001`, `8002` and `8003` respectively. Press `Ctrl+C` to stop them. You can also launch an individual backend, for example `./scripts/run-local.sh token-check`. The script will automatically create a `.env` file from `.env.example` for the Approov-protected projects if one is not already present. + First, setup the [Approov CLI](https://approov.io/docs/latest/approov-installation/index.html#initializing-the-approov-cli). Now, register the API domain for which Approov will issues tokens: diff --git a/docker-compose.yml b/docker-compose.yml index 4f3508d..4ad2916 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,37 +1,34 @@ -version: "2.3" +version: "3.8" -services: +x-dotnet-service: &dotnet-service + build: . + image: approov/dotnet:6.0 + working_dir: /home/developer/workspace + environment: + ASPNETCORE_ENVIRONMENT: Development + restart: unless-stopped +services: unprotected-server: - image: approov/dotnet:6.0 - build: ./ - networks: - - default - command: bash -c "dotnet run" + <<: *dotnet-service + command: dotnet run --urls http://0.0.0.0:8001 ports: - - ${HOST_IP:-127.0.0.1}:${HTTP_PORT:-8002}:${HTTP_PORT:-8002} + - ${HOST_IP:-127.0.0.1}:8001:8001 volumes: - ./servers/hello/src/unprotected-server:/home/developer/workspace approov-token-check: - image: approov/dotnet:6.0 - build: ./ - networks: - - default - command: bash -c "dotnet run" + <<: *dotnet-service + command: dotnet run --urls http://0.0.0.0:8002 ports: - - ${HOST_IP:-127.0.0.1}:${HTTP_PORT:-8002}:${HTTP_PORT:-8002} + - ${HOST_IP:-127.0.0.1}:8002:8002 volumes: - ./servers/hello/src/approov-protected-server/token-check:/home/developer/workspace approov-token-binding-check: - image: approov/dotnet:6.0 - build: ./ - networks: - - default - command: bash -c "dotnet run" + <<: *dotnet-service + command: dotnet run --urls http://0.0.0.0:8003 ports: - - ${HOST_IP:-127.0.0.1}:${HTTP_PORT:-8002}:${HTTP_PORT:-8002} + - ${HOST_IP:-127.0.0.1}:8003:8003 volumes: - ./servers/hello/src/approov-protected-server/token-binding-check:/home/developer/workspace - diff --git a/servers/archived/postman/approov-dotnet-example.postman_collection.json b/servers/archived/postman/approov-dotnet-example.postman_collection.json index f1bea6a..73eaef1 100644 --- a/servers/archived/postman/approov-dotnet-example.postman_collection.json +++ b/servers/archived/postman/approov-dotnet-example.postman_collection.json @@ -128,6 +128,68 @@ "response": [] } ] + }, + { + "name": "message signing", + "description": "Examples showing how to call the Approov-protected endpoint with message signing headers. Populate the collection variables `approov_token`, `signature_input`, and `message_signature` before sending.", + "item": [ + { + "name": "Approov Token with message signature", + "request": { + "method": "GET", + "header": [ + { + "key": "Approov-Token", + "type": "text", + "value": "{{approov_token}}" + }, + { + "key": "Signature-Input", + "type": "text", + "value": "{{signature_input}}" + }, + { + "key": "Signature", + "type": "text", + "value": "{{message_signature}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "http://localhost:8002/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8002", + "path": [ + "" + ] + } + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "approov_token", + "value": "", + "type": "string" + }, + { + "key": "signature_input", + "value": "", + "type": "string" + }, + { + "key": "message_signature", + "value": "", + "type": "string" } ] -} \ No newline at end of file +} diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs index cac6246..8730e99 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs @@ -1,14 +1,25 @@ namespace Hello.Helpers; +using System; using System.Buffers.Binary; using System.Linq; using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Http; +public sealed record CanonicalMessage( + string Method, + string PathAndQuery, + IReadOnlyDictionary Headers, + string? BodyHashBase64, + byte[] PayloadBytes) +{ + public string Payload => Encoding.UTF8.GetString(PayloadBytes); +} + public static class MessageSigningUtilities { - public static async Task BuildCanonicalMessageAsync(HttpRequest request, IEnumerable headerNames) + public static async Task BuildCanonicalMessageAsync(HttpRequest request, IEnumerable headerNames) { var orderedHeaders = headerNames .Select(name => name.Trim()) @@ -17,18 +28,19 @@ public static async Task BuildCanonicalMessageAsync(HttpRequest request, .Distinct() .ToList(); + var method = request.Method.ToUpperInvariant(); + var pathAndQuery = BuildPathAndQuery(request); + var headerMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var parts = new List { - request.Method.ToUpperInvariant(), - BuildPathAndQuery(request) + method, + pathAndQuery }; foreach (var headerName in orderedHeaders) { if (!request.Headers.TryGetValue(headerName, out var values)) { - // HeaderDictionary is case-insensitive, but TryGetValue requires the original case. - // If a lowercase lookup fails, attempt using the original casing by enumerating the dictionary. var match = request.Headers.FirstOrDefault(pair => string.Equals(pair.Key, headerName, StringComparison.OrdinalIgnoreCase)); values = match.Value; } @@ -37,17 +49,20 @@ public static async Task BuildCanonicalMessageAsync(HttpRequest request, ? string.Join(", ", values.Select(v => v.Trim())) : string.Empty; + headerMap[headerName] = canonicalValue; parts.Add($"{headerName}:{canonicalValue}"); } var bodyHash = await ComputeBodyHashAsync(request).ConfigureAwait(false); + string? bodyHashBase64 = null; if (bodyHash != null) { - parts.Add(Convert.ToBase64String(bodyHash)); + bodyHashBase64 = Convert.ToBase64String(bodyHash); + parts.Add(bodyHashBase64); } - var canonical = string.Join('\n', parts); - return Encoding.UTF8.GetBytes(canonical); + var payloadBytes = Encoding.UTF8.GetBytes(string.Join('\n', parts)); + return new CanonicalMessage(method, pathAndQuery, headerMap, bodyHashBase64, payloadBytes); } public static bool VerifyInstallationSignature(byte[] message, string signature, string publicKeyBase64) diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index 60f043c..3016b12 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs @@ -23,7 +23,7 @@ public async Task Invoke(HttpContext context) var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(token)) { - _logger.LogInformation("Missing Approov-Token header."); + _logger.LogInformation("DebugLogToRemove: Missing Approov-Token header."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } @@ -45,7 +45,7 @@ private bool verifyApproovToken(HttpContext context, string token) var tokenHandler = new JwtSecurityTokenHandler(); if (_appSettings.ApproovSecretBytes == null || _appSettings.ApproovSecretBytes.Length == 0) { - _logger.LogError("Approov secret bytes not configured."); + _logger.LogError("DebugLogToRemove: Approov secret bytes not configured."); return false; } @@ -60,19 +60,34 @@ private bool verifyApproovToken(HttpContext context, string token) }, out SecurityToken validatedToken); var jwtToken = (JwtSecurityToken)validatedToken; - captureApproovTokenMetadata(context, jwtToken); + _logger.LogInformation("DebugLogToRemove: Approov token header alg={Alg} kid={Kid}", jwtToken.Header.Alg, jwtToken.Header.Kid ?? ""); + var metadata = captureApproovTokenMetadata(context, jwtToken); + if (!string.IsNullOrEmpty(metadata.DeviceId)) + { + _logger.LogInformation("DebugLogToRemove: Approov token device id={DeviceId}", metadata.DeviceId); + } + if (metadata.ExpiryUtc.HasValue) + { + _logger.LogInformation("DebugLogToRemove: Approov token expiry (UTC)={Expiry}", metadata.ExpiryUtc.Value); + } + if (!string.IsNullOrEmpty(metadata.InstallationPublicKey)) + { + _logger.LogInformation("DebugLogToRemove: Approov token installation public key present"); + } return true; } catch (SecurityTokenException exception) { - _logger.LogInformation(exception.Message); + _logger.LogInformation("DebugLogToRemove: Approov token validation failed: {Message}", exception.Message); return false; } catch (Exception exception) { - _logger.LogInformation(exception.Message); + _logger.LogInformation("DebugLogToRemove: Unexpected token validation failure: {Message}", exception.Message); return false; } } - private static void captureApproovTokenMetadata(HttpContext context, JwtSecurityToken jwtToken) + private readonly record struct TokenMetadata(string? DeviceId, DateTimeOffset? ExpiryUtc, string? InstallationPublicKey); + + private static TokenMetadata captureApproovTokenMetadata(HttpContext context, JwtSecurityToken jwtToken) { var deviceId = jwtToken.Claims.FirstOrDefault(claim => string.Equals(claim.Type, "did", StringComparison.OrdinalIgnoreCase) || @@ -86,11 +101,13 @@ private static void captureApproovTokenMetadata(HttpContext context, JwtSecurity } var expiryUnixSeconds = jwtToken.Payload.Exp; + DateTimeOffset? expiryValue = null; if (expiryUnixSeconds.HasValue) { try { - context.Items[ApproovTokenContextKeys.TokenExpiry] = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds.Value); + expiryValue = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds.Value); + context.Items[ApproovTokenContextKeys.TokenExpiry] = expiryValue; } catch (ArgumentOutOfRangeException) { @@ -102,7 +119,8 @@ private static void captureApproovTokenMetadata(HttpContext context, JwtSecurity var expClaim = jwtToken.Claims.FirstOrDefault(claim => string.Equals(claim.Type, JwtRegisteredClaimNames.Exp, StringComparison.OrdinalIgnoreCase))?.Value; if (expClaim != null && long.TryParse(expClaim, out var parsedExp)) { - context.Items[ApproovTokenContextKeys.TokenExpiry] = DateTimeOffset.FromUnixTimeSeconds(parsedExp); + expiryValue = DateTimeOffset.FromUnixTimeSeconds(parsedExp); + context.Items[ApproovTokenContextKeys.TokenExpiry] = expiryValue; } } @@ -116,5 +134,7 @@ private static void captureApproovTokenMetadata(HttpContext context, JwtSecurity { context.Items[ApproovTokenContextKeys.InstallationPublicKey] = installationPubKey; } + + return new TokenMetadata(deviceId, expiryValue, installationPubKey); } } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs index b1cf47a..5967f86 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -1,8 +1,11 @@ namespace Hello.Middleware; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Text; using Hello.Helpers; using Microsoft.Extensions.Options; @@ -23,77 +26,149 @@ public MessageSigningMiddleware(RequestDelegate next, IOptions sett public async Task InvokeAsync(HttpContext context) { + await LogRequestAsync(context); + if (_settings.MessageSigningMode == MessageSigningMode.None) { - await _next(context); + var originalBodyStream = context.Response.Body; + using var buffer = new MemoryStream(); + context.Response.Body = buffer; + + try + { + await _next(context); + } + finally + { + context.Response.Body = originalBodyStream; + await LogResponseAsync(context, buffer, originalBodyStream); + } return; } + var originalResponseStream = context.Response.Body; + using var responseBuffer = new MemoryStream(); + context.Response.Body = responseBuffer; + if (!context.Items.TryGetValue(ApproovTokenContextKeys.ApproovToken, out var tokenObject) || tokenObject is not string approovToken) { - _logger.LogWarning("Approov token not found in context items. Message signing verification aborted."); + _logger.LogWarning("DebugLogToRemove: Approov token not found in context items. Message signing verification aborted."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } var signatureHeader = context.Request.Headers["Signature"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(signatureHeader)) { - _logger.LogInformation("Missing Signature header."); + _logger.LogInformation("DebugLogToRemove: Missing Signature header."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } + _logger.LogInformation("DebugLogToRemove: Raw Signature header value: {SignatureHeader}", signatureHeader); + if (!HttpSignatureParser.TryParseSignature(signatureHeader, out var signatureEntry, out var signatureError)) { - _logger.LogInformation("Invalid Signature header: {Error}", signatureError); + _logger.LogInformation("DebugLogToRemove: Invalid Signature header: {Error}", signatureError); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } + _logger.LogInformation("DebugLogToRemove: Parsed signature entry label={Label} signature={Signature}", signatureEntry.Label, signatureEntry.Signature); + HttpSignatureInput? signatureInput = null; var signatureInputHeader = context.Request.Headers["Signature-Input"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(signatureInputHeader)) { if (!HttpSignatureParser.TryParseSignatureInput(signatureInputHeader, signatureEntry.Label, out signatureInput, out var inputError)) { - _logger.LogInformation("Invalid Signature-Input header: {Error}", inputError); + _logger.LogInformation("DebugLogToRemove: Invalid Signature-Input header: {Error}", inputError); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } + + var inputDetails = signatureInput!; + _logger.LogInformation( + "DebugLogToRemove: Signature-Input components={Components} created={Created} expires={Expires} nonce={Nonce}", + inputDetails.Components == null ? "" : string.Join(",", inputDetails.Components), + inputDetails.Created, + inputDetails.Expires, + inputDetails.Nonce ?? ""); } else if (_settings.MessageSigningMaxAgeSeconds > 0 || _settings.RequireSignatureNonce) { - _logger.LogInformation("Missing Signature-Input header required for metadata validation."); + _logger.LogInformation("DebugLogToRemove: Missing Signature-Input header required for metadata validation."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } if (!ValidateMetadata(signatureInput, out var metadataError)) { - _logger.LogInformation("Message signature metadata validation failed: {Error}", metadataError); + _logger.LogInformation("DebugLogToRemove: Message signature metadata validation failed: {Error}", metadataError); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } var headerNames = DetermineHeaderNames(signatureInput?.Components); + _logger.LogInformation("DebugLogToRemove: Canonical header selection => {Headers}", string.Join(",", headerNames)); + var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); + _logger.LogInformation("DebugLogToRemove: Canonical message method={Method} path+query={Path} bodyHash={BodyHash}", + canonicalMessage.Method, + canonicalMessage.PathAndQuery, + canonicalMessage.BodyHashBase64 ?? ""); + + if (canonicalMessage.Headers.Count > 0) + { + foreach (var kvp in canonicalMessage.Headers) + { + _logger.LogInformation("DebugLogToRemove: Canonical header entry {Header} => {Value}", kvp.Key, kvp.Value); + } + } + + _logger.LogInformation("DebugLogToRemove: Canonical payload string=\n{Payload}", canonicalMessage.Payload); + + _logger.LogInformation("DebugLogToRemove: Performing verification in {Mode} mode", _settings.MessageSigningMode); + var verified = _settings.MessageSigningMode switch { - MessageSigningMode.Installation => VerifyInstallationSignature(context, canonicalMessage, signatureEntry.Signature), - MessageSigningMode.Account => VerifyAccountSignature(context, canonicalMessage, signatureEntry.Signature), + MessageSigningMode.Installation => VerifyInstallationSignature(context, canonicalMessage.PayloadBytes, signatureEntry.Signature), + MessageSigningMode.Account => VerifyAccountSignature(context, canonicalMessage.PayloadBytes, signatureEntry.Signature), _ => false }; if (!verified) { + _logger.LogInformation("DebugLogToRemove: Verification failed for mode {Mode}", _settings.MessageSigningMode); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); return; } - _logger.LogDebug("Approov message signature verified using {Mode} mode.", _settings.MessageSigningMode); - await _next(context); + try + { + _logger.LogInformation("DebugLogToRemove: Approov message signature verified using {Mode} mode.", _settings.MessageSigningMode); + await _next(context); + } + finally + { + context.Response.Body = originalResponseStream; + await LogResponseAsync(context, responseBuffer, originalResponseStream); + } } private bool VerifyInstallationSignature(HttpContext context, byte[] canonicalMessage, string encodedSignature) @@ -101,16 +176,20 @@ private bool VerifyInstallationSignature(HttpContext context, byte[] canonicalMe if (!context.Items.TryGetValue(ApproovTokenContextKeys.InstallationPublicKey, out var publicKeyObj) || publicKeyObj is not string installationPublicKey) { - _logger.LogInformation("Installation public key not found in Approov token."); + _logger.LogInformation("DebugLogToRemove: Installation public key not found in Approov token."); return false; } + _logger.LogInformation("DebugLogToRemove: Installation public key (base64)={PublicKey}", installationPublicKey); + _logger.LogInformation("DebugLogToRemove: Installation signature (base64)={Signature}", encodedSignature); + if (!MessageSigningUtilities.VerifyInstallationSignature(canonicalMessage, encodedSignature, installationPublicKey)) { - _logger.LogInformation("Installation message signature verification failed."); + _logger.LogInformation("DebugLogToRemove: Installation message signature verification failed."); return false; } + _logger.LogInformation("DebugLogToRemove: Installation signature verification succeeded."); return true; } @@ -118,21 +197,21 @@ private bool VerifyAccountSignature(HttpContext context, byte[] canonicalMessage { if (_settings.AccountMessageBaseSecretBytes is null || _settings.AccountMessageBaseSecretBytes.Length == 0) { - _logger.LogError("Account message signing base secret not configured."); + _logger.LogError("DebugLogToRemove: Account message signing base secret not configured."); return false; } if (!context.Items.TryGetValue(ApproovTokenContextKeys.DeviceId, out var deviceIdObj) || deviceIdObj is not string deviceId || string.IsNullOrWhiteSpace(deviceId)) { - _logger.LogInformation("Device ID not present in Approov token."); + _logger.LogInformation("DebugLogToRemove: Device ID not present in Approov token."); return false; } if (!context.Items.TryGetValue(ApproovTokenContextKeys.TokenExpiry, out var expiryObj) || expiryObj is not DateTimeOffset tokenExpiry) { - _logger.LogInformation("Token expiry missing from Approov token context."); + _logger.LogInformation("DebugLogToRemove: Token expiry missing from Approov token context."); return false; } @@ -143,16 +222,21 @@ private bool VerifyAccountSignature(HttpContext context, byte[] canonicalMessage } catch (Exception ex) when (ex is ArgumentException or FormatException or CryptographicException) { - _logger.LogInformation("Failed to derive account message signing secret: {Message}", ex.Message); + _logger.LogInformation("DebugLogToRemove: Failed to derive account message signing secret: {Message}", ex.Message); return false; } + _logger.LogInformation("DebugLogToRemove: Account verification metadata deviceId={DeviceId} expiry={Expiry} derivedSecretLength={Length}", deviceId, tokenExpiry, derivedSecret.Length); + _logger.LogInformation("DebugLogToRemove: Account derived secret (base64)={DerivedSecret}", Convert.ToBase64String(derivedSecret)); + _logger.LogInformation("DebugLogToRemove: Account signature (base64)={Signature}", encodedSignature); + if (!MessageSigningUtilities.VerifyAccountSignature(canonicalMessage, encodedSignature, derivedSecret)) { - _logger.LogInformation("Account message signature verification failed."); + _logger.LogInformation("DebugLogToRemove: Account message signature verification failed."); return false; } + _logger.LogInformation("DebugLogToRemove: Account signature verification succeeded."); return true; } @@ -182,6 +266,8 @@ private bool ValidateMetadata(HttpSignatureInput? signatureInput, out string? er var createdAt = DateTimeOffset.FromUnixTimeSeconds(signatureInput.Created.Value); var now = DateTimeOffset.UtcNow; + _logger.LogInformation("DebugLogToRemove: Signature creation timestamp {Created} (UTC {CreatedUtc}) current UTC {Now}", signatureInput.Created.Value, createdAt, now); + if (now - createdAt > TimeSpan.FromSeconds(_settings.MessageSigningMaxAgeSeconds)) { error = "Signature creation time outside allowable age."; @@ -198,6 +284,7 @@ private bool ValidateMetadata(HttpSignatureInput? signatureInput, out string? er if (signatureInput.Expires.HasValue) { var expiresAt = DateTimeOffset.FromUnixTimeSeconds(signatureInput.Expires.Value); + _logger.LogInformation("DebugLogToRemove: Signature expires at {Expires} (UTC {ExpiresUtc})", signatureInput.Expires.Value, expiresAt); if (DateTimeOffset.UtcNow > expiresAt) { error = "Signature has expired."; @@ -236,4 +323,54 @@ private List DetermineHeaderNames(IReadOnlyList? components) return headers; } + + private async Task LogRequestAsync(HttpContext context) + { + var request = context.Request; + request.EnableBuffering(); + string bodyText = ""; + if (request.Body.CanSeek) + { + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var content = await reader.ReadToEndAsync(); + if (!string.IsNullOrEmpty(content)) + { + bodyText = content; + } + request.Body.Position = 0; + } + + var headerText = string.Join(", ", request.Headers.Select(h => $"{h.Key}:{h.Value}")); + _logger.LogInformation( + "DebugLogToRemove: Incoming request {Method} {Path}{Query} from {RemoteIp} headers={Headers} body={Body}", + request.Method, + request.Path, + request.QueryString, + context.Connection.RemoteIpAddress?.ToString() ?? "", + headerText, + bodyText); + } + + private async Task LogResponseAsync(HttpContext context, MemoryStream buffer, Stream originalBodyStream) + { + buffer.Position = 0; + string bodyText; + using (var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true)) + { + var content = await reader.ReadToEndAsync(); + bodyText = string.IsNullOrEmpty(content) ? "" : content; + } + + var headerText = string.Join(", ", context.Response.Headers.Select(h => $"{h.Key}:{h.Value}")); + _logger.LogInformation( + "DebugLogToRemove: Outgoing response status={Status} headers={Headers} body={Body}", + context.Response.StatusCode, + headerText, + bodyText); + + buffer.Position = 0; + await buffer.CopyToAsync(originalBodyStream); + await originalBodyStream.FlushAsync(); + } } diff --git a/servers/hello/src/approov-protected-server/token-check/Program.cs b/servers/hello/src/approov-protected-server/token-check/Program.cs index 1256ade..7625aab 100644 --- a/servers/hello/src/approov-protected-server/token-check/Program.cs +++ b/servers/hello/src/approov-protected-server/token-check/Program.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Microsoft.Extensions.Configuration; using Hello.Helpers; @@ -48,7 +49,12 @@ } var configuredSignedHeaders = approovSection.GetSection("SignedHeaders").Get() ?? Array.Empty(); +var messageSigningHeaderNames = NormalizeSignedHeaders(configuredSignedHeaders); var messageSigningMaxAgeSeconds = approovSection.GetValue("MessageSigningMaxAgeSeconds") ?? 300; +if (messageSigningMaxAgeSeconds < 0) +{ + throw new InvalidOperationException("Approov:MessageSigningMaxAgeSeconds cannot be negative."); +} var requireSignatureNonce = approovSection.GetValue("RequireSignatureNonce") ?? false; builder.Services.Configure(appSettings => @@ -56,7 +62,7 @@ appSettings.ApproovSecretBytes = approovSecretBytes; appSettings.MessageSigningMode = messageSigningMode; appSettings.AccountMessageBaseSecretBytes = accountMessageBaseSecretBytes; - appSettings.MessageSigningHeaderNames = configuredSignedHeaders; + appSettings.MessageSigningHeaderNames = messageSigningHeaderNames; appSettings.MessageSigningMaxAgeSeconds = messageSigningMaxAgeSeconds; appSettings.RequireSignatureNonce = requireSignatureNonce; }); @@ -157,3 +163,12 @@ static byte[] DecodeBase32(string encodedSecret) return output.ToArray(); } + +static string[] NormalizeSignedHeaders(IEnumerable headers) +{ + return headers + .Where(header => !string.IsNullOrWhiteSpace(header)) + .Select(header => header.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/tests/Hello.Tests/UnitTest1.cs b/tests/Hello.Tests/UnitTest1.cs index ef4bbd2..c8fbb5a 100644 --- a/tests/Hello.Tests/UnitTest1.cs +++ b/tests/Hello.Tests/UnitTest1.cs @@ -25,11 +25,10 @@ public async Task BuildCanonicalMessageAsync_ComposesExpectedString() context.Request.Body = new MemoryStream(bodyBytes); context.Request.ContentLength = bodyBytes.Length; - var canonicalBytes = await MessageSigningUtilities.BuildCanonicalMessageAsync( + var canonical = await MessageSigningUtilities.BuildCanonicalMessageAsync( context.Request, new[] { "Approov-Token", "Custom-Header" }); - var canonical = Encoding.UTF8.GetString(canonicalBytes); var bodyHash = Convert.ToBase64String(SHA256.HashData(bodyBytes)); var expected = string.Join('\n', new[] { @@ -40,7 +39,11 @@ public async Task BuildCanonicalMessageAsync_ComposesExpectedString() bodyHash }); - Assert.Equal(expected, canonical); + Assert.Equal(expected, canonical.Payload); + Assert.Equal(bodyHash, canonical.BodyHashBase64); + Assert.Equal("POST", canonical.Method); + Assert.Equal("/api/test?q=1", canonical.PathAndQuery); + Assert.Equal("token123", canonical.Headers["approov-token"]); } [Fact] @@ -125,7 +128,7 @@ public async Task InstallationMode_ValidSignature_AllowsRequest() var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var signature = Convert.ToBase64String(ecdsa.SignData(canonicalMessage, HashAlgorithmName.SHA256)); + var signature = Convert.ToBase64String(ecdsa.SignData(canonicalMessage.PayloadBytes, HashAlgorithmName.SHA256)); var publicKey = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()); var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -193,7 +196,7 @@ public async Task AccountMode_ValidSignature_AllowsRequest() var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); var derivedSecret = MessageSigningUtilities.DeriveSecret(baseSecret, deviceId, tokenExpiry); using var hmac = new HMACSHA256(derivedSecret); - var signature = Convert.ToBase64String(hmac.ComputeHash(canonicalMessage)); + var signature = Convert.ToBase64String(hmac.ComputeHash(canonicalMessage.PayloadBytes)); var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\" \"content-type\");created=" + created; From 1690738fae989b894ee12c649631a5488cea07a9 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 11:11:26 +0000 Subject: [PATCH 04/21] complete refactor --- .../Controllers/ApproovController.cs | 182 ++++++++ .../token-check/Hello.csproj | 1 + .../token-check/Helpers/AppSettings.cs | 12 - .../ApproovMessageSignatureVerifier.cs | 391 ++++++++++++++++++ .../Helpers/HttpSignatureParser.cs | 292 ------------- .../Helpers/MessageSigningUtilities.cs | 232 ----------- .../Helpers/StructuredFieldFormatter.cs | 182 ++++++++ .../Middleware/ApproovTokenMiddleware.cs | 83 +--- .../Middleware/MessageSigningMiddleware.cs | 359 ++-------------- .../token-check/Program.cs | 118 +----- .../token-check/README.md | 70 ++-- .../token-check/appsettings.Development.json | 9 - .../token-check/appsettings.json | 11 +- tests/Hello.Tests/UnitTest1.cs | 278 +++---------- 14 files changed, 879 insertions(+), 1341 deletions(-) create mode 100644 servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs create mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs delete mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs delete mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs create mode 100644 servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs new file mode 100644 index 0000000..686d056 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -0,0 +1,182 @@ +using System.Security.Cryptography; +using System.Text; +using Hello.Helpers; +using Microsoft.AspNetCore.Mvc; +using StructuredFieldValues; + +namespace Hello.Controllers; + +[ApiController] +[Produces("text/plain")] +public class ApproovController : ControllerBase +{ + private readonly ILogger _logger; + + public ApproovController(ILogger logger) + { + _logger = logger; + } + + [HttpGet("/hello")] + public IActionResult Hello() => Content("hello, world", "text/plain"); + + [HttpGet("/token")] + [HttpPost("/token")] + public IActionResult Token() => Content("Good Token", "text/plain"); + + [HttpGet("/ipk_test")] + public IActionResult IpkTest() + { + var ipkHeader = Request.Headers["ipk"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(ipkHeader)) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var privateKeyDer = key.ExportECPrivateKey(); + var publicKeyDer = key.ExportSubjectPublicKeyInfo(); + + var privateKeyBase64 = Convert.ToBase64String(privateKeyDer); + var publicKeyBase64 = Convert.ToBase64String(publicKeyDer); + _logger.LogInformation("DebugLogToRemove: Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); + + return Content("No IPK header, generated keys logged", "text/plain"); + } + + try + { + var publicKey = Convert.FromBase64String(ipkHeader); + using var key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(publicKey, out _); + return Content("IPK roundtrip OK", "text/plain"); + } + catch (Exception ex) + { + _logger.LogInformation("DebugLogToRemove: Failed to import IPK header - {Message}", ex.Message); + Response.StatusCode = StatusCodes.Status401Unauthorized; + return Content("Failed: failed to create public key", "text/plain"); + } + } + + [HttpGet("/ipk_message_sign_test")] + public IActionResult IpkMessageSignTest() + { + var privateKeyBase64 = Request.Headers["private-key"].FirstOrDefault(); + var messageBase64 = Request.Headers["msg"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(privateKeyBase64) || string.IsNullOrWhiteSpace(messageBase64)) + { + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content("Missing private-key or msg header", "text/plain"); + } + + try + { + var privateKey = Convert.FromBase64String(privateKeyBase64); + var messageBytes = Convert.FromBase64String(messageBase64); + + using var ecdsa = ECDsa.Create(); + ecdsa.ImportECPrivateKey(privateKey, out _); + var signature = ecdsa.SignData(messageBytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return Content(Convert.ToBase64String(signature), "text/plain"); + } + catch (FormatException ex) + { + _logger.LogInformation("DebugLogToRemove: Invalid input for signing - {Message}", ex.Message); + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content("Failed to generate signature", "text/plain"); + } + catch (CryptographicException ex) + { + _logger.LogInformation("DebugLogToRemove: Cryptographic failure during signing - {Message}", ex.Message); + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content("Failed to generate signature", "text/plain"); + } + } + + [HttpGet("/sfv_test")] + public IActionResult StructuredFieldTest() + { + var sfvType = Request.Headers["sfvt"].FirstOrDefault(); + var sfvHeader = CombineHeaderValues(Request.Headers["sfv"]); + + if (string.IsNullOrWhiteSpace(sfvType) || string.IsNullOrWhiteSpace(sfvHeader)) + { + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content("Missing sfv or sfvt header", "text/plain"); + } + + sfvType = sfvType.Trim().ToUpperInvariant(); + string serialized; + ParseError? error; + + switch (sfvType) + { + case "ITEM": + error = SfvParser.ParseItem(sfvHeader, out var item); + if (error.HasValue) + { + return StructuredFieldFailure(error.Value.Message); + } + serialized = StructuredFieldFormatter.SerializeItem(item); + break; + case "LIST": + error = SfvParser.ParseList(sfvHeader, out var list); + if (error.HasValue || list is null) + { + return StructuredFieldFailure(error?.Message ?? "Invalid list header"); + } + serialized = StructuredFieldFormatter.SerializeList(list); + break; + case "DICTIONARY": + error = SfvParser.ParseDictionary(sfvHeader, out var dictionary); + if (error.HasValue || dictionary is null) + { + return StructuredFieldFailure(error?.Message ?? "Invalid dictionary header"); + } + serialized = StructuredFieldFormatter.SerializeDictionary(dictionary); + break; + default: + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content($"Unsupported sfvt value '{sfvType}'", "text/plain"); + } + + if (!string.Equals(serialized, sfvHeader, StringComparison.Ordinal)) + { + return StructuredFieldFailure("Serialized object does not match original"); + } + + return Content("SFV roundtrip OK", "text/plain"); + } + + private IActionResult StructuredFieldFailure(string message) + { + _logger.LogInformation("DebugLogToRemove: Structured field roundtrip failure - {Message}", message); + Response.StatusCode = StatusCodes.Status401Unauthorized; + return Content("Failed SFV roundtrip", "text/plain"); + } + + private static string CombineHeaderValues(IReadOnlyList values) + { + if (values.Count == 0) + { + return string.Empty; + } + + if (values.Count == 1) + { + return values[0].Trim(); + } + + var builder = new StringBuilder(); + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append(values[i].Trim()); + } + + return builder.ToString(); + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Hello.csproj b/servers/hello/src/approov-protected-server/token-check/Hello.csproj index 8718487..f08ff3b 100644 --- a/servers/hello/src/approov-protected-server/token-check/Hello.csproj +++ b/servers/hello/src/approov-protected-server/token-check/Hello.csproj @@ -8,6 +8,7 @@ + diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs index 71ec1e5..d6999d0 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs @@ -1,18 +1,6 @@ namespace Hello.Helpers; -public enum MessageSigningMode -{ - None, - Installation, - Account -} - public class AppSettings { public byte[]? ApproovSecretBytes { get; set; } - public MessageSigningMode MessageSigningMode { get; set; } = MessageSigningMode.None; - public byte[]? AccountMessageBaseSecretBytes { get; set; } - public string[] MessageSigningHeaderNames { get; set; } = Array.Empty(); - public int MessageSigningMaxAgeSeconds { get; set; } = 300; - public bool RequireSignatureNonce { get; set; } } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs new file mode 100644 index 0000000..3efc36c --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -0,0 +1,391 @@ +namespace Hello.Helpers; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using StructuredFieldValues; + +public sealed class ApproovMessageSignatureVerifier +{ + private static readonly ISet SupportedContentDigestAlgorithms = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "sha-256", + "sha-512" + }; + + private readonly ILogger _logger; + + public ApproovMessageSignatureVerifier(ILogger logger) + { + _logger = logger; + } + + public async Task VerifyAsync(HttpContext context, string publicKeyBase64) + { + var signatureHeader = CombineHeaderValues(context.Request.Headers["Signature"]); + var signatureInputHeader = CombineHeaderValues(context.Request.Headers["Signature-Input"]); + + if (string.IsNullOrWhiteSpace(signatureHeader) || string.IsNullOrWhiteSpace(signatureInputHeader)) + { + return MessageSignatureResult.Failure("Missing Signature or Signature-Input headers"); + } + + var signatureParseError = SfvParser.ParseDictionary(signatureHeader, out var signatureDictionary); + if (signatureParseError.HasValue || signatureDictionary is null) + { + return MessageSignatureResult.Failure($"Failed to parse signature header: {signatureParseError?.Message ?? "Unknown error"}"); + } + + var signatureInputParseError = SfvParser.ParseDictionary(signatureInputHeader, out var signatureInputDictionary); + if (signatureInputParseError.HasValue || signatureInputDictionary is null) + { + return MessageSignatureResult.Failure($"Failed to parse signature-input header: {signatureInputParseError?.Message ?? "Unknown error"}"); + } + + if (!signatureDictionary.TryGetValue("install", out var signatureItem)) + { + return MessageSignatureResult.Failure("Signature header missing 'install' entry"); + } + + if (!signatureInputDictionary.TryGetValue("install", out var signatureInputItem)) + { + return MessageSignatureResult.Failure("Signature-Input header missing 'install' entry"); + } + + if (signatureItem.Value is not ReadOnlyMemory signatureBytes) + { + return MessageSignatureResult.Failure("Signature item is not encoded as a byte sequence"); + } + + if (signatureInputItem.Value is not IReadOnlyList componentIdentifiers) + { + return MessageSignatureResult.Failure("Signature-Input entry does not contain an inner list of components"); + } + + var signatureParameters = signatureInputItem.Parameters ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!TryGetAlgorithm(signatureParameters, out var algorithm) || !string.Equals(algorithm, "ecdsa-p256-sha256", StringComparison.OrdinalIgnoreCase)) + { + return MessageSignatureResult.Failure($"Unsupported signature algorithm '{algorithm ?? ""}'"); + } + + if (!TryGetUnixEpoch(signatureParameters, "created", out var createdUnix)) + { + return MessageSignatureResult.Failure("Signature missing 'created' parameter"); + } + + var canonicalBase = await BuildCanonicalMessageAsync(context, componentIdentifiers, signatureInputItem.Parameters); + if (!canonicalBase.Success) + { + return MessageSignatureResult.Failure(canonicalBase.Error!); + } + + var canonicalPayloadBytes = Encoding.UTF8.GetBytes(canonicalBase.Payload!); + + var contentDigestValidation = await VerifyContentDigestAsync(context); + if (!contentDigestValidation.Success) + { + return MessageSignatureResult.Failure(contentDigestValidation.Error!); + } + + if (!TryVerifySignature(publicKeyBase64, signatureBytes, canonicalPayloadBytes)) + { + return MessageSignatureResult.Failure("Signature verification failed"); + } + + return MessageSignatureResult.Succeeded(canonicalBase.Payload ?? string.Empty); + } + + private Task<(bool Success, string? Payload, string? Error)> BuildCanonicalMessageAsync(HttpContext context, IReadOnlyList components, IReadOnlyDictionary? parameters) + { + context.Request.EnableBuffering(); + + var lines = new List(); + foreach (var component in components) + { + if (component.Value is not string identifier) + { + return Task.FromResult<(bool Success, string? Payload, string? Error)>((false, null, $"Unsupported component type '{component.Value?.GetType()}' in signature input")); + } + + string value; + try + { + value = ResolveComponentValue(context, identifier, component.Parameters); + } + catch (MessageSignatureException ex) + { + return Task.FromResult<(bool Success, string? Payload, string? Error)>((false, null, ex.Message)); + } + + var label = StructuredFieldFormatter.SerializeItem(component); + lines.Add($"{label}: {value}"); + } + + var signatureParams = StructuredFieldFormatter.SerializeInnerList(components, parameters); + lines.Add("\"@signature-params\": " + signatureParams); + + var payload = string.Join('\n', lines); + return Task.FromResult<(bool Success, string? Payload, string? Error)>((true, payload, (string?)null)); + } + + private async Task<(bool Success, string? Error)> VerifyContentDigestAsync(HttpContext context) + { + var contentDigestHeader = CombineHeaderValues(context.Request.Headers["Content-Digest"]); + if (string.IsNullOrWhiteSpace(contentDigestHeader)) + { + return (true, null); + } + + var parseError = SfvParser.ParseDictionary(contentDigestHeader, out var digestDictionary); + if (parseError.HasValue || digestDictionary is null) + { + return (false, $"Failed to parse content-digest header: {parseError?.Message ?? "Unknown error"}"); + } + + context.Request.EnableBuffering(); + context.Request.Body.Position = 0; + using var memoryStream = new MemoryStream(); + await context.Request.Body.CopyToAsync(memoryStream); + var bodyBytes = memoryStream.ToArray(); + context.Request.Body.Position = 0; + + foreach (var entry in digestDictionary) + { + if (!SupportedContentDigestAlgorithms.Contains(entry.Key)) + { + return (false, $"Unsupported content-digest algorithm '{entry.Key}'"); + } + + if (entry.Value.Value is not string && entry.Value.Value is not ReadOnlyMemory) + { + return (false, "Content-Digest entry is not a string or byte sequence"); + } + + var expectedDigest = entry.Value.Value is string text + ? text + : ":" + Convert.ToBase64String(((ReadOnlyMemory)entry.Value.Value!).ToArray()) + ":"; + + var actualDigest = ComputeDigest(entry.Key, bodyBytes); + + if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expectedDigest), Encoding.ASCII.GetBytes(actualDigest))) + { + return (false, $"Content digest verification failed for algorithm '{entry.Key}'"); + } + } + + return (true, null); + } + + private static string ComputeDigest(string algorithm, byte[] body) + { + return algorithm.Equals("sha-512", StringComparison.OrdinalIgnoreCase) + ? ":" + Convert.ToBase64String(SHA512.HashData(body)) + ":" + : ":" + Convert.ToBase64String(SHA256.HashData(body)) + ":"; + } + + private bool TryVerifySignature(string publicKeyBase64, ReadOnlyMemory signatureBytes, byte[] canonicalPayload) + { + try + { + var publicKey = Convert.FromBase64String(publicKeyBase64); + using var ecdsa = ECDsa.Create(); + ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _); + + var signature = signatureBytes.ToArray(); + return ecdsa.VerifyData(canonicalPayload, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (FormatException ex) + { + _logger.LogInformation("DebugLogToRemove: invalid public key format - {Message}", ex.Message); + return false; + } + catch (CryptographicException ex) + { + _logger.LogInformation("DebugLogToRemove: cryptographic failure - {Message}", ex.Message); + return false; + } + } + + private static string CombineHeaderValues(IReadOnlyList values) + { + if (values.Count == 0) + { + return string.Empty; + } + + if (values.Count == 1) + { + return values[0].Trim(); + } + + var builder = new StringBuilder(); + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append(values[i].Trim()); + } + + return builder.ToString(); + } + + private static bool TryGetAlgorithm(IReadOnlyDictionary parameters, out string? algorithm) + { + if (parameters.TryGetValue("alg", out var value) && value is string text) + { + algorithm = text; + return true; + } + + algorithm = null; + return false; + } + + private static bool TryGetUnixEpoch(IReadOnlyDictionary parameters, string key, out long value) + { + if (parameters.TryGetValue(key, out var parameterValue) && parameterValue is long integer) + { + value = integer; + return true; + } + + value = default; + return false; + } + + private string ResolveComponentValue(HttpContext context, string identifier, IReadOnlyDictionary? parameters) + { + switch (identifier) + { + case "@method": + return context.Request.Method; + case "@target-uri": + return BuildTargetUri(context.Request); + case "@authority": + return context.Request.Host.ToUriComponent(); + case "@scheme": + return context.Request.Scheme; + case "@path": + return context.Request.Path.HasValue ? context.Request.Path.Value! : string.Empty; + case "@query": + return context.Request.QueryString.HasValue ? context.Request.QueryString.Value!.TrimStart('?') : string.Empty; + case "@request-target": + return BuildTargetUri(context.Request); + case "@query-param": + return ResolveQueryParam(context, parameters); + default: + return ResolveHeaderComponent(context, identifier, parameters); + } + } + + private static string BuildTargetUri(HttpRequest request) + { + var builder = new StringBuilder(); + builder.Append(request.Scheme); + builder.Append("://"); + builder.Append(request.Host.ToUriComponent()); + builder.Append(request.Path.ToString()); + builder.Append(request.QueryString.ToString()); + return builder.ToString(); + } + + private static string ResolveQueryParam(HttpContext context, IReadOnlyDictionary? parameters) + { + if (parameters is null || !parameters.TryGetValue("name", out var value) || value is not string name) + { + throw new MessageSignatureException("@query-param requires a 'name' parameter"); + } + + var queryValues = context.Request.Query[name]; + if (queryValues.Count == 0) + { + throw new MessageSignatureException($"Missing query parameter '{name}' for @query-param component"); + } + + return string.Join(',', queryValues.ToArray()); + } + + private string ResolveHeaderComponent(HttpContext context, string headerName, IReadOnlyDictionary? parameters) + { + if (!context.Request.Headers.TryGetValue(headerName, out var values)) + { + throw new MessageSignatureException($"Missing header '{headerName}' referenced in signature"); + } + + if (parameters is null || parameters.Count == 0) + { + return CombineHeaderValues(values); + } + + if (parameters.TryGetValue("sf", out var sfValue) && sfValue is bool sf && sf) + { + return SerializeStructuredFieldHeader(headerName, values); + } + + if (parameters.TryGetValue("key", out var keyValue) && keyValue is string key) + { + var raw = CombineHeaderValues(values); + var parseError = SfvParser.ParseDictionary(raw, out var dictionary); + if (parseError.HasValue || dictionary is null) + { + throw new MessageSignatureException($"Failed to parse header '{headerName}' as dictionary: {parseError?.Message ?? "unknown error"}"); + } + + if (!dictionary.TryGetValue(key, out var item)) + { + throw new MessageSignatureException($"Header '{headerName}' dictionary missing key '{key}'"); + } + + return StructuredFieldFormatter.SerializeItem(item); + } + + return CombineHeaderValues(values); + } + + private static string SerializeStructuredFieldHeader(string headerName, IReadOnlyList values) + { + var raw = CombineHeaderValues(values); + var dictionaryError = SfvParser.ParseDictionary(raw, out var dictionary); + if (!dictionaryError.HasValue && dictionary is not null) + { + return StructuredFieldFormatter.SerializeDictionary(dictionary); + } + + var listError = SfvParser.ParseList(raw, out var list); + if (!listError.HasValue && list is not null) + { + return StructuredFieldFormatter.SerializeList(list); + } + + var itemError = SfvParser.ParseItem(raw, out var item); + if (!itemError.HasValue) + { + return StructuredFieldFormatter.SerializeItem(item); + } + + var errorMessage = dictionaryError?.Message ?? listError?.Message ?? itemError?.Message ?? "unknown error"; + throw new MessageSignatureException($"Failed to parse header '{headerName}' as structured field value: {errorMessage}"); + } +} + +public sealed record MessageSignatureResult(bool Success, string? Error, string? CanonicalMessage) +{ + public static MessageSignatureResult Failure(string error) => new(false, error, null); + public static MessageSignatureResult Succeeded(string canonical) => new(true, null, canonical); +} + +public sealed class MessageSignatureException : Exception +{ + public MessageSignatureException(string message) : base(message) + { + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs deleted file mode 100644 index 7a97df8..0000000 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/HttpSignatureParser.cs +++ /dev/null @@ -1,292 +0,0 @@ -namespace Hello.Helpers; - -using System.Collections.Generic; -using System.Text; - -public readonly record struct HttpSignatureEntry(string Label, string Signature); - -public record HttpSignatureInput( - string Label, - IReadOnlyList Components, - long? Created, - long? Expires, - string? Nonce); - -public static class HttpSignatureParser -{ - public static bool TryParseSignature(string headerValue, out HttpSignatureEntry entry, out string? error) - { - entry = default; - error = null; - - if (string.IsNullOrWhiteSpace(headerValue)) - { - error = "Signature header is empty."; - return false; - } - - foreach (var segment in SplitTopLevel(headerValue)) - { - var trimmed = segment.Trim(); - if (string.IsNullOrEmpty(trimmed)) - { - continue; - } - - var equalsIndex = trimmed.IndexOf('='); - if (equalsIndex < 0) - { - continue; - } - - var label = trimmed[..equalsIndex].Trim(); - var remainder = trimmed[(equalsIndex + 1)..].Trim(); - - if (string.IsNullOrEmpty(label)) - { - continue; - } - - string signatureValue; - - if (remainder.StartsWith(":", StringComparison.Ordinal)) - { - var closingColonIndex = remainder.IndexOf(':', 1); - if (closingColonIndex < 0) - { - error = "Signature header value missing closing colon."; - return false; - } - - signatureValue = remainder.Substring(1, closingColonIndex - 1); - } - else - { - var semicolonIndex = remainder.IndexOf(';'); - if (semicolonIndex >= 0) - { - remainder = remainder[..semicolonIndex]; - } - - signatureValue = remainder.Trim().Trim('"'); - } - - if (string.IsNullOrEmpty(signatureValue)) - { - error = "Signature header missing encoded signature."; - return false; - } - - entry = new HttpSignatureEntry(label, signatureValue); - return true; - } - - error = "Signature header missing expected entry."; - return false; - } - - public static bool TryParseSignatureInput(string headerValue, string expectedLabel, out HttpSignatureInput? signatureInput, out string? error) - { - signatureInput = null; - error = null; - - if (string.IsNullOrWhiteSpace(headerValue)) - { - error = "Signature-Input header is empty."; - return false; - } - - foreach (var segment in SplitTopLevel(headerValue)) - { - var trimmed = segment.Trim(); - if (string.IsNullOrEmpty(trimmed)) - { - continue; - } - - var equalsIndex = trimmed.IndexOf('='); - if (equalsIndex < 0) - { - continue; - } - - var label = trimmed[..equalsIndex].Trim(); - if (!string.IsNullOrEmpty(expectedLabel) && !string.Equals(label, expectedLabel, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var remainder = trimmed[(equalsIndex + 1)..].Trim(); - var openParenIndex = remainder.IndexOf('('); - var closeParenIndex = remainder.IndexOf(')', openParenIndex + 1); - - if (openParenIndex < 0 || closeParenIndex < 0 || closeParenIndex <= openParenIndex) - { - error = "Signature-Input header missing component list."; - return false; - } - - var componentsSegment = remainder.Substring(openParenIndex + 1, closeParenIndex - openParenIndex - 1); - var components = ParseComponents(componentsSegment); - - var parametersSegment = remainder[(closeParenIndex + 1)..]; - long? created = null; - long? expires = null; - string? nonce = null; - - if (!string.IsNullOrWhiteSpace(parametersSegment)) - { - var parameterParts = parametersSegment.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var parameter in parameterParts) - { - var separatorIndex = parameter.IndexOf('='); - var key = separatorIndex >= 0 ? parameter[..separatorIndex].Trim() : parameter.Trim(); - var value = separatorIndex >= 0 ? parameter[(separatorIndex + 1)..].Trim() : string.Empty; - value = TrimQuotes(value); - - switch (key) - { - case "created": - if (long.TryParse(value, out var createdValue)) - { - created = createdValue; - } - break; - case "expires": - if (long.TryParse(value, out var expiresValue)) - { - expires = expiresValue; - } - break; - case "nonce": - nonce = value; - break; - } - } - } - - signatureInput = new HttpSignatureInput(label, components, created, expires, nonce); - return true; - } - - error = string.IsNullOrEmpty(expectedLabel) - ? "Signature-Input header missing required entry." - : $"Signature-Input header missing entry for label '{expectedLabel}'."; - return false; - } - - private static IReadOnlyList ParseComponents(string segment) - { - var components = new List(); - var builder = new StringBuilder(); - var insideQuotes = false; - var escaping = false; - - foreach (var ch in segment) - { - if (escaping) - { - builder.Append(ch); - escaping = false; - continue; - } - - if (ch == '\\' && insideQuotes) - { - escaping = true; - continue; - } - - if (ch == '"') - { - if (insideQuotes) - { - components.Add(builder.ToString()); - builder.Clear(); - } - - insideQuotes = !insideQuotes; - continue; - } - - if (insideQuotes) - { - builder.Append(ch); - } - } - - return components; - } - - private static IEnumerable SplitTopLevel(string headerValue) - { - var segments = new List(); - var builder = new StringBuilder(); - var insideQuotes = false; - var escaping = false; - var parenDepth = 0; - - foreach (var ch in headerValue) - { - if (escaping) - { - builder.Append(ch); - escaping = false; - continue; - } - - switch (ch) - { - case '\\': - if (insideQuotes) - { - escaping = true; - continue; - } - break; - case '"': - insideQuotes = !insideQuotes; - break; - case '(': - if (!insideQuotes) - { - parenDepth++; - } - break; - case ')': - if (!insideQuotes && parenDepth > 0) - { - parenDepth--; - } - break; - case ',': - if (!insideQuotes && parenDepth == 0) - { - segments.Add(builder.ToString()); - builder.Clear(); - continue; - } - break; - } - - builder.Append(ch); - } - - if (builder.Length > 0) - { - segments.Add(builder.ToString()); - } - - return segments; - } - - private static string TrimQuotes(string value) - { - if (value.Length >= 2 && value.StartsWith('"') && value.EndsWith('"')) - { - return value[1..^1]; - } - - return value; - } -} diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs deleted file mode 100644 index 8730e99..0000000 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/MessageSigningUtilities.cs +++ /dev/null @@ -1,232 +0,0 @@ -namespace Hello.Helpers; - -using System; -using System.Buffers.Binary; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Http; - -public sealed record CanonicalMessage( - string Method, - string PathAndQuery, - IReadOnlyDictionary Headers, - string? BodyHashBase64, - byte[] PayloadBytes) -{ - public string Payload => Encoding.UTF8.GetString(PayloadBytes); -} - -public static class MessageSigningUtilities -{ - public static async Task BuildCanonicalMessageAsync(HttpRequest request, IEnumerable headerNames) - { - var orderedHeaders = headerNames - .Select(name => name.Trim()) - .Where(name => !string.IsNullOrEmpty(name)) - .Select(name => name.ToLowerInvariant()) - .Distinct() - .ToList(); - - var method = request.Method.ToUpperInvariant(); - var pathAndQuery = BuildPathAndQuery(request); - var headerMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - var parts = new List - { - method, - pathAndQuery - }; - - foreach (var headerName in orderedHeaders) - { - if (!request.Headers.TryGetValue(headerName, out var values)) - { - var match = request.Headers.FirstOrDefault(pair => string.Equals(pair.Key, headerName, StringComparison.OrdinalIgnoreCase)); - values = match.Value; - } - - var canonicalValue = values.Count > 0 - ? string.Join(", ", values.Select(v => v.Trim())) - : string.Empty; - - headerMap[headerName] = canonicalValue; - parts.Add($"{headerName}:{canonicalValue}"); - } - - var bodyHash = await ComputeBodyHashAsync(request).ConfigureAwait(false); - string? bodyHashBase64 = null; - if (bodyHash != null) - { - bodyHashBase64 = Convert.ToBase64String(bodyHash); - parts.Add(bodyHashBase64); - } - - var payloadBytes = Encoding.UTF8.GetBytes(string.Join('\n', parts)); - return new CanonicalMessage(method, pathAndQuery, headerMap, bodyHashBase64, payloadBytes); - } - - public static bool VerifyInstallationSignature(byte[] message, string signature, string publicKeyBase64) - { - if (!TryDecodeBase64(signature, out var signatureBytes)) - { - return false; - } - - byte[] publicKeyBytes; - try - { - publicKeyBytes = Convert.FromBase64String(publicKeyBase64); - } - catch (FormatException) - { - return false; - } - - using var ecdsa = ECDsa.Create(); - try - { - ecdsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _); - } - catch (CryptographicException) - { - return false; - } - - if (ecdsa.VerifyData(message, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)) - { - return true; - } - - if (signatureBytes.Length == 64 && - ecdsa.VerifyData(message, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) - { - return true; - } - - return false; - } - - public static byte[] DeriveSecret(byte[] baseSecret, string deviceId, DateTimeOffset tokenExpiry) - { - if (baseSecret.Length == 0) - { - throw new ArgumentException("Base secret must not be empty.", nameof(baseSecret)); - } - - byte[] deviceIdBytes; - try - { - deviceIdBytes = Convert.FromBase64String(deviceId); - } - catch (FormatException) - { - deviceIdBytes = Encoding.UTF8.GetBytes(deviceId); - } - - Span expiryBytes = stackalloc byte[8]; - BinaryPrimitives.WriteInt64BigEndian(expiryBytes, tokenExpiry.ToUnixTimeSeconds()); - - var payloadLength = deviceIdBytes.Length + expiryBytes.Length; - var payload = new byte[payloadLength]; - Buffer.BlockCopy(deviceIdBytes, 0, payload, 0, deviceIdBytes.Length); - expiryBytes.CopyTo(payload.AsSpan(deviceIdBytes.Length)); - - using var hmac = new HMACSHA256(baseSecret); - return hmac.ComputeHash(payload); - } - - public static bool VerifyAccountSignature(byte[] message, string signature, byte[] derivedSecret) - { - if (!TryDecodeBase64(signature, out var signatureBytes)) - { - return false; - } - - using var hmac = new HMACSHA256(derivedSecret); - var expectedSignature = hmac.ComputeHash(message); - - if (signatureBytes.Length != expectedSignature.Length) - { - return false; - } - - return CryptographicOperations.FixedTimeEquals(signatureBytes, expectedSignature); - } - - private static async Task ComputeBodyHashAsync(HttpRequest request) - { - request.EnableBuffering(); - if (!request.Body.CanRead) - { - return null; - } - - request.Body.Position = 0; - - using var sha256 = SHA256.Create(); - var buffer = new byte[8192]; - long totalRead = 0; - int bytesRead; - - while ((bytesRead = await request.Body.ReadAsync(buffer.AsMemory(0, buffer.Length)).ConfigureAwait(false)) > 0) - { - sha256.TransformBlock(buffer, 0, bytesRead, null, 0); - totalRead += bytesRead; - } - - sha256.TransformFinalBlock(Array.Empty(), 0, 0); - request.Body.Position = 0; - - return totalRead > 0 ? sha256.Hash : null; - } - - private static string BuildPathAndQuery(HttpRequest request) - { - var path = request.Path.HasValue ? request.Path.Value : "/"; - var query = request.QueryString.HasValue ? request.QueryString.Value : string.Empty; - return string.Concat(path, query); - } - - private static bool TryDecodeBase64(string value, out byte[] bytes) - { - bytes = Array.Empty(); - - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - var trimmed = value.Trim(); - - try - { - bytes = Convert.FromBase64String(trimmed); - return true; - } - catch (FormatException) - { - // Fall through and try Base64Url decoding. - } - - var normalized = trimmed.Replace('-', '+').Replace('_', '/'); - switch (normalized.Length % 4) - { - case 2: - normalized += "=="; - break; - case 3: - normalized += "="; - break; - } - - try - { - bytes = Convert.FromBase64String(normalized); - return true; - } - catch (FormatException) - { - return false; - } - } -} diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs new file mode 100644 index 0000000..21d16e8 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -0,0 +1,182 @@ +namespace Hello.Helpers; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using StructuredFieldValues; + +public static class StructuredFieldFormatter +{ + public static string SerializeItem(ParsedItem item) + { + var builder = new StringBuilder(); + builder.Append(SerializeBareItem(item.Value)); + + if (item.Parameters is { Count: > 0 }) + { + foreach (var parameter in item.Parameters) + { + builder.Append(';'); + builder.Append(parameter.Key); + if (parameter.Value is bool boolean && boolean) + { + continue; + } + + builder.Append('='); + builder.Append(SerializeBareItem(parameter.Value)); + } + } + + return builder.ToString(); + } + + public static string SerializeInnerList(IReadOnlyList items, IReadOnlyDictionary? parameters) + { + var builder = new StringBuilder(); + builder.Append('('); + for (var i = 0; i < items.Count; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + builder.Append(SerializeItem(items[i])); + } + + builder.Append(')'); + if (parameters is { Count: > 0 }) + { + foreach (var parameter in parameters) + { + builder.Append(';'); + builder.Append(parameter.Key); + if (parameter.Value is bool boolean && boolean) + { + continue; + } + + builder.Append('='); + builder.Append(SerializeBareItem(parameter.Value)); + } + } + + return builder.ToString(); + } + + public static string SerializeList(IReadOnlyList items) + { + var builder = new StringBuilder(); + for (var i = 0; i < items.Count; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(SerializeItem(items[i])); + } + + return builder.ToString(); + } + + public static string SerializeDictionary(IReadOnlyDictionary dictionary) + { + var builder = new StringBuilder(); + var first = true; + foreach (var entry in dictionary) + { + if (!first) + { + builder.Append(", "); + } + + builder.Append(entry.Key); + + // Bare item with boolean true is encoded without explicit value. + if (entry.Value.Value is bool boolean && boolean && (entry.Value.Parameters == null || entry.Value.Parameters.Count == 0)) + { + first = false; + continue; + } + + builder.Append('='); + builder.Append(SerializeItem(entry.Value)); + first = false; + } + + return builder.ToString(); + } + + private static string SerializeBareItem(object? value) + { + switch (value) + { + case null: + return string.Empty; + case bool boolean: + return boolean ? "?1" : "?0"; + case long integer: + return integer.ToString(CultureInfo.InvariantCulture); + case int smallInteger: + return smallInteger.ToString(CultureInfo.InvariantCulture); + case double number: + return number.ToString("G", CultureInfo.InvariantCulture); + case string text: + return SerializeString(text); + case Token token: + return token.ToString(); + case DisplayString display: + return SerializeDisplayString(display); + case ReadOnlyMemory bytes: + return ":" + Convert.ToBase64String(bytes.ToArray()) + ":"; + case IReadOnlyList innerList: + return SerializeInnerList(innerList, null); + default: + throw new NotSupportedException($"Unsupported structured field value type '{value.GetType()}'."); + } + } + + private static string SerializeString(string value) + { + var builder = new StringBuilder(); + builder.Append('"'); + foreach (var ch in value) + { + if (ch == '"' || ch == '\\') + { + builder.Append('\\'); + } + + builder.Append(ch); + } + builder.Append('"'); + return builder.ToString(); + } + + private static string SerializeDisplayString(DisplayString displayString) + { + var text = displayString.ToString(); + var bytes = Encoding.UTF8.GetBytes(text); + var builder = new StringBuilder(); + builder.Append('%'); + + foreach (var b in bytes) + { + var ch = (char)b; + if (ch >= 0x20 && ch <= 0x7E && ch != '%' && ch != '"') + { + builder.Append(ch); + } + else + { + builder.Append('%'); + builder.Append(b.ToString("X2", CultureInfo.InvariantCulture)); + } + } + + return builder.ToString(); + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index 3016b12..d1f5dc3 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs @@ -22,14 +22,13 @@ public async Task Invoke(HttpContext context) { var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(token)) { - _logger.LogInformation("DebugLogToRemove: Missing Approov-Token header."); + if (token == null) { + _logger.LogInformation("Missing Approov-Token header."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } if (verifyApproovToken(context, token)) { - context.Items[ApproovTokenContextKeys.ApproovToken] = token; await _next(context); return; } @@ -43,11 +42,6 @@ private bool verifyApproovToken(HttpContext context, string token) try { var tokenHandler = new JwtSecurityTokenHandler(); - if (_appSettings.ApproovSecretBytes == null || _appSettings.ApproovSecretBytes.Length == 0) - { - _logger.LogError("DebugLogToRemove: Approov secret bytes not configured."); - return false; - } tokenHandler.ValidateToken(token, new TokenValidationParameters { @@ -59,82 +53,13 @@ private bool verifyApproovToken(HttpContext context, string token) ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); - var jwtToken = (JwtSecurityToken)validatedToken; - _logger.LogInformation("DebugLogToRemove: Approov token header alg={Alg} kid={Kid}", jwtToken.Header.Alg, jwtToken.Header.Kid ?? ""); - var metadata = captureApproovTokenMetadata(context, jwtToken); - if (!string.IsNullOrEmpty(metadata.DeviceId)) - { - _logger.LogInformation("DebugLogToRemove: Approov token device id={DeviceId}", metadata.DeviceId); - } - if (metadata.ExpiryUtc.HasValue) - { - _logger.LogInformation("DebugLogToRemove: Approov token expiry (UTC)={Expiry}", metadata.ExpiryUtc.Value); - } - if (!string.IsNullOrEmpty(metadata.InstallationPublicKey)) - { - _logger.LogInformation("DebugLogToRemove: Approov token installation public key present"); - } - return true; } catch (SecurityTokenException exception) { - _logger.LogInformation("DebugLogToRemove: Approov token validation failed: {Message}", exception.Message); + _logger.LogInformation(exception.Message); return false; } catch (Exception exception) { - _logger.LogInformation("DebugLogToRemove: Unexpected token validation failure: {Message}", exception.Message); + _logger.LogInformation(exception.Message); return false; } } - - private readonly record struct TokenMetadata(string? DeviceId, DateTimeOffset? ExpiryUtc, string? InstallationPublicKey); - - private static TokenMetadata captureApproovTokenMetadata(HttpContext context, JwtSecurityToken jwtToken) - { - var deviceId = jwtToken.Claims.FirstOrDefault(claim => - string.Equals(claim.Type, "did", StringComparison.OrdinalIgnoreCase) || - string.Equals(claim.Type, "device_id", StringComparison.OrdinalIgnoreCase) || - string.Equals(claim.Type, "device", StringComparison.OrdinalIgnoreCase)) - ?.Value; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; - } - - var expiryUnixSeconds = jwtToken.Payload.Exp; - DateTimeOffset? expiryValue = null; - if (expiryUnixSeconds.HasValue) - { - try - { - expiryValue = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds.Value); - context.Items[ApproovTokenContextKeys.TokenExpiry] = expiryValue; - } - catch (ArgumentOutOfRangeException) - { - // Leave unset if the exp claim contains an invalid value. - } - } - else - { - var expClaim = jwtToken.Claims.FirstOrDefault(claim => string.Equals(claim.Type, JwtRegisteredClaimNames.Exp, StringComparison.OrdinalIgnoreCase))?.Value; - if (expClaim != null && long.TryParse(expClaim, out var parsedExp)) - { - expiryValue = DateTimeOffset.FromUnixTimeSeconds(parsedExp); - context.Items[ApproovTokenContextKeys.TokenExpiry] = expiryValue; - } - } - - var installationPubKey = jwtToken.Claims.FirstOrDefault(claim => - string.Equals(claim.Type, "ipk", StringComparison.OrdinalIgnoreCase) || - string.Equals(claim.Type, "installation_pubkey", StringComparison.OrdinalIgnoreCase) || - string.Equals(claim.Type, "installation_public_key", StringComparison.OrdinalIgnoreCase)) - ?.Value; - - if (!string.IsNullOrWhiteSpace(installationPubKey)) - { - context.Items[ApproovTokenContextKeys.InstallationPublicKey] = installationPubKey; - } - - return new TokenMetadata(deviceId, expiryValue, installationPubKey); - } } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs index 5967f86..fd198d9 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -1,376 +1,67 @@ namespace Hello.Middleware; using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; using Hello.Helpers; -using Microsoft.Extensions.Options; public class MessageSigningMiddleware { - private static readonly TimeSpan FutureTimestampSkewTolerance = TimeSpan.FromSeconds(30); - private readonly RequestDelegate _next; - private readonly AppSettings _settings; private readonly ILogger _logger; + private readonly ApproovMessageSignatureVerifier _verifier; - public MessageSigningMiddleware(RequestDelegate next, IOptions settings, ILogger logger) + public MessageSigningMiddleware(RequestDelegate next, ILogger logger, ApproovMessageSignatureVerifier verifier) { _next = next; - _settings = settings.Value; _logger = logger; + _verifier = verifier; } public async Task InvokeAsync(HttpContext context) { - await LogRequestAsync(context); - - if (_settings.MessageSigningMode == MessageSigningMode.None) - { - var originalBodyStream = context.Response.Body; - using var buffer = new MemoryStream(); - context.Response.Body = buffer; - - try - { - await _next(context); - } - finally - { - context.Response.Body = originalBodyStream; - await LogResponseAsync(context, buffer, originalBodyStream); - } - return; - } - - var originalResponseStream = context.Response.Body; - using var responseBuffer = new MemoryStream(); - context.Response.Body = responseBuffer; - - if (!context.Items.TryGetValue(ApproovTokenContextKeys.ApproovToken, out var tokenObject) || tokenObject is not string approovToken) + var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(token)) { - _logger.LogWarning("DebugLogToRemove: Approov token not found in context items. Message signing verification aborted."); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); - return; - } - - var signatureHeader = context.Request.Headers["Signature"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(signatureHeader)) - { - _logger.LogInformation("DebugLogToRemove: Missing Signature header."); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); - return; - } - - _logger.LogInformation("DebugLogToRemove: Raw Signature header value: {SignatureHeader}", signatureHeader); - - if (!HttpSignatureParser.TryParseSignature(signatureHeader, out var signatureEntry, out var signatureError)) - { - _logger.LogInformation("DebugLogToRemove: Invalid Signature header: {Error}", signatureError); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); - return; - } - - _logger.LogInformation("DebugLogToRemove: Parsed signature entry label={Label} signature={Signature}", signatureEntry.Label, signatureEntry.Signature); - - HttpSignatureInput? signatureInput = null; - var signatureInputHeader = context.Request.Headers["Signature-Input"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(signatureInputHeader)) - { - if (!HttpSignatureParser.TryParseSignatureInput(signatureInputHeader, signatureEntry.Label, out signatureInput, out var inputError)) - { - _logger.LogInformation("DebugLogToRemove: Invalid Signature-Input header: {Error}", inputError); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); - return; - } - - var inputDetails = signatureInput!; - _logger.LogInformation( - "DebugLogToRemove: Signature-Input components={Components} created={Created} expires={Expires} nonce={Nonce}", - inputDetails.Components == null ? "" : string.Join(",", inputDetails.Components), - inputDetails.Created, - inputDetails.Expires, - inputDetails.Nonce ?? ""); - } - else if (_settings.MessageSigningMaxAgeSeconds > 0 || _settings.RequireSignatureNonce) - { - _logger.LogInformation("DebugLogToRemove: Missing Signature-Input header required for metadata validation."); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); + await _next(context); return; } - if (!ValidateMetadata(signatureInput, out var metadataError)) + var installationPublicKey = ExtractInstallationPublicKey(token); + if (string.IsNullOrWhiteSpace(installationPublicKey)) { - _logger.LogInformation("DebugLogToRemove: Message signature metadata validation failed: {Error}", metadataError); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); + await _next(context); return; } - var headerNames = DetermineHeaderNames(signatureInput?.Components); - _logger.LogInformation("DebugLogToRemove: Canonical header selection => {Headers}", string.Join(",", headerNames)); - - var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); - - _logger.LogInformation("DebugLogToRemove: Canonical message method={Method} path+query={Path} bodyHash={BodyHash}", - canonicalMessage.Method, - canonicalMessage.PathAndQuery, - canonicalMessage.BodyHashBase64 ?? ""); - - if (canonicalMessage.Headers.Count > 0) + var result = await _verifier.VerifyAsync(context, installationPublicKey); + if (!result.Success) { - foreach (var kvp in canonicalMessage.Headers) - { - _logger.LogInformation("DebugLogToRemove: Canonical header entry {Header} => {Value}", kvp.Key, kvp.Value); - } - } - - _logger.LogInformation("DebugLogToRemove: Canonical payload string=\n{Payload}", canonicalMessage.Payload); - - _logger.LogInformation("DebugLogToRemove: Performing verification in {Mode} mode", _settings.MessageSigningMode); - - var verified = _settings.MessageSigningMode switch - { - MessageSigningMode.Installation => VerifyInstallationSignature(context, canonicalMessage.PayloadBytes, signatureEntry.Signature), - MessageSigningMode.Account => VerifyAccountSignature(context, canonicalMessage.PayloadBytes, signatureEntry.Signature), - _ => false - }; - - if (!verified) - { - _logger.LogInformation("DebugLogToRemove: Verification failed for mode {Mode}", _settings.MessageSigningMode); + _logger.LogInformation("DebugLogToRemove: message signing verification failed: {Reason}", result.Error); context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); + await context.Response.WriteAsync("Invalid Token"); return; } - try - { - _logger.LogInformation("DebugLogToRemove: Approov message signature verified using {Mode} mode.", _settings.MessageSigningMode); - await _next(context); - } - finally - { - context.Response.Body = originalResponseStream; - await LogResponseAsync(context, responseBuffer, originalResponseStream); - } - } - - private bool VerifyInstallationSignature(HttpContext context, byte[] canonicalMessage, string encodedSignature) - { - if (!context.Items.TryGetValue(ApproovTokenContextKeys.InstallationPublicKey, out var publicKeyObj) || - publicKeyObj is not string installationPublicKey) - { - _logger.LogInformation("DebugLogToRemove: Installation public key not found in Approov token."); - return false; - } - - _logger.LogInformation("DebugLogToRemove: Installation public key (base64)={PublicKey}", installationPublicKey); - _logger.LogInformation("DebugLogToRemove: Installation signature (base64)={Signature}", encodedSignature); - - if (!MessageSigningUtilities.VerifyInstallationSignature(canonicalMessage, encodedSignature, installationPublicKey)) - { - _logger.LogInformation("DebugLogToRemove: Installation message signature verification failed."); - return false; - } - - _logger.LogInformation("DebugLogToRemove: Installation signature verification succeeded."); - return true; + _logger.LogDebug("DebugLogToRemove: message signature verified"); + await _next(context); } - private bool VerifyAccountSignature(HttpContext context, byte[] canonicalMessage, string encodedSignature) + private static string? ExtractInstallationPublicKey(string token) { - if (_settings.AccountMessageBaseSecretBytes is null || _settings.AccountMessageBaseSecretBytes.Length == 0) - { - _logger.LogError("DebugLogToRemove: Account message signing base secret not configured."); - return false; - } - - if (!context.Items.TryGetValue(ApproovTokenContextKeys.DeviceId, out var deviceIdObj) || - deviceIdObj is not string deviceId || string.IsNullOrWhiteSpace(deviceId)) - { - _logger.LogInformation("DebugLogToRemove: Device ID not present in Approov token."); - return false; - } - - if (!context.Items.TryGetValue(ApproovTokenContextKeys.TokenExpiry, out var expiryObj) || - expiryObj is not DateTimeOffset tokenExpiry) - { - _logger.LogInformation("DebugLogToRemove: Token expiry missing from Approov token context."); - return false; - } - - byte[] derivedSecret; try { - derivedSecret = MessageSigningUtilities.DeriveSecret(_settings.AccountMessageBaseSecretBytes, deviceId, tokenExpiry); - } - catch (Exception ex) when (ex is ArgumentException or FormatException or CryptographicException) - { - _logger.LogInformation("DebugLogToRemove: Failed to derive account message signing secret: {Message}", ex.Message); - return false; - } - - _logger.LogInformation("DebugLogToRemove: Account verification metadata deviceId={DeviceId} expiry={Expiry} derivedSecretLength={Length}", deviceId, tokenExpiry, derivedSecret.Length); - _logger.LogInformation("DebugLogToRemove: Account derived secret (base64)={DerivedSecret}", Convert.ToBase64String(derivedSecret)); - _logger.LogInformation("DebugLogToRemove: Account signature (base64)={Signature}", encodedSignature); - - if (!MessageSigningUtilities.VerifyAccountSignature(canonicalMessage, encodedSignature, derivedSecret)) - { - _logger.LogInformation("DebugLogToRemove: Account message signature verification failed."); - return false; - } - - _logger.LogInformation("DebugLogToRemove: Account signature verification succeeded."); - return true; - } - - private bool ValidateMetadata(HttpSignatureInput? signatureInput, out string? error) - { - error = null; - - if (signatureInput is null) - { - return true; - } - - if (_settings.RequireSignatureNonce && string.IsNullOrWhiteSpace(signatureInput.Nonce)) - { - error = "Signature nonce required but not provided."; - return false; - } - - if (_settings.MessageSigningMaxAgeSeconds > 0) - { - if (!signatureInput.Created.HasValue) - { - error = "Signature-Input missing 'created' parameter."; - return false; - } - - var createdAt = DateTimeOffset.FromUnixTimeSeconds(signatureInput.Created.Value); - var now = DateTimeOffset.UtcNow; - - _logger.LogInformation("DebugLogToRemove: Signature creation timestamp {Created} (UTC {CreatedUtc}) current UTC {Now}", signatureInput.Created.Value, createdAt, now); - - if (now - createdAt > TimeSpan.FromSeconds(_settings.MessageSigningMaxAgeSeconds)) - { - error = "Signature creation time outside allowable age."; - return false; - } - - if (createdAt - now > FutureTimestampSkewTolerance) - { - error = "Signature creation time is in the future."; - return false; - } - } - - if (signatureInput.Expires.HasValue) - { - var expiresAt = DateTimeOffset.FromUnixTimeSeconds(signatureInput.Expires.Value); - _logger.LogInformation("DebugLogToRemove: Signature expires at {Expires} (UTC {ExpiresUtc})", signatureInput.Expires.Value, expiresAt); - if (DateTimeOffset.UtcNow > expiresAt) - { - error = "Signature has expired."; - return false; - } + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); + var ipkClaim = jwt.Claims.FirstOrDefault(claim => string.Equals(claim.Type, "ipk", StringComparison.OrdinalIgnoreCase)); + return ipkClaim?.Value; } - - return true; - } - - private List DetermineHeaderNames(IReadOnlyList? components) - { - var headers = new List(); - - if (components is not null && components.Count > 0) + catch (ArgumentException) { - foreach (var component in components) - { - if (string.IsNullOrWhiteSpace(component) || component.StartsWith("@", StringComparison.Ordinal)) - { - continue; - } - - headers.Add(component); - } + return null; } - else if (_settings.MessageSigningHeaderNames.Length > 0) - { - headers.AddRange(_settings.MessageSigningHeaderNames); - } - - if (!headers.Any(header => string.Equals(header, "Approov-Token", StringComparison.OrdinalIgnoreCase))) + catch (Exception) { - headers.Insert(0, "Approov-Token"); + return null; } - - return headers; - } - - private async Task LogRequestAsync(HttpContext context) - { - var request = context.Request; - request.EnableBuffering(); - string bodyText = ""; - if (request.Body.CanSeek) - { - request.Body.Position = 0; - using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); - var content = await reader.ReadToEndAsync(); - if (!string.IsNullOrEmpty(content)) - { - bodyText = content; - } - request.Body.Position = 0; - } - - var headerText = string.Join(", ", request.Headers.Select(h => $"{h.Key}:{h.Value}")); - _logger.LogInformation( - "DebugLogToRemove: Incoming request {Method} {Path}{Query} from {RemoteIp} headers={Headers} body={Body}", - request.Method, - request.Path, - request.QueryString, - context.Connection.RemoteIpAddress?.ToString() ?? "", - headerText, - bodyText); - } - - private async Task LogResponseAsync(HttpContext context, MemoryStream buffer, Stream originalBodyStream) - { - buffer.Position = 0; - string bodyText; - using (var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true)) - { - var content = await reader.ReadToEndAsync(); - bodyText = string.IsNullOrEmpty(content) ? "" : content; - } - - var headerText = string.Join(", ", context.Response.Headers.Select(h => $"{h.Key}:{h.Value}")); - _logger.LogInformation( - "DebugLogToRemove: Outgoing response status={Status} headers={Headers} body={Body}", - context.Response.StatusCode, - headerText, - bodyText); - - buffer.Position = 0; - await buffer.CopyToAsync(originalBodyStream); - await originalBodyStream.FlushAsync(); } } diff --git a/servers/hello/src/approov-protected-server/token-check/Program.cs b/servers/hello/src/approov-protected-server/token-check/Program.cs index 7625aab..4f5f722 100644 --- a/servers/hello/src/approov-protected-server/token-check/Program.cs +++ b/servers/hello/src/approov-protected-server/token-check/Program.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.Extensions.Configuration; using Hello.Helpers; ////////////////////////// @@ -29,43 +26,11 @@ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -var approovSection = builder.Configuration.GetSection("Approov"); -var messageSigningModeValue = approovSection["MessageSigningMode"] ?? nameof(MessageSigningMode.None); -if (!Enum.TryParse(messageSigningModeValue, true, out var messageSigningMode)) -{ - throw new InvalidOperationException($"Unsupported Approov message signing mode '{messageSigningModeValue}'."); -} - -var accountMessageBaseSecretRaw = approovSection["AccountMessageBaseSecret"]; -byte[]? accountMessageBaseSecretBytes = null; -if (!string.IsNullOrWhiteSpace(accountMessageBaseSecretRaw)) -{ - accountMessageBaseSecretBytes = DecodeAccountMessageBaseSecret(accountMessageBaseSecretRaw); -} - -if (messageSigningMode == MessageSigningMode.Account && accountMessageBaseSecretBytes == null) -{ - throw new InvalidOperationException("Message signing mode 'Account' requires Approov:AccountMessageBaseSecret to be configured."); -} - -var configuredSignedHeaders = approovSection.GetSection("SignedHeaders").Get() ?? Array.Empty(); -var messageSigningHeaderNames = NormalizeSignedHeaders(configuredSignedHeaders); -var messageSigningMaxAgeSeconds = approovSection.GetValue("MessageSigningMaxAgeSeconds") ?? 300; -if (messageSigningMaxAgeSeconds < 0) -{ - throw new InvalidOperationException("Approov:MessageSigningMaxAgeSeconds cannot be negative."); -} -var requireSignatureNonce = approovSection.GetValue("RequireSignatureNonce") ?? false; - builder.Services.Configure(appSettings => { appSettings.ApproovSecretBytes = approovSecretBytes; - appSettings.MessageSigningMode = messageSigningMode; - appSettings.AccountMessageBaseSecretBytes = accountMessageBaseSecretBytes; - appSettings.MessageSigningHeaderNames = messageSigningHeaderNames; - appSettings.MessageSigningMaxAgeSeconds = messageSigningMaxAgeSeconds; - appSettings.RequireSignatureNonce = requireSignatureNonce; }); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -91,84 +56,3 @@ app.MapControllers(); app.Run(); - -static byte[] DecodeAccountMessageBaseSecret(string encodedSecret) -{ - var sanitized = encodedSecret.Trim(); - if (string.IsNullOrWhiteSpace(sanitized)) - { - throw new FormatException("Account message signing base secret cannot be empty."); - } - - try - { - return Convert.FromBase64String(sanitized); - } - catch (FormatException) - { - // Intentionally fall through to try Base32 decoding. - } - - return DecodeBase32(sanitized); -} - -static byte[] DecodeBase32(string encodedSecret) -{ - const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - var cleaned = encodedSecret - .Trim() - .Replace("-", string.Empty) - .Replace(" ", string.Empty) - .TrimEnd('=') - .ToUpperInvariant(); - - if (cleaned.Length == 0) - { - throw new FormatException("Account message signing base secret cannot be empty."); - } - - var output = new List(); - var buffer = 0; - var bitsLeft = 0; - - foreach (var ch in cleaned) - { - var index = alphabet.IndexOf(ch); - if (index < 0) - { - throw new FormatException($"Invalid Base32 character '{ch}'."); - } - - buffer = (buffer << 5) | index; - bitsLeft += 5; - - if (bitsLeft >= 8) - { - bitsLeft -= 8; - var value = (byte)((buffer >> bitsLeft) & 0xFF); - output.Add(value); - buffer &= (1 << bitsLeft) - 1; - } - } - - if (bitsLeft > 0 && (buffer & ((1 << bitsLeft) - 1)) != 0) - { - throw new FormatException("Invalid Base32 padding in account message signing base secret."); - } - - if (output.Count == 0) - { - throw new FormatException("Account message signing base secret decoding produced no data."); - } - - return output.ToArray(); -} - -static string[] NormalizeSignedHeaders(IEnumerable headers) -{ - return headers - .Where(header => !string.IsNullOrWhiteSpace(header)) - .Select(header => header.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); -} diff --git a/servers/hello/src/approov-protected-server/token-check/README.md b/servers/hello/src/approov-protected-server/token-check/README.md index 936d049..23d2868 100644 --- a/servers/hello/src/approov-protected-server/token-check/README.md +++ b/servers/hello/src/approov-protected-server/token-check/README.md @@ -20,15 +20,15 @@ To lock down your API server to your mobile app. Please read the brief summary i ## How it works? -The API server is very simple and is defined at [src/approov-protected-server/token-check](src/approov-protected-server/token-check), and only responds to the endpoint `/` with this message: +The sample API mirrors the OpenResty/Lua reference implementation. It lives at [src/approov-protected-server/token-check](src/approov-protected-server/token-check) and exposes the following endpoints: -```json -{"message": "Hello, World!"} -``` - -The `200` response is only sent when a valid Approov token is present on the header of the request, otherwise a `401` response is sent back. +* `/hello` – plain text check that the service is alive. +* `/token` – validates the Approov token and, when the `ipk` claim is present, verifies the Approov installation message signature. Success returns `Good Token`; failures return a `401` with `Invalid Token`. +* `/ipk_test` – development helper. Without an `ipk` header it generates and logs a fresh P-256 key pair. With an `ipk` header it validates that the provided public key can be decoded. +* `/ipk_message_sign_test` – accepts a `private-key` (base64 DER) and a `msg` (base64 canonical message) header and returns an ECDSA P-256/SHA-256 raw signature. The Lua scripts call this to create deterministic signatures. +* `/sfv_test` – parses and reserialises Structured Field Value headers. The OpenResty quickstart invokes this when running `request_tests_sfv.sh`. -Take a look at the `verifyApproovToken()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs) class to see the simple code for the check. +Approov tokens are validated by the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs). Message signing is enforced by [MessageSigningMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs) which shares the same canonical string construction, structured field parsing, and ECDSA verification logic as the Lua quickstart. For more background on Approov, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. @@ -54,62 +54,40 @@ From `servers/hello/src/approov-protected-server/token-check` execute the follow cp .env.example .env ``` -Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to it in order to be able to test the Approov integration with the provided [Postman collection](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). - -[TOC](#toc---table-of-contents) - -## Message Signing Configuration - -After the Approov token is validated the API can optionally verify an [Approov Message Signing](https://approov.io/docs/latest/approov-usage-documentation/#installation-message-signing) signature. Configure the behaviour in `appsettings.json`: - -```jsonc -"Approov": { - "MessageSigningMode": "Installation", // None | Installation | Account - "AccountMessageBaseSecret": "", // Required when Account mode is selected - "MessageSigningMaxAgeSeconds": 300, // Reject signatures older than this age - "RequireSignatureNonce": false, // Enforce nonce presence if you track replay protection - "SignedHeaders": [ - "Approov-Token", - "Content-Type" - ] -} -``` - -* **None** — message signing checks are skipped and only the token is validated. -* **Installation** — expects an `ipk` claim in the Approov token. The middleware extracts the Elliptic Curve public key and verifies an ECDSA P-256/SHA-256 signature over the canonical request representation (HTTP method, path + query, configured headers and body hash when present). -* **Account** — derives a per-token HMAC key using the configured base secret, the device ID (`did` claim) and token expiry. The base secret can be provided in base64 or base32 form exactly as exported by `approov secret -messageSigningKey`. - -The canonical message always includes the `Approov-Token` header to prevent replay. If the client supplies a [`Signature-Input`](https://www.rfc-editor.org/rfc/rfc9421) header its declared components are honoured; otherwise the server falls back to the `SignedHeaders` list. Set `MessageSigningMaxAgeSeconds` and `RequireSignatureNonce` to mirror your policy for timestamp freshness and nonce enforcement. +Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to the `APPROOV_BASE64_SECRET` entry. [TOC](#toc---table-of-contents) ## Try the Approov Integration Example -First, you need to run this example from the `src/approov-protected-server/token-check` folder with: +The quickest way to bring up all sample backends (unprotected, token-check, token-binding) is: ```bash -dotnet run +./scripts/run-local.sh all ``` -Next, you can test that it works with: +The Lua quickstart scripts expect the token-check server on `http://0.0.0.0:8002`. Once the service is running you can execute the shell helpers that ship with the OpenResty repo, for example: ```bash -curl -iX GET 'http://localhost:8002' +./request_tests_approov_msg.sh 8002 +./request_tests_sfv.sh 8002 ``` -The response will be a `401` unauthorized request: +The commands above exercise the `/token`, `/ipk_message_sign_test`, `/ipk_test` and `/sfv_test` endpoints in exactly the same way as the Lua environment. You can also interact with the endpoints manually: -```text -HTTP/1.1 401 Unauthorized -Content-Length: 0 -Date: Wed, 01 Jun 2022 11:42:42 GMT -Server: Kestrel -``` +```bash +# basic token check (replace with a valid Approov token) +curl -H "Approov-Token: " http://localhost:8002/token -The reason you got a `401` is because the Approoov token isn't provided in the headers of the request. +# generate a deterministic signature for a canonical message +curl -H "private-key: " \ + -H "msg: " \ + http://localhost:8002/ipk_message_sign_test -Finally, you can test that the Approov integration example works as expected with this [Postman collection](/TESTING.md#testing-with-postman) or with some cURL requests [examples](/TESTING.md#testing-with-curl). +# verify Structured Field Value parsing +curl -H "sfv:?1;param=123" -H "sfvt:ITEM" http://localhost:8002/sfv_test +``` Run the automated unit tests with: diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json index bd777b1..0c208ae 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json @@ -4,14 +4,5 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "Approov": { - "MessageSigningMode": "None", - "AccountMessageBaseSecret": "", - "MessageSigningMaxAgeSeconds": 300, - "RequireSignatureNonce": false, - "SignedHeaders": [ - "Approov-Token" - ] } } diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.json b/servers/hello/src/approov-protected-server/token-check/appsettings.json index e499876..10f68b8 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.json @@ -5,14 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "Approov": { - "MessageSigningMode": "None", - "AccountMessageBaseSecret": "", - "MessageSigningMaxAgeSeconds": 300, - "RequireSignatureNonce": false, - "SignedHeaders": [ - "Approov-Token" - ] - } + "AllowedHosts": "*" } diff --git a/tests/Hello.Tests/UnitTest1.cs b/tests/Hello.Tests/UnitTest1.cs index c8fbb5a..55227ed 100644 --- a/tests/Hello.Tests/UnitTest1.cs +++ b/tests/Hello.Tests/UnitTest1.cs @@ -2,258 +2,116 @@ using System.Security.Cryptography; using System.Text; +using Hello.Controllers; using Hello.Helpers; using Hello.Middleware; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -public class MessageSigningUtilitiesTests +public class MessageSigningVerifierTests { - [Fact] - public async Task BuildCanonicalMessageAsync_ComposesExpectedString() - { - var context = new DefaultHttpContext(); - context.Request.Method = "POST"; - context.Request.Path = "/api/test"; - context.Request.QueryString = new QueryString("?q=1"); - context.Request.Headers["Approov-Token"] = "token123"; - context.Request.Headers["Custom-Header"] = "SomeValue"; - context.Request.Headers["Content-Type"] = "application/json"; - - var bodyBytes = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}"); - context.Request.Body = new MemoryStream(bodyBytes); - context.Request.ContentLength = bodyBytes.Length; - - var canonical = await MessageSigningUtilities.BuildCanonicalMessageAsync( - context.Request, - new[] { "Approov-Token", "Custom-Header" }); - - var bodyHash = Convert.ToBase64String(SHA256.HashData(bodyBytes)); - var expected = string.Join('\n', new[] - { - "POST", - "/api/test?q=1", - "approov-token:token123", - "custom-header:SomeValue", - bodyHash - }); - - Assert.Equal(expected, canonical.Payload); - Assert.Equal(bodyHash, canonical.BodyHashBase64); - Assert.Equal("POST", canonical.Method); - Assert.Equal("/api/test?q=1", canonical.PathAndQuery); - Assert.Equal("token123", canonical.Headers["approov-token"]); - } + internal const string TestPrivateKey = "MHcCAQEEIHWZ2Ueq6odQNG+aaYmEbp7C6nujYNGr7nYKK2jqQ2asoAoGCCqGSM49AwEHoUQDQgAEJSm4DMcivAwvhM+KNce2C/X26cj3oGyUwWVUPuNuZHtd2qyVsM+0g7qX73Qh0Of6fn10AApLnl8vRQsvx94fZQ=="; + private const string TestPublicKey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJSm4DMcivAwvhM+KNce2C/X26cj3oGyUwWVUPuNuZHtd2qyVsM+0g7qX73Qh0Of6fn10AApLnl8vRQsvx94fZQ=="; + private const string ApproovToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiZXhwIjoxOTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDAsImlzcyI6IkFwcHJvb3ZBY2NvdW50SUQuYXBwcm9vdi5pbyIsInN1YiI6ImFwcHJvb3Z8RXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwiaXAiOiIxLjIuMy40IiwiaXBrIjoiTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFSlNtNERNY2l2QXd2aE0rS05jZTJDL1gyNmNqM29HeVV3V1ZVUHVOdVpIdGQycXlWc00rMGc3cVg3M1FoME9mNmZuMTBBQXBMbmw4dlJRc3Z4OTRmWlE9PSIsImRpZCI6IkV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSJ9.hV6xTkGsp9uWwrD-yKkIGTBJawbofJEsuRLw9Qa5YXY"; [Fact] - public void VerifyInstallationSignature_ReturnsTrueForValidSignature() + public async Task VerifyAsync_WithValidSignature_ReturnsSuccess() { - var message = Encoding.UTF8.GetBytes("installation-message"); + var (context, signatureInput) = BuildRequest(); + var canonicalMessage = BuildCanonicalMessage(signatureInput); + var signature = SignCanonicalMessage(canonicalMessage); - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var signature = Convert.ToBase64String(ecdsa.SignData(message, HashAlgorithmName.SHA256)); - var publicKey = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()); + context.Request.Headers["Signature"] = $"install=:{signature}:"; - Assert.True(MessageSigningUtilities.VerifyInstallationSignature(message, signature, publicKey)); - Assert.False(MessageSigningUtilities.VerifyInstallationSignature(Encoding.UTF8.GetBytes("tampered"), signature, publicKey)); + var verifier = new ApproovMessageSignatureVerifier(NullLogger.Instance); + var result = await verifier.VerifyAsync(context, TestPublicKey); + + Assert.True(result.Success); } [Fact] - public void VerifyAccountSignature_ReturnsTrueForValidSignature() + public async Task VerifyAsync_WithInvalidSignature_ReturnsFailure() { - var baseSecret = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); - var deviceIdBytes = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray(); - var deviceId = Convert.ToBase64String(deviceIdBytes); - var tokenExpiry = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000); + var (context, signatureInput) = BuildRequest(); + var canonicalMessage = BuildCanonicalMessage(signatureInput); + var signature = SignCanonicalMessage(canonicalMessage); + var tampered = signature[..^1] + (signature[^1] == 'A' ? "B" : "A"); - var derivedSecret = MessageSigningUtilities.DeriveSecret(baseSecret, deviceId, tokenExpiry); - var messageBytes = Encoding.UTF8.GetBytes("account-message"); - using var hmac = new HMACSHA256(derivedSecret); - var signature = Convert.ToBase64String(hmac.ComputeHash(messageBytes)); + context.Request.Headers["Signature"] = $"install=:{tampered}:"; - Assert.True(MessageSigningUtilities.VerifyAccountSignature(messageBytes, signature, derivedSecret)); + var verifier = new ApproovMessageSignatureVerifier(NullLogger.Instance); + var result = await verifier.VerifyAsync(context, TestPublicKey); - var tamperedSignature = signature[..^1] + (signature[^1] == 'A' ? "B" : "A"); - Assert.False(MessageSigningUtilities.VerifyAccountSignature(messageBytes, tamperedSignature, derivedSecret)); + Assert.False(result.Success); } -} -public class MessageSigningMiddlewareTests -{ - [Fact] - public async Task NoneMode_BypassesVerification() + private static (DefaultHttpContext Context, string SignatureInput) BuildRequest() { - var settings = Options.Create(new AppSettings - { - MessageSigningMode = MessageSigningMode.None - }); + const string signatureInput = "(\"@method\" \"approov-token\");alg=\"ecdsa-p256-sha256\";created=1744292750;expires=1999999999"; - var invoked = false; - RequestDelegate next = _ => - { - invoked = true; - return Task.CompletedTask; - }; - - var middleware = new MessageSigningMiddleware(next, settings, NullLogger.Instance); var context = new DefaultHttpContext(); - - await middleware.InvokeAsync(context); - - Assert.True(invoked); + context.Request.Method = "GET"; + context.Request.Scheme = "http"; + context.Request.Host = new HostString("0.0.0.0", 8002); + context.Request.Path = "/token"; + context.Request.QueryString = new QueryString("?param1=value1¶m2=value2"); + context.Request.Headers["Approov-Token"] = ApproovToken; + context.Request.Headers["Signature-Input"] = $"install={signatureInput}"; + + return (context, signatureInput); } - [Fact] - public async Task InstallationMode_ValidSignature_AllowsRequest() + private static string BuildCanonicalMessage(string signatureInput) { - var settings = Options.Create(new AppSettings - { - MessageSigningMode = MessageSigningMode.Installation, - MessageSigningHeaderNames = new[] { "Approov-Token", "Content-Type" }, - MessageSigningMaxAgeSeconds = 300 - }); - - var called = false; - RequestDelegate next = _ => - { - called = true; - return Task.CompletedTask; - }; - - var middleware = new MessageSigningMiddleware(next, settings, NullLogger.Instance); - var context = BuildHttpContext(); - - var headerNames = new[] { "Approov-Token", "Content-Type" }; - var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); - - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var signature = Convert.ToBase64String(ecdsa.SignData(canonicalMessage.PayloadBytes, HashAlgorithmName.SHA256)); - var publicKey = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()); - - var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\" \"content-type\");created=" + created; - context.Request.Headers["Signature"] = "sig1=:" + signature + ":"; - context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); - context.Items[ApproovTokenContextKeys.InstallationPublicKey] = publicKey; - - await middleware.InvokeAsync(context); - - Assert.True(called); - Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + var builder = new StringBuilder(); + builder.AppendLine("\"@method\": GET"); + builder.AppendLine($"\"approov-token\": {ApproovToken}"); + builder.Append("\"@signature-params\": "); + builder.Append(signatureInput); + return builder.ToString(); } - [Fact] - public async Task InstallationMode_InvalidSignature_DeniesRequest() + private static string SignCanonicalMessage(string canonicalMessage) { - var settings = Options.Create(new AppSettings - { - MessageSigningMode = MessageSigningMode.Installation, - MessageSigningHeaderNames = new[] { "Approov-Token" }, - MessageSigningMaxAgeSeconds = 300 - }); - - var middleware = new MessageSigningMiddleware(_ => Task.CompletedTask, settings, NullLogger.Instance); - var context = BuildHttpContext(); - - context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\");created=" + DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - context.Request.Headers["Signature"] = "sig1=:invalid:"; - context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); - context.Items[ApproovTokenContextKeys.InstallationPublicKey] = Convert.ToBase64String(ECDsa.Create(ECCurve.NamedCurves.nistP256).ExportSubjectPublicKeyInfo()); - - await middleware.InvokeAsync(context); - - Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + var messageBytes = Encoding.UTF8.GetBytes(canonicalMessage); + using var ecdsa = ECDsa.Create(); + var privateKey = Convert.FromBase64String(TestPrivateKey); + ecdsa.ImportECPrivateKey(privateKey, out _); + var signature = ecdsa.SignData(messageBytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return Convert.ToBase64String(signature); } +} +public class ControllerSmokeTests +{ [Fact] - public async Task AccountMode_ValidSignature_AllowsRequest() + public void IpkTest_GeneratesKeysWhenHeaderMissing() { - var baseSecret = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); - var deviceIdBytes = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray(); - var deviceId = Convert.ToBase64String(deviceIdBytes); - var tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(2); - - var settings = Options.Create(new AppSettings - { - MessageSigningMode = MessageSigningMode.Account, - AccountMessageBaseSecretBytes = baseSecret, - MessageSigningHeaderNames = new[] { "Approov-Token", "Content-Type" }, - MessageSigningMaxAgeSeconds = 300 - }); - - var called = false; - RequestDelegate next = _ => - { - called = true; - return Task.CompletedTask; - }; - - var middleware = new MessageSigningMiddleware(next, settings, NullLogger.Instance); - var context = BuildHttpContext(); - - var headerNames = new[] { "Approov-Token", "Content-Type" }; - var canonicalMessage = await MessageSigningUtilities.BuildCanonicalMessageAsync(context.Request, headerNames); - var derivedSecret = MessageSigningUtilities.DeriveSecret(baseSecret, deviceId, tokenExpiry); - using var hmac = new HMACSHA256(derivedSecret); - var signature = Convert.ToBase64String(hmac.ComputeHash(canonicalMessage.PayloadBytes)); - - var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\" \"content-type\");created=" + created; - context.Request.Headers["Signature"] = "sig1=:" + signature + ":"; - context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); - context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; - context.Items[ApproovTokenContextKeys.TokenExpiry] = tokenExpiry; + var controller = new ApproovController(NullLogger.Instance); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; - await middleware.InvokeAsync(context); + var result = controller.IpkTest() as ContentResult; - Assert.True(called); - Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + Assert.NotNull(result); + Assert.Equal("No IPK header, generated keys logged", result!.Content); } [Fact] - public async Task AccountMode_ExpiredSignature_DeniesRequest() + public void IpKMessageSign_ReturnsSignature() { - var baseSecret = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); - var deviceId = Convert.ToBase64String(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray()); + var context = new DefaultHttpContext(); + context.Request.Headers["private-key"] = MessageSigningVerifierTests.TestPrivateKey; + context.Request.Headers["msg"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("payload")); - var settings = Options.Create(new AppSettings + var controller = new ApproovController(NullLogger.Instance) { - MessageSigningMode = MessageSigningMode.Account, - AccountMessageBaseSecretBytes = baseSecret, - MessageSigningHeaderNames = new[] { "Approov-Token" }, - MessageSigningMaxAgeSeconds = 60 - }); - - var middleware = new MessageSigningMiddleware(_ => Task.CompletedTask, settings, NullLogger.Instance); - var context = BuildHttpContext(); - - var created = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds(); - context.Request.Headers["Signature-Input"] = "sig1=(\"@method\" \"@path\" \"approov-token\");created=" + created; - context.Request.Headers["Signature"] = "sig1=:invalid:"; - context.Items[ApproovTokenContextKeys.ApproovToken] = context.Request.Headers["Approov-Token"].ToString(); - context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; - context.Items[ApproovTokenContextKeys.TokenExpiry] = DateTimeOffset.UtcNow; - - await middleware.InvokeAsync(context); - - Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); - } - - private static DefaultHttpContext BuildHttpContext() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - context.Request.Method = "POST"; - context.Request.Path = "/resource"; - context.Request.QueryString = new QueryString("?id=123"); - context.Request.Headers["Approov-Token"] = "token-value"; - context.Request.Headers["Content-Type"] = "application/json"; + ControllerContext = new ControllerContext { HttpContext = context } + }; - var body = Encoding.UTF8.GetBytes("{\"key\":\"value\"}"); - context.Request.Body = new MemoryStream(body); - context.Request.ContentLength = body.Length; + var actionResult = controller.IpkMessageSignTest() as ContentResult; - return context; + Assert.NotNull(actionResult); + Assert.False(string.IsNullOrEmpty(actionResult!.Content)); } } From 3c3988da5d91708bf2fbe3a8496772b27cb8f9cf Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 13:14:32 +0000 Subject: [PATCH 05/21] Update logging levels, adjust token-check port, and enhance Approov token handling. Add token binding support to the token-check for testing purposes --- scripts/run-local.sh | 2 +- .../appsettings.Development.json | 2 +- .../token-binding-check/appsettings.json | 2 +- .../Controllers/ApproovController.cs | 76 ++++++++++++++++--- .../ApproovMessageSignatureVerifier.cs | 4 +- .../Helpers/ApproovTokenContextKeys.cs | 1 + .../Helpers/StructuredFieldFormatter.cs | 73 +++++++++--------- .../Middleware/ApproovTokenMiddleware.cs | 63 ++++++++++++++- .../Middleware/MessageSigningMiddleware.cs | 4 +- .../token-check/appsettings.Development.json | 2 +- .../token-check/appsettings.json | 2 +- 11 files changed, 175 insertions(+), 56 deletions(-) diff --git a/scripts/run-local.sh b/scripts/run-local.sh index e08b1af..02918fc 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -27,7 +27,7 @@ project_dir_for() { project_port_for() { case "$1" in unprotected) printf '8001\n' ;; - token-check) printf '8002\n' ;; + token-check) printf '8111\n' ;; token-binding) printf '8003\n' ;; *) return 1 ;; esac diff --git a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json b/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json index 0c208ae..3e1a225 100644 --- a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json +++ b/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } } } diff --git a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json b/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json index 10f68b8..4282acb 100644 --- a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json +++ b/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } }, "AllowedHosts": "*" diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index 686d056..523fa8c 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -1,8 +1,9 @@ -using System.Security.Cryptography; -using System.Text; using Hello.Helpers; using Microsoft.AspNetCore.Mvc; using StructuredFieldValues; +using System.Security.Cryptography; +using System.Text; +using System.Collections.Generic; namespace Hello.Controllers; @@ -24,6 +25,33 @@ public ApproovController(ILogger logger) [HttpPost("/token")] public IActionResult Token() => Content("Good Token", "text/plain"); + [HttpGet("/token_binding")] + public IActionResult TokenBinding() + { + var payClaim = HttpContext.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var value) + ? value as string + : null; + + if (string.IsNullOrWhiteSpace(payClaim)) + { + return Unauthorized(); + } + + var combinedBinding = BuildBindingValue(new[] { "Authorization", "X-Device-Id" }); + if (!combinedBinding.Success) + { + return Unauthorized(); + } + + var computedHash = ComputeSha256Base64(combinedBinding.Value); + if (!HashesMatch(payClaim, computedHash)) + { + return Unauthorized(); + } + + return Content("Good Token Binding", "text/plain"); + } + [HttpGet("/ipk_test")] public IActionResult IpkTest() { @@ -36,7 +64,7 @@ public IActionResult IpkTest() var privateKeyBase64 = Convert.ToBase64String(privateKeyDer); var publicKeyBase64 = Convert.ToBase64String(publicKeyDer); - _logger.LogInformation("DebugLogToRemove: Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); + _logger.LogDebug("Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); return Content("No IPK header, generated keys logged", "text/plain"); } @@ -50,7 +78,7 @@ public IActionResult IpkTest() } catch (Exception ex) { - _logger.LogInformation("DebugLogToRemove: Failed to import IPK header - {Message}", ex.Message); + _logger.LogWarning("Failed to import IPK header - {Message}", ex.Message); Response.StatusCode = StatusCodes.Status401Unauthorized; return Content("Failed: failed to create public key", "text/plain"); } @@ -80,13 +108,13 @@ public IActionResult IpkMessageSignTest() } catch (FormatException ex) { - _logger.LogInformation("DebugLogToRemove: Invalid input for signing - {Message}", ex.Message); + _logger.LogWarning("Invalid input for signing - {Message}", ex.Message); Response.StatusCode = StatusCodes.Status400BadRequest; return Content("Failed to generate signature", "text/plain"); } catch (CryptographicException ex) { - _logger.LogInformation("DebugLogToRemove: Cryptographic failure during signing - {Message}", ex.Message); + _logger.LogError(ex, "Cryptographic failure during signing"); Response.StatusCode = StatusCodes.Status400BadRequest; return Content("Failed to generate signature", "text/plain"); } @@ -114,7 +142,7 @@ public IActionResult StructuredFieldTest() error = SfvParser.ParseItem(sfvHeader, out var item); if (error.HasValue) { - return StructuredFieldFailure(error.Value.Message); + return StructuredFieldFailure(error.Value.Message); } serialized = StructuredFieldFormatter.SerializeItem(item); break; @@ -141,7 +169,7 @@ public IActionResult StructuredFieldTest() if (!string.Equals(serialized, sfvHeader, StringComparison.Ordinal)) { - return StructuredFieldFailure("Serialized object does not match original"); + return StructuredFieldFailure($"Serialized object does not match original: {serialized} != {sfvHeader}"); } return Content("SFV roundtrip OK", "text/plain"); @@ -149,7 +177,7 @@ public IActionResult StructuredFieldTest() private IActionResult StructuredFieldFailure(string message) { - _logger.LogInformation("DebugLogToRemove: Structured field roundtrip failure - {Message}", message); + _logger.LogDebug("Structured field roundtrip failure - {Message}", message); Response.StatusCode = StatusCodes.Status401Unauthorized; return Content("Failed SFV roundtrip", "text/plain"); } @@ -179,4 +207,34 @@ private static string CombineHeaderValues(IReadOnlyList values) return builder.ToString(); } + + private (bool Success, string Value) BuildBindingValue(IEnumerable headerNames) + { + var builder = new StringBuilder(); + foreach (var headerName in headerNames) + { + if (!Request.Headers.TryGetValue(headerName, out var values) || values.Count == 0) + { + return (false, string.Empty); + } + + builder.Append(CombineHeaderValues(values)); + } + + return (true, builder.ToString()); + } + + private static string ComputeSha256Base64(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hash = SHA256.HashData(bytes); + return Convert.ToBase64String(hash); + } + + private static bool HashesMatch(string expectedBase64, string actualBase64) + { + var expectedBytes = Encoding.UTF8.GetBytes(expectedBase64); + var actualBytes = Encoding.UTF8.GetBytes(actualBase64); + return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs index 3efc36c..0bf4f33 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -202,12 +202,12 @@ private bool TryVerifySignature(string publicKeyBase64, ReadOnlyMemory sig } catch (FormatException ex) { - _logger.LogInformation("DebugLogToRemove: invalid public key format - {Message}", ex.Message); + _logger.LogWarning("Invalid public key format - {Message}", ex.Message); return false; } catch (CryptographicException ex) { - _logger.LogInformation("DebugLogToRemove: cryptographic failure - {Message}", ex.Message); + _logger.LogError(ex, "Cryptographic failure during signature verification"); return false; } } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs index cf251c7..858e0ce 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs @@ -6,4 +6,5 @@ public static class ApproovTokenContextKeys public const string DeviceId = "ApproovDeviceId"; public const string TokenExpiry = "ApproovTokenExpiry"; public const string InstallationPublicKey = "ApproovInstallationPublicKey"; + public const string TokenBinding = "ApproovTokenBinding"; } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs index 21d16e8..ff69dbd 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -13,21 +13,7 @@ public static string SerializeItem(ParsedItem item) var builder = new StringBuilder(); builder.Append(SerializeBareItem(item.Value)); - if (item.Parameters is { Count: > 0 }) - { - foreach (var parameter in item.Parameters) - { - builder.Append(';'); - builder.Append(parameter.Key); - if (parameter.Value is bool boolean && boolean) - { - continue; - } - - builder.Append('='); - builder.Append(SerializeBareItem(parameter.Value)); - } - } + AppendParameters(builder, item.Parameters); return builder.ToString(); } @@ -47,21 +33,7 @@ public static string SerializeInnerList(IReadOnlyList items, IReadOn } builder.Append(')'); - if (parameters is { Count: > 0 }) - { - foreach (var parameter in parameters) - { - builder.Append(';'); - builder.Append(parameter.Key); - if (parameter.Value is bool boolean && boolean) - { - continue; - } - - builder.Append('='); - builder.Append(SerializeBareItem(parameter.Value)); - } - } + AppendParameters(builder, parameters); return builder.ToString(); } @@ -95,9 +67,9 @@ public static string SerializeDictionary(IReadOnlyDictionary builder.Append(entry.Key); - // Bare item with boolean true is encoded without explicit value. - if (entry.Value.Value is bool boolean && boolean && (entry.Value.Parameters == null || entry.Value.Parameters.Count == 0)) + if (entry.Value.Value is bool boolean && boolean) { + AppendParameters(builder, entry.Value.Parameters); first = false; continue; } @@ -132,6 +104,10 @@ private static string SerializeBareItem(object? value) return SerializeDisplayString(display); case ReadOnlyMemory bytes: return ":" + Convert.ToBase64String(bytes.ToArray()) + ":"; + case DateTime dateTime: + return SerializeDateTime(dateTime); + case DateTimeOffset dateTimeOffset: + return SerializeDateTime(dateTimeOffset.UtcDateTime); case IReadOnlyList innerList: return SerializeInnerList(innerList, null); default: @@ -161,7 +137,7 @@ private static string SerializeDisplayString(DisplayString displayString) var text = displayString.ToString(); var bytes = Encoding.UTF8.GetBytes(text); var builder = new StringBuilder(); - builder.Append('%'); + builder.Append("%\""); foreach (var b in bytes) { @@ -173,10 +149,39 @@ private static string SerializeDisplayString(DisplayString displayString) else { builder.Append('%'); - builder.Append(b.ToString("X2", CultureInfo.InvariantCulture)); + builder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); } } + builder.Append('"'); return builder.ToString(); } + + private static string SerializeDateTime(DateTime value) + { + var utc = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); + var seconds = new DateTimeOffset(utc).ToUnixTimeSeconds(); + return "@" + seconds.ToString(CultureInfo.InvariantCulture); + } + + private static void AppendParameters(StringBuilder builder, IReadOnlyDictionary? parameters) + { + if (parameters is not { Count: > 0 }) + { + return; + } + + foreach (var parameter in parameters) + { + builder.Append(';'); + builder.Append(parameter.Key); + if (parameter.Value is bool boolean && boolean) + { + continue; + } + + builder.Append('='); + builder.Append(SerializeBareItem(parameter.Value)); + } + } } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index d1f5dc3..c5963b2 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs @@ -4,6 +4,7 @@ namespace Hello.Middleware; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using Hello.Helpers; +using System.Security.Claims; public class ApproovTokenMiddleware { @@ -20,10 +21,17 @@ public ApproovTokenMiddleware(RequestDelegate next, IOptions appSet public async Task Invoke(HttpContext context) { + var path = context.Request.Path.Value; + if (IsBypassedPath(path)) + { + await _next(context); + return; + } + var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); if (token == null) { - _logger.LogInformation("Missing Approov-Token header."); + _logger.LogDebug("Missing Approov-Token header."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } @@ -53,13 +61,60 @@ private bool verifyApproovToken(HttpContext context, string token) ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); + if (validatedToken is JwtSecurityToken jwtToken) + { + context.Items[ApproovTokenContextKeys.ApproovToken] = jwtToken; + context.Items[ApproovTokenContextKeys.TokenExpiry] = jwtToken.ValidTo; + + var claims = jwtToken.Claims; + var deviceId = claims.FirstOrDefault(c => string.Equals(c.Type, "did", StringComparison.OrdinalIgnoreCase))?.Value; + if (!string.IsNullOrWhiteSpace(deviceId)) + { + context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; + } + + var payClaim = claims.FirstOrDefault(c => string.Equals(c.Type, "pay", StringComparison.OrdinalIgnoreCase))?.Value; + if (!string.IsNullOrWhiteSpace(payClaim)) + { + context.Items[ApproovTokenContextKeys.TokenBinding] = payClaim; + } + + var installationPublicKey = claims.FirstOrDefault(c => string.Equals(c.Type, "ipk", StringComparison.OrdinalIgnoreCase))?.Value; + if (!string.IsNullOrWhiteSpace(installationPublicKey)) + { + context.Items[ApproovTokenContextKeys.InstallationPublicKey] = installationPublicKey; + } + } + return true; - } catch (SecurityTokenException exception) { - _logger.LogInformation(exception.Message); + } catch (SecurityTokenExpiredException) { + _logger.LogDebug("Approov token rejected: expired"); + return false; + } catch (SecurityTokenNoExpirationException) { + _logger.LogDebug("Approov token rejected: missing expiration"); + return false; + } catch (SecurityTokenInvalidSignatureException) { + _logger.LogDebug("Approov token rejected: invalid signature"); + return false; + } catch (SecurityTokenException) { + _logger.LogDebug("Approov token rejected: failed validation"); return false; } catch (Exception exception) { - _logger.LogInformation(exception.Message); + _logger.LogError(exception, "Unexpected error during Approov token validation"); + return false; + } + } + + private static bool IsBypassedPath(string? path) + { + if (string.IsNullOrEmpty(path)) + { return false; } + + return path.Equals("/sfv_test", StringComparison.OrdinalIgnoreCase) + || path.Equals("/ipk_test", StringComparison.OrdinalIgnoreCase) + || path.Equals("/ipk_message_sign_test", StringComparison.OrdinalIgnoreCase) + || path.Equals("/hello", StringComparison.OrdinalIgnoreCase); } } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs index fd198d9..acc5e25 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -37,13 +37,13 @@ public async Task InvokeAsync(HttpContext context) var result = await _verifier.VerifyAsync(context, installationPublicKey); if (!result.Success) { - _logger.LogInformation("DebugLogToRemove: message signing verification failed: {Reason}", result.Error); + _logger.LogWarning("Message signing verification failed: {Reason}", result.Error); context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync("Invalid Token"); return; } - _logger.LogDebug("DebugLogToRemove: message signature verified"); + _logger.LogDebug("Message signature verified"); await _next(context); } diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json index 0c208ae..3e1a225 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } } } diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.json b/servers/hello/src/approov-protected-server/token-check/appsettings.json index 10f68b8..4282acb 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } }, "AllowedHosts": "*" From e75508d601abca31c093f4b30972452285db4b09 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 14:47:27 +0000 Subject: [PATCH 06/21] Refactor token binding response and update logging for key generation. Add request-target construction method per RFC 9421. --- .../token-check/Controllers/ApproovController.cs | 12 ++++++++---- .../Helpers/ApproovMessageSignatureVerifier.cs | 11 ++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index 523fa8c..4238ea0 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -51,7 +51,6 @@ public IActionResult TokenBinding() return Content("Good Token Binding", "text/plain"); } - [HttpGet("/ipk_test")] public IActionResult IpkTest() { @@ -64,7 +63,7 @@ public IActionResult IpkTest() var privateKeyBase64 = Convert.ToBase64String(privateKeyDer); var publicKeyBase64 = Convert.ToBase64String(publicKeyDer); - _logger.LogDebug("Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); + //_logger.LogDebug("Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); return Content("No IPK header, generated keys logged", "text/plain"); } @@ -83,7 +82,12 @@ public IActionResult IpkTest() return Content("Failed: failed to create public key", "text/plain"); } } - +/* + Sending cryptographic values across an insecure network without encrypting them is extremely unsafe, + as anyone that intercepts these values can then decrypt your data. This endpoint exposes private keys to interception, + logging by web servers and proxies, and storage in browser history. + Make sure this endpoint is only used in a secure testing environment and never in production. + */ [HttpGet("/ipk_message_sign_test")] public IActionResult IpkMessageSignTest() { @@ -177,7 +181,7 @@ public IActionResult StructuredFieldTest() private IActionResult StructuredFieldFailure(string message) { - _logger.LogDebug("Structured field roundtrip failure - {Message}", message); + _logger.LogDebug("Structured field roundtrip failure - {Message}", message); Response.StatusCode = StatusCodes.Status401Unauthorized; return Content("Failed SFV roundtrip", "text/plain"); } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs index 0bf4f33..baef66b 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -279,7 +279,7 @@ private string ResolveComponentValue(HttpContext context, string identifier, IRe case "@query": return context.Request.QueryString.HasValue ? context.Request.QueryString.Value!.TrimStart('?') : string.Empty; case "@request-target": - return BuildTargetUri(context.Request); + return BuildRequestTarget(context.Request); case "@query-param": return ResolveQueryParam(context, parameters); default: @@ -298,6 +298,15 @@ private static string BuildTargetUri(HttpRequest request) return builder.ToString(); } + // Constructs the origin-form request-target as per RFC 9421 + private static string BuildRequestTarget(HttpRequest request) + { + var builder = new StringBuilder(); + builder.Append(request.Path.ToString()); + builder.Append(request.QueryString.ToString()); + return builder.ToString(); + } + private static string ResolveQueryParam(HttpContext context, IReadOnlyDictionary? parameters) { if (parameters is null || !parameters.TryGetValue("name", out var value) || value is not string name) From f6c8e13a8f6fdcc8809066e7f1d15be2bc9b1930 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 14:49:21 +0000 Subject: [PATCH 07/21] Added strict printable-ASCII validation in the structured field string serializer so it now rejects control/non-ASCII code points while still escaping quotes and backslashes. --- .../token-check/Helpers/StructuredFieldFormatter.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs index ff69dbd..4676448 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -121,6 +121,11 @@ private static string SerializeString(string value) builder.Append('"'); foreach (var ch in value) { + if (ch < 0x20 || ch > 0x7E) + { + throw new FormatException($"Invalid character U+{((int)ch):X4} in structured field string."); + } + if (ch == '"' || ch == '\\') { builder.Append('\\'); From cac233db8b9edb46509197a0290b1e03d3841ce8 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 14:55:40 +0000 Subject: [PATCH 08/21] - Replaced the double bare-item serialization with fixed-point formatting, trimming trailing zeros and any trailing decimal point so emitted values stay in standard decimal form (servers/hello/src/approov- protected-server/token-check/Helpers/StructuredFieldFormatter.cs:104). - Added guards against non-finite values, scientific notation, and integer parts longer than 12 digits, raising FormatException when RFC 8941 constraints are violated (servers/hello/src/approov-protected- server/token-check/Helpers/StructuredFieldFormatter.cs:99). - Normalized negative zero outputs to 0 to avoid spurious sign information in serialized numbers (servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs:110). --- .../Helpers/StructuredFieldFormatter.cs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs index 4676448..39364ba 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -95,7 +95,38 @@ private static string SerializeBareItem(object? value) case int smallInteger: return smallInteger.ToString(CultureInfo.InvariantCulture); case double number: - return number.ToString("G", CultureInfo.InvariantCulture); + { + if (double.IsNaN(number) || double.IsInfinity(number)) + { + throw new FormatException("Structured field number must be finite."); + } + + var formatted = number.ToString("F3", CultureInfo.InvariantCulture).TrimEnd('0'); + if (formatted.EndsWith(".", StringComparison.Ordinal)) + { + formatted = formatted.Substring(0, formatted.Length - 1); + } + + if (formatted == "-0") + { + formatted = "0"; + } + + if (formatted.IndexOfAny(new[] { 'e', 'E' }) >= 0) + { + throw new FormatException("Structured field number must not use scientific notation."); + } + + var signOffset = formatted.StartsWith("-", StringComparison.Ordinal) ? 1 : 0; + var decimalIndex = formatted.IndexOf('.', signOffset); + var integerLength = decimalIndex >= 0 ? decimalIndex - signOffset : formatted.Length - signOffset; + if (integerLength > 12) + { + throw new FormatException("Structured field number has more than 12 digits before the decimal point."); + } + + return formatted; + } case string text: return SerializeString(text); case Token token: From 358042e97c4ecd6d82904b1a4e648d9947ac3071 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 15:39:19 +0000 Subject: [PATCH 09/21] Consolidate all middleware components into one server to simplify the architecture. --- EXAMPLES.md | 28 +---- QUICKSTARTS.md | 4 +- README.md | 2 +- docker-compose.yml | 12 +- docs/APPROOV_TOKEN_BINDING_QUICKSTART.md | 89 +++++++------- scripts/run-local.sh | 8 +- ...oov-dotnet-example.postman_collection.json | 4 +- .../token-binding-check/.env.example | 1 - .../Controllers/HelloController.cs | 22 ---- .../token-binding-check/Hello.csproj | 15 --- .../Helpers/AppSettings.cs | 6 - .../ApproovTokenBindingMiddleware.cs | 79 ------------ .../Middleware/ApproovTokenMiddleware.cs | 74 ----------- .../token-binding-check/Program.cs | 56 --------- .../Properties/launchSettings.json | 31 ----- .../token-binding-check/README.md | 115 ------------------ .../appsettings.Development.json | 8 -- .../token-binding-check/appsettings.json | 9 -- .../Controllers/ApproovController.cs | 45 +------ .../Helpers/ApproovTokenContextKeys.cs | 1 + .../ApproovTokenBindingMiddleware.cs | 64 ++++++++++ .../token-check/Program.cs | 1 + .../Properties/launchSettings.json | 2 +- .../token-check/README.md | 18 +-- .../Properties/launchSettings.json | 2 +- .../hello/src/unprotected-server/README.md | 2 +- tests/Hello.Tests/UnitTest1.cs | 2 +- 27 files changed, 143 insertions(+), 557 deletions(-) delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/.env.example delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Controllers/HelloController.cs delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Hello.csproj delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Helpers/AppSettings.cs delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenBindingMiddleware.cs delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenMiddleware.cs delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Program.cs delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/Properties/launchSettings.json delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/README.md delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json delete mode 100644 servers/hello/src/approov-protected-server/token-binding-check/appsettings.json create mode 100644 servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs diff --git a/EXAMPLES.md b/EXAMPLES.md index 5b06bba..264cb83 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -12,34 +12,32 @@ If you are looking for the Approov quickstarts to integrate Approov in your ASP. To learn more about each Hello server example you need to read the README for each one at: * [Unprotected Server](/servers/hello/src/unprotected-server) -* [Approov Protected Server - Token Check](/servers/hello/src/approov-protected-server/token-check) -* [Approov Protected Server - Token Binding Check](/servers/hello/src/approov-protected-server/token-binding-check) +* [Approov Protected Server](/servers/hello/src/approov-protected-server/token-check) ## Setup Environment -Do not forget to properly setup the `.env` file in the root of each Approov protected server example before you run the server with the docker stack. +Do not forget to properly setup the `.env` file in the root of the Approov protected server example before you run the server with the docker stack. ```bash cp servers/hello/src/approov-protected-server/token-check/.env.example servers/hello/src/approov-protected-server/token-check/.env -cp servers/hello/src/approov-protected-server/token-binding-check/.env.example servers/hello/src/approov-protected-server/token-binding-check/.env ``` -Edit each file and add the [dummy secret](/TESTING.md#the-dummy-secret) to it in order to be able to test the Approov integration with the provided [Postman collection](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). +Edit the file and add the [dummy secret](/TESTING.md#the-dummy-secret) to it in order to be able to test the Approov integration with the provided [Postman collection](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). ## Docker Stack The docker stack provided via the `docker-compose.yml` file in this folder is used for development proposes and if you are familiar with docker then feel free to also use it to follow along the examples on the README of each server. -If you decide to use the docker stack then you need to bear in mind that the Postman collections, used to test the servers examples, will connect to port `8002` therefore you cannot start all docker compose services at once, for example with `docker-compose up`, instead you need to run one at a time as exemplified below. +If you decide to use the docker stack then you need to bear in mind that the Postman collections, used to test the servers examples, will connect to port `8111` therefore you cannot start all docker compose services at once, for example with `docker-compose up`, instead you need to run one at a time as exemplified below. ### Build the Docker Stack -The three services in the `docker-compose.yml` use the same Dockerfile, therefore to build the Docker image we just need to used one of them: +The services in the `docker-compose.yml` use the same Dockerfile, therefore to build the Docker image we just need to use one of them: ```bash -sudo docker-compose build approov-token-binding-check +sudo docker-compose build approov-token-check ``` Now, you are ready to start using the Docker stack for ASP.Net. @@ -76,20 +74,6 @@ or get a bash shell inside the container: sudo docker-compose run --rm --service-ports approov-token-check zsh ``` -#### For the Approov Token Binding Check - -Run the container attached to the shell: - -```bash -sudo docker-compose up approov-token-binding-check -``` - -or get a bash shell inside the container: - -```bash -sudo docker-compose run --rm --service-ports approov-token-binding-check zsh -``` - ## Issues If you find any issue while following our instructions then just report it [here](https://github.com/approov/quickstart-asp.net-token-check/issues), with the steps to reproduce it, and we will sort it out and/or guide you to the correct path. diff --git a/QUICKSTARTS.md b/QUICKSTARTS.md index 4ba9796..54965d0 100644 --- a/QUICKSTARTS.md +++ b/QUICKSTARTS.md @@ -5,11 +5,11 @@ ## The Quickstarts -The quickstart code for the Approov backend server is split into two implementations. The first gets you up and running with basic token checking. The second uses a more advanced Approov feature, _token binding_. Token binding may be used to link the Approov token with other properties of the request, such as user authentication (more details can be found [here](https://approov.io/docs/latest/approov-usage-documentation/#token-binding)). +The quickstart code for the Approov backend server now lives in a single implementation (`servers/hello/src/approov-protected-server/token-check`). The core quickstart focuses on basic token checking, while the token binding guide extends the same project to showcase the optional binding feature (more details can be found [here](https://approov.io/docs/latest/approov-usage-documentation/#token-binding)). * [Approov token check quickstart](/docs/APPROOV_TOKEN_QUICKSTART.md) * [Approov token check with token binding quickstart](/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md) -Both the quickstarts are built from the unprotected example server defined [here](servers/hello/src/unprotected-server). +Both guides build upon the unprotected example server defined [here](servers/hello/src/unprotected-server). ## Issues diff --git a/README.md b/README.md index e0f620e..a32d59d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The repository now ships with a helper script that launches the demo servers dir ./scripts/run-local.sh all ``` -This starts the unprotected, token-check and token-binding examples on ports `8001`, `8002` and `8003` respectively. Press `Ctrl+C` to stop them. You can also launch an individual backend, for example `./scripts/run-local.sh token-check`. The script will automatically create a `.env` file from `.env.example` for the Approov-protected projects if one is not already present. +This starts the unprotected sample on port `8001` and the consolidated Approov-protected sample on port `8111`. Press `Ctrl+C` to stop them. You can also launch an individual backend, for example `./scripts/run-local.sh token-check`. The script will automatically create a `.env` file from `.env.example` for the Approov-protected project if one is not already present. First, setup the [Approov CLI](https://approov.io/docs/latest/approov-installation/index.html#initializing-the-approov-cli). diff --git a/docker-compose.yml b/docker-compose.yml index 4ad2916..9f6ac51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,16 +19,8 @@ services: approov-token-check: <<: *dotnet-service - command: dotnet run --urls http://0.0.0.0:8002 + command: dotnet run --urls http://0.0.0.0:8111 ports: - - ${HOST_IP:-127.0.0.1}:8002:8002 + - ${HOST_IP:-127.0.0.1}:8111:8111 volumes: - ./servers/hello/src/approov-protected-server/token-check:/home/developer/workspace - - approov-token-binding-check: - <<: *dotnet-service - command: dotnet run --urls http://0.0.0.0:8003 - ports: - - ${HOST_IP:-127.0.0.1}:8003:8003 - volumes: - - ./servers/hello/src/approov-protected-server/token-binding-check:/home/developer/workspace diff --git a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md index febc077..7691c7e 100644 --- a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md +++ b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md @@ -23,7 +23,7 @@ To lock down your API server to your mobile app. Please read the brief summary i For more background, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. -Take a look at the `verifyApproovToken()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-bindingcheck/Middleware/ApproovTokenMiddleware.cs) class to see the simple code for the Approov Token check. To also see the code for the Approov token binding check you just need to look for the `verifyApproovTokenBinding()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-bindingcheck/Middleware/ApproovTokenBindingMiddleware.cs) class. +Take a look at the `verifyApproovToken()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs) class to see the simple code for the Approov Token check. To also see the code for the Approov token binding check you just need to look for the `VerifyApproovTokenBinding()` function at the [ApproovTokenBindingMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs) class. [TOC](#toc---table-of-contents) @@ -118,6 +118,18 @@ public class AppSettings } ``` +Add a helper to keep context item keys in one place: + +```c# +namespace AppName.Helpers; + +public static class ApproovTokenContextKeys +{ + public const string TokenBinding = "ApproovTokenBinding"; + public const string TokenBindingVerified = "ApproovTokenBindingVerified"; +} +``` + Now, add the `ApproovTokenMiddleware` class to your project: ```c# @@ -182,8 +194,10 @@ public class ApproovTokenMiddleware var claims = jwtToken.Claims; var payClaim = claims.FirstOrDefault(x => x.Type == "pay")?.Value; - - context.Items["ApproovTokenBinding"] = payClaim; + if (!string.IsNullOrWhiteSpace(payClaim)) + { + context.Items[ApproovTokenContextKeys.TokenBinding] = payClaim; + } return true; } catch (SecurityTokenException exception) { @@ -202,81 +216,66 @@ Next, add the `ApproovTokenBindingMiddleware` class: ```c# namespace AppName.Middleware; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.Text; -using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; +using System.Text; using AppName.Helpers; - public class ApproovTokenBindingMiddleware { private readonly RequestDelegate _next; - private readonly AppSettings _appSettings; private readonly ILogger _logger; - public ApproovTokenBindingMiddleware(RequestDelegate next, IOptions appSettings, ILogger logger) + public ApproovTokenBindingMiddleware(RequestDelegate next, ILogger logger) { _next = next; - _appSettings = appSettings.Value; _logger = logger; } public async Task Invoke(HttpContext context) { - var tokenBinding = context.Items["ApproovTokenBinding"]?.ToString(); + var tokenBinding = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingValue) + ? bindingValue as string + : null; - if (tokenBinding == null) { - _logger.LogInformation("The pay claim is missing in the Approov token."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; + if (string.IsNullOrWhiteSpace(tokenBinding)) + { + await _next(context); return; } var authorizationToken = context.Request.Headers["Authorization"].FirstOrDefault(); - - if (authorizationToken == null) { - _logger.LogInformation("Missing the Authorization token header to use for the Approov token binding."); + if (string.IsNullOrWhiteSpace(authorizationToken)) + { + _logger.LogInformation("Approov token binding requested but Authorization header is missing."); context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } - if (verifyApproovTokenBinding(authorizationToken, tokenBinding)) { - await _next(context); + if (!VerifyApproovTokenBinding(authorizationToken, tokenBinding)) + { + _logger.LogInformation("Invalid Approov token binding."); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } - _logger.LogInformation("Invalid Approov token binding."); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; + context.Items[ApproovTokenContextKeys.TokenBindingVerified] = true; + await _next(context); } - private bool verifyApproovTokenBinding(string authorizationToken, string tokenBinding) + private static bool VerifyApproovTokenBinding(string authorizationToken, string tokenBinding) { - var hash = sha256Base64Endoded(authorizationToken); - - StringComparer comparer = StringComparer.OrdinalIgnoreCase; - - return comparer.Compare(tokenBinding, hash) == 0; + var computedHash = Sha256Base64Encoded(authorizationToken); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(tokenBinding), + Encoding.UTF8.GetBytes(computedHash)); } - public static string sha256Base64Endoded(string input) + private static string Sha256Base64Encoded(string input) { - try - { - SHA256 sha256 = SHA256.Create(); - - byte[] inputBytes = new UTF8Encoding().GetBytes(input); - byte[] hashBytes = sha256.ComputeHash(inputBytes); - - sha256.Dispose(); - - return Convert.ToBase64String(hashBytes); - } - catch (Exception ex) - { - throw new Exception(ex.Message, ex); - } + using var sha256 = SHA256.Create(); + var inputBytes = Encoding.UTF8.GetBytes(input); + var hashBytes = sha256.ComputeHash(inputBytes); + return Convert.ToBase64String(hashBytes); } } ``` diff --git a/scripts/run-local.sh b/scripts/run-local.sh index 02918fc..dc8317e 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -7,7 +7,7 @@ HELLO_ROOT="${ROOT_DIR}/servers/hello/src" print_usage() { cat <<'EOF' -Usage: scripts/run-local.sh [unprotected|token-check|token-binding|all] +Usage: scripts/run-local.sh [unprotected|token-check|all] Runs the sample APIs directly with the local dotnet SDK, avoiding Docker. For Approov-protected apps the script ensures a .env file exists by copying @@ -19,7 +19,6 @@ project_dir_for() { case "$1" in unprotected) printf '%s\n' "${HELLO_ROOT}/unprotected-server" ;; token-check) printf '%s\n' "${HELLO_ROOT}/approov-protected-server/token-check" ;; - token-binding) printf '%s\n' "${HELLO_ROOT}/approov-protected-server/token-binding-check" ;; *) return 1 ;; esac } @@ -28,7 +27,6 @@ project_port_for() { case "$1" in unprotected) printf '8001\n' ;; token-check) printf '8111\n' ;; - token-binding) printf '8003\n' ;; *) return 1 ;; esac } @@ -73,7 +71,7 @@ run_all() { trap 'echo "Stopping services..."; for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done; wait || true' INT TERM - for key in unprotected token-check token-binding; do + for key in unprotected token-check; do project_dir="$(project_dir_for "${key}")" || { echo "Unknown project key '${key}'" >&2 exit 1 @@ -103,7 +101,7 @@ main() { fi case "$1" in - unprotected|token-check|token-binding) + unprotected|token-check) run_project "$1" ;; all) diff --git a/servers/archived/postman/approov-dotnet-example.postman_collection.json b/servers/archived/postman/approov-dotnet-example.postman_collection.json index 73eaef1..e47d9df 100644 --- a/servers/archived/postman/approov-dotnet-example.postman_collection.json +++ b/servers/archived/postman/approov-dotnet-example.postman_collection.json @@ -159,12 +159,12 @@ "raw": "" }, "url": { - "raw": "http://localhost:8002/", + "raw": "http://localhost:8111/", "protocol": "http", "host": [ "localhost" ], - "port": "8002", + "port": "8111", "path": [ "" ] diff --git a/servers/hello/src/approov-protected-server/token-binding-check/.env.example b/servers/hello/src/approov-protected-server/token-binding-check/.env.example deleted file mode 100644 index 5fca7a5..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/.env.example +++ /dev/null @@ -1 +0,0 @@ -APPROOV_BASE64_SECRET=__ADD_HERE_THE_APPROOV_BASE64_SECRET__ diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Controllers/HelloController.cs b/servers/hello/src/approov-protected-server/token-binding-check/Controllers/HelloController.cs deleted file mode 100644 index b0498f0..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Controllers/HelloController.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Hello.Controllers; - -[ApiController] -[Produces("application/json")] -[Route("/")] -public class HelloController : ControllerBase -{ - private readonly IConfiguration _configuration; - - public HelloController(IConfiguration configuration) - { - _configuration = configuration; - } - - [HttpGet] - public IActionResult Get() - { - return Ok( new { message = "Hello, World!" } ); - } -} diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Hello.csproj b/servers/hello/src/approov-protected-server/token-binding-check/Hello.csproj deleted file mode 100644 index 2b79157..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Hello.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - net6.0 - enable - enable - - - - - - - - - diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Helpers/AppSettings.cs b/servers/hello/src/approov-protected-server/token-binding-check/Helpers/AppSettings.cs deleted file mode 100644 index ba12e29..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Helpers/AppSettings.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hello.Helpers; - -public class AppSettings -{ - public byte[] ?ApproovSecretBytes { get; set; } -} diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenBindingMiddleware.cs deleted file mode 100644 index c77f562..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenBindingMiddleware.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace Hello.Middleware; - -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.Text; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Cryptography; -using Hello.Helpers; - - -public class ApproovTokenBindingMiddleware -{ - private readonly RequestDelegate _next; - private readonly AppSettings _appSettings; - private readonly ILogger _logger; - - public ApproovTokenBindingMiddleware(RequestDelegate next, IOptions appSettings, ILogger logger) - { - _next = next; - _appSettings = appSettings.Value; - _logger = logger; - } - - public async Task Invoke(HttpContext context) - { - var tokenBinding = context.Items["ApproovTokenBinding"]?.ToString(); - - if (tokenBinding == null) { - _logger.LogInformation("The pay claim is missing in the Approov token."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - var authorizationToken = context.Request.Headers["Authorization"].FirstOrDefault(); - - if (authorizationToken == null) { - _logger.LogInformation("Missing the Authorization token header to use for the Approov token binding."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - if (verifyApproovTokenBinding(authorizationToken, tokenBinding)) { - await _next(context); - return; - } - - _logger.LogInformation("Invalid Approov token binding."); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - private bool verifyApproovTokenBinding(string authorizationToken, string tokenBinding) - { - var hash = sha256Base64Endoded(authorizationToken); - - StringComparer comparer = StringComparer.OrdinalIgnoreCase; - - return comparer.Compare(tokenBinding, hash) == 0; - } - - public static string sha256Base64Endoded(string input) - { - try - { - SHA256 sha256 = SHA256.Create(); - - byte[] inputBytes = new UTF8Encoding().GetBytes(input); - byte[] hashBytes = sha256.ComputeHash(inputBytes); - - sha256.Dispose(); - - return Convert.ToBase64String(hashBytes); - } - catch (Exception ex) - { - throw new Exception(ex.Message, ex); - } - } -} diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenMiddleware.cs deleted file mode 100644 index 1e6be41..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Middleware/ApproovTokenMiddleware.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Hello.Middleware; - -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Text; -using Hello.Helpers; -using System.Security.Claims; - -public class ApproovTokenMiddleware -{ - private readonly RequestDelegate _next; - private readonly AppSettings _appSettings; - private readonly ILogger _logger; - - public ApproovTokenMiddleware(RequestDelegate next, IOptions appSettings, ILogger logger) - { - _next = next; - _appSettings = appSettings.Value; - _logger = logger; - } - - public async Task Invoke(HttpContext context) - { - var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); - - if (token == null) { - _logger.LogInformation("Missing Approov-Token header."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - if (verifyApproovToken(context, token)) { - await _next(context); - return; - } - - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - private bool verifyApproovToken(HttpContext context, string token) - { - try - { - var tokenHandler = new JwtSecurityTokenHandler(); - - tokenHandler.ValidateToken(token, new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(_appSettings.ApproovSecretBytes), - ValidateIssuer = false, - ValidateAudience = false, - // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) - ClockSkew = TimeSpan.Zero - }, out SecurityToken validatedToken); - - var jwtToken = (JwtSecurityToken)validatedToken; - var claims = jwtToken.Claims; - - var payClaim = claims.FirstOrDefault(x => x.Type == "pay")?.Value; - - context.Items["ApproovTokenBinding"] = payClaim; - - return true; - } catch (SecurityTokenException exception) { - _logger.LogInformation(exception.Message); - return false; - } catch (Exception exception) { - _logger.LogInformation(exception.Message); - return false; - } - } -} diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Program.cs b/servers/hello/src/approov-protected-server/token-binding-check/Program.cs deleted file mode 100644 index d328067..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Program.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Hello.Helpers; - -////////////////////////// -// SETUP APPROOV SECRET -////////////////////////// - -DotNetEnv.Env.Load(); - -var approovBase64Secret = DotNetEnv.Env.GetString("APPROOV_BASE64_SECRET"); - -if(approovBase64Secret == null) { - throw new Exception("Missing the env var APPROOV_BASE64_SECRET or its empty."); -} - -var approovSecretBytes = System.Convert.FromBase64String(approovBase64Secret); - - -/////////////// -// BUILD APP -/////////////// - -// Add services to the container. - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.Configure(appSettings => { - appSettings.ApproovSecretBytes = approovSecretBytes; -}); - -var app = builder.Build(); - - -////////////// -// RUN APP -////////////// - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -// app.UseHttpsRedirection(); - -app.UseMiddleware(); -app.UseMiddleware(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); diff --git a/servers/hello/src/approov-protected-server/token-binding-check/Properties/launchSettings.json b/servers/hello/src/approov-protected-server/token-binding-check/Properties/launchSettings.json deleted file mode 100644 index 2fb5730..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/Properties/launchSettings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:8612", - "sslPort": 44323 - } - }, - "profiles": { - "Hello": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://0.0.0.0:8002", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/servers/hello/src/approov-protected-server/token-binding-check/README.md b/servers/hello/src/approov-protected-server/token-binding-check/README.md deleted file mode 100644 index a4d9d82..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# Approov Token Binding Integration Example - -This Approov integration example is from where the code example for the [Approov token binding check quickstart](/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md) is extracted, and you can use it as a playground to better understand how simple and easy it is to implement [Approov](https://approov.io) in a ASP.Net API server. - - -## TOC - Table of Contents - -* [Why?](#why) -* [How it Works?](#how-it-works) -* [Requirements](#requirements) -* [Try the Approov Integration Example](#try-the-approov-integration-example) - - -## Why? - -To lock down your API server to your mobile app. Please read the brief summary in the [Approov Overview](/OVERVIEW.md#why) at the root of this repo or visit our [website](https://approov.io/product) for more details. - -[TOC](#toc---table-of-contents) - - -## How it works? - -The API server is very simple and is defined at [src/approov-protected-server/token-bindingcheck](src/approov-protected-server/token-bindingcheck), and only responds to the endpoint `/` with this message: - -```json -{"message": "Hello, World!"} -``` - -The `200` response is only sent when a valid Approov token is present on the header of the request, otherwise a `401` response is sent back. - -Take a look at the `verifyApproovToken()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-bindingcheck/Middleware/ApproovTokenMiddleware.cs) class to see the simple code for the Approov Token check. To also see the code for the Approov token binding check you just need to look for the `verifyApproovTokenBinding()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-bindingcheck/Middleware/ApproovTokenBindingMiddleware.cs) class. - -For more background on Approov, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. - - -[TOC](#toc---table-of-contents) - - -## Requirements - -To run this example you will need to have installed: - -* [.NET 6 SDK](https://docs.microsoft.com/en-us/dotnet/core/install/) - - -[TOC](#toc---table-of-contents) - - -## Setup Env File - -From `servers/hello/src/approov-protected-server/token-bindingcheck` execute the following: - -```bash -cp .env.example .env -``` - -Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to it in order to be able to test the Approov integration with the provided [Postman collection](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). - -[TOC](#toc---table-of-contents) - - -## Try the Approov Integration Example - -First, you need to run this example from the `src/approov-protected-server/token-bindingcheck` folder with: - -```bash -dotnet run -``` - -Next, you can test that it works with: - -```bash -curl -iX GET 'http://localhost:8002' -``` - -The response will be a `401` unauthorized request: - -```text -HTTP/1.1 401 Unauthorized -Content-Length: 0 -Date: Wed, 01 Jun 2022 11:42:42 GMT -Server: Kestrel -``` - -The reason you got a `401` is because the Approoov token isn't provided in the headers of the request. - -Finally, you can test that the Approov integration example works as expected with this [Postman collection](/TESTING.md#testing-with-postman) or with some cURL requests [examples](/TESTING.md#testing-with-curl). - -[TOC](#toc---table-of-contents) - - -## Issues - -If you find any issue while following our instructions then just report it [here](https://github.com/approov/quickstart-asp.net-token-bindingcheck/issues), with the steps to reproduce it, and we will sort it out and/or guide you to the correct path. - - -[TOC](#toc---table-of-contents) - - -## Useful Links - -If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: - -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) -* [Approov Get Started](https://approov.io/product/demo) -* [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) -* [Approov Docs](https://approov.io/docs) -* [Approov Blog](https://approov.io/blog/) -* [Approov Resources](https://approov.io/resource/) -* [Approov Customer Stories](https://approov.io/customer) -* [Approov Support](https://approov.io/contact) -* [About Us](https://approov.io/company) -* [Contact Us](https://approov.io/contact) - -[TOC](#toc---table-of-contents) diff --git a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json b/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json deleted file mode 100644 index 3e1a225..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Information" - } - } -} diff --git a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json b/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json deleted file mode 100644 index 4282acb..0000000 --- a/servers/hello/src/approov-protected-server/token-binding-check/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index 4238ea0..b2beb27 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -32,19 +32,11 @@ public IActionResult TokenBinding() ? value as string : null; - if (string.IsNullOrWhiteSpace(payClaim)) - { - return Unauthorized(); - } + var bindingVerified = HttpContext.Items.TryGetValue(ApproovTokenContextKeys.TokenBindingVerified, out var verifiedValue) + && verifiedValue is bool verifiedFlag + && verifiedFlag; - var combinedBinding = BuildBindingValue(new[] { "Authorization", "X-Device-Id" }); - if (!combinedBinding.Success) - { - return Unauthorized(); - } - - var computedHash = ComputeSha256Base64(combinedBinding.Value); - if (!HashesMatch(payClaim, computedHash)) + if (string.IsNullOrWhiteSpace(payClaim) || !bindingVerified) { return Unauthorized(); } @@ -212,33 +204,4 @@ private static string CombineHeaderValues(IReadOnlyList values) return builder.ToString(); } - private (bool Success, string Value) BuildBindingValue(IEnumerable headerNames) - { - var builder = new StringBuilder(); - foreach (var headerName in headerNames) - { - if (!Request.Headers.TryGetValue(headerName, out var values) || values.Count == 0) - { - return (false, string.Empty); - } - - builder.Append(CombineHeaderValues(values)); - } - - return (true, builder.ToString()); - } - - private static string ComputeSha256Base64(string input) - { - var bytes = Encoding.UTF8.GetBytes(input); - var hash = SHA256.HashData(bytes); - return Convert.ToBase64String(hash); - } - - private static bool HashesMatch(string expectedBase64, string actualBase64) - { - var expectedBytes = Encoding.UTF8.GetBytes(expectedBase64); - var actualBytes = Encoding.UTF8.GetBytes(actualBase64); - return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); - } } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs index 858e0ce..677febe 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs @@ -7,4 +7,5 @@ public static class ApproovTokenContextKeys public const string TokenExpiry = "ApproovTokenExpiry"; public const string InstallationPublicKey = "ApproovInstallationPublicKey"; public const string TokenBinding = "ApproovTokenBinding"; + public const string TokenBindingVerified = "ApproovTokenBindingVerified"; } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs new file mode 100644 index 0000000..be08adb --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs @@ -0,0 +1,64 @@ +namespace Hello.Middleware; + +using System.Security.Cryptography; +using System.Text; +using Hello.Helpers; + +public class ApproovTokenBindingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ApproovTokenBindingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + var tokenBinding = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingValue) + ? bindingValue as string + : null; + + if (string.IsNullOrWhiteSpace(tokenBinding)) + { + await _next(context); + return; + } + + var authorizationToken = context.Request.Headers["Authorization"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(authorizationToken)) + { + _logger.LogInformation("Approov token binding requested but Authorization header is missing."); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + if (!VerifyApproovTokenBinding(authorizationToken, tokenBinding)) + { + _logger.LogInformation("Invalid Approov token binding."); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + context.Items[ApproovTokenContextKeys.TokenBindingVerified] = true; + await _next(context); + } + + private static bool VerifyApproovTokenBinding(string authorizationToken, string tokenBinding) + { + var computedHash = Sha256Base64Encoded(authorizationToken); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(tokenBinding), + Encoding.UTF8.GetBytes(computedHash)); + } + + private static string Sha256Base64Encoded(string input) + { + using var sha256 = SHA256.Create(); + var inputBytes = Encoding.UTF8.GetBytes(input); + var hashBytes = sha256.ComputeHash(inputBytes); + return Convert.ToBase64String(hashBytes); + } +} diff --git a/servers/hello/src/approov-protected-server/token-check/Program.cs b/servers/hello/src/approov-protected-server/token-check/Program.cs index 4f5f722..d3bc91a 100644 --- a/servers/hello/src/approov-protected-server/token-check/Program.cs +++ b/servers/hello/src/approov-protected-server/token-check/Program.cs @@ -49,6 +49,7 @@ // app.UseHttpsRedirection(); app.UseMiddleware(); +app.UseMiddleware(); app.UseMiddleware(); app.UseAuthorization(); diff --git a/servers/hello/src/approov-protected-server/token-check/Properties/launchSettings.json b/servers/hello/src/approov-protected-server/token-check/Properties/launchSettings.json index 2fb5730..346bdc3 100644 --- a/servers/hello/src/approov-protected-server/token-check/Properties/launchSettings.json +++ b/servers/hello/src/approov-protected-server/token-check/Properties/launchSettings.json @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "http://0.0.0.0:8002", + "applicationUrl": "http://0.0.0.0:8111", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/servers/hello/src/approov-protected-server/token-check/README.md b/servers/hello/src/approov-protected-server/token-check/README.md index 23d2868..6b2c30f 100644 --- a/servers/hello/src/approov-protected-server/token-check/README.md +++ b/servers/hello/src/approov-protected-server/token-check/README.md @@ -20,7 +20,7 @@ To lock down your API server to your mobile app. Please read the brief summary i ## How it works? -The sample API mirrors the OpenResty/Lua reference implementation. It lives at [src/approov-protected-server/token-check](src/approov-protected-server/token-check) and exposes the following endpoints: +The sample API mirrors the OpenResty/Lua reference implementation. It lives at [src/approov-protected-server/token-check](src/approov-protected-server/token-check), and consolidates the token check, token binding, and message signing examples into a single project. It exposes the following endpoints: * `/hello` – plain text check that the service is alive. * `/token` – validates the Approov token and, when the `ipk` claim is present, verifies the Approov installation message signature. Success returns `Good Token`; failures return a `401` with `Invalid Token`. @@ -28,7 +28,7 @@ The sample API mirrors the OpenResty/Lua reference implementation. It lives at [ * `/ipk_message_sign_test` – accepts a `private-key` (base64 DER) and a `msg` (base64 canonical message) header and returns an ECDSA P-256/SHA-256 raw signature. The Lua scripts call this to create deterministic signatures. * `/sfv_test` – parses and reserialises Structured Field Value headers. The OpenResty quickstart invokes this when running `request_tests_sfv.sh`. -Approov tokens are validated by the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs). Message signing is enforced by [MessageSigningMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs) which shares the same canonical string construction, structured field parsing, and ECDSA verification logic as the Lua quickstart. +Approov tokens are validated by the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs). Token binding is enforced by the [ApproovTokenBindingMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs), and message signing is handled by [MessageSigningMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs) which shares the same canonical string construction, structured field parsing, and ECDSA verification logic as the Lua quickstart. For more background on Approov, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. @@ -61,32 +61,32 @@ Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to ## Try the Approov Integration Example -The quickest way to bring up all sample backends (unprotected, token-check, token-binding) is: +The quickest way to bring up the sample backends (unprotected and Approov-protected) is: ```bash ./scripts/run-local.sh all ``` -The Lua quickstart scripts expect the token-check server on `http://0.0.0.0:8002`. Once the service is running you can execute the shell helpers that ship with the OpenResty repo, for example: +The Lua quickstart scripts expect the token-check server on `http://0.0.0.0:8111`. Once the service is running you can execute the shell helpers that ship with the OpenResty repo, for example: ```bash -./request_tests_approov_msg.sh 8002 -./request_tests_sfv.sh 8002 +./request_tests_approov_msg.sh 8111 +./request_tests_sfv.sh 8111 ``` The commands above exercise the `/token`, `/ipk_message_sign_test`, `/ipk_test` and `/sfv_test` endpoints in exactly the same way as the Lua environment. You can also interact with the endpoints manually: ```bash # basic token check (replace with a valid Approov token) -curl -H "Approov-Token: " http://localhost:8002/token +curl -H "Approov-Token: " http://localhost:8111/token # generate a deterministic signature for a canonical message curl -H "private-key: " \ -H "msg: " \ - http://localhost:8002/ipk_message_sign_test + http://localhost:8111/ipk_message_sign_test # verify Structured Field Value parsing -curl -H "sfv:?1;param=123" -H "sfvt:ITEM" http://localhost:8002/sfv_test +curl -H "sfv:?1;param=123" -H "sfvt:ITEM" http://localhost:8111/sfv_test ``` Run the automated unit tests with: diff --git a/servers/hello/src/unprotected-server/Properties/launchSettings.json b/servers/hello/src/unprotected-server/Properties/launchSettings.json index 9e7791f..9aef2ae 100644 --- a/servers/hello/src/unprotected-server/Properties/launchSettings.json +++ b/servers/hello/src/unprotected-server/Properties/launchSettings.json @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "http://0.0.0.0:8002", + "applicationUrl": "http://0.0.0.0:8111", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/servers/hello/src/unprotected-server/README.md b/servers/hello/src/unprotected-server/README.md index ab13517..c711aa5 100644 --- a/servers/hello/src/unprotected-server/README.md +++ b/servers/hello/src/unprotected-server/README.md @@ -50,7 +50,7 @@ dotnet run Finally, you can test that it works with: ```text -curl -iX GET 'http://localhost:8002' +curl -iX GET 'http://localhost:8111' ``` The response will be: diff --git a/tests/Hello.Tests/UnitTest1.cs b/tests/Hello.Tests/UnitTest1.cs index 55227ed..b363dce 100644 --- a/tests/Hello.Tests/UnitTest1.cs +++ b/tests/Hello.Tests/UnitTest1.cs @@ -53,7 +53,7 @@ private static (DefaultHttpContext Context, string SignatureInput) BuildRequest( var context = new DefaultHttpContext(); context.Request.Method = "GET"; context.Request.Scheme = "http"; - context.Request.Host = new HostString("0.0.0.0", 8002); + context.Request.Host = new HostString("0.0.0.0", 8111); context.Request.Path = "/token"; context.Request.QueryString = new QueryString("?param1=value1¶m2=value2"); context.Request.Headers["Approov-Token"] = ApproovToken; From 7c59a8fba9e28c7dc123d92f662dc93eda3af38e Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 16:50:56 +0000 Subject: [PATCH 10/21] Update ApproovTokenBindingMiddleware to support a new environment variable specifying which header the token is bound to. Remove support for multiple header bindings for simplicity. Add test scripts. --- docs/APPROOV_TOKEN_BINDING_QUICKSTART.md | 75 +++++- .../token-check/.env.example | 1 + .../token-check/Helpers/AppSettings.cs | 1 + .../ApproovTokenBindingMiddleware.cs | 36 ++- .../token-check/Program.cs | 2 + .../token-check/README.md | 12 +- test-scripts/request_tests_approov.sh | 108 ++++++++ test-scripts/request_tests_approov_msg.sh | 231 ++++++++++++++++++ test-scripts/request_tests_sfv.sh | 108 ++++++++ 9 files changed, 550 insertions(+), 24 deletions(-) create mode 100755 test-scripts/request_tests_approov.sh create mode 100755 test-scripts/request_tests_approov_msg.sh create mode 100755 test-scripts/request_tests_sfv.sh diff --git a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md index 7691c7e..07118d3 100644 --- a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md +++ b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md @@ -70,12 +70,13 @@ First, enable your Approov `admin` role with: ```bash eval `approov role admin` -```` +``` For the Windows powershell: ```bash set APPROOV_ROLE=admin:___YOUR_APPROOV_ACCOUNT_NAME_HERE___ + ``` Next, retrieve the Approov secret with: @@ -90,6 +91,7 @@ Open the `.env` file and add the Approov secret to the var: ```bash APPROOV_BASE64_SECRET=approov_base64_secret_here +APPROOV_TOKEN_BINDING_HEADER=Authorization ``` [TOC](#toc---table-of-contents) @@ -112,9 +114,11 @@ Next, let's add the class to load the app settings: ```c# namespace AppName.Helpers; + public class AppSettings { public byte[] ?ApproovSecretBytes { get; set; } + public string? TokenBindingHeader { get; set; } } ``` @@ -219,6 +223,7 @@ namespace AppName.Middleware; using System.Security.Cryptography; using System.Text; using AppName.Helpers; +using Microsoft.Extensions.Primitives; public class ApproovTokenBindingMiddleware { @@ -243,15 +248,24 @@ public class ApproovTokenBindingMiddleware return; } - var authorizationToken = context.Request.Headers["Authorization"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(authorizationToken)) + var authorizationHeader = context.Request.Headers["Authorization"]; + var authorizationValue = CombineHeaderValues(authorizationHeader); + if (string.IsNullOrWhiteSpace(authorizationValue)) { _logger.LogInformation("Approov token binding requested but Authorization header is missing."); context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } - if (!VerifyApproovTokenBinding(authorizationToken, tokenBinding)) + var bindingCandidates = new List { authorizationValue }; + + var deviceIdValue = CombineHeaderValues(context.Request.Headers["X-Device-Id"]); + if (!string.IsNullOrWhiteSpace(deviceIdValue)) + { + bindingCandidates.Add(string.Concat(authorizationValue, deviceIdValue)); + } + + if (!VerifyApproovTokenBinding(bindingCandidates, tokenBinding)) { _logger.LogInformation("Invalid Approov token binding."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; @@ -262,12 +276,25 @@ public class ApproovTokenBindingMiddleware await _next(context); } - private static bool VerifyApproovTokenBinding(string authorizationToken, string tokenBinding) + private static bool VerifyApproovTokenBinding(IEnumerable bindingSources, string tokenBinding) { - var computedHash = Sha256Base64Encoded(authorizationToken); - return CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(tokenBinding), - Encoding.UTF8.GetBytes(computedHash)); + foreach (var candidate in bindingSources) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + var computedHash = Sha256Base64Encoded(candidate); + if (CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(tokenBinding), + Encoding.UTF8.GetBytes(computedHash))) + { + return true; + } + } + + return false; } private static string Sha256Base64Encoded(string input) @@ -277,9 +304,37 @@ public class ApproovTokenBindingMiddleware var hashBytes = sha256.ComputeHash(inputBytes); return Convert.ToBase64String(hashBytes); } + + private static string CombineHeaderValues(StringValues values) + { + if (values.Count == 0) + { + return string.Empty; + } + + if (values.Count == 1) + { + return values[0].Trim(); + } + + var builder = new StringBuilder(); + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append(values[i].Trim()); + } + + return builder.ToString(); + } } ``` +> **Tip:** The quickstart tests bind to the `Authorization` header. Set `APPROOV_TOKEN_BINDING_HEADER=Authorization` in your environment (or `.env`) to mirror the sample requests. Leaving the variable empty disables token binding checks entirely. + Now, in `Program.cs` load the secrets from the `.env` file and inject it into `AppSettiongs`: ```c# @@ -294,10 +349,12 @@ if(approovBase64Secret == null) { } var approovSecretBytes = System.Convert.FromBase64String(approovBase64Secret); +var tokenBindingHeader = DotNetEnv.Env.GetString("APPROOV_TOKEN_BINDING_HEADER"); var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(appSettings => { appSettings.ApproovSecretBytes = approovSecretBytes; + appSettings.TokenBindingHeader = tokenBindingHeader; }); // ... omitted boilerplate and/or your code diff --git a/servers/hello/src/approov-protected-server/token-check/.env.example b/servers/hello/src/approov-protected-server/token-check/.env.example index 5fca7a5..82e501c 100644 --- a/servers/hello/src/approov-protected-server/token-check/.env.example +++ b/servers/hello/src/approov-protected-server/token-check/.env.example @@ -1 +1,2 @@ APPROOV_BASE64_SECRET=__ADD_HERE_THE_APPROOV_BASE64_SECRET__ +APPROOV_TOKEN_BINDING_HEADER=Authorization diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs index d6999d0..8f412a1 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs @@ -3,4 +3,5 @@ namespace Hello.Helpers; public class AppSettings { public byte[]? ApproovSecretBytes { get; set; } + public string? TokenBindingHeader { get; set; } } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs index be08adb..41c03f0 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs @@ -3,39 +3,55 @@ namespace Hello.Middleware; using System.Security.Cryptography; using System.Text; using Hello.Helpers; +using Microsoft.Extensions.Options; public class ApproovTokenBindingMiddleware { private readonly RequestDelegate _next; + private readonly AppSettings _appSettings; private readonly ILogger _logger; - public ApproovTokenBindingMiddleware(RequestDelegate next, ILogger logger) + public ApproovTokenBindingMiddleware( + RequestDelegate next, + IOptions appSettings, + ILogger logger) { _next = next; + _appSettings = appSettings.Value; _logger = logger; } public async Task Invoke(HttpContext context) { - var tokenBinding = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingValue) - ? bindingValue as string + var tokenBindingClaim = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingObject) + ? bindingObject as string : null; - if (string.IsNullOrWhiteSpace(tokenBinding)) + if (string.IsNullOrWhiteSpace(tokenBindingClaim)) { await _next(context); return; } - var authorizationToken = context.Request.Headers["Authorization"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(authorizationToken)) + var headerName = _appSettings.TokenBindingHeader; + if (string.IsNullOrWhiteSpace(headerName)) { - _logger.LogInformation("Approov token binding requested but Authorization header is missing."); + _logger.LogDebug("Token binding claim present but no binding header configured; skipping verification."); + await _next(context); + return; + } + + var headerValue = context.Request.Headers[headerName].ToString().Trim(); + if (string.IsNullOrWhiteSpace(headerValue)) + { + _logger.LogInformation( + "Approov token binding requested but required header '{Header}' is missing or empty.", + headerName); context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } - if (!VerifyApproovTokenBinding(authorizationToken, tokenBinding)) + if (!VerifyApproovTokenBinding(headerValue, tokenBindingClaim)) { _logger.LogInformation("Invalid Approov token binding."); context.Response.StatusCode = StatusCodes.Status401Unauthorized; @@ -46,9 +62,9 @@ public async Task Invoke(HttpContext context) await _next(context); } - private static bool VerifyApproovTokenBinding(string authorizationToken, string tokenBinding) + private static bool VerifyApproovTokenBinding(string headerValue, string tokenBinding) { - var computedHash = Sha256Base64Encoded(authorizationToken); + var computedHash = Sha256Base64Encoded(headerValue); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(tokenBinding), Encoding.UTF8.GetBytes(computedHash)); diff --git a/servers/hello/src/approov-protected-server/token-check/Program.cs b/servers/hello/src/approov-protected-server/token-check/Program.cs index d3bc91a..fae089f 100644 --- a/servers/hello/src/approov-protected-server/token-check/Program.cs +++ b/servers/hello/src/approov-protected-server/token-check/Program.cs @@ -26,9 +26,11 @@ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +var tokenBindingHeader = DotNetEnv.Env.GetString("APPROOV_TOKEN_BINDING_HEADER"); builder.Services.Configure(appSettings => { appSettings.ApproovSecretBytes = approovSecretBytes; + appSettings.TokenBindingHeader = tokenBindingHeader; }); builder.Services.AddSingleton(); diff --git a/servers/hello/src/approov-protected-server/token-check/README.md b/servers/hello/src/approov-protected-server/token-check/README.md index 6b2c30f..0b93d7f 100644 --- a/servers/hello/src/approov-protected-server/token-check/README.md +++ b/servers/hello/src/approov-protected-server/token-check/README.md @@ -20,15 +20,17 @@ To lock down your API server to your mobile app. Please read the brief summary i ## How it works? -The sample API mirrors the OpenResty/Lua reference implementation. It lives at [src/approov-protected-server/token-check](src/approov-protected-server/token-check), and consolidates the token check, token binding, and message signing examples into a single project. It exposes the following endpoints: +The sample API exposes the following endpoints: * `/hello` – plain text check that the service is alive. * `/token` – validates the Approov token and, when the `ipk` claim is present, verifies the Approov installation message signature. Success returns `Good Token`; failures return a `401` with `Invalid Token`. * `/ipk_test` – development helper. Without an `ipk` header it generates and logs a fresh P-256 key pair. With an `ipk` header it validates that the provided public key can be decoded. -* `/ipk_message_sign_test` – accepts a `private-key` (base64 DER) and a `msg` (base64 canonical message) header and returns an ECDSA P-256/SHA-256 raw signature. The Lua scripts call this to create deterministic signatures. +* `/ipk_message_sign_test` – accepts a `private-key` (base64 DER) and a `msg` (base64 canonical message) header and returns an ECDSA P-256/SHA-256 raw signature. The scripts call this to create deterministic signatures. * `/sfv_test` – parses and reserialises Structured Field Value headers. The OpenResty quickstart invokes this when running `request_tests_sfv.sh`. -Approov tokens are validated by the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs). Token binding is enforced by the [ApproovTokenBindingMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs), and message signing is handled by [MessageSigningMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs) which shares the same canonical string construction, structured field parsing, and ECDSA verification logic as the Lua quickstart. +Approov tokens are validated by the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs). Token binding is enforced by the [ApproovTokenBindingMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs), and message signing is handled by [MessageSigningMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs) which shares the same canonical string construction, structured field parsing, and ECDSA verification logic. + +You can tune which request headers participate in the binding by setting the `APPROOV_TOKEN_BINDING_HEADER` environment variable (for example `Authorization`). When the variable is unset or empty the server skips token binding checks. For more background on Approov, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. @@ -67,14 +69,14 @@ The quickest way to bring up the sample backends (unprotected and Approov-protec ./scripts/run-local.sh all ``` -The Lua quickstart scripts expect the token-check server on `http://0.0.0.0:8111`. Once the service is running you can execute the shell helpers that ship with the OpenResty repo, for example: +The quickstart scripts expect the token-check server on `http://0.0.0.0:8111`. Once the service is running you can execute the shell helpers that ship with the OpenResty repo, for example: ```bash ./request_tests_approov_msg.sh 8111 ./request_tests_sfv.sh 8111 ``` -The commands above exercise the `/token`, `/ipk_message_sign_test`, `/ipk_test` and `/sfv_test` endpoints in exactly the same way as the Lua environment. You can also interact with the endpoints manually: +The commands above exercise the `/token`, `/ipk_message_sign_test`, `/ipk_test` and `/sfv_test` endpoints. You can also interact with the endpoints manually: ```bash # basic token check (replace with a valid Approov token) diff --git a/test-scripts/request_tests_approov.sh b/test-scripts/request_tests_approov.sh new file mode 100755 index 0000000..9864197 --- /dev/null +++ b/test-scripts/request_tests_approov.sh @@ -0,0 +1,108 @@ +#! /bin/bash + +# Several requests to issue to a running resty container + +BOUND_PORT="${1:-8111}" + +# For JWTs - construct them using https://jwt.io although for obvious reasons +# you should never copy a real secret into the website. +# +# Test secret in base64 and base64url formats for use in the jwt.io form: +# TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/AA +# TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_AA +# + +# Timestamps used in test tokens: +# 1999999999 - Wed 18 May 04:33:19 BST 2033 +# 1700000000 - Tue 14 Nov 22:13:20 GMT 2023 +# 1710000000 - Sat 9 Mar 16:00:00 GMT 2024 + +# Good full token: +# Header: { +# "alg": "HS256", +# "typ": "JWT" +# } +# Payload: { +# "aud": "approov.io", +# "exp": 1999999999, +# "iat": 1700000000, +# "iss": "ApproovAccountID.approov.io", +# "sub": "approov|ExampleApproovTokenDID==", +# "ip": "1.2.3.4", +# "did": "ExampleApproovTokenDID==" +# } +# +GOOD_FULL_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiZXhwIjoxOTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDAsImlzcyI6IkFwcHJvb3ZBY2NvdW50SUQuYXBwcm9vdi5pbyIsInN1YiI6ImFwcHJvb3Z8RXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwiaXAiOiIxLjIuMy40IiwiZGlkIjoiRXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09In0.1jKWka6OrKAURvibZ26ApvOLNl6pv5cVuu2pJnExvW0' + +# Good full token with token binding pay claim which requires the following header values: +# - Authorization: Bearer myauth_token +# - X-Device-Id: my-device-id +# Header: { +# "alg": "HS256", +# "typ": "JWT" +# } +# Payload: { +# "aud": "approov.io", +# "exp": 1999999999, +# "iat": 1700000000, +# "iss": "ApproovAccountID.approov.io", +# "sub": "approov|ExampleApproovTokenDID==", +# "ip": "1.2.3.4", +# "did": "ExampleApproovTokenDID==", +# "pay": "71tnS3rSq2lanEWrKz4MoexiOMtv7w0fspfM8BAQKNU=" +# } +# +GOOD_FULL_TOKEN_WITH_BINDING='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiZXhwIjoxOTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDAsImlzcyI6IkFwcHJvb3ZBY2NvdW50SUQuYXBwcm9vdi5pbyIsInN1YiI6ImFwcHJvb3Z8RXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwiaXAiOiIxLjIuMy40IiwiZGlkIjoiRXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwicGF5IjoiNzF0blMzclNxMmxhbkVXckt6NE1vZXhpT010djd3MGZzcGZNOEJBUUtOVT0ifQ.M0CJoQ-cQto-8OIR_d7MaLBOixzKZeN6lmW_ot76Y0Q' + +# Good minimal token: +# Header: { +# "alg": "HS256", +# "typ": "JWT" +# } +# Payload: { +# "exp": 1999999999, +# "did": "ExampleApproovTokenDID==" +# } +# +GOOD_MIN_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5OTk5OTk5OTksImRpZCI6IkV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSJ9.1dR3xFNCWonw3Cdm3UbZRIlfL-IWy_ncnF3aA_hdDps' + +BAD_TOKEN_BAD_SIG='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5OTk5OTk5OTksImRpZCI6IkV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSJ9.2dR3xFNCWonw3Cdm3UbZRIlfL-IWy_ncnF3aA_hdDps' + +BAD_TOKEN_INVALID_ENCODING='eyJ0eXAiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIn0.eyJleHAiOjE5OTk5OTk5OTksImRpZCI6IkV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSJ9.NwqfsaOUBfXaf8KxRZovYCy0c6hqy29g88z1LIgzuQY' + +BAD_TOKEN_NO_EXPIRY='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiaWF0IjoxNzAwMDAwMDAwLCJpc3MiOiJBcHByb292QWNjb3VudElELmFwcHJvb3YuaW8iLCJzdWIiOiJhcHByb292fEV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSIsImlwIjoiMS4yLjMuNCIsImRpZCI6IkV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSJ9.eSOEdq__Wg4fiMHGLN2afqIsymYwH4KSamKwHM_r0OE' + +BAD_TOKEN_EXPIRED='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiZXhwIjoxNzEwMDAwMDAwLCJpYXQiOjE3MDAwMDAwMDAsImlzcyI6IkFwcHJvb3ZBY2NvdW50SUQuYXBwcm9vdi5pbyIsInN1YiI6ImFwcHJvb3Z8RXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwiaXAiOiIxLjIuMy40IiwiZGlkIjoiRXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09In0.Mv8Y73reHTPdPsFXCqS-TC7J60Y5t1jxeojZOjli_iQ' + +# signed with the correct secret but contains a mismatching token binding +BAD_TOKEN_WRONG_BINDING='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiZXhwIjoxOTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDAsImlzcyI6IkFwcHJvb3ZBY2NvdW50SUQuYXBwcm9vdi5pbyIsInN1YiI6ImFwcHJvb3Z8RXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwiaXAiOiIxLjIuMy40IiwiZGlkIjoiRXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwicGF5IjoiTWlzbWF0Y2hpbmdCaW5kaW5nIn0.kriaAIhtORaMXYwgEgA8AdW_5RUcVb2NRgwVM1trrjY' + +printf "\n\n*** Test good full token ***\n" +curl -D- -H "Approov-Token:${GOOD_FULL_TOKEN}" http://0.0.0.0:${BOUND_PORT}/token + +printf "\n\n*** Test good minimal token ***\n" +curl -D- -H "Approov-Token:${GOOD_MIN_TOKEN}" http://0.0.0.0:${BOUND_PORT}/token + +printf "\n\n*** Test good full token with binding ***\n" +curl -D- -H "Authorization:Bearer myauth_token" -H "X-Device-Id: my-device-id" -H "Approov-Token:${GOOD_FULL_TOKEN_WITH_BINDING}" http://0.0.0.0:${BOUND_PORT}/token_binding + +printf "\n\n*** Test bad token - bad signature ***\n" +curl -D- -H "Authorization:Bearer myauth_token" -H "X-Device-Id: my-device-id" -H "Approov-Token:${BAD_TOKEN_BAD_SIG}" http://0.0.0.0:${BOUND_PORT}/token + +printf "\n\n*** Test bad token - invalid encoding ***\n" +curl -D- -H "Approov-Token:${BAD_TOKEN_INVALID_ENCODING}" http://0.0.0.0:${BOUND_PORT}/token + +printf "\n\n*** Test bad token - no expiry ***\n" +curl -D- -H "Approov-Token:${BAD_TOKEN_NO_EXPIRY}" http://0.0.0.0:${BOUND_PORT}/token + +printf "\n\n*** Test bad token - expired ***\n" +curl -D- -H "Approov-Token:${BAD_TOKEN_EXPIRED}" http://0.0.0.0:${BOUND_PORT}/token + +printf "\n\n*** Test missing binding with good full token ***\n" +curl -D- -H "Authorization:Bearer myauth_token" -H "X-Device-Id: my-device-id" -H "Approov-Token:${GOOD_FULL_TOKEN}" http://0.0.0.0:${BOUND_PORT}/token_binding + +printf "\n\n*** Test missing authorization with valid token binding token ***\n" +curl -D- -H "X-Device-Id: my-device-id" -H "Approov-Token:${GOOD_FULL_TOKEN_WITH_BINDING}" http://0.0.0.0:${BOUND_PORT}/token_binding + +printf "\n\n*** Test bad token - correctly signed but with the wrong binding ***\n" +curl -D- -H "Authorization:Bearer myauth_token" -H "X-Device-Id: my-device-id" -H "Approov-Token:${BAD_TOKEN_WRONG_BINDING}" http://0.0.0.0:${BOUND_PORT}/token_binding diff --git a/test-scripts/request_tests_approov_msg.sh b/test-scripts/request_tests_approov_msg.sh new file mode 100755 index 0000000..19d1ae1 --- /dev/null +++ b/test-scripts/request_tests_approov_msg.sh @@ -0,0 +1,231 @@ +#! /bin/bash + +# Several requests to issue to a running resty container + +BOUND_PORT="${1:-8111}" + +# For JWTs - construct them using https://jwt.io although for obvious reasons +# you should never copy a real secret into the website. +# +# Test secret in base64url format for use in the jwt.io form: +# TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_TEST-SECRET_AA +# + +# Timestamps used in test tokens: +# 1999999999 - Wed 18 May 04:33:19 BST 2033 +# 1700000000 - Tue 14 Nov 22:13:20 GMT 2023 +# 1710000000 - Sat 9 Mar 16:00:00 GMT 2024 + +# Good full token: +# Header: { +# "alg": "HS256", +# "typ": "JWT" +# } +# Payload: { +# "aud": "approov.io", +# "exp": 1999999999, +# "iat": 1700000000, +# "iss": "ApproovAccountID.approov.io", +# "sub": "approov|ExampleApproovTokenDID==", +# "ip": "1.2.3.4", +# "ipk": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJSm4DMcivAwvhM+KNce2C/X26cj3oGyUwWVUPuNuZHtd2qyVsM+0g7qX73Qh0Of6fn10AApLnl8vRQsvx94fZQ==", +# "did": "ExampleApproovTokenDID==" +# } +# +GOOD_IPK_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcHByb292LmlvIiwiZXhwIjoxOTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDAsImlzcyI6IkFwcHJvb3ZBY2NvdW50SUQuYXBwcm9vdi5pbyIsInN1YiI6ImFwcHJvb3Z8RXhhbXBsZUFwcHJvb3ZUb2tlbkRJRD09IiwiaXAiOiIxLjIuMy40IiwiaXBrIjoiTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFSlNtNERNY2l2QXd2aE0rS05jZTJDL1gyNmNqM29HeVV3V1ZVUHVOdVpIdGQycXlWc00rMGc3cVg3M1FoME9mNmZuMTBBQXBMbmw4dlJRc3Z4OTRmWlE9PSIsImRpZCI6IkV4YW1wbGVBcHByb292VG9rZW5ESUQ9PSJ9.hV6xTkGsp9uWwrD-yKkIGTBJawbofJEsuRLw9Qa5YXY' + +# B64 DER encoded ASN1 private key that we are using for test +TEST_PRIVATE_KEY="MHcCAQEEIHWZ2Ueq6odQNG+aaYmEbp7C6nujYNGr7nYKK2jqQ2asoAoGCCqGSM49AwEHoUQDQgAEJSm4DMcivAwvhM+KNce2C/X26cj3oGyUwWVUPuNuZHtd2qyVsM+0g7qX73Qh0Of6fn10AApLnl8vRQsvx94fZQ==" +# B64 DER encoded ASN1 public key that we are using for test +TEST_IPK="MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJSm4DMcivAwvhM+KNce2C/X26cj3oGyUwWVUPuNuZHtd2qyVsM+0g7qX73Qh0Of6fn10AApLnl8vRQsvx94fZQ==" + +printf "\n\n*** Test1 start - almost minimal, check the method and approov token ***\n" +############################## +# Set test1 specific variables +############################## +TEST1_TARGET_URI="http://0.0.0.0:${BOUND_PORT}/token?param1=value1¶m2=value2" +TEST1_SIGNATURE_INPUT='("@method" "approov-token");alg="ecdsa-p256-sha256";created=1744292750;expires=1999999999' +# Message as it should be regenerated by "/token" handler +TEST1_MESSAGE=$(cat < Date: Wed, 5 Nov 2025 17:06:30 +0000 Subject: [PATCH 11/21] TODO to implement freshness test --- .../token-check/Helpers/ApproovMessageSignatureVerifier.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs index baef66b..77876e8 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -78,6 +78,7 @@ public async Task VerifyAsync(HttpContext context, strin { return MessageSignatureResult.Failure("Signature missing 'created' parameter"); } + // TODO: enforce a freshness window for the 'created' timestamp to guard against replayed signatures. var canonicalBase = await BuildCanonicalMessageAsync(context, componentIdentifiers, signatureInputItem.Parameters); if (!canonicalBase.Success) From 319e6cd2e5df33fd041c17d1312fc5b457eb6149 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 17:11:54 +0000 Subject: [PATCH 12/21] Set logging to debugg --- .../token-check/appsettings.Development.json | 5 +++-- .../approov-protected-server/token-check/appsettings.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json index 3e1a225..58219c5 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.Development.json @@ -1,8 +1,9 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Information" } - } + }, + "AllowedHosts": "*" } diff --git a/servers/hello/src/approov-protected-server/token-check/appsettings.json b/servers/hello/src/approov-protected-server/token-check/appsettings.json index 4282acb..58219c5 100644 --- a/servers/hello/src/approov-protected-server/token-check/appsettings.json +++ b/servers/hello/src/approov-protected-server/token-check/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Information" } }, From a364e31b366a562df85b425bf171b10c5eeb1074 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 17:25:54 +0000 Subject: [PATCH 13/21] comment update --- docs/APPROOV_TOKEN_BINDING_QUICKSTART.md | 2 +- .../token-check/Controllers/ApproovController.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md index 07118d3..ecbe935 100644 --- a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md +++ b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md @@ -216,7 +216,7 @@ public class ApproovTokenMiddleware ``` Next, add the `ApproovTokenBindingMiddleware` class: - +//TODO: This needs to be updated to reflect current code. ```c# namespace AppName.Middleware; diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index b2beb27..0568070 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -57,7 +57,7 @@ public IActionResult IpkTest() var publicKeyBase64 = Convert.ToBase64String(publicKeyDer); //_logger.LogDebug("Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); - return Content("No IPK header, generated keys logged", "text/plain"); + return Content("No IPK header provided", "text/plain"); } try From 0a73cbf8d1a53f7d4b6562b313164e060d8a1ae1 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Wed, 5 Nov 2025 17:36:48 +0000 Subject: [PATCH 14/21] Add comments --- .../token-check/Controllers/ApproovController.cs | 9 +++++++++ .../token-check/Helpers/AppSettings.cs | 1 + .../Helpers/ApproovMessageSignatureVerifier.cs | 10 ++++++++++ .../token-check/Helpers/ApproovTokenContextKeys.cs | 1 + .../token-check/Helpers/StructuredFieldFormatter.cs | 1 + .../Middleware/ApproovTokenBindingMiddleware.cs | 3 +++ .../token-check/Middleware/ApproovTokenMiddleware.cs | 3 +++ .../token-check/Middleware/MessageSigningMiddleware.cs | 2 ++ 8 files changed, 30 insertions(+) diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index 0568070..25db9d7 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -19,13 +19,16 @@ public ApproovController(ILogger logger) } [HttpGet("/hello")] + // Serves a minimal plaintext response to act as an HTTP liveness probe. public IActionResult Hello() => Content("hello, world", "text/plain"); [HttpGet("/token")] [HttpPost("/token")] + // Confirms the caller presented a valid Approov token by echoing a success sentinel. public IActionResult Token() => Content("Good Token", "text/plain"); [HttpGet("/token_binding")] + // Verifies that the binding middleware accepted the pay claim before acknowledging the request. public IActionResult TokenBinding() { var payClaim = HttpContext.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var value) @@ -43,7 +46,9 @@ public IActionResult TokenBinding() return Content("Good Token Binding", "text/plain"); } + [HttpGet("/ipk_test")] + // Exercises import/export of an installation public key so clients can validate the DER encoding roundtrip. public IActionResult IpkTest() { var ipkHeader = Request.Headers["ipk"].FirstOrDefault(); @@ -81,6 +86,7 @@ public IActionResult IpkTest() Make sure this endpoint is only used in a secure testing environment and never in production. */ [HttpGet("/ipk_message_sign_test")] + // Signs an arbitrary message with a caller-supplied EC private key to help generate deterministic test vectors. public IActionResult IpkMessageSignTest() { var privateKeyBase64 = Request.Headers["private-key"].FirstOrDefault(); @@ -117,6 +123,7 @@ public IActionResult IpkMessageSignTest() } [HttpGet("/sfv_test")] + // Validates HTTP Structured Field parsing and serialization against the caller-provided sample. public IActionResult StructuredFieldTest() { var sfvType = Request.Headers["sfvt"].FirstOrDefault(); @@ -171,6 +178,7 @@ public IActionResult StructuredFieldTest() return Content("SFV roundtrip OK", "text/plain"); } + // Centralised failure path for structured field tests to keep logging and status consistent. private IActionResult StructuredFieldFailure(string message) { _logger.LogDebug("Structured field roundtrip failure - {Message}", message); @@ -178,6 +186,7 @@ private IActionResult StructuredFieldFailure(string message) return Content("Failed SFV roundtrip", "text/plain"); } + // Normalizes multi-value headers into a single comma-delimited field as per HTTP header semantics. private static string CombineHeaderValues(IReadOnlyList values) { if (values.Count == 0) diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs index 8f412a1..eceb11c 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs @@ -1,5 +1,6 @@ namespace Hello.Helpers; +// Holds runtime configuration injected into the request-processing pipeline. public class AppSettings { public byte[]? ApproovSecretBytes { get; set; } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs index 77876e8..a673e94 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -11,6 +11,7 @@ namespace Hello.Helpers; using Microsoft.AspNetCore.Http; using StructuredFieldValues; +// Reconstructs the HTTP message signature base and validates ECDSA P-256 signatures from the Approov SDK. public sealed class ApproovMessageSignatureVerifier { private static readonly ISet SupportedContentDigestAlgorithms = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -26,6 +27,7 @@ public ApproovMessageSignatureVerifier(ILogger _logger = logger; } + // Entry point used by middleware to validate signatures referenced by the 'install' label. public async Task VerifyAsync(HttpContext context, string publicKeyBase64) { var signatureHeader = CombineHeaderValues(context.Request.Headers["Signature"]); @@ -102,6 +104,7 @@ public async Task VerifyAsync(HttpContext context, strin return MessageSignatureResult.Succeeded(canonicalBase.Payload ?? string.Empty); } + // Reconstructs the canonical message according to the Structured Field component list supplied by the client. private Task<(bool Success, string? Payload, string? Error)> BuildCanonicalMessageAsync(HttpContext context, IReadOnlyList components, IReadOnlyDictionary? parameters) { context.Request.EnableBuffering(); @@ -135,6 +138,7 @@ public async Task VerifyAsync(HttpContext context, strin return Task.FromResult<(bool Success, string? Payload, string? Error)>((true, payload, (string?)null)); } + // Ensures any Content-Digest headers align with the current request body bytes. private async Task<(bool Success, string? Error)> VerifyContentDigestAsync(HttpContext context) { var contentDigestHeader = CombineHeaderValues(context.Request.Headers["Content-Digest"]); @@ -183,6 +187,7 @@ public async Task VerifyAsync(HttpContext context, strin return (true, null); } + // Computes the HTTP message digest value for supported algorithms. private static string ComputeDigest(string algorithm, byte[] body) { return algorithm.Equals("sha-512", StringComparison.OrdinalIgnoreCase) @@ -190,6 +195,7 @@ private static string ComputeDigest(string algorithm, byte[] body) : ":" + Convert.ToBase64String(SHA256.HashData(body)) + ":"; } + // Imports the EC public key and validates the raw signature bytes against the canonical payload. private bool TryVerifySignature(string publicKeyBase64, ReadOnlyMemory signatureBytes, byte[] canonicalPayload) { try @@ -213,6 +219,7 @@ private bool TryVerifySignature(string publicKeyBase64, ReadOnlyMemory sig } } + // Normalises multi-value Structured Field headers into a single string for parsing. private static string CombineHeaderValues(IReadOnlyList values) { if (values.Count == 0) @@ -239,6 +246,7 @@ private static string CombineHeaderValues(IReadOnlyList values) return builder.ToString(); } + // Extracts the 'alg' parameter from the signature input metadata. private static bool TryGetAlgorithm(IReadOnlyDictionary parameters, out string? algorithm) { if (parameters.TryGetValue("alg", out var value) && value is string text) @@ -251,6 +259,7 @@ private static bool TryGetAlgorithm(IReadOnlyDictionary paramete return false; } + // Looks for unix-timestamp style parameters (created, expires, etc.). private static bool TryGetUnixEpoch(IReadOnlyDictionary parameters, string key, out long value) { if (parameters.TryGetValue(key, out var parameterValue) && parameterValue is long integer) @@ -263,6 +272,7 @@ private static bool TryGetUnixEpoch(IReadOnlyDictionary paramete return false; } + // Resolves each structured field component identifier into the actual HTTP request value. private string ResolveComponentValue(HttpContext context, string identifier, IReadOnlyDictionary? parameters) { switch (identifier) diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs index 677febe..88cec9c 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs @@ -1,5 +1,6 @@ namespace Hello.Helpers; +// Centralises the HttpContext.Items keys used by the Approov middleware chain. public static class ApproovTokenContextKeys { public const string ApproovToken = "ApproovToken"; diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs index 39364ba..1d324af 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -6,6 +6,7 @@ namespace Hello.Helpers; using System.Text; using StructuredFieldValues; +// Utility helpers that serialise structured field values back into header wire format. public static class StructuredFieldFormatter { public static string SerializeItem(ParsedItem item) diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs index 41c03f0..1097d34 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs @@ -5,6 +5,7 @@ namespace Hello.Middleware; using Hello.Helpers; using Microsoft.Extensions.Options; +// Confirms that the pay claim matches the configured binding header before continuing the request. public class ApproovTokenBindingMiddleware { private readonly RequestDelegate _next; @@ -62,6 +63,7 @@ public async Task Invoke(HttpContext context) await _next(context); } + // Recomputes the binding hash and checks it matches the pay claim in a timing-safe manner. private static bool VerifyApproovTokenBinding(string headerValue, string tokenBinding) { var computedHash = Sha256Base64Encoded(headerValue); @@ -70,6 +72,7 @@ private static bool VerifyApproovTokenBinding(string headerValue, string tokenBi Encoding.UTF8.GetBytes(computedHash)); } + // Helper to keep hashing/encoding consistent with the mobile SDK implementation. private static string Sha256Base64Encoded(string input) { using var sha256 = SHA256.Create(); diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index c5963b2..e49c29c 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs @@ -6,6 +6,7 @@ namespace Hello.Middleware; using Hello.Helpers; using System.Security.Claims; +// Enforces Approov JWT validation before the application pipeline sees the request. public class ApproovTokenMiddleware { private readonly RequestDelegate _next; @@ -45,6 +46,7 @@ public async Task Invoke(HttpContext context) return; } + // Validates the JWT signature, extracts convenience claims, and caches them in HttpContext.Items. private bool verifyApproovToken(HttpContext context, string token) { try @@ -105,6 +107,7 @@ private bool verifyApproovToken(HttpContext context, string token) } } + // Skips token enforcement for internal test endpoints that operate without Approov headers. private static bool IsBypassedPath(string? path) { if (string.IsNullOrEmpty(path)) diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs index acc5e25..da74f71 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -5,6 +5,7 @@ namespace Hello.Middleware; using System.Threading.Tasks; using Hello.Helpers; +// Validates Approov HTTP message signatures when the token carries an installation public key. public class MessageSigningMiddleware { private readonly RequestDelegate _next; @@ -47,6 +48,7 @@ public async Task InvokeAsync(HttpContext context) await _next(context); } + // Extracts the ipk claim from the JWT without validating payload contents again. private static string? ExtractInstallationPublicKey(string token) { try From 1d0499f0d6d0c163769b0bcc187ff1f266eed24b Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 6 Nov 2025 12:40:46 +0000 Subject: [PATCH 15/21] Update project to .NET 8.0 and enhance Approov token binding configuration - Upgrade Dockerfile to use .NET SDK 8.0 - Update .env.example with new Approov configuration options - Change target framework in Hello.csproj to net8.0 - Refactor AppSettings to support multiple token binding headers - Improve ApproovMessageSignatureVerifier to validate signature metadata and timestamps - Modify ApproovTokenBindingMiddleware to handle multiple header values - Enhance ApproovTokenMiddleware to extract additional claims - Update MessageSigningMiddleware to clarify installation public key usage - Refactor Program.cs to configure message signature validation options --- Dockerfile | 2 +- .../token-check/.env.example | 20 ++ .../token-check/Hello.csproj | 8 +- .../token-check/Helpers/AppSettings.cs | 23 +- .../ApproovMessageSignatureVerifier.cs | 279 +++++++++++++++--- .../ApproovTokenBindingMiddleware.cs | 51 +++- .../Middleware/ApproovTokenMiddleware.cs | 12 +- .../Middleware/MessageSigningMiddleware.cs | 1 + .../token-check/Program.cs | 61 +++- 9 files changed, 402 insertions(+), 55 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2cca7eb..3efae28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG TAG=6.0 +ARG TAG=8.0 FROM mcr.microsoft.com/dotnet/sdk:${TAG} diff --git a/servers/hello/src/approov-protected-server/token-check/.env.example b/servers/hello/src/approov-protected-server/token-check/.env.example index 82e501c..4f831a2 100644 --- a/servers/hello/src/approov-protected-server/token-check/.env.example +++ b/servers/hello/src/approov-protected-server/token-check/.env.example @@ -1,2 +1,22 @@ +# Approov configuration for the token binding quickstart server + +# Replace the placeholder with your Approov Base64 secret, or for testing with provided /test-scripts use -> TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/AA== APPROOV_BASE64_SECRET=__ADD_HERE_THE_APPROOV_BASE64_SECRET__ + +# The header to use for token binding. For example: Authorization or X-Device-Id, it must match the header used by the client app. +# Note: This header can contain multiple comma-separated values which will be concatenated before hashing. APPROOV_TOKEN_BINDING_HEADER=Authorization + +# Message signature verification settings +APPROOV_SIGNATURE_REQUIRE_CREATED=true +APPROOV_SIGNATURE_REQUIRE_EXPIRES=false + +# Enabling the following settings adds extra security to message signing to +# prevent replay attacks by ensuring each signed message is recent or unique. +# Message signature timing settings (in seconds) +# Set to empty to use default values +# Maximum allowed age for a signature since its creation time +APPROOV_SIGNATURE_MAX_AGE_SECONDS= + +# Allowed clock skew when verifying signature creation and expiration times +APPROOV_SIGNATURE_CLOCK_SKEW_SECONDS= diff --git a/servers/hello/src/approov-protected-server/token-check/Hello.csproj b/servers/hello/src/approov-protected-server/token-check/Hello.csproj index f08ff3b..cc526d4 100644 --- a/servers/hello/src/approov-protected-server/token-check/Hello.csproj +++ b/servers/hello/src/approov-protected-server/token-check/Hello.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable @@ -9,9 +9,9 @@ - - + + - + diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs index eceb11c..138357b 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/AppSettings.cs @@ -1,8 +1,29 @@ namespace Hello.Helpers; +using System; +using System.Collections.Generic; + // Holds runtime configuration injected into the request-processing pipeline. public class AppSettings { public byte[]? ApproovSecretBytes { get; set; } - public string? TokenBindingHeader { get; set; } + + // Comma-delimited header list used when recomputing the Approov token binding hash. + public IList TokenBindingHeaders { get; set; } = new List(); +} + +// Tweaks applied to HTTP message signature validation without needing code changes. +public sealed class MessageSignatureValidationOptions +{ + // When true we require a `created` parameter and optionally enforce an age window. + public bool RequireCreated { get; set; } = true; + + // When true we require an `expires` parameter and ensure it has not elapsed. + public bool RequireExpires { get; set; } = false; + + // Optional freshness window applied to the `created` timestamp. + public TimeSpan? MaximumSignatureAge { get; set; } = null; + + // Permits small client/server drift when checking created/expires timestamps. + public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.Zero; } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs index a673e94..4e43b7e 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -9,6 +9,7 @@ namespace Hello.Helpers; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using StructuredFieldValues; // Reconstructs the HTTP message signature base and validates ECDSA P-256 signatures from the Approov SDK. @@ -20,19 +21,36 @@ public sealed class ApproovMessageSignatureVerifier "sha-512" }; + // Collates the structured field parameters that ride alongside the signature input list. + private sealed record SignatureMetadata( + string? Algorithm, + long? Created, + long? Expires, + string? KeyId, + string? Nonce, + string? Tag); + private readonly ILogger _logger; + private readonly MessageSignatureValidationOptions _signatureOptions; - public ApproovMessageSignatureVerifier(ILogger logger) + public ApproovMessageSignatureVerifier( + ILogger logger, + IOptions signatureOptions) { _logger = logger; + _signatureOptions = signatureOptions.Value; } // Entry point used by middleware to validate signatures referenced by the 'install' label. public async Task VerifyAsync(HttpContext context, string publicKeyBase64) { + // Signature metadata is supplied via Structured Field headers that may be split across multiple header lines. var signatureHeader = CombineHeaderValues(context.Request.Headers["Signature"]); var signatureInputHeader = CombineHeaderValues(context.Request.Headers["Signature-Input"]); + _logger.LogDebug("Approov message signing: raw Signature header {Header}", signatureHeader); + _logger.LogDebug("Approov message signing: raw Signature-Input header {Header}", signatureInputHeader); + if (string.IsNullOrWhiteSpace(signatureHeader) || string.IsNullOrWhiteSpace(signatureInputHeader)) { return MessageSignatureResult.Failure("Missing Signature or Signature-Input headers"); @@ -50,6 +68,12 @@ public async Task VerifyAsync(HttpContext context, strin return MessageSignatureResult.Failure($"Failed to parse signature-input header: {signatureInputParseError?.Message ?? "Unknown error"}"); } + var headerConsistency = EnsureMatchingSignatureLabels(signatureDictionary, signatureInputDictionary); + if (!headerConsistency.Success) + { + return MessageSignatureResult.Failure(headerConsistency.Error!); + } + if (!signatureDictionary.TryGetValue("install", out var signatureItem)) { return MessageSignatureResult.Failure("Signature header missing 'install' entry"); @@ -70,17 +94,33 @@ public async Task VerifyAsync(HttpContext context, strin return MessageSignatureResult.Failure("Signature-Input entry does not contain an inner list of components"); } - var signatureParameters = signatureInputItem.Parameters ?? new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!TryGetAlgorithm(signatureParameters, out var algorithm) || !string.Equals(algorithm, "ecdsa-p256-sha256", StringComparison.OrdinalIgnoreCase)) + // Parse and validate signature parameters such as algorithm, created and expires. + var metadataResult = TryExtractSignatureMetadata(signatureInputItem.Parameters); + if (!metadataResult.Success) { - return MessageSignatureResult.Failure($"Unsupported signature algorithm '{algorithm ?? ""}'"); + return MessageSignatureResult.Failure(metadataResult.Error!); } - if (!TryGetUnixEpoch(signatureParameters, "created", out var createdUnix)) + var metadata = metadataResult.Metadata!; + _logger.LogDebug( + "Approov message signing: metadata alg={Alg} created={Created} expires={Expires} keyId={KeyId} nonce={Nonce} tag={Tag}", + metadata.Algorithm, + metadata.Created, + metadata.Expires, + metadata.KeyId, + metadata.Nonce, + metadata.Tag); + if (!string.Equals(metadata.Algorithm, "ecdsa-p256-sha256", StringComparison.OrdinalIgnoreCase)) { - return MessageSignatureResult.Failure("Signature missing 'created' parameter"); + return MessageSignatureResult.Failure($"Unsupported signature algorithm '{metadata.Algorithm ?? ""}'"); + } + + // Check the created/expires timestamps according to the configured policy. + var timestampValidation = ValidateTimestampPolicy(metadata); + if (!timestampValidation.Success) + { + return MessageSignatureResult.Failure(timestampValidation.Error!); } - // TODO: enforce a freshness window for the 'created' timestamp to guard against replayed signatures. var canonicalBase = await BuildCanonicalMessageAsync(context, componentIdentifiers, signatureInputItem.Parameters); if (!canonicalBase.Success) @@ -89,6 +129,7 @@ public async Task VerifyAsync(HttpContext context, strin } var canonicalPayloadBytes = Encoding.UTF8.GetBytes(canonicalBase.Payload!); + _logger.LogTrace("Approov message signing: canonical payload built:\n{Payload}", canonicalBase.Payload); var contentDigestValidation = await VerifyContentDigestAsync(context); if (!contentDigestValidation.Success) @@ -104,9 +145,191 @@ public async Task VerifyAsync(HttpContext context, strin return MessageSignatureResult.Succeeded(canonicalBase.Payload ?? string.Empty); } + private static (bool Success, string? Error) EnsureMatchingSignatureLabels( + IReadOnlyDictionary signatureDictionary, + IReadOnlyDictionary signatureInputDictionary) + { + var signatureKeys = new HashSet(signatureDictionary.Keys, StringComparer.Ordinal); + var inputKeys = new HashSet(signatureInputDictionary.Keys, StringComparer.Ordinal); + + // Both headers must expose the same set of labels so that the client cannot inject + // components or signatures that we never evaluate. + var missingInInput = signatureKeys.Except(inputKeys).FirstOrDefault(); + if (!string.IsNullOrEmpty(missingInInput)) + { + return (false, $"Signature-Input header missing '{missingInInput}' entry"); + } + + var missingInSignature = inputKeys.Except(signatureKeys).FirstOrDefault(); + if (!string.IsNullOrEmpty(missingInSignature)) + { + return (false, $"Signature header missing '{missingInSignature}' entry"); + } + + return (true, null); + } + + private (bool Success, SignatureMetadata? Metadata, string? Error) TryExtractSignatureMetadata(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + { + return (false, null, "Signature parameters missing 'alg' entry"); + } + + // Rejects unknown parameter keys to tighten the validation + string? algorithm = null; + long? created = null; + long? expires = null; + string? keyId = null; + string? nonce = null; + string? tag = null; + + foreach (var parameter in parameters) + { + switch (parameter.Key) + { + case "alg": + if (parameter.Value is string text) + { + algorithm = text; + } + else + { + return (false, null, "Signature parameter 'alg' must be a string"); + } + + break; + case "created": + if (!TryConvertToLong(parameter.Value, out var createdValue)) + { + return (false, null, "Signature parameter 'created' must be an integer"); + } + + created = createdValue; + break; + case "expires": + if (!TryConvertToLong(parameter.Value, out var expiresValue)) + { + return (false, null, "Signature parameter 'expires' must be an integer"); + } + + expires = expiresValue; + break; + case "keyid": + if (parameter.Value is string keyIdValue) + { + keyId = keyIdValue; + break; + } + + return (false, null, "Signature parameter 'keyid' must be a string"); + case "nonce": + if (parameter.Value is string nonceValue) + { + nonce = nonceValue; + break; + } + + return (false, null, "Signature parameter 'nonce' must be a string"); + case "tag": + if (parameter.Value is string tagValue) + { + tag = tagValue; + break; + } + + return (false, null, "Signature parameter 'tag' must be a string"); + default: + return (false, null, $"Unsupported signature parameter '{parameter.Key}'"); + } + } + + if (string.IsNullOrWhiteSpace(algorithm)) + { + return (false, null, "Signature missing 'alg' parameter"); + } + + return (true, new SignatureMetadata(algorithm, created, expires, keyId, nonce, tag), null); + } + + private (bool Success, string? Error) ValidateTimestampPolicy(SignatureMetadata metadata) + { + var now = DateTimeOffset.UtcNow; + + if (_signatureOptions.RequireCreated && !metadata.Created.HasValue) + { + _logger.LogDebug("Approov message signing: missing created timestamp"); + return (false, "Signature missing 'created' parameter"); + } + + if (metadata.Created.HasValue) + { + // Apply both freshness checks and future drift tolerance to the created timestamp. + var createdInstant = DateTimeOffset.FromUnixTimeSeconds(metadata.Created.Value); + var freshnessWindow = _signatureOptions.MaximumSignatureAge; + var skew = _signatureOptions.AllowedClockSkew; + + if (freshnessWindow.HasValue && createdInstant < now - freshnessWindow.Value - skew) + { + _logger.LogDebug("Approov message signing: created timestamp {Created} is stale compared to window {Window}s", createdInstant.ToUnixTimeSeconds(), freshnessWindow.Value.TotalSeconds); + return (false, "Signature 'created' timestamp is older than the allowed freshness window"); + } + + if (createdInstant > now + skew) + { + _logger.LogDebug("Approov message signing: created timestamp {Created} ahead of server time {Now}", createdInstant.ToUnixTimeSeconds(), now.ToUnixTimeSeconds()); + return (false, "Signature 'created' timestamp is in the future"); + } + } + + if (_signatureOptions.RequireExpires && !metadata.Expires.HasValue) + { + _logger.LogDebug("Approov message signing: missing expires timestamp"); + return (false, "Signature missing 'expires' parameter"); + } + + if (metadata.Expires.HasValue) + { + var expiresInstant = DateTimeOffset.FromUnixTimeSeconds(metadata.Expires.Value); + if (expiresInstant + _signatureOptions.AllowedClockSkew < now) + { + _logger.LogDebug("Approov message signing: expires timestamp {Expires} has elapsed (now {Now})", expiresInstant.ToUnixTimeSeconds(), now.ToUnixTimeSeconds()); + return (false, "Signature has expired"); + } + } + + if (metadata.Created.HasValue && metadata.Expires.HasValue && metadata.Expires.Value < metadata.Created.Value) + { + _logger.LogDebug("Approov message signing: expires {Expires} precedes created {Created}", metadata.Expires.Value, metadata.Created.Value); + return (false, "Signature 'expires' parameter precedes the 'created' timestamp"); + } + + return (true, null); + } + + private static bool TryConvertToLong(object value, out long result) + { + switch (value) + { + case long longValue: + result = longValue; + return true; + case int intValue: + result = intValue; + return true; + case short shortValue: + result = shortValue; + return true; + default: + result = default; + return false; + } + } + // Reconstructs the canonical message according to the Structured Field component list supplied by the client. private Task<(bool Success, string? Payload, string? Error)> BuildCanonicalMessageAsync(HttpContext context, IReadOnlyList components, IReadOnlyDictionary? parameters) { + // Allow multiple reads of the body and request metadata while we rebuild the canonical form. context.Request.EnableBuffering(); var lines = new List(); @@ -124,9 +347,12 @@ public async Task VerifyAsync(HttpContext context, strin } catch (MessageSignatureException ex) { + _logger.LogDebug("Approov message signing: failed to resolve component {Identifier} - {Error}", identifier, ex.Message); return Task.FromResult<(bool Success, string? Payload, string? Error)>((false, null, ex.Message)); } + _logger.LogTrace("Approov message signing: component {Identifier} -> {Value}", identifier, value); + // Serialise the Structured Field token back into its textual label (e.g. "@method"). var label = StructuredFieldFormatter.SerializeItem(component); lines.Add($"{label}: {value}"); } @@ -177,6 +403,11 @@ public async Task VerifyAsync(HttpContext context, strin : ":" + Convert.ToBase64String(((ReadOnlyMemory)entry.Value.Value!).ToArray()) + ":"; var actualDigest = ComputeDigest(entry.Key, bodyBytes); + _logger.LogTrace( + "Approov message signing: content-digest check algorithm={Algorithm} expected={Expected} actual={Actual}", + entry.Key, + expectedDigest, + actualDigest); if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expectedDigest), Encoding.ASCII.GetBytes(actualDigest))) { @@ -205,7 +436,13 @@ private bool TryVerifySignature(string publicKeyBase64, ReadOnlyMemory sig ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _); var signature = signatureBytes.ToArray(); - return ecdsa.VerifyData(canonicalPayload, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + var verified = ecdsa.VerifyData(canonicalPayload, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + if (!verified) + { + _logger.LogDebug("Approov message signing: signature verification failed for payload length {Length}", canonicalPayload.Length); + } + + return verified; } catch (FormatException ex) { @@ -246,32 +483,6 @@ private static string CombineHeaderValues(IReadOnlyList values) return builder.ToString(); } - // Extracts the 'alg' parameter from the signature input metadata. - private static bool TryGetAlgorithm(IReadOnlyDictionary parameters, out string? algorithm) - { - if (parameters.TryGetValue("alg", out var value) && value is string text) - { - algorithm = text; - return true; - } - - algorithm = null; - return false; - } - - // Looks for unix-timestamp style parameters (created, expires, etc.). - private static bool TryGetUnixEpoch(IReadOnlyDictionary parameters, string key, out long value) - { - if (parameters.TryGetValue(key, out var parameterValue) && parameterValue is long integer) - { - value = integer; - return true; - } - - value = default; - return false; - } - // Resolves each structured field component identifier into the actual HTTP request value. private string ResolveComponentValue(HttpContext context, string identifier, IReadOnlyDictionary? parameters) { diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs index 1097d34..1ca6e52 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs @@ -1,11 +1,12 @@ namespace Hello.Middleware; +using System.Collections.Generic; using System.Security.Cryptography; using System.Text; using Hello.Helpers; using Microsoft.Extensions.Options; -// Confirms that the pay claim matches the configured binding header before continuing the request. +// Confirms that the pay claim matches the configured binding header values before continuing the request. public class ApproovTokenBindingMiddleware { private readonly RequestDelegate _next; @@ -27,38 +28,68 @@ public async Task Invoke(HttpContext context) var tokenBindingClaim = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingObject) ? bindingObject as string : null; - + //TODO: Shall we skip if pay claim is missing? Or fail the request? How will the test without pay claim pass, will this not open a security hole? if (string.IsNullOrWhiteSpace(tokenBindingClaim)) { + _logger.LogDebug("Approov token binding: skipping because pay claim is missing"); await _next(context); return; } - var headerName = _appSettings.TokenBindingHeader; - if (string.IsNullOrWhiteSpace(headerName)) + var headerNames = _appSettings.TokenBindingHeaders; + if (headerNames is null || headerNames.Count == 0) { _logger.LogDebug("Token binding claim present but no binding header configured; skipping verification."); await _next(context); return; } - var headerValue = context.Request.Headers[headerName].ToString().Trim(); - if (string.IsNullOrWhiteSpace(headerValue)) + // This method concatenates multiple header values before hashing. We mirror that + var concatenatedBinding = new StringBuilder(); + var missingHeaders = new List(); + + foreach (var headerName in headerNames) + { + var value = context.Request.Headers[headerName].ToString(); + if (string.IsNullOrWhiteSpace(value)) + { + missingHeaders.Add(headerName); + _logger.LogDebug("Approov token binding: header {Header} missing/empty", headerName); + continue; + } + + concatenatedBinding.Append(value.Trim()); + } + + if (missingHeaders.Count > 0) { _logger.LogInformation( - "Approov token binding requested but required header '{Header}' is missing or empty.", - headerName); + "Approov token binding requested but required header(s) '{Headers}' are missing or empty.", + string.Join(", ", missingHeaders)); context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } - if (!VerifyApproovTokenBinding(headerValue, tokenBindingClaim)) + var bindingValue = concatenatedBinding.ToString(); + + if (string.IsNullOrWhiteSpace(bindingValue)) { - _logger.LogInformation("Invalid Approov token binding."); + _logger.LogInformation("Approov token binding requested but concatenated header values are empty."); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + if (!VerifyApproovTokenBinding(bindingValue, tokenBindingClaim)) + { + _logger.LogInformation( + "Invalid Approov token binding: expected={Expected} actual={Actual}", + Sha256Base64Encoded(bindingValue), + tokenBindingClaim); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + _logger.LogDebug("Approov token binding: binding verified for headers {Headers}", string.Join(",", headerNames)); context.Items[ApproovTokenContextKeys.TokenBindingVerified] = true; await _next(context); } diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index e49c29c..3210fb7 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs @@ -28,7 +28,7 @@ public async Task Invoke(HttpContext context) await _next(context); return; } - + var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); if (token == null) { @@ -69,22 +69,26 @@ private bool verifyApproovToken(HttpContext context, string token) context.Items[ApproovTokenContextKeys.TokenExpiry] = jwtToken.ValidTo; var claims = jwtToken.Claims; + // Extract device id var deviceId = claims.FirstOrDefault(c => string.Equals(c.Type, "did", StringComparison.OrdinalIgnoreCase))?.Value; if (!string.IsNullOrWhiteSpace(deviceId)) { context.Items[ApproovTokenContextKeys.DeviceId] = deviceId; + _logger.LogDebug("Approov token: extracted device id"); } - + // Extract token binding (pay) claim for token binding verification var payClaim = claims.FirstOrDefault(c => string.Equals(c.Type, "pay", StringComparison.OrdinalIgnoreCase))?.Value; if (!string.IsNullOrWhiteSpace(payClaim)) { context.Items[ApproovTokenContextKeys.TokenBinding] = payClaim; + _logger.LogDebug("Approov token: extracted pay claim for token binding"); } - + // Extract installation public key (ipk) claim var installationPublicKey = claims.FirstOrDefault(c => string.Equals(c.Type, "ipk", StringComparison.OrdinalIgnoreCase))?.Value; if (!string.IsNullOrWhiteSpace(installationPublicKey)) { context.Items[ApproovTokenContextKeys.InstallationPublicKey] = installationPublicKey; + _logger.LogDebug("Approov token: extracted installation public key"); } } @@ -107,7 +111,7 @@ private bool verifyApproovToken(HttpContext context, string token) } } - // Skips token enforcement for internal test endpoints that operate without Approov headers. + // Skips token enforcement for internal test endpoints from /test-scripts. private static bool IsBypassedPath(string? path) { if (string.IsNullOrEmpty(path)) diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs index da74f71..08c998a 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -28,6 +28,7 @@ public async Task InvokeAsync(HttpContext context) return; } + // The ipk claim carries the installation public key used to verify the raw HTTP message signature. var installationPublicKey = ExtractInstallationPublicKey(token); if (string.IsNullOrWhiteSpace(installationPublicKey)) { diff --git a/servers/hello/src/approov-protected-server/token-check/Program.cs b/servers/hello/src/approov-protected-server/token-check/Program.cs index fae089f..73c262c 100644 --- a/servers/hello/src/approov-protected-server/token-check/Program.cs +++ b/servers/hello/src/approov-protected-server/token-check/Program.cs @@ -1,4 +1,7 @@ using Hello.Helpers; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; ////////////////////////// // SETUP APPROOV SECRET @@ -27,10 +30,22 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var tokenBindingHeader = DotNetEnv.Env.GetString("APPROOV_TOKEN_BINDING_HEADER"); +var signatureRequireCreated = ReadBoolean(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_REQUIRE_CREATED"), true); +var signatureRequireExpires = ReadBoolean(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_REQUIRE_EXPIRES"), false); +var signatureMaxAge = ReadTimeSpanFromSeconds(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_MAX_AGE_SECONDS")); +var signatureClockSkew = ReadTimeSpanFromSeconds(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_CLOCK_SKEW_SECONDS")) ?? TimeSpan.Zero; +var tokenBindingHeaders = ParseHeaderList(tokenBindingHeader); builder.Services.Configure(appSettings => { appSettings.ApproovSecretBytes = approovSecretBytes; - appSettings.TokenBindingHeader = tokenBindingHeader; + appSettings.TokenBindingHeaders = tokenBindingHeaders.ToList(); +}); +builder.Services.Configure(options => +{ + options.RequireCreated = signatureRequireCreated; + options.RequireExpires = signatureRequireExpires; + options.MaximumSignatureAge = signatureMaxAge; + options.AllowedClockSkew = signatureClockSkew; }); builder.Services.AddSingleton(); @@ -59,3 +74,47 @@ app.MapControllers(); app.Run(); + +static bool ReadBoolean(string? value, bool defaultValue) +{ + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (bool.TryParse(value, out var parsed)) + { + return parsed; + } + + return defaultValue; +} + +static TimeSpan? ReadTimeSpanFromSeconds(string? value) +{ + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0) + { + return TimeSpan.FromSeconds(seconds); + } + + return null; +} + +static IList ParseHeaderList(string? raw) +{ + if (string.IsNullOrWhiteSpace(raw)) + { + return new List(); + } + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .ToList(); +} From c09e95c6f38b4f845cafcbf7197a233a042b10ca Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 6 Nov 2025 13:08:07 +0000 Subject: [PATCH 16/21] Correct the example .env to demonstrate token binding to multiple headers --- .gitignore | 2 +- quickstart-asp.net-token-check.sln | 61 +++++++++++++++++++ .../approov-protected-server/token-check/.env | 4 ++ .../token-check/.env.example | 2 +- 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 quickstart-asp.net-token-check.sln create mode 100644 servers/hello/src/approov-protected-server/token-check/.env diff --git a/.gitignore b/.gitignore index 91b3f12..d5994a2 100644 --- a/.gitignore +++ b/.gitignore @@ -263,5 +263,5 @@ paket-files/ __pycache__/ *.pyc -.env +".env" .DS_Store diff --git a/quickstart-asp.net-token-check.sln b/quickstart-asp.net-token-check.sln new file mode 100644 index 0000000..6357170 --- /dev/null +++ b/quickstart-asp.net-token-check.sln @@ -0,0 +1,61 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "servers", "servers", "{769B9289-B2AF-0079-2183-40A0D5B6CA35}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hello.Tests", "tests\Hello.Tests\Hello.Tests.csproj", "{42314DAE-499E-1B0C-8341-150D9361EA84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetCoreJWTAuth.App", "servers\archived\NetCoreJWTAuth.App.csproj", "{2F85E5BB-6ED0-5A91-1AC4-2102294D8AE5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hello", "hello", "{EA624CAD-10C0-FFE8-F194-BC34749DE3DF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B3090F22-B8DB-801B-6AF7-CD9A94B502B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hello", "servers\hello\src\unprotected-server\Hello.csproj", "{095F8A0F-36C2-79E0-D416-53FA0D1CDE8A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "approov-protected-server", "approov-protected-server", "{620BC03F-03E1-4AA9-1BD4-F9A3B8CA9304}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hello", "servers\hello\src\approov-protected-server\token-check\Hello.csproj", "{406F3656-8303-BB41-796B-AE766423224D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {42314DAE-499E-1B0C-8341-150D9361EA84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42314DAE-499E-1B0C-8341-150D9361EA84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42314DAE-499E-1B0C-8341-150D9361EA84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42314DAE-499E-1B0C-8341-150D9361EA84}.Release|Any CPU.Build.0 = Release|Any CPU + {2F85E5BB-6ED0-5A91-1AC4-2102294D8AE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F85E5BB-6ED0-5A91-1AC4-2102294D8AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F85E5BB-6ED0-5A91-1AC4-2102294D8AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F85E5BB-6ED0-5A91-1AC4-2102294D8AE5}.Release|Any CPU.Build.0 = Release|Any CPU + {095F8A0F-36C2-79E0-D416-53FA0D1CDE8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {095F8A0F-36C2-79E0-D416-53FA0D1CDE8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {095F8A0F-36C2-79E0-D416-53FA0D1CDE8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {095F8A0F-36C2-79E0-D416-53FA0D1CDE8A}.Release|Any CPU.Build.0 = Release|Any CPU + {406F3656-8303-BB41-796B-AE766423224D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {406F3656-8303-BB41-796B-AE766423224D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {406F3656-8303-BB41-796B-AE766423224D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {406F3656-8303-BB41-796B-AE766423224D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {42314DAE-499E-1B0C-8341-150D9361EA84} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {2F85E5BB-6ED0-5A91-1AC4-2102294D8AE5} = {769B9289-B2AF-0079-2183-40A0D5B6CA35} + {EA624CAD-10C0-FFE8-F194-BC34749DE3DF} = {769B9289-B2AF-0079-2183-40A0D5B6CA35} + {B3090F22-B8DB-801B-6AF7-CD9A94B502B8} = {EA624CAD-10C0-FFE8-F194-BC34749DE3DF} + {095F8A0F-36C2-79E0-D416-53FA0D1CDE8A} = {B3090F22-B8DB-801B-6AF7-CD9A94B502B8} + {620BC03F-03E1-4AA9-1BD4-F9A3B8CA9304} = {B3090F22-B8DB-801B-6AF7-CD9A94B502B8} + {406F3656-8303-BB41-796B-AE766423224D} = {620BC03F-03E1-4AA9-1BD4-F9A3B8CA9304} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7B9439D5-281E-498F-A07D-ECE54A67DDD8} + EndGlobalSection +EndGlobal diff --git a/servers/hello/src/approov-protected-server/token-check/.env b/servers/hello/src/approov-protected-server/token-check/.env new file mode 100644 index 0000000..ca79f33 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/.env @@ -0,0 +1,4 @@ +APPROOV_BASE64_SECRET=TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/TEST+SECRET/AA== +APPROOV_TOKEN_BINDING_HEADER=Authorization, X-Device-Id +#APPROOV_SIGNATURE_MAX_AGE_SECONDS=300 +APPROOV_SIGNATURE_REQUIRE_CREATED=false diff --git a/servers/hello/src/approov-protected-server/token-check/.env.example b/servers/hello/src/approov-protected-server/token-check/.env.example index 4f831a2..e3bb9cc 100644 --- a/servers/hello/src/approov-protected-server/token-check/.env.example +++ b/servers/hello/src/approov-protected-server/token-check/.env.example @@ -4,7 +4,7 @@ APPROOV_BASE64_SECRET=__ADD_HERE_THE_APPROOV_BASE64_SECRET__ # The header to use for token binding. For example: Authorization or X-Device-Id, it must match the header used by the client app. -# Note: This header can contain multiple comma-separated values which will be concatenated before hashing. +# Note: This header can contain multiple comma-separated values which will be concatenated before hashing. For example to execute /test-scripts this needs to be set to "Authorization, X-Device-Id". APPROOV_TOKEN_BINDING_HEADER=Authorization # Message signature verification settings From 3eabb040657c765f760b1e579f0640c4497f6c7e Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 6 Nov 2025 13:34:20 +0000 Subject: [PATCH 17/21] Comments updates --- .../token-check/.env.example | 4 ++-- .../token-check/Controllers/ApproovController.cs | 12 ++++++------ .../Middleware/ApproovTokenBindingMiddleware.cs | 1 - 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/servers/hello/src/approov-protected-server/token-check/.env.example b/servers/hello/src/approov-protected-server/token-check/.env.example index e3bb9cc..d5712b1 100644 --- a/servers/hello/src/approov-protected-server/token-check/.env.example +++ b/servers/hello/src/approov-protected-server/token-check/.env.example @@ -8,8 +8,8 @@ APPROOV_BASE64_SECRET=__ADD_HERE_THE_APPROOV_BASE64_SECRET__ APPROOV_TOKEN_BINDING_HEADER=Authorization # Message signature verification settings -APPROOV_SIGNATURE_REQUIRE_CREATED=true -APPROOV_SIGNATURE_REQUIRE_EXPIRES=false +APPROOV_SIGNATURE_REQUIRE_CREATED=true # Require 'created' timestamp in signature +APPROOV_SIGNATURE_REQUIRE_EXPIRES=false # Do not require 'expires' timestamp # Enabling the following settings adds extra security to message signing to # prevent replay attacks by ensuring each signed message is recent or unique. diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index 25db9d7..ab5a675 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -79,12 +79,12 @@ public IActionResult IpkTest() return Content("Failed: failed to create public key", "text/plain"); } } -/* - Sending cryptographic values across an insecure network without encrypting them is extremely unsafe, - as anyone that intercepts these values can then decrypt your data. This endpoint exposes private keys to interception, - logging by web servers and proxies, and storage in browser history. - Make sure this endpoint is only used in a secure testing environment and never in production. - */ + /* + Sending cryptographic values across an insecure network without encrypting them is extremely unsafe, + as anyone that intercepts these values can then decrypt your data. This endpoint exposes private keys to interception, + logging by web servers and proxies, and storage in browser history. + Make sure this endpoint is only used in a secure testing environment and never in production. + */ [HttpGet("/ipk_message_sign_test")] // Signs an arbitrary message with a caller-supplied EC private key to help generate deterministic test vectors. public IActionResult IpkMessageSignTest() diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs index 1ca6e52..3b5aca1 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs @@ -28,7 +28,6 @@ public async Task Invoke(HttpContext context) var tokenBindingClaim = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingObject) ? bindingObject as string : null; - //TODO: Shall we skip if pay claim is missing? Or fail the request? How will the test without pay claim pass, will this not open a security hole? if (string.IsNullOrWhiteSpace(tokenBindingClaim)) { _logger.LogDebug("Approov token binding: skipping because pay claim is missing"); From 9dcff8ca022c92fbb96bc03e6f7d6abea1feba31 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 6 Nov 2025 14:12:53 +0000 Subject: [PATCH 18/21] comment update --- .../token-check/Middleware/MessageSigningMiddleware.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs index 08c998a..46b64f9 100644 --- a/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -28,7 +28,7 @@ public async Task InvokeAsync(HttpContext context) return; } - // The ipk claim carries the installation public key used to verify the raw HTTP message signature. + // The ipk claim in Approov token carries the installation public key used to verify the raw HTTP message signature. var installationPublicKey = ExtractInstallationPublicKey(token); if (string.IsNullOrWhiteSpace(installationPublicKey)) { @@ -49,7 +49,7 @@ public async Task InvokeAsync(HttpContext context) await _next(context); } - // Extracts the ipk claim from the JWT without validating payload contents again. + // Extracts the ipk claim from the JWT token private static string? ExtractInstallationPublicKey(string token) { try From 3583bd006697675f26ec129c6341d385e99c78c5 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 6 Nov 2025 14:36:24 +0000 Subject: [PATCH 19/21] Removing code duplication for method CombineHeaderValues - move it to the helper --- .../Controllers/ApproovController.cs | 30 +------------- .../ApproovMessageSignatureVerifier.cs | 41 ++++--------------- .../Helpers/StructuredFieldFormatter.cs | 27 ++++++++++++ 3 files changed, 35 insertions(+), 63 deletions(-) diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index ab5a675..2be0a46 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using StructuredFieldValues; using System.Security.Cryptography; -using System.Text; -using System.Collections.Generic; namespace Hello.Controllers; @@ -127,7 +125,7 @@ public IActionResult IpkMessageSignTest() public IActionResult StructuredFieldTest() { var sfvType = Request.Headers["sfvt"].FirstOrDefault(); - var sfvHeader = CombineHeaderValues(Request.Headers["sfv"]); + var sfvHeader = StructuredFieldFormatter.CombineHeaderValues(Request.Headers["sfv"]); if (string.IsNullOrWhiteSpace(sfvType) || string.IsNullOrWhiteSpace(sfvHeader)) { @@ -186,31 +184,5 @@ private IActionResult StructuredFieldFailure(string message) return Content("Failed SFV roundtrip", "text/plain"); } - // Normalizes multi-value headers into a single comma-delimited field as per HTTP header semantics. - private static string CombineHeaderValues(IReadOnlyList values) - { - if (values.Count == 0) - { - return string.Empty; - } - - if (values.Count == 1) - { - return values[0].Trim(); - } - - var builder = new StringBuilder(); - for (var i = 0; i < values.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - builder.Append(values[i].Trim()); - } - - return builder.ToString(); - } } diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs index 4e43b7e..b90afc1 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -45,8 +45,8 @@ public ApproovMessageSignatureVerifier( public async Task VerifyAsync(HttpContext context, string publicKeyBase64) { // Signature metadata is supplied via Structured Field headers that may be split across multiple header lines. - var signatureHeader = CombineHeaderValues(context.Request.Headers["Signature"]); - var signatureInputHeader = CombineHeaderValues(context.Request.Headers["Signature-Input"]); + var signatureHeader = StructuredFieldFormatter.CombineHeaderValues(context.Request.Headers["Signature"]); + var signatureInputHeader = StructuredFieldFormatter.CombineHeaderValues(context.Request.Headers["Signature-Input"]); _logger.LogDebug("Approov message signing: raw Signature header {Header}", signatureHeader); _logger.LogDebug("Approov message signing: raw Signature-Input header {Header}", signatureInputHeader); @@ -367,7 +367,7 @@ private static bool TryConvertToLong(object value, out long result) // Ensures any Content-Digest headers align with the current request body bytes. private async Task<(bool Success, string? Error)> VerifyContentDigestAsync(HttpContext context) { - var contentDigestHeader = CombineHeaderValues(context.Request.Headers["Content-Digest"]); + var contentDigestHeader = StructuredFieldFormatter.CombineHeaderValues(context.Request.Headers["Content-Digest"]); if (string.IsNullOrWhiteSpace(contentDigestHeader)) { return (true, null); @@ -456,33 +456,6 @@ private bool TryVerifySignature(string publicKeyBase64, ReadOnlyMemory sig } } - // Normalises multi-value Structured Field headers into a single string for parsing. - private static string CombineHeaderValues(IReadOnlyList values) - { - if (values.Count == 0) - { - return string.Empty; - } - - if (values.Count == 1) - { - return values[0].Trim(); - } - - var builder = new StringBuilder(); - for (var i = 0; i < values.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - builder.Append(values[i].Trim()); - } - - return builder.ToString(); - } - // Resolves each structured field component identifier into the actual HTTP request value. private string ResolveComponentValue(HttpContext context, string identifier, IReadOnlyDictionary? parameters) { @@ -554,7 +527,7 @@ private string ResolveHeaderComponent(HttpContext context, string headerName, IR if (parameters is null || parameters.Count == 0) { - return CombineHeaderValues(values); + return StructuredFieldFormatter.CombineHeaderValues(values); } if (parameters.TryGetValue("sf", out var sfValue) && sfValue is bool sf && sf) @@ -564,7 +537,7 @@ private string ResolveHeaderComponent(HttpContext context, string headerName, IR if (parameters.TryGetValue("key", out var keyValue) && keyValue is string key) { - var raw = CombineHeaderValues(values); + var raw = StructuredFieldFormatter.CombineHeaderValues(values); var parseError = SfvParser.ParseDictionary(raw, out var dictionary); if (parseError.HasValue || dictionary is null) { @@ -579,12 +552,12 @@ private string ResolveHeaderComponent(HttpContext context, string headerName, IR return StructuredFieldFormatter.SerializeItem(item); } - return CombineHeaderValues(values); + return StructuredFieldFormatter.CombineHeaderValues(values); } private static string SerializeStructuredFieldHeader(string headerName, IReadOnlyList values) { - var raw = CombineHeaderValues(values); + var raw = StructuredFieldFormatter.CombineHeaderValues(values); var dictionaryError = SfvParser.ParseDictionary(raw, out var dictionary); if (!dictionaryError.HasValue && dictionary is not null) { diff --git a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs index 1d324af..887ef35 100644 --- a/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -201,6 +201,33 @@ private static string SerializeDateTime(DateTime value) return "@" + seconds.ToString(CultureInfo.InvariantCulture); } + // Normalises multi-value HTTP headers into the canonical comma-delimited form. + public static string CombineHeaderValues(IReadOnlyList values) + { + if (values.Count == 0) + { + return string.Empty; + } + + if (values.Count == 1) + { + return values[0].Trim(); + } + + var builder = new StringBuilder(); + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append(values[i].Trim()); + } + + return builder.ToString(); + } + private static void AppendParameters(StringBuilder builder, IReadOnlyDictionary? parameters) { if (parameters is not { Count: > 0 }) From 439a51b22257090f53c7e74daa975897532c7cd4 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Thu, 6 Nov 2025 16:51:58 +0000 Subject: [PATCH 20/21] Refactor Approov Token Quickstart documentation for ASP.NET 8 compatibility and clarity - Updated the quickstart guide to reflect changes for ASP.NET 8, including package versions and middleware registration. - Streamlined the explanation of Approov setup, token validation, and server changes. - Added detailed instructions for message signing integration and middleware registration in a new quickstart document. - Improved the README in the token-check server example to include new endpoints and clarify usage. - Enhanced comments and structure for better readability and understanding of the Approov integration process. --- EXAMPLES.md | 5 +- OVERVIEW.md | 8 +- QUICKSTARTS.md | 32 +- README.md | 259 ++------- TESTING.md | 19 +- docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md | 155 +++++ docs/APPROOV_TOKEN_BINDING_QUICKSTART.md | 539 +++++------------- docs/APPROOV_TOKEN_QUICKSTART.md | 461 ++++++--------- .../Controllers/ApproovController.cs | 3 +- .../token-check/README.md | 29 +- 10 files changed, 560 insertions(+), 950 deletions(-) create mode 100644 docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md diff --git a/EXAMPLES.md b/EXAMPLES.md index 264cb83..b5ddb6b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -14,6 +14,7 @@ To learn more about each Hello server example you need to read the README for ea * [Unprotected Server](/servers/hello/src/unprotected-server) * [Approov Protected Server](/servers/hello/src/approov-protected-server/token-check) +The repository also includes helper scripts in `/test-scripts` that exercise token validation, token binding, message signing, and Structured Field parsing against the protected server. ## Setup Environment @@ -23,7 +24,7 @@ Do not forget to properly setup the `.env` file in the root of the Approov prote cp servers/hello/src/approov-protected-server/token-check/.env.example servers/hello/src/approov-protected-server/token-check/.env ``` -Edit the file and add the [dummy secret](/TESTING.md#the-dummy-secret) to it in order to be able to test the Approov integration with the provided [Postman collection](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). +Edit the file and add the [dummy secret](/TESTING.md#the-dummy-secret) to it in order to be able to test the Approov integration with the provided [Postman collection](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). Set `APPROOV_TOKEN_BINDING_HEADER` (for example `Authorization`) and tweak the optional `APPROOV_SIGNATURE_*` variables to explore token binding and message signing policies. ## Docker Stack @@ -83,7 +84,7 @@ If you find any issue while following our instructions then just report it [here If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) +* [Approov Free Trial](https://approov.io/signup) (no credit card needed) * [Approov Get Started](https://approov.io/product/demo) * [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) * [Approov Docs](https://approov.io/docs) diff --git a/OVERVIEW.md b/OVERVIEW.md index 40f2398..0c4d490 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -33,8 +33,10 @@ The backend server ensures that the token supplied in the `Approov-Token` header The request is handled such that: -* If the Approov Token is valid, the request is allowed to be processed by the API endpoint -* If the Approov Token is invalid, an HTTP 401 Unauthorized response is returned +* If the Approov Token is valid, the request is allowed to be processed by the API endpoint. +* If the Approov Token is invalid, an HTTP 401 Unauthorized response is returned. +* Optional [token binding](https://approov.io/docs/latest/approov-usage-documentation/#token-binding) recomputes the binding hash from headers such as `Authorization` and must match the token’s `pay` claim before the request is processed. +* Optional [message signing](https://approov.io/docs/latest/approov-usage-documentation/#message-signing) reconstructs the canonical HTTP message and validates the signature supplied in the `Signature` / `Signature-Input` headers using the installation public key embedded in the token. You can choose to log JWT verification failures, but we left it out on purpose so that you can have the choice of how you prefer to do it and decide the right amount of information you want to log. @@ -43,7 +45,7 @@ You can choose to log JWT verification failures, but we left it out on purpose s If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) +* [Approov Free Trial](https://approov.io/signup) (no credit card needed) * [Approov Get Started](https://approov.io/product/demo) * [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) * [Approov Docs](https://approov.io/docs) diff --git a/QUICKSTARTS.md b/QUICKSTARTS.md index 54965d0..315dea1 100644 --- a/QUICKSTARTS.md +++ b/QUICKSTARTS.md @@ -1,15 +1,17 @@ # Approov Integration Quickstarts -[Approov](https://approov.io) is an API security solution used to verify that requests received by your backend services originate from trusted versions of your mobile apps. +[Approov](https://approov.io) ensures that API traffic reaching your backend originates from trusted versions of your mobile apps. This repository collects the server-side quickstarts for ASP.NET 8 and reuses a single reference implementation at `servers/hello/src/approov-protected-server/token-check`. ## The Quickstarts -The quickstart code for the Approov backend server now lives in a single implementation (`servers/hello/src/approov-protected-server/token-check`). The core quickstart focuses on basic token checking, while the token binding guide extends the same project to showcase the optional binding feature (more details can be found [here](https://approov.io/docs/latest/approov-usage-documentation/#token-binding)). -* [Approov token check quickstart](/docs/APPROOV_TOKEN_QUICKSTART.md) -* [Approov token check with token binding quickstart](/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md) +Pick the guide that matches the level of protection you want to implement: -Both guides build upon the unprotected example server defined [here](servers/hello/src/unprotected-server). +- [Approov token check](docs/APPROOV_TOKEN_QUICKSTART.md) - validate the JWT presented in the `Approov-Token` header. +- [Approov token binding](docs/APPROOV_TOKEN_BINDING_QUICKSTART.md) - bind tokens to headers such as `Authorization` to prevent replay. +- [Approov message signing](docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md) - verify HTTP message signatures using the installation public key (IPK). + +Each build upon the previous one, so start with the token quickstart before layering binding or message signing. ## Issues @@ -21,13 +23,13 @@ If you find any issue while following our instructions then just report it [here If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) -* [Approov Get Started](https://approov.io/product/demo) -* [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) -* [Approov Docs](https://approov.io/docs) -* [Approov Blog](https://approov.io/blog/) -* [Approov Resources](https://approov.io/resource/) -* [Approov Customer Stories](https://approov.io/customer) -* [Approov Support](https://approov.io/contact) -* [About Us](https://approov.io/company) -* [Contact Us](https://approov.io/contact) +- [Approov Free Trial](https://approov.io/signup) (no credit card needed) +- [Approov Get Started](https://approov.io/product/demo) +- [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) +- [Approov Docs](https://approov.io/docs) +- [Approov Blog](https://approov.io/blog/) +- [Approov Resources](https://approov.io/resource/) +- [Approov Customer Stories](https://approov.io/customer) +- [Approov Support](https://approov.io/contact) +- [About Us](https://approov.io/company) +- [Contact Us](https://approov.io/contact) diff --git a/README.md b/README.md index a32d59d..cb5ebcb 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,87 @@ -# Approov QuickStart - ASP.Net Token Check +# Approov QuickStart - ASP.NET Token Check -[Approov](https://approov.io) is an API security solution used to verify that requests received by your backend services originate from trusted versions of your mobile apps. +[Approov](https://approov.io) validates that requests reaching your backend originate from trusted builds of your mobile apps. This quickstart demonstrates how to enforce Approov tokens in ASP.NET 8, optionally add [token binding](https://approov.io/docs/latest/approov-usage-documentation/#token-binding), and verify [HTTP message signatures](https://approov.io/docs/latest/approov-usage-documentation/#message-signing) produced by the Approov SDK. -This repo implements the Approov server-side request verification code for the ASP.Net framework, which performs the verification check before allowing valid traffic to be processed by the API endpoint. +The sample backend that accompanies this guide lives at `servers/hello/src/approov-protected-server/token-check`. It exposes minimal endpoints that illustrate each protection layer: +- `/token` returns `Good Token` after validating the Approov token. +- `/token_binding` echoes `Good Token Binding` when the configured headers hash to the `pay` claim. +- `/ipk_message_sign_test` and `/ipk_test` generate deterministic signatures and validate installation public keys for local testing. +An unprotected reference backend lives at `servers/hello/src/unprotected-server` so you can compare behaviour with and without Approov. -## Approov Integration Quickstart -The quickstart was tested with the following Operating Systems: +## Prerequisites -* Ubuntu 20.04 -* MacOS Big Sur -* Windows 10 WSL2 - Ubuntu 20.04 +- [.NET 8 SDK](https://dotnet.microsoft.com/download) for building/running the samples. +- [Approov CLI](https://approov.io/docs/latest/approov-installation/#approov-tool) with an account that can manage API domains and secrets. +- An API domain registered with Approov: `approov api -add your.api.domain.com`. +- The account secret exported in base64 form. Enable the admin role (`eval \`approov role admin\`` on Unix shells or `set APPROOV_ROLE=admin:` in PowerShell) and run `approov secret -get base64`. -### Running the sample APIs without Docker +When using symmetric signing (HS256) you must keep the secret confidential. Approov also supports asymmetric keys; see [Managing Key Sets](https://approov.io/docs/latest/approov-usage-documentation/#managing-key-sets) for guidance. -The repository now ships with a helper script that launches the demo servers directly with the local .NET SDK. From the repository root run: -```bash -./scripts/run-local.sh all -``` +## Getting Started -This starts the unprotected sample on port `8001` and the consolidated Approov-protected sample on port `8111`. Press `Ctrl+C` to stop them. You can also launch an individual backend, for example `./scripts/run-local.sh token-check`. The script will automatically create a `.env` file from `.env.example` for the Approov-protected project if one is not already present. +1. Copy the environment template and add your secret: + ```bash + cp servers/hello/src/approov-protected-server/token-check/.env.example \ + servers/hello/src/approov-protected-server/token-check/.env + ``` + Edit `.env` and set `APPROOV_BASE64_SECRET` to the value returned by `approov secret -get base64`. The optional variables in that file enable token binding and message signature policy enforcement. -First, setup the [Approov CLI](https://approov.io/docs/latest/approov-installation/index.html#initializing-the-approov-cli). +2. Run the sample APIs with the local .NET SDK: + ```bash + ./scripts/run-local.sh all + ``` + The script launches the unprotected server on `8001` and the Approov-protected server on `8111`. Press `Ctrl+C` to stop both. Launch a single backend with `./scripts/run-local.sh token-check`. -Now, register the API domain for which Approov will issues tokens: +3. Exercise the protections using the helper scripts: + ```bash + ./test-scripts/request_tests_approov_msg.sh 8111 + ./test-scripts/request_tests_sfv.sh 8111 + ``` + These scripts cover token validation, token binding, canonical message reconstruction, and signature verification. -```bash -approov api -add api.example.com -``` -> **NOTE:** By default a symmetric key (HS256) is used to sign the Approov token on a valid attestation of the mobile app for each API domain it's added with the Approov CLI, so that all APIs will share the same secret and the backend needs to take care to keep this secret secure. -> -> A more secure alternative is to use asymmetric keys (RS256 or others) that allows for a different keyset to be used on each API domain and for the Approov token to be verified with a public key that can only verify, but not sign, Approov tokens. -> -> To implement the asymmetric key you need to change from using the symmetric HS256 algorithm to an asymmetric algorithm, for example RS256, that requires you to first [add a new key](https://approov.io/docs/latest/approov-usage-documentation/#adding-a-new-key), and then specify it when [adding each API domain](https://approov.io/docs/latest/approov-usage-documentation/#keyset-key-api-addition). Please visit [Managing Key Sets](https://approov.io/docs/latest/approov-usage-documentation/#managing-key-sets) on the Approov documentation for more details. +## Implementing Approov in Your Project -Next, enable your Approov `admin` role with: +Follow the detailed quickstarts to bring the same protections into your own API: -```bash -eval `approov role admin` -```` +- [Token validation quickstart](docs/APPROOV_TOKEN_QUICKSTART.md) - integrate the middleware that enforces Approov tokens. +- [Token binding quickstart](docs/APPROOV_TOKEN_BINDING_QUICKSTART.md) - bind Approov tokens to request headers such as `Authorization`. +- [Message signing quickstart](docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md) - verify HTTP message signatures using the installation public key included in the Approov token. -For the Windows powershell: +Each guide includes package requirements, configuration snippets, and testing instructions that match the code in this repository. -```bash -set APPROOV_ROLE=admin:___YOUR_APPROOV_ACCOUNT_NAME_HERE___ -``` -Now, get your Approov Secret with the [Approov CLI](https://approov.io/docs/latest/approov-installation/index.html#initializing-the-approov-cli): +## Testing and Examples -```bash -approov secret -get base64 -``` +- [TESTING.md](TESTING.md) summarises manual and automated test options, including how to use the published dummy secret for local verification. +- [EXAMPLES.md](EXAMPLES.md) explains the sample server layout and optional Docker workflow. +- Run unit tests for the helper components with `dotnet test tests/Hello.Tests/Hello.Tests.csproj`. -Next, add the [Approov secret](https://approov.io/docs/latest/approov-usage-documentation/#account-secret-key-export) to your project `.env` file: -```env -APPROOV_BASE64_SECRET=approov_base64_secret_here -``` +## Additional Resources -Now, add to your `appname.csproj` file the dependencies: +- [Approov Overview](OVERVIEW.md) +- [Approov Quickstarts](QUICKSTARTS.md) +- [Approov Integration Examples](EXAMPLES.md) -```xml - - - - - -``` - -Next, in `Program.cs` load the secrets from the `.env` file and inject it into `AppSettiongs`: - -```c# -using AppName.Helpers; - -DotNetEnv.Env.Load(); - -var approovBase64Secret = DotNetEnv.Env.GetString("APPROOV_BASE64_SECRET"); - -if(approovBase64Secret == null) { - throw new Exception("Missing the env var APPROOV_BASE64_SECRET or its empty."); -} - -var approovSecretBytes = System.Convert.FromBase64String(approovBase64Secret); - -var builder = WebApplication.CreateBuilder(args); -builder.Services.Configure(appSettings => { - appSettings.ApproovSecretBytes = approovSecretBytes; -}); - -// ... omitted boilerplate and/or your code - -var app = builder.Build(); - -// Needs to be the first. No need to process other stuff in the request if the -// request isn't deemed as trustworthy by having a valid Approov token that -// hasn't expired yet. -app.UseMiddleware(); - -// ... omitted boilerplate and/or your code -``` - -Now, let's add the class to load the app settings: - -```c# -namespace AppName.Helpers; - -public class AppSettings -{ - public byte[] ?ApproovSecretBytes { get; set; } -} -``` - -Next, add the `ApproovTokenMiddleware` class to your project: - -```c# -namespace AppName.Middleware; - -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Text; -using AppName.Helpers; -using System.Security.Claims; - -public class ApproovTokenMiddleware -{ - private readonly RequestDelegate _next; - private readonly AppSettings _appSettings; - private readonly ILogger _logger; - - public ApproovTokenMiddleware(RequestDelegate next, IOptions appSettings, ILogger logger) - { - _next = next; - _appSettings = appSettings.Value; - _logger = logger; - } - - public async Task Invoke(HttpContext context) - { - var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); - - if (token == null) { - _logger.LogInformation("Missing Approov-Token header."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - if (verifyApproovToken(context, token)) { - await _next(context); - return; - } - - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - private bool verifyApproovToken(HttpContext context, string token) - { - try - { - var tokenHandler = new JwtSecurityTokenHandler(); - - tokenHandler.ValidateToken(token, new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(_appSettings.ApproovSecretBytes), - ValidateIssuer = false, - ValidateAudience = false, - // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) - ClockSkew = TimeSpan.Zero - }, out SecurityToken validatedToken); - - var jwtToken = (JwtSecurityToken)validatedToken; - var claims = jwtToken.Claims; - - var payClaim = claims.FirstOrDefault(x => x.Type == "pay")?.Value; - - context.Items["ApproovTokenBinding"] = payClaim; - - return true; - } catch (SecurityTokenException exception) { - _logger.LogInformation(exception.Message); - return false; - } catch (Exception exception) { - _logger.LogInformation(exception.Message); - return false; - } - } -} -``` - -> **NOTE:** When the Approov token validation fails we return a `401` with an empty body, because we don't want to give clues to an attacker about the reason the request failed, and you can go even further by returning a `400`. - -Not enough details in the bare bones quickstart? No worries, check the [detailed quickstarts](QUICKSTARTS.md) that contain a more comprehensive set of instructions, including how to test the Approov integration. - - -## More Information - -* [Approov Overview](OVERVIEW.md) -* [Detailed Quickstarts](QUICKSTARTS.md) -* [Examples](EXAMPLES.md) -* [Testing](TESTING.md) - -### System Clock - -In order to correctly check for the expiration times of the Approov tokens is very important that the backend server is synchronizing automatically the system clock over the network with an authoritative time source. In Linux this is usually done with a NTP server. +Keep the backend clock synchronised with an authoritative time source (for example via NTP). Accurate clocks are essential when checking JWT expiry times and HTTP message signature lifetimes. ## Issues -If you find any issue while following our instructions then just report it [here](https://github.com/approov/quickstart-asp.net-token-check/issues), with the steps to reproduce it, and we will sort it out and/or guide you to the correct path. - - -[TOC](#toc---table-of-contents) +Report problems or request enhancements via [GitHub issues](https://github.com/approov/quickstart-asp.net-token-check/issues). Include reproduction steps so we can assist quickly. ## Useful Links -If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: - -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) -* [Approov Get Started](https://approov.io/product/demo) -* [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) -* [Approov Docs](https://approov.io/docs) -* [Approov Blog](https://approov.io/blog/) -* [Approov Resources](https://approov.io/resource/) -* [Approov Customer Stories](https://approov.io/customer) -* [Approov Support](https://approov.io/contact) -* [About Us](https://approov.io/company) -* [Contact Us](https://approov.io/contact) - -[TOC](#toc---table-of-contents) +- [Approov Free Trial](https://approov.io/signup) (no credit card needed) +- [Approov Product Tour](https://approov.io/product/demo) +- [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) +- [Approov Docs](https://approov.io/docs) +- [Approov Blog](https://approov.io/blog/) +- [Approov Resources](https://approov.io/resource/) +- [Approov Customer Stories](https://approov.io/customer) +- [Approov Support](https://approov.io/contact) diff --git a/TESTING.md b/TESTING.md index 08ea765..32522bd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,20 +6,19 @@ Each Quickstart has at their end a dedicated section for testing, that will walk you through the necessary steps to use the Approov CLI to generate valid and invalid tokens to test your Approov integration without the need to rely on the genuine mobile app(s) using your backend. -* [Approov Token](/docs/APPROOV_TOKEN_QUICKSTART.md#test-your-approov-integration) test examples. -* [Approov Token Binding](/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md#test-your-approov-integration) test examples. +* [Approov Token](/docs/APPROOV_TOKEN_QUICKSTART.md#testing) test examples. +* [Approov Token Binding](/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md#testing) test examples. +* [Approov Message Signing](/docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md#testing) test examples. -### Testing with Postman +### Testing with Scripts and Tools -A ready-to-use Postman collection can be found [here](https://raw.githubusercontent.com/approov/postman-collections/master/quickstarts/hello-world/hello-world.postman_collection.json). It contains a comprehensive set of example requests to send to the backend server for testing. The collection contains requests with valid and invalid Approov tokens, and with and without token binding. - -### Testing with Curl - -An alternative to the Postman collection is to use cURL to make the API requests. Check some examples [here](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). +* **Shell scripts** - `./test-scripts/request_tests_approov_msg.sh` executes a suite of token, token binding, and message signing requests. `./test-scripts/request_tests_sfv.sh` exercises the structured field helpers. +* **Postman** - a ready-to-use Postman collection can be found [here](https://raw.githubusercontent.com/approov/postman-collections/master/quickstarts/hello-world/hello-world.postman_collection.json). It contains a comprehensive set of example requests with and without token binding. +* **cURL** - example curl commands are available [here](https://github.com/approov/postman-collections/blob/master/quickstarts/hello-world/hello-world.postman_curl_requests_examples.md). ### The Dummy Secret -The valid Approov tokens in the Postman collection and cURL requests examples were signed with a dummy secret that was generated with `openssl rand -base64 64 | tr -d '\n'; echo`, therefore not a production secret retrieved with `approov secret -get base64`, thus in order to use it you need to set the `APPROOV_BASE64_SECRET`, in the `.env` file for each [Approov integration example](/src/approov-protected-server), to the following value: `h+CX0tOzdAAR9l15bWAqvq7w9olk66daIH+Xk+IAHhVVHszjDzeGobzNnqyRze3lw/WVyWrc2gZfh3XXfBOmww==`. +The valid Approov tokens in the Postman collection and cURL requests examples were signed with a dummy secret that was generated with `openssl rand -base64 64 | tr -d '\n'; echo`, therefore not a production secret retrieved with `approov secret -get base64`, thus in order to use it you need to set the `APPROOV_BASE64_SECRET`, in the `.env` file for each [Approov integration example](/servers/hello/src/approov-protected-server/token-check), to the following value: `h+CX0tOzdAAR9l15bWAqvq7w9olk66daIH+Xk+IAHhVVHszjDzeGobzNnqyRze3lw/WVyWrc2gZfh3XXfBOmww==`. ## Issues @@ -31,7 +30,7 @@ If you find any issue while following our instructions then just report it [here If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) +* [Approov Free Trial](https://approov.io/signup) (no credit card needed) * [Approov Get Started](https://approov.io/product/demo) * [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) * [Approov Docs](https://approov.io/docs) diff --git a/docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md b/docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md new file mode 100644 index 0000000..3e07ca9 --- /dev/null +++ b/docs/APPROOV_MESSAGE_SIGNING_QUICKSTART.md @@ -0,0 +1,155 @@ +# Approov Message Signing Quickstart + +Message signing protects against tampering and replay by requiring each request to carry an HTTP message signature generated on the device. This guide explains how the ASP.NET quickstart verifies those signatures using the *installation public key* (`ipk`) delivered inside the Approov token. + +- [Overview](#overview) +- [Requirements](#requirements) +- [Configuration](#configuration) +- [Message signature verifier](#message-signature-verifier) +- [Middleware registration](#middleware-registration) +- [Testing](#testing) + + +## Overview + +When the Approov SDK is configured for message signing it embeds an installation public key in the `ipk` claim of the Approov token. The SDK also signs a canonical representation of the HTTP request and sends the components via the [`Signature`](https://www.rfc-editor.org/rfc/rfc9421) and `Signature-Input` headers. + +On the server we: + +1. Extract the `ipk` claim in the token middleware. +2. Rebuild the canonical message using the component identifiers from `Signature-Input`. +3. Verify that the supplied signature (`Signature` header) matches the canonical message using ECDSA P-256. +4. Optionally enforce signature freshness (`created`, `expires`) and presence of a `Content-Digest` header. + +The full implementation is in `Helpers/ApproovMessageSignatureVerifier.cs` and `Middleware/MessageSigningMiddleware.cs`. + + +## Requirements + +- Complete the [token validation](APPROOV_TOKEN_QUICKSTART.md) quickstart so the `Approov-Token` header is already enforced. +- Approov mobile SDK configured to enable message signing and include the `ipk` claim. +- `StructuredFieldValues` NuGet package (the sample uses version `0.7.6`) to parse RFC 8941 structured fields. + + +## Configuration + +The verifier exposes a few policy knobs that you can tune via configuration. In `.env` they are named: + +```env +APPROOV_SIGNATURE_REQUIRE_CREATED=true +APPROOV_SIGNATURE_REQUIRE_EXPIRES=false +APPROOV_SIGNATURE_MAX_AGE_SECONDS= +APPROOV_SIGNATURE_CLOCK_SKEW_SECONDS= +``` + +- `APPROOV_SIGNATURE_REQUIRE_CREATED` - require the `created` parameter. Defaults to `true`. +- `APPROOV_SIGNATURE_REQUIRE_EXPIRES` - require the `expires` parameter. Defaults to `false`. +- `APPROOV_SIGNATURE_MAX_AGE_SECONDS` - reject signatures older than the configured window. +- `APPROOV_SIGNATURE_CLOCK_SKEW_SECONDS` - allow small clock differences between client and server. + +Load the settings in `Program.cs` and bind them to `MessageSignatureValidationOptions`: + +```csharp +builder.Services.Configure(options => +{ + options.RequireCreated = ReadBoolean(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_REQUIRE_CREATED"), true); + options.RequireExpires = ReadBoolean(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_REQUIRE_EXPIRES"), false); + options.MaximumSignatureAge = ReadTimeSpanFromSeconds(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_MAX_AGE_SECONDS")); + options.AllowedClockSkew = ReadTimeSpanFromSeconds(DotNetEnv.Env.GetString("APPROOV_SIGNATURE_CLOCK_SKEW_SECONDS")) ?? TimeSpan.Zero; +}); + +builder.Services.AddSingleton(); +``` + +The helper methods `ReadBoolean` and `ReadTimeSpanFromSeconds` are shown in the sample `Program.cs`. + + +## Message Signature Verifier + +The verifier reconstructs the canonical message, validates the metadata, and checks the signature. A simplified version of the entry point is shown below: + +- Combine split header values using `StructuredFieldFormatter.CombineHeaderValues`. +- Parse the `Signature` and `Signature-Input` dictionaries with `SfvParser.ParseDictionary`, ensuring both share the same label (`install`). +- Extract and validate the metadata parameters (algorithm, `created`, `expires`, `nonce`, `tag`) using `TryExtractSignatureMetadata`. +- Rebuild the canonical payload via `BuildCanonicalMessageAsync`, honouring pseudo headers such as `@method` and `@target-uri`. +- Optionally verify `Content-Digest` headers (currently supports `sha-256` and `sha-512`) so the request body cannot be swapped. +- Validate the ECDSA P-256 signature with `TryVerifySignature`, using the decoded installation public key from the Approov token. + +See `Helpers/ApproovMessageSignatureVerifier.cs` for the full implementation along with detailed error reporting and logging hooks. + + +## Middleware Registration + +Add the message signing middleware after the token binding middleware (if used). It extracts the `ipk` claim and invokes the verifier whenever a signature is present: + +```csharp +public class MessageSigningMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly ApproovMessageSignatureVerifier _verifier; + + public MessageSigningMiddleware( + RequestDelegate next, + ILogger logger, + ApproovMessageSignatureVerifier verifier) + { + _next = next; + _logger = logger; + _verifier = verifier; + } + + public async Task InvokeAsync(HttpContext context) + { + var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(token)) + { + await _next(context); + return; + } + + var installationPublicKey = ExtractInstallationPublicKey(token); + if (string.IsNullOrWhiteSpace(installationPublicKey)) + { + await _next(context); + return; + } + + var result = await _verifier.VerifyAsync(context, installationPublicKey); + if (!result.Success) + { + _logger.LogWarning("Message signing verification failed: {Reason}", result.Error); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("Invalid Token"); + return; + } + + await _next(context); + } +} +``` + +Register it after the token (and token binding) middleware: + +```csharp +app.UseMiddleware(); +app.UseMiddleware(); // optional +app.UseMiddleware(); +``` + + +## Testing + +1. Start the sample backend with the dummy secret from [TESTING.md](../TESTING.md#the-dummy-secret). +2. Use the helper script to generate deterministic signatures and send requests: + ```bash + ./test-scripts/request_tests_approov_msg.sh 8111 + ``` + The script exercises GET and POST requests, canonical component ordering, and `Content-Digest` enforcement. +3. For manual testing: + - Obtain a valid Approov token containing an `ipk` claim. + - Compute the canonical message base used by the SDK (method, target URI, headers). + - Sign the canonical payload with the matching private key (ECDSA P-256, IEEE P1363 format). + - Send the request with `Approov-Token`, `Signature`, and `Signature-Input` headers. + +If the signature is missing, malformed, or fails verification the middleware returns HTTP 401. Missing headers listed in the signature components yield HTTP 400 with an explanatory log entry. diff --git a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md index ecbe935..1b4eb3d 100644 --- a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md +++ b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md @@ -1,273 +1,154 @@ # Approov Token Binding Quickstart -This quickstart is for developers familiar with ASP.Net who are looking for a quick intro into how they can add [Approov](https://approov.io) into an existing project. Therefore this will guide you through the necessary steps for adding Approov with token binding to an existing ASP.Net API server. +This guide builds on the [Approov token quickstart](APPROOV_TOKEN_QUICKSTART.md) to enforce token binding. Binding ties the `Approov-Token` to one or more request headers (for example the `Authorization` header) so an attacker cannot replay a token with a different credential. -## TOC - Table of Contents +- [Overview](#overview) +- [Requirements](#requirements) +- [Configuration](#configuration) +- [Middleware](#middleware) +- [Testing](#testing) -* [Why?](#why) -* [How it Works?](#how-it-works) -* [Requirements](#requirements) -* [Approov Setup](#approov-setup) -* [Approov Token Check](#approov-token-check) -* [Try the Approov Integration Example](#try-the-approov-integration-example) +## Overview -## Why? +The Approov mobile SDK hashes selected header values and places the result in the `pay` claim inside the Approov token. The backend recomputes the hash using the request headers and compares it against the claim. If any header is missing or the hashes differ, the request is rejected. -To lock down your API server to your mobile app. Please read the brief summary in the [Approov Overview](/OVERVIEW.md#why) at the root of this repo or visit our [website](https://approov.io/product) for more details. - -[TOC](#toc---table-of-contents) - - -## How it works? - -For more background, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. - -Take a look at the `verifyApproovToken()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs) class to see the simple code for the Approov Token check. To also see the code for the Approov token binding check you just need to look for the `VerifyApproovTokenBinding()` function at the [ApproovTokenBindingMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs) class. - -[TOC](#toc---table-of-contents) +The sample implementation is available in `Middleware/ApproovTokenBindingMiddleware.cs`. ## Requirements -To complete this quickstart you will need both the .Net SDK and the Approov CLI tool installed. - -* [.NET 6 SDK](https://docs.microsoft.com/en-us/dotnet/core/install/) -* Approov CLI - Follow our [installation instructions](https://approov.io/docs/latest/approov-installation/#approov-tool) and read more about each command and its options in the [documentation reference](https://approov.io/docs/latest/approov-cli-tool-reference/) - -[TOC](#toc---table-of-contents) - - -## Approov Setup - -To use Approov with the ASP.Net API server we need a small amount of configuration. First, Approov needs to know the API domain that will be protected. Second, the ASP.Net API server needs the Approov Base64 encoded secret that will be used to verify the tokens generated by the Approov cloud service. - -### Configure API Domain - -Approov needs to know the domain name of the API for which it will issue tokens. - -Add it with: - -```bash -approov api -add your.api.domain.com -``` - -> **NOTE:** By default a symmetric key (HS256) is used to sign the Approov token on a valid attestation of the mobile app for each API domain it's added with the Approov CLI, so that all APIs will share the same secret and the backend needs to take care to keep this secret secure. -> -> A more secure alternative is to use asymmetric keys (RS256 or others) that allows for a different keyset to be used on each API domain and for the Approov token to be verified with a public key that can only verify, but not sign, Approov tokens. -> -> To implement the asymmetric key you need to change from using the symmetric HS256 algorithm to an asymmetric algorithm, for example RS256, that requires you to first [add a new key](https://approov.io/docs/latest/approov-usage-documentation/#adding-a-new-key), and then specify it when [adding each API domain](https://approov.io/docs/latest/approov-usage-documentation/#keyset-key-api-addition). Please visit [Managing Key Sets](https://approov.io/docs/latest/approov-usage-documentation/#managing-key-sets) on the Approov documentation for more details. - -Adding the API domain also configures the [dynamic certificate pinning](https://approov.io/docs/latest/approov-usage-documentation/#dynamic-pinning) setup, out of the box. - -> **NOTE:** By default the pin is extracted from the public key of the leaf certificate served by the domain, as visible to the box issuing the Approov CLI command and the Approov servers. - -### Approov Secret - -Approov tokens are signed with a symmetric secret. To verify tokens, we need to grab the secret using the [Approov secret command](https://approov.io/docs/latest/approov-cli-tool-reference/#secret-command) and plug it into the ASP.Net API server environment to check the signatures of the [Approov Tokens](https://www.approov.io/docs/latest/approov-usage-documentation/#approov-tokens) that it processes. - -First, enable your Approov `admin` role with: - -```bash -eval `approov role admin` -``` - -For the Windows powershell: - -```bash -set APPROOV_ROLE=admin:___YOUR_APPROOV_ACCOUNT_NAME_HERE___ - -``` - -Next, retrieve the Approov secret with: - -```bash -approov secret -get base64 -``` - -#### Set the Approov Secret - -Open the `.env` file and add the Approov secret to the var: - -```bash -APPROOV_BASE64_SECRET=approov_base64_secret_here -APPROOV_TOKEN_BINDING_HEADER=Authorization -``` - -[TOC](#toc---table-of-contents) - - -## Approov Token Check - -First, add to your `appname.csproj` file the dependencies: - -```xml - - - - - -``` - -Next, let's add the class to load the app settings: - -```c# -namespace AppName.Helpers; - - -public class AppSettings -{ - public byte[] ?ApproovSecretBytes { get; set; } - public string? TokenBindingHeader { get; set; } -} -``` - -Add a helper to keep context item keys in one place: - -```c# -namespace AppName.Helpers; - -public static class ApproovTokenContextKeys -{ - public const string TokenBinding = "ApproovTokenBinding"; - public const string TokenBindingVerified = "ApproovTokenBindingVerified"; -} -``` - -Now, add the `ApproovTokenMiddleware` class to your project: - -```c# -namespace AppName.Middleware; - -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; +- Complete the [token validation quickstart](APPROOV_TOKEN_QUICKSTART.md). Token binding builds on the middleware and configuration shown there. +- Approov CLI 3.2 or later (required to generate example tokens with binding claims). + + +## Configuration + +1. **Extend the settings class** + Add a header list to `AppSettings` so the binding middleware knows which headers to hash: + ```csharp + public class AppSettings + { + public byte[]? ApproovSecretBytes { get; set; } + public IList TokenBindingHeaders { get; set; } = new List(); + } + ``` + +2. **Parse the binding headers in `Program.cs`** + Accept a comma-separated list via the environment: + ```csharp + var bindingHeaderRaw = DotNetEnv.Env.GetString("APPROOV_TOKEN_BINDING_HEADER"); + var bindingHeaders = (bindingHeaderRaw ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .ToList(); + + builder.Services.Configure(settings => + { + settings.ApproovSecretBytes = approovSecretBytes; + settings.TokenBindingHeaders = bindingHeaders; + }); + ``` + + In `.env` supply the headers that must be present on each request. When multiple headers are listed their trimmed values are concatenated before hashing: + ```env + APPROOV_TOKEN_BINDING_HEADER=Authorization, X-Device-Id + ``` + It's crucial that client mobile app mirrors the server configuration exactly. In case you are using multiple headers as binding tokens, please note that order of the headers matters. + +3. **Share context keys** + The token middleware stores the `pay` claim in `HttpContext.Items`. Keep the keys in one place: + ```csharp + namespace YourApp.Helpers; + + public static class ApproovTokenContextKeys + { + public const string TokenBinding = "ApproovTokenBinding"; + public const string TokenBindingVerified = "ApproovTokenBindingVerified"; + } + ``` + + +## Middleware + +Insert the binding middleware immediately after the token middleware. It recomputes the SHA-256 hash of the configured headers and compares it against the `pay` claim. + +```csharp +namespace YourApp.Middleware; + +using System.Collections.Generic; +using System.Security.Cryptography; using System.Text; -using AppName.Helpers; -using System.Security.Claims; +using Microsoft.Extensions.Options; +using YourApp.Helpers; -public class ApproovTokenMiddleware +public class ApproovTokenBindingMiddleware { private readonly RequestDelegate _next; - private readonly AppSettings _appSettings; - private readonly ILogger _logger; + private readonly AppSettings _settings; + private readonly ILogger _logger; - public ApproovTokenMiddleware(RequestDelegate next, IOptions appSettings, ILogger logger) + public ApproovTokenBindingMiddleware( + RequestDelegate next, + IOptions settings, + ILogger logger) { _next = next; - _appSettings = appSettings.Value; + _settings = settings.Value; _logger = logger; } public async Task Invoke(HttpContext context) { - var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); + var payClaim = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var value) + ? value as string + : null; - if (token == null) { - _logger.LogInformation("Missing Approov-Token header."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; + if (string.IsNullOrWhiteSpace(payClaim)) + { + _logger.LogDebug("Token binding skipped: pay claim missing"); + await _next(context); return; } - if (verifyApproovToken(context, token)) { + if (_settings.TokenBindingHeaders is null || _settings.TokenBindingHeaders.Count == 0) + { + _logger.LogDebug("Token binding skipped: no headers configured"); await _next(context); return; } - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } + var builder = new StringBuilder(); + var missingHeaders = new List(); - private bool verifyApproovToken(HttpContext context, string token) - { - try + foreach (var header in _settings.TokenBindingHeaders) { - var tokenHandler = new JwtSecurityTokenHandler(); - - tokenHandler.ValidateToken(token, new TokenValidationParameters + var valueToHash = context.Request.Headers[header].ToString(); + if (string.IsNullOrWhiteSpace(valueToHash)) { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(_appSettings.ApproovSecretBytes), - ValidateIssuer = false, - ValidateAudience = false, - // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) - ClockSkew = TimeSpan.Zero - }, out SecurityToken validatedToken); - - var jwtToken = (JwtSecurityToken)validatedToken; - var claims = jwtToken.Claims; - - var payClaim = claims.FirstOrDefault(x => x.Type == "pay")?.Value; - if (!string.IsNullOrWhiteSpace(payClaim)) - { - context.Items[ApproovTokenContextKeys.TokenBinding] = payClaim; + missingHeaders.Add(header); + continue; } - return true; - } catch (SecurityTokenException exception) { - _logger.LogInformation(exception.Message); - return false; - } catch (Exception exception) { - _logger.LogInformation(exception.Message); - return false; - } - } -} -``` - -Next, add the `ApproovTokenBindingMiddleware` class: -//TODO: This needs to be updated to reflect current code. -```c# -namespace AppName.Middleware; - -using System.Security.Cryptography; -using System.Text; -using AppName.Helpers; -using Microsoft.Extensions.Primitives; - -public class ApproovTokenBindingMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - public ApproovTokenBindingMiddleware(RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger; - } - - public async Task Invoke(HttpContext context) - { - var tokenBinding = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingValue) - ? bindingValue as string - : null; - - if (string.IsNullOrWhiteSpace(tokenBinding)) - { - await _next(context); - return; + builder.Append(valueToHash.Trim()); } - var authorizationHeader = context.Request.Headers["Authorization"]; - var authorizationValue = CombineHeaderValues(authorizationHeader); - if (string.IsNullOrWhiteSpace(authorizationValue)) + if (missingHeaders.Count > 0) { - _logger.LogInformation("Approov token binding requested but Authorization header is missing."); + _logger.LogInformation("Token binding header(s) missing: {Headers}", string.Join(", ", missingHeaders)); context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } - var bindingCandidates = new List { authorizationValue }; - - var deviceIdValue = CombineHeaderValues(context.Request.Headers["X-Device-Id"]); - if (!string.IsNullOrWhiteSpace(deviceIdValue)) - { - bindingCandidates.Add(string.Concat(authorizationValue, deviceIdValue)); - } + var concatenated = builder.ToString(); + var computedHash = Sha256Base64(concatenated); - if (!VerifyApproovTokenBinding(bindingCandidates, tokenBinding)) + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(payClaim), + Encoding.UTF8.GetBytes(computedHash))) { - _logger.LogInformation("Invalid Approov token binding."); + _logger.LogInformation("Token binding verification failed"); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } @@ -276,202 +157,40 @@ public class ApproovTokenBindingMiddleware await _next(context); } - private static bool VerifyApproovTokenBinding(IEnumerable bindingSources, string tokenBinding) - { - foreach (var candidate in bindingSources) - { - if (string.IsNullOrWhiteSpace(candidate)) - { - continue; - } - - var computedHash = Sha256Base64Encoded(candidate); - if (CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(tokenBinding), - Encoding.UTF8.GetBytes(computedHash))) - { - return true; - } - } - - return false; - } - - private static string Sha256Base64Encoded(string input) + private static string Sha256Base64(string input) { using var sha256 = SHA256.Create(); - var inputBytes = Encoding.UTF8.GetBytes(input); - var hashBytes = sha256.ComputeHash(inputBytes); - return Convert.ToBase64String(hashBytes); + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha256.ComputeHash(bytes); + return Convert.ToBase64String(hash); } - - private static string CombineHeaderValues(StringValues values) - { - if (values.Count == 0) - { - return string.Empty; - } - - if (values.Count == 1) - { - return values[0].Trim(); - } - - var builder = new StringBuilder(); - for (var i = 0; i < values.Count; i++) - { - if (i > 0) - { - builder.Append(','); - } - - builder.Append(values[i].Trim()); - } - - return builder.ToString(); - } -} -``` - -> **Tip:** The quickstart tests bind to the `Authorization` header. Set `APPROOV_TOKEN_BINDING_HEADER=Authorization` in your environment (or `.env`) to mirror the sample requests. Leaving the variable empty disables token binding checks entirely. - -Now, in `Program.cs` load the secrets from the `.env` file and inject it into `AppSettiongs`: - -```c# -using AppName.Helpers; - -DotNetEnv.Env.Load(); - -var approovBase64Secret = DotNetEnv.Env.GetString("APPROOV_BASE64_SECRET"); - -if(approovBase64Secret == null) { - throw new Exception("Missing the env var APPROOV_BASE64_SECRET or its empty."); } - -var approovSecretBytes = System.Convert.FromBase64String(approovBase64Secret); -var tokenBindingHeader = DotNetEnv.Env.GetString("APPROOV_TOKEN_BINDING_HEADER"); - -var builder = WebApplication.CreateBuilder(args); -builder.Services.Configure(appSettings => { - appSettings.ApproovSecretBytes = approovSecretBytes; - appSettings.TokenBindingHeader = tokenBindingHeader; -}); - -// ... omitted boilerplate and/or your code - -var app = builder.Build(); - -// Needs to be the first. No need to process other stuff in the request if the -// request isn't deemed as trustworthy by having a valid Approov token that -// hasn't expired yet. -app.UseMiddleware(); -app.UseMiddleware(); - -// ... omitted boilerplate and/or your code -``` - -> **NOTE:** When the Approov token validation fails we return a `401` with an empty body, because we don't want to give clues to an attacker about the reason the request failed, and you can go even further by returning a `400`. - -A full working example for a simple Hello World server can be found at [servers/hello/src/approov-protected-server/token-check](/servers/hello/src/approov-protected-server/token-check). - -[TOC](#toc---table-of-contents) - - -## Test your Approov Integration - -The following examples below use cURL, but you can also use the [Postman Collection](/README.md#testing-with-postman) to make the API requests. Just remember that you need to adjust the urls and tokens defined in the collection to match your deployment. Alternatively, the above README also contains instructions for using the preset _dummy_ secret to test your Approov integration. - -#### With Valid Approov Tokens - -Generate a valid token example from the Approov Cloud service: - -```bash -approov token -setDataHashInToken 'Bearer authorizationtoken' -genExample your.api.domain.com -``` - -Then make the request with the generated token: - -```bash -curl -i --request GET 'https://your.api.domain.com/v1/shapes' \ - --header 'Authorization: Bearer authorizationtoken' \ - --header 'Approov-Token: APPROOV_TOKEN_EXAMPLE_HERE' -``` - -The request should be accepted. For example: - -```text -HTTP/2 200 - -... - -{"message": "Hello, World!"} -``` - -#### With Invalid Approov Tokens - -##### No Authorization Token - -Let's just remove the Authorization header from the request: - -```bash -curl -i --request GET 'https://your.api.domain.com/v1/shapes' \ - --header 'Approov-Token: APPROOV_TOKEN_EXAMPLE_HERE' ``` -The above request should fail with an Unauthorized error. For example: - -```text -HTTP/2 401 - -... +Register the middleware in `Program.cs` after the token middleware: -{} +```csharp +app.UseMiddleware(); +app.UseMiddleware(); ``` -##### Same Approov Token with a Different Authorization Token - -Make the request with the same generated token, but with another random authorization token: - -```bash -curl -i --request GET 'https://your.api.domain.com/v1/shapes' \ - --header 'Authorization: Bearer anotherauthorizationtoken' \ - --header 'Approov-Token: APPROOV_TOKEN_EXAMPLE_HERE' -``` - -The above request should also fail with an Unauthorized error. For example: - -```text -HTTP/2 401 - -... - -{} -``` - -[TOC](#toc---table-of-contents) - - -## Issues - -If you find any issue while following our instructions then just report it [here](https://github.com/approov/quickstart-asp.net-token-check/issues), with the steps to reproduce it, and we will sort it out and/or guide you to the correct path. - - -[TOC](#toc---table-of-contents) +## Testing -## Useful Links +1. Generate a bound token example. The string supplied to `-setDataHashInToken` must exactly match the header value(s) that the client will send. For a bearer token: + ```bash + approov token \ + -setDataHashInToken 'Bearer authorizationtoken' \ + -genExample your.api.domain.com + ``` -If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: +2. Call your endpoint with both the `Approov-Token` and `Authorization` headers: + ```bash + curl -i https://your.api.domain.com/hello \ + -H "Authorization: Bearer authorizationtoken" \ + -H "Approov-Token: " + ``` -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) -* [Approov Get Started](https://approov.io/product/demo) -* [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) -* [Approov Docs](https://approov.io/docs) -* [Approov Blog](https://approov.io/blog/) -* [Approov Resources](https://approov.io/resource/) -* [Approov Customer Stories](https://approov.io/customer) -* [Approov Support](https://approov.io/contact) -* [About Us](https://approov.io/company) -* [Contact Us](https://approov.io/contact) + Expect HTTP 200 for matching values, HTTP 400 if the binding header is missing, and HTTP 401 if the header value differs. -[TOC](#toc---table-of-contents) +Refer to [TESTING.md](../TESTING.md) for additional tooling options and ready-made scripts that exercise the binding logic. diff --git a/docs/APPROOV_TOKEN_QUICKSTART.md b/docs/APPROOV_TOKEN_QUICKSTART.md index 5bf102f..7b6130b 100644 --- a/docs/APPROOV_TOKEN_QUICKSTART.md +++ b/docs/APPROOV_TOKEN_QUICKSTART.md @@ -1,316 +1,195 @@ # Approov Token Quickstart -This quickstart is for developers familiar with ASP.Net who are looking for a quick intro into how they can add [Approov](https://approov.io) into an existing project. Therefore this will guide you through the necessary steps for adding Approov to an existing ASP.Net API server. +This quickstart shows how to enforce Approov tokens in an existing ASP.NET 8 API. The code samples match the implementation in `servers/hello/src/approov-protected-server/token-check`, so you can copy/paste with confidence and review the complete project for context. -## TOC - Table of Contents +- [Why](#why) +- [Requirements](#requirements) +- [Approov setup](#approov-setup) +- [Server changes](#server-changes) +- [Testing](#testing) -* [Why?](#why) -* [How it Works?](#how-it-works) -* [Requirements](#requirements) -* [Approov Setup](#approov-setup) -* [Approov Token Check](#approov-token-check) -* [Try the Approov Integration Example](#try-the-approov-integration-example) +## Why -## Why? - -To lock down your API server to your mobile app. Please read the brief summary in the [Approov Overview](/OVERVIEW.md#why) at the root of this repo or visit our [website](https://approov.io/product) for more details. - -[TOC](#toc---table-of-contents) - - -## How it works? - -For more background, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. - -The main functionality for the Approov token check is in the `verifyApproovToken()` function at the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs) class, that you should take a look at to see how simple the code is for the token check. - -[TOC](#toc---table-of-contents) +Approov ensures that API requests originate from attested builds of your mobile apps. Tokens issued by the Approov cloud service are presented in the `Approov-Token` header by the mobile SDK and must be validated by your backend before the request is processed. See the [Approov Overview](../OVERVIEW.md) for background on the end-to-end flow. ## Requirements -To complete this quickstart you will need both the .Net SDK and the Approov CLI tool installed. - -* [.NET 6 SDK](https://docs.microsoft.com/en-us/dotnet/core/install/) -* Approov CLI - Follow our [installation instructions](https://approov.io/docs/latest/approov-installation/#approov-tool) and read more about each command and its options in the [documentation reference](https://approov.io/docs/latest/approov-cli-tool-reference/) - -[TOC](#toc---table-of-contents) +- [.NET 8 SDK](https://dotnet.microsoft.com/download) +- [Approov CLI](https://approov.io/docs/latest/approov-installation/#approov-tool) +- Access to an Approov account with permission to manage API domains and secrets ## Approov Setup -To use Approov with the ASP.Net API server we need a small amount of configuration. First, Approov needs to know the API domain that will be protected. Second, the ASP.Net API server needs the Approov Base64 encoded secret that will be used to verify the tokens generated by the Approov cloud service. - -### Configure API Domain - -Approov needs to know the domain name of the API for which it will issue tokens. - -Add it with: - -```bash -approov api -add your.api.domain.com -``` - -> **NOTE:** By default a symmetric key (HS256) is used to sign the Approov token on a valid attestation of the mobile app for each API domain it's added with the Approov CLI, so that all APIs will share the same secret and the backend needs to take care to keep this secret secure. -> -> A more secure alternative is to use asymmetric keys (RS256 or others) that allows for a different keyset to be used on each API domain and for the Approov token to be verified with a public key that can only verify, but not sign, Approov tokens. -> -> To implement the asymmetric key you need to change from using the symmetric HS256 algorithm to an asymmetric algorithm, for example RS256, that requires you to first [add a new key](https://approov.io/docs/latest/approov-usage-documentation/#adding-a-new-key), and then specify it when [adding each API domain](https://approov.io/docs/latest/approov-usage-documentation/#keyset-key-api-addition). Please visit [Managing Key Sets](https://approov.io/docs/latest/approov-usage-documentation/#managing-key-sets) on the Approov documentation for more details. - -Adding the API domain also configures the [dynamic certificate pinning](https://approov.io/docs/latest/approov-usage-documentation/#dynamic-pinning) setup, out of the box. - -> **NOTE:** By default the pin is extracted from the public key of the leaf certificate served by the domain, as visible to the box issuing the Approov CLI command and the Approov servers. - -### Approov Secret - -Approov tokens are signed with a symmetric secret. To verify tokens, we need to grab the secret using the [Approov secret command](https://approov.io/docs/latest/approov-cli-tool-reference/#secret-command) and plug it into the ASP.Net API server environment to check the signatures of the [Approov Tokens](https://www.approov.io/docs/latest/approov-usage-documentation/#approov-tokens) that it processes. - -First, enable your Approov `admin` role with: - -```bash -eval `approov role admin` -```` - -For the Windows powershell: - -```bash -set APPROOV_ROLE=admin:___YOUR_APPROOV_ACCOUNT_NAME_HERE___ -``` - -Next, retrieve the Approov secret with: - -```bash -approov secret -get base64 -``` - -#### Set the Approov Secret - -Open the `.env` file and add the Approov secret to the var: - -```bash -APPROOV_BASE64_SECRET=approov_base64_secret_here -``` - -[TOC](#toc---table-of-contents) - - -## Approov Token Check - -First, add to your `appname.csproj` file the dependencies: - -```xml - - - - - -``` - -Next, let's add the class to load the app settings: - -```c# -namespace AppName.Helpers; - -public class AppSettings -{ - public byte[] ?ApproovSecretBytes { get; set; } -} -``` - -Now, add the `ApproovTokenMiddleware` class to your project: - -```c# -namespace AppName.Middleware; - -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Text; -using AppName.Helpers; -using System.Security.Claims; - -public class ApproovTokenMiddleware -{ - private readonly RequestDelegate _next; - private readonly AppSettings _appSettings; - private readonly ILogger _logger; - - public ApproovTokenMiddleware(RequestDelegate next, IOptions appSettings, ILogger logger) - { - _next = next; - _appSettings = appSettings.Value; - _logger = logger; - } - - public async Task Invoke(HttpContext context) - { - var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); - - if (token == null) { - _logger.LogInformation("Missing Approov-Token header."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - if (verifyApproovToken(context, token)) { - await _next(context); - return; - } - - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - private bool verifyApproovToken(HttpContext context, string token) - { - try - { - var tokenHandler = new JwtSecurityTokenHandler(); - - tokenHandler.ValidateToken(token, new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(_appSettings.ApproovSecretBytes), - ValidateIssuer = false, - ValidateAudience = false, - // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) - ClockSkew = TimeSpan.Zero - }, out SecurityToken validatedToken); - - var jwtToken = (JwtSecurityToken)validatedToken; - var claims = jwtToken.Claims; - - var payClaim = claims.FirstOrDefault(x => x.Type == "pay")?.Value; - - context.Items["ApproovTokenBinding"] = payClaim; - - return true; - } catch (SecurityTokenException exception) { - _logger.LogInformation(exception.Message); - return false; - } catch (Exception exception) { - _logger.LogInformation(exception.Message); - return false; - } - } -} -``` - -Next, in `Program.cs` load the secrets from the `.env` file and inject it into `AppSettiongs`: - -```c# -using AppName.Helpers; - -DotNetEnv.Env.Load(); - -var approovBase64Secret = DotNetEnv.Env.GetString("APPROOV_BASE64_SECRET"); - -if(approovBase64Secret == null) { - throw new Exception("Missing the env var APPROOV_BASE64_SECRET or its empty."); -} - -var approovSecretBytes = System.Convert.FromBase64String(approovBase64Secret); - -var builder = WebApplication.CreateBuilder(args); -builder.Services.Configure(appSettings => { - appSettings.ApproovSecretBytes = approovSecretBytes; -}); - -// ... omitted boilerplate and/or your code - -var app = builder.Build(); - -// Needs to be the first. No need to process other stuff in the request if the -// request isn't deemed as trustworthy by having a valid Approov token that -// hasn't expired yet. -app.UseMiddleware(); - -// ... omitted boilerplate and/or your code -``` - -> **NOTE:** When the Approov token validation fails we return a `401` with an empty body, because we don't want to give clues to an attacker about the reason the request failed, and you can go even further by returning a `400`. - -A full working example for a simple Hello World server can be found at [servers/hello/src/approov-protected-server/token-check](/servers/hello/src/approov-protected-server/token-check). - -[TOC](#toc---table-of-contents) - - -## Test your Approov Integration - -The following examples below use cURL, but you can also use the [Postman Collection](/README.md#testing-with-postman) to make the API requests. Just remember that you need to adjust the urls and tokens defined in the collection to match your deployment. Alternatively, the above README also contains instructions for using the preset _dummy_ secret to test your Approov integration. - -#### With Valid Approov Tokens - -Generate a valid token example from the Approov Cloud service: +1. **Register your API domain** + Inform Approov which API hostname will be protected: + ```bash + approov api -add your.api.domain.com + ``` + + By default Approov uses a symmetric key (HS256) to sign tokens for the domain. You may switch to asymmetric signing (for example RS256) by adding a new keyset and associating it with the domain. Refer to [Managing Key Sets](https://approov.io/docs/latest/approov-usage-documentation/#managing-key-sets) for the complete workflow. + +2. **Export the Approov secret** + Enable the admin role in your shell and retrieve the token verification secret: + ```bash + eval `approov role admin` # Unix shells + # or + set APPROOV_ROLE=admin: # Windows PowerShell + + approov secret -get base64 + ``` + +3. **Provide the secret to your server** + Store the base64 value in an environment variable or configuration source. In this quickstart we expect it in `.env`: + ```env + APPROOV_BASE64_SECRET=approov_base64_secret_here + ``` + + +## Server Changes + +1. **Add the required packages** + Ensure your project references the JWT libraries used for validation. The sample project uses: + ```xml + + + + ``` + +2. **Create a settings class** + Configure dependency injection to supply the secret bytes at runtime: + ```csharp + namespace YourApp.Helpers; + + public class AppSettings + { + public byte[]? ApproovSecretBytes { get; set; } + } + ``` + +3. **Load the secret in `Program.cs`** + Convert the base64 string to bytes and register `AppSettings`: + ```csharp + DotNetEnv.Env.Load(); + + var secretBase64 = DotNetEnv.Env.GetString("APPROOV_BASE64_SECRET") + ?? throw new Exception("APPROOV_BASE64_SECRET is missing"); + var approovSecretBytes = Convert.FromBase64String(secretBase64); + + builder.Services.Configure(settings => + { + settings.ApproovSecretBytes = approovSecretBytes; + }); + ``` + +4. **Add the middleware** + Copy the middleware below (the full reference implementation lives in `Middleware/ApproovTokenMiddleware.cs`): + ```csharp + namespace YourApp.Middleware; + + using System.IdentityModel.Tokens.Jwt; + using Microsoft.Extensions.Options; + using Microsoft.IdentityModel.Tokens; + using YourApp.Helpers; + + public class ApproovTokenMiddleware + { + private readonly RequestDelegate _next; + private readonly AppSettings _settings; + private readonly ILogger _logger; + + public ApproovTokenMiddleware( + RequestDelegate next, + IOptions settings, + ILogger logger) + { + _next = next; + _settings = settings.Value; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + var token = context.Request.Headers["Approov-Token"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogDebug("Approov-Token header is missing"); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + if (!ValidateToken(context, token)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + await _next(context); + } + + private bool ValidateToken(HttpContext context, string token) + { + try + { + var handler = new JwtSecurityTokenHandler(); + handler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(_settings.ApproovSecretBytes), + ValidateIssuer = false, + ValidateAudience = false, + ClockSkew = TimeSpan.Zero + }, out var validatedToken); + + if (validatedToken is JwtSecurityToken jwtToken) + { + context.Items["ApproovToken"] = jwtToken; + context.Items["ApproovTokenExpiry"] = jwtToken.ValidTo; + } + + return true; + } + catch (SecurityTokenException ex) + { + _logger.LogDebug("Approov token rejected: {Message}", ex.Message); + return false; + } + } + } + ``` + +5. **Register the middleware early in the pipeline** + In `Program.cs` insert the middleware before your endpoints: + ```csharp + var app = builder.Build(); + + app.UseMiddleware(); + app.MapControllers(); + app.Run(); + ``` + +The middleware rejects requests lacking a valid token with HTTP 401. On success it caches the parsed JWT and expiry in `HttpContext.Items` so downstream components can access the claims if required. + + +## Testing + +Use the Approov CLI to generate example tokens for your API domain: ```bash +# Valid token approov token -genExample your.api.domain.com -``` - -Then make the request with the generated token: -```bash -curl -i --request GET 'https://your.api.domain.com' \ - --header 'Approov-Token: APPROOV_TOKEN_EXAMPLE_HERE' -``` - -The request should be accepted. For example: - -```text -HTTP/2 200 - -... - -{"message": "Hello, World!"} -``` - -#### With Invalid Approov Tokens - -Generate an invalid token example from the Approov Cloud service: - -```bash +# Invalid example approov token -type invalid -genExample your.api.domain.com ``` -Then make the request with the generated token: +Then invoke your endpoint with `curl`: ```bash -curl -i --request GET 'https://your.api.domain.com' \ - --header 'Approov-Token: APPROOV_INVALID_TOKEN_EXAMPLE_HERE' +curl -i https://your.api.domain.com/hello \ + -H "Approov-Token: " ``` -The above request should fail with an Unauthorized error. For example: - -```text -HTTP/2 401 - -... - -{} -``` - -## Issues - -If you find any issue while following our instructions then just report it [here](https://github.com/approov/quickstart-asp.net-token-check/issues), with the steps to reproduce it, and we will sort it out and/or guide you to the correct path. - - -[TOC](#toc---table-of-contents) - - -## Useful Links - -If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: - -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) -* [Approov Get Started](https://approov.io/product/demo) -* [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) -* [Approov Docs](https://approov.io/docs) -* [Approov Blog](https://approov.io/blog/) -* [Approov Resources](https://approov.io/resource/) -* [Approov Customer Stories](https://approov.io/customer) -* [Approov Support](https://approov.io/contact) -* [About Us](https://approov.io/company) -* [Contact Us](https://approov.io/contact) - -[TOC](#toc---table-of-contents) +Expect a 200 response with the valid token and a 401 with the invalid token. See [TESTING.md](../TESTING.md) for additional options, including the dummy secret used by the repository’s integration scripts. diff --git a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs index 2be0a46..e4f3875 100644 --- a/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -122,6 +122,7 @@ public IActionResult IpkMessageSignTest() [HttpGet("/sfv_test")] // Validates HTTP Structured Field parsing and serialization against the caller-provided sample. + // Relevant only for testing the StructuredFieldValues library itself using /test-scripts/request_tests_sfv.sh public IActionResult StructuredFieldTest() { var sfvType = Request.Headers["sfvt"].FirstOrDefault(); @@ -140,7 +141,7 @@ public IActionResult StructuredFieldTest() switch (sfvType) { case "ITEM": - error = SfvParser.ParseItem(sfvHeader, out var item); + error = SfvParser.ParseItem(sfvHeader, out var item); // TODO: This does fail for "Test inner list 1.." with error invalid discriminator.. Issue with the library? if (error.HasValue) { return StructuredFieldFailure(error.Value.Message); diff --git a/servers/hello/src/approov-protected-server/token-check/README.md b/servers/hello/src/approov-protected-server/token-check/README.md index 0b93d7f..eaface3 100644 --- a/servers/hello/src/approov-protected-server/token-check/README.md +++ b/servers/hello/src/approov-protected-server/token-check/README.md @@ -1,4 +1,4 @@ -# Approov Token Integration Example +# Approov Integration Example This Approov integration example is from where the code example for the [Approov token check quickstart](/docs/APPROOV_TOKEN_QUICKSTART.md) is extracted, and you can use it as a playground to better understand how simple and easy it is to implement [Approov](https://approov.io) in an ASP.Net API server. @@ -22,15 +22,16 @@ To lock down your API server to your mobile app. Please read the brief summary i The sample API exposes the following endpoints: -* `/hello` – plain text check that the service is alive. -* `/token` – validates the Approov token and, when the `ipk` claim is present, verifies the Approov installation message signature. Success returns `Good Token`; failures return a `401` with `Invalid Token`. -* `/ipk_test` – development helper. Without an `ipk` header it generates and logs a fresh P-256 key pair. With an `ipk` header it validates that the provided public key can be decoded. -* `/ipk_message_sign_test` – accepts a `private-key` (base64 DER) and a `msg` (base64 canonical message) header and returns an ECDSA P-256/SHA-256 raw signature. The scripts call this to create deterministic signatures. -* `/sfv_test` – parses and reserialises Structured Field Value headers. The OpenResty quickstart invokes this when running `request_tests_sfv.sh`. +* `/hello` - plain text check that the service is alive. +* `/token` - validates the Approov token and, when the `ipk` claim is present, verifies the Approov installation message signature. Success returns `Good Token`; failures return a `401` with `Invalid Token`. +* `/token_binding` - echoes success when the `pay` claim matches the concatenated request headers configured for binding. +* `/ipk_test` - development helper. Without an `ipk` header it generates and logs a fresh P-256 key pair. With an `ipk` header it validates that the provided public key can be decoded. +* `/ipk_message_sign_test` - accepts a `private-key` (base64 DER) and a `msg` (base64 canonical message) header and returns an ECDSA P-256/SHA-256 raw signature. The scripts call this to create deterministic signatures. +* `/sfv_test` - parses and reserialises Structured Field Value headers. The OpenResty quickstart invokes this when running `request_tests_sfv.sh`. Approov tokens are validated by the [ApproovTokenMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs). Token binding is enforced by the [ApproovTokenBindingMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs), and message signing is handled by [MessageSigningMiddleware](/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs) which shares the same canonical string construction, structured field parsing, and ECDSA verification logic. -You can tune which request headers participate in the binding by setting the `APPROOV_TOKEN_BINDING_HEADER` environment variable (for example `Authorization`). When the variable is unset or empty the server skips token binding checks. +You can tune which request headers participate in the binding by setting the `APPROOV_TOKEN_BINDING_HEADER` environment variable (for example `Authorization`). When the variable is unset or empty the server skips token binding checks. Message signature freshness is controlled by the `APPROOV_SIGNATURE_*` environment variables explained in the `.env.example` file. For more background on Approov, see the [Approov Overview](/OVERVIEW.md#how-it-works) at the root of this repo. @@ -42,7 +43,7 @@ For more background on Approov, see the [Approov Overview](/OVERVIEW.md#how-it-w To run this example you will need to have installed: -* [.NET 6 SDK](https://docs.microsoft.com/en-us/dotnet/core/install/) +* [.NET 8 SDK](https://docs.microsoft.com/en-us/dotnet/core/install/) [TOC](#toc---table-of-contents) @@ -56,7 +57,7 @@ From `servers/hello/src/approov-protected-server/token-check` execute the follow cp .env.example .env ``` -Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to the `APPROOV_BASE64_SECRET` entry. +Edit the `.env` file and add the [dummy secret](/TESTING.md#the-dummy-secret) to the `APPROOV_BASE64_SECRET` entry. Adjust `APPROOV_TOKEN_BINDING_HEADER` and the optional `APPROOV_SIGNATURE_*` variables to mirror the behaviour you want to exercise. [TOC](#toc---table-of-contents) @@ -69,14 +70,14 @@ The quickest way to bring up the sample backends (unprotected and Approov-protec ./scripts/run-local.sh all ``` -The quickstart scripts expect the token-check server on `http://0.0.0.0:8111`. Once the service is running you can execute the shell helpers that ship with the OpenResty repo, for example: +The quickstart scripts expect the token-check server on `http://0.0.0.0:8111`. Once the service is running you can execute the shell helpers that ship with this repo: ```bash -./request_tests_approov_msg.sh 8111 -./request_tests_sfv.sh 8111 +./test-scripts/request_tests_approov_msg.sh 8111 +./test-scripts/request_tests_sfv.sh 8111 ``` -The commands above exercise the `/token`, `/ipk_message_sign_test`, `/ipk_test` and `/sfv_test` endpoints. You can also interact with the endpoints manually: +The commands above exercise the `/token`, `/token_binding`, `/ipk_message_sign_test`, `/ipk_test` and `/sfv_test` endpoints. You can also interact with the endpoints manually: ```bash # basic token check (replace with a valid Approov token) @@ -112,7 +113,7 @@ If you find any issue while following our instructions then just report it [here If you wish to explore the Approov solution in more depth, then why not try one of the following links as a jumping off point: -* [Approov Free Trial](https://approov.io/signup)(no credit card needed) +* [Approov Free Trial](https://approov.io/signup) (no credit card needed) * [Approov Get Started](https://approov.io/product/demo) * [Approov QuickStarts](https://approov.io/docs/latest/approov-integration-examples/) * [Approov Docs](https://approov.io/docs) From 6c58dce5263bc5e1153cc635aa73122b4d203a60 Mon Sep 17 00:00:00 2001 From: adriantuk Date: Mon, 10 Nov 2025 12:45:28 +0000 Subject: [PATCH 21/21] Update UnitTest1 to build with the updated code --- tests/Hello.Tests/UnitTest1.cs | 86 +++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/tests/Hello.Tests/UnitTest1.cs b/tests/Hello.Tests/UnitTest1.cs index b363dce..cb84ff1 100644 --- a/tests/Hello.Tests/UnitTest1.cs +++ b/tests/Hello.Tests/UnitTest1.cs @@ -1,5 +1,7 @@ namespace Hello.Tests; +using System; +using System.Collections.Generic; using System.Security.Cryptography; using System.Text; using Hello.Controllers; @@ -8,6 +10,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StructuredFieldValues; public class MessageSigningVerifierTests { @@ -18,13 +22,13 @@ public class MessageSigningVerifierTests [Fact] public async Task VerifyAsync_WithValidSignature_ReturnsSuccess() { - var (context, signatureInput) = BuildRequest(); - var canonicalMessage = BuildCanonicalMessage(signatureInput); + var context = BuildRequest(); + var canonicalMessage = BuildCanonicalMessage(context); var signature = SignCanonicalMessage(canonicalMessage); context.Request.Headers["Signature"] = $"install=:{signature}:"; - var verifier = new ApproovMessageSignatureVerifier(NullLogger.Instance); + var verifier = CreateVerifier(); var result = await verifier.VerifyAsync(context, TestPublicKey); Assert.True(result.Success); @@ -33,22 +37,24 @@ public async Task VerifyAsync_WithValidSignature_ReturnsSuccess() [Fact] public async Task VerifyAsync_WithInvalidSignature_ReturnsFailure() { - var (context, signatureInput) = BuildRequest(); - var canonicalMessage = BuildCanonicalMessage(signatureInput); + var context = BuildRequest(); + var canonicalMessage = BuildCanonicalMessage(context); var signature = SignCanonicalMessage(canonicalMessage); var tampered = signature[..^1] + (signature[^1] == 'A' ? "B" : "A"); context.Request.Headers["Signature"] = $"install=:{tampered}:"; - var verifier = new ApproovMessageSignatureVerifier(NullLogger.Instance); + var verifier = CreateVerifier(); var result = await verifier.VerifyAsync(context, TestPublicKey); Assert.False(result.Success); } - private static (DefaultHttpContext Context, string SignatureInput) BuildRequest() + private static DefaultHttpContext BuildRequest() { - const string signatureInput = "(\"@method\" \"approov-token\");alg=\"ecdsa-p256-sha256\";created=1744292750;expires=1999999999"; + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 30; + var expires = created + 600; + var signatureInput = $"(\"@method\" \"approov-token\");alg=\"ecdsa-p256-sha256\";created={created};expires={expires}"; var context = new DefaultHttpContext(); context.Request.Method = "GET"; @@ -59,17 +65,43 @@ private static (DefaultHttpContext Context, string SignatureInput) BuildRequest( context.Request.Headers["Approov-Token"] = ApproovToken; context.Request.Headers["Signature-Input"] = $"install={signatureInput}"; - return (context, signatureInput); + return context; } - private static string BuildCanonicalMessage(string signatureInput) + private static string BuildCanonicalMessage(DefaultHttpContext context) { - var builder = new StringBuilder(); - builder.AppendLine("\"@method\": GET"); - builder.AppendLine($"\"approov-token\": {ApproovToken}"); - builder.Append("\"@signature-params\": "); - builder.Append(signatureInput); - return builder.ToString(); + var signatureInputHeader = StructuredFieldFormatter.CombineHeaderValues(context.Request.Headers["Signature-Input"]); + var parseError = SfvParser.ParseDictionary(signatureInputHeader, out var dictionary); + Assert.False(parseError.HasValue, parseError?.Message ?? "Failed to parse Signature-Input header"); + Assert.NotNull(dictionary); + Assert.True(dictionary.TryGetValue("install", out var signatureInputItem)); + + var components = Assert.IsAssignableFrom>(signatureInputItem.Value); + var lines = new List(); + + foreach (var component in components) + { + var identifier = Assert.IsType(component.Value); + var value = ResolveComponentValue(context, identifier); + var label = StructuredFieldFormatter.SerializeItem(component); + lines.Add($"{label}: {value}"); + } + + var signatureParams = StructuredFieldFormatter.SerializeInnerList(components, signatureInputItem.Parameters); + lines.Add("\"@signature-params\": " + signatureParams); + + return string.Join('\n', lines); + } + + private static string ResolveComponentValue(DefaultHttpContext context, string identifier) + { + if (identifier == "@method") + { + return context.Request.Method; + } + + Assert.True(context.Request.Headers.TryGetValue(identifier, out var values), $"Missing header '{identifier}'"); + return StructuredFieldFormatter.CombineHeaderValues(values); } private static string SignCanonicalMessage(string canonicalMessage) @@ -78,9 +110,27 @@ private static string SignCanonicalMessage(string canonicalMessage) using var ecdsa = ECDsa.Create(); var privateKey = Convert.FromBase64String(TestPrivateKey); ecdsa.ImportECPrivateKey(privateKey, out _); - var signature = ecdsa.SignData(messageBytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + var signature = ecdsa.SignData( + messageBytes, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); return Convert.ToBase64String(signature); } + + private static ApproovMessageSignatureVerifier CreateVerifier() + { + var options = Options.Create(new MessageSignatureValidationOptions + { + RequireCreated = true, + RequireExpires = false, + MaximumSignatureAge = null, + AllowedClockSkew = TimeSpan.FromMinutes(5) + }); + + return new ApproovMessageSignatureVerifier( + NullLogger.Instance, + options); + } } public class ControllerSmokeTests @@ -94,7 +144,7 @@ public void IpkTest_GeneratesKeysWhenHeaderMissing() var result = controller.IpkTest() as ContentResult; Assert.NotNull(result); - Assert.Equal("No IPK header, generated keys logged", result!.Content); + Assert.Equal("No IPK header provided", result!.Content); } [Fact]