Skip to content
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
d6e3573
Add signature hash validator.
bjorntore Aug 14, 2025
eb88444
Use service owner auth if the data type is restricted.
bjorntore Sep 17, 2025
9bd750d
Add claudes tests.
bjorntore Sep 17, 2025
a07d326
Test tweaks.
bjorntore Sep 17, 2025
56f7d1d
Nitpick
bjorntore Sep 17, 2025
bbe8741
Remove settings.local.json.
bjorntore Sep 17, 2025
82c037e
Actually stream the data.
bjorntore Sep 18, 2025
de6ff8a
Dispose stream.
bjorntore Sep 18, 2025
94b2139
Adjust test.
bjorntore Sep 18, 2025
7f6779c
Some cleanup. Extract method for validation logic. Never return null …
bjorntore Sep 18, 2025
eab540d
Add remark that the sha digest formatting needs to match Storage.
bjorntore Sep 18, 2025
26ae23e
Test that exception from underlying code in ShouldRunForTask bubbles up.
bjorntore Sep 18, 2025
967d18b
Formatting that got past rider.
bjorntore Sep 18, 2025
0e30e33
Add tests for GetBinaryDataStream.
bjorntore Sep 18, 2025
933a50a
Rename GetUnbufferedAsync to GetStreamingAsync to avoid confusion. Ad…
bjorntore Sep 23, 2025
5be7cb8
Adjust cancellation token param.
bjorntore Sep 23, 2025
a91dc2f
Add correct verified.txt.
bjorntore Sep 23, 2025
2f69711
Merge branch 'main' into feat/signature-validation
bjorntore Sep 23, 2025
0b829ab
Add ResponseWrapperStream to ensure that the http response is dispose…
bjorntore Sep 23, 2025
7db9c25
Add async overrides to ResponseWrapperStream.
bjorntore Sep 23, 2025
a72fe6e
Create platform exception before disposing response.
bjorntore Sep 23, 2025
c1c510c
Null checks in ResponseWrapperStream.
bjorntore Sep 23, 2025
24bebc9
PlatformHttpResponseSnapshotException first edition.
bjorntore Sep 24, 2025
7ac2d12
PlatformHttpResponseSnapshotException that inherits from PlatformHttp…
bjorntore Sep 24, 2025
9f2b786
Tweaks to PlatformHttpResponseSnapshowException.
bjorntore Sep 24, 2025
94199df
Use a separate streaming http client with longer timeout when streami…
bjorntore Sep 24, 2025
98e62ea
Make PlatformHttpResponseSnapshotException internal.
bjorntore Oct 10, 2025
8413f59
Don't default to any content type in PlatformHttpResponseSnapshotExce…
bjorntore Oct 10, 2025
c8d6d21
Redact sensitive headers from PlatformHttpResponseSnapshotException.
bjorntore Oct 10, 2025
5f17178
PlatformHttpResponseSnapshotException: Snapshot content with bounded …
bjorntore Oct 10, 2025
cb044ed
Various ai suggestions.
bjorntore Oct 13, 2025
5239d47
More ai suggested disposing in DataClienTests.cs.
bjorntore Oct 13, 2025
ab6aed7
Merge branch 'main' into feat/signature-validation
bjorntore Oct 13, 2025
cba78ae
Remove this unnecessary check for null.
bjorntore Oct 13, 2025
c2530c7
Merge branch 'main' into feat/signature-validation
bjorntore Oct 16, 2025
841451f
Base timeout in DataClient on cancellation token instead of HttpClien…
bjorntore Oct 16, 2025
c15edb5
Remove unused streaming http client.
bjorntore Oct 16, 2025
412ad8e
remove more streaming client stuff
bjorntore Oct 16, 2025
6370f20
Set hard coded max of 30 minute timeout on streaming.
bjorntore Oct 17, 2025
7018d57
Add cancellation token to methods in HttpClientExtension.
bjorntore Oct 17, 2025
84b31ae
Merge branch 'main' into feat/signature-validation
bjorntore Oct 17, 2025
ce1bceb
Use the http context cancellation token in SignatureHashValidator.
bjorntore Oct 17, 2025
9fa8624
Remove unused org and app params from IDataCient.GetBinaryDataStream.
bjorntore Oct 20, 2025
9fa048d
Make cancellation token pattern in DataClient slightly less noisy by …
bjorntore Oct 21, 2025
dbc85c4
Revert to having a dedicated streaming http client to avoid merge con…
bjorntore Oct 21, 2025
8c2aa6c
Re-add named param for cancellation token.
bjorntore Oct 21, 2025
c32f408
Formatting.
bjorntore Oct 21, 2025
8320792
Merge branch 'main' into feat/signature-validation
bjorntore Oct 21, 2025
37c6082
Attempt to use CustomTextKey.
bjorntore Oct 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Altinn.App.Api/Controllers/ProcessController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,19 @@ private ObjectResult ExceptionResponse(Exception exception, string message)
{
_logger.LogError(exception, message);

if (exception is PlatformHttpResponseSnapshotException phse)
{
return StatusCode(
phse.StatusCode,
new ProblemDetails()
{
Detail = phse.Message,
Status = phse.StatusCode,
Title = message,
}
);
}

if (exception is PlatformHttpException phe)
{
return StatusCode(
Expand Down
59 changes: 51 additions & 8 deletions src/Altinn.App.Core/Extensions/HttpClientExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ public static class HttpClientExtension
/// <param name="requestUri">The request Uri</param>
/// <param name="content">The http content</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>A HttpResponseMessage</returns>
public static async Task<HttpResponseMessage> PostAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
HttpContent? content,
string? platformAccessToken = null
string? platformAccessToken = null,
CancellationToken cancellationToken = default
)
{
using HttpRequestMessage request = new(HttpMethod.Post, requestUri);
Expand All @@ -37,7 +39,7 @@ public static async Task<HttpResponseMessage> PostAsync(
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, CancellationToken.None);
return await httpClient.SendAsync(request, cancellationToken);
}

/// <summary>
Expand All @@ -48,13 +50,15 @@ public static async Task<HttpResponseMessage> PostAsync(
/// <param name="requestUri">The request Uri</param>
/// <param name="content">The http content</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>A HttpResponseMessage</returns>
public static async Task<HttpResponseMessage> PutAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
HttpContent? content,
string? platformAccessToken = null
string? platformAccessToken = null,
CancellationToken cancellationToken = default
)
{
using HttpRequestMessage request = new(HttpMethod.Put, requestUri);
Expand All @@ -70,7 +74,7 @@ public static async Task<HttpResponseMessage> PutAsync(
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, CancellationToken.None);
return await httpClient.SendAsync(request, cancellationToken);
}

/// <summary>
Expand All @@ -80,12 +84,14 @@ public static async Task<HttpResponseMessage> PutAsync(
/// <param name="authorizationToken">the authorization token (jwt)</param>
/// <param name="requestUri">The request Uri</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>A HttpResponseMessage</returns>
public static async Task<HttpResponseMessage> GetAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
string? platformAccessToken = null
string? platformAccessToken = null,
CancellationToken cancellationToken = default
)
{
using HttpRequestMessage request = new(HttpMethod.Get, requestUri);
Expand All @@ -100,7 +106,42 @@ public static async Task<HttpResponseMessage> GetAsync(
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None);
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
}

/// <summary>
/// Extension that adds authorization header to request and returns response configured for streaming.
/// Response returns immediately after headers are received, allowing content to be streamed.
/// </summary>
/// <param name="httpClient">The HttpClient</param>
/// <param name="authorizationToken">the authorization token (jwt)</param>
/// <param name="requestUri">The request Uri</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>A HttpResponseMessage</returns>
/// <remarks>When using GetStreamingAsync() for large file downloads, ensure your HttpClient
/// instance has an appropriate timeout configured. The default timeout may be too short for large files.</remarks>
public static async Task<HttpResponseMessage> GetStreamingAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
string? platformAccessToken = null,
CancellationToken cancellationToken = default
)
{
using HttpRequestMessage request = new(HttpMethod.Get, requestUri);

request.Headers.Authorization = new AuthenticationHeaderValue(
Constants.AuthorizationSchemes.Bearer,
authorizationToken
);

if (!string.IsNullOrEmpty(platformAccessToken))
{
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}

/// <summary>
Expand All @@ -110,12 +151,14 @@ public static async Task<HttpResponseMessage> GetAsync(
/// <param name="authorizationToken">the authorization token (jwt)</param>
/// <param name="requestUri">The request Uri</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>A HttpResponseMessage</returns>
public static async Task<HttpResponseMessage> DeleteAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
string? platformAccessToken = null
string? platformAccessToken = null,
CancellationToken cancellationToken = default
)
{
using HttpRequestMessage request = new(HttpMethod.Delete, requestUri);
Expand All @@ -130,6 +173,6 @@ public static async Task<HttpResponseMessage> DeleteAsync(
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, CancellationToken.None);
return await httpClient.SendAsync(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ private static void AddValidationServices(IServiceCollection services, IConfigur
services.AddTransient<IDataElementValidator, DefaultDataElementValidator>();
services.AddTransient<ITaskValidator, DefaultTaskValidator>();
services.AddTransient<IValidator, SigningTaskValidator>();
services.AddTransient<IValidator, SignatureHashValidator>();

var appSettings = configuration.GetSection("AppSettings").Get<AppSettings>();
if (appSettings?.RequiredValidation is true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using System.Diagnostics;
using System.Security.Cryptography;
using Altinn.App.Core.Features.Signing.Models;
using Altinn.App.Core.Features.Signing.Services;
using Altinn.App.Core.Internal.App;
using Altinn.App.Core.Internal.Data;
using Altinn.App.Core.Internal.Process;
using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
using Altinn.App.Core.Models;
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

namespace Altinn.App.Core.Features.Validation.Default;

/// <summary>
/// Validates that signature hashes are still valid.
/// </summary>
internal sealed class SignatureHashValidator(
ISigningService signingService,
IProcessReader processReader,
IDataClient dataClient,
IAppMetadata appMetadata,
IHttpContextAccessor httpContextAccessor,
ILogger<SignatureHashValidator> logger
) : IValidator
{
private const string SigningTaskType = "signing";

/// <summary>
/// We implement <see cref="ShouldRunForTask"/> instead.
/// </summary>
public string TaskId => "*";

/// <summary>
/// Only runs for tasks that are of type "signing".
/// </summary>
public bool ShouldRunForTask(string taskId)
{
AltinnTaskExtension? taskConfig = processReader.GetAltinnTaskExtension(taskId);
return taskConfig?.TaskType is SigningTaskType;
}

public bool NoIncrementalValidation => true;

/// <inheritdoc />
public Task<bool> HasRelevantChanges(IInstanceDataAccessor dataAccessor, string taskId, DataElementChanges changes)
{
throw new UnreachableException(
"HasRelevantChanges should not be called because NoIncrementalValidation is true."
);
}

public async Task<List<ValidationIssue>> Validate(
IInstanceDataAccessor dataAccessor,
string taskId,
string? language
)
{
CancellationToken cancellationToken = httpContextAccessor.HttpContext?.RequestAborted ?? CancellationToken.None;

Instance instance = dataAccessor.Instance;

AltinnSignatureConfiguration signingConfiguration =
processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration
?? throw new ApplicationConfigException("Signing configuration not found in AltinnTaskExtension");

ApplicationMetadata applicationMetadata = await appMetadata.GetApplicationMetadata();

List<SigneeContext> signeeContextsResults = await signingService.GetSigneeContexts(
dataAccessor,
signingConfiguration,
cancellationToken
);

foreach (SigneeContext signeeContext in signeeContextsResults)
{
List<SignDocument.DataElementSignature> dataElementSignatures =
signeeContext.SignDocument?.DataElementSignatures ?? [];

foreach (SignDocument.DataElementSignature dataElementSignature in dataElementSignatures)
{
ValidationIssue? validationIssue = await ValidateDataElementSignature(
dataElementSignature,
instance,
applicationMetadata,
cancellationToken
);

if (validationIssue != null)
{
return [validationIssue];
}
}
}

logger.LogInformation("All signature hashes are valid for instance {InstanceId}", instance.Id);

return [];
}

private async Task<ValidationIssue?> ValidateDataElementSignature(
SignDocument.DataElementSignature dataElementSignature,
Instance instance,
ApplicationMetadata applicationMetadata,
CancellationToken cancellationToken
)
{
var instanceIdentifier = new InstanceIdentifier(instance);

await using Stream dataStream = await dataClient.GetBinaryDataStream(
instanceIdentifier.InstanceOwnerPartyId,
instanceIdentifier.InstanceGuid,
Guid.Parse(dataElementSignature.DataElementId),
HasRestrictedRead(applicationMetadata, instance, dataElementSignature.DataElementId)
? StorageAuthenticationMethod.ServiceOwner()
: null,
cancellationToken
);

string sha256Hash = await GenerateSha256Hash(dataStream);

if (sha256Hash != dataElementSignature.Sha256Hash)
{
logger.LogError(
"Found an invalid signature for data element {DataElementId} on instance {InstanceId}. Expected hash {ExpectedHash}, calculated hash {CalculatedHash}",
dataElementSignature.DataElementId,
instance.Id,
dataElementSignature.Sha256Hash,
sha256Hash
);

return new ValidationIssue
{
Code = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash,
Severity = ValidationIssueSeverity.Error,
Description = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash,
};
}

return null;
}

private static bool HasRestrictedRead(
ApplicationMetadata applicationMetadata,
Instance instance,
string dataElementId
)
{
DataElement? dataElement = instance.Data.FirstOrDefault(de => de.Id == dataElementId);
string? dataTypeId = dataElement?.DataType;
DataType? dataType = applicationMetadata.DataTypes.FirstOrDefault(dt => dt.Id == dataTypeId);

if (dataType == null)
{
throw new ApplicationConfigException(
$"Unable to find data type {dataTypeId} for data element {dataElementId} in applicationmetadata.json."
);
}

return !string.IsNullOrEmpty(dataType.ActionRequiredToRead);
}

private static async Task<string> GenerateSha256Hash(Stream stream)
{
using var sha256 = SHA256.Create();
byte[] digest = await sha256.ComputeHashAsync(stream);
return FormatShaDigest(digest);
}

/// <summary>
/// Formats a SHA digest with common best practice:<br/>
/// Lowercase hexadecimal representation without delimiters
/// </summary>
/// <param name="digest">The hash code (digest) to format</param>
/// <returns>String representation of the digest</returns>
/// <remarks>This mirrors how the altinn-storage formats the Sha digest when creating the signature document, and it must stay in sync.</remarks>
private static string FormatShaDigest(byte[] digest)
{
return Convert.ToHexString(digest).ToLowerInvariant();
}
}
Loading
Loading