├── .gitignore ├── renovate.json ├── altinn-securify ├── Models │ ├── DecryptionRequest.cs │ ├── Dto │ │ ├── EncyptionResultDto.cs │ │ ├── SenderDto.cs │ │ ├── DecryptionResultDto.cs │ │ ├── DecryptionRequestDto.cs │ │ ├── EncryptionRequestDto.cs │ │ └── EncryptionSettingsDto.cs │ ├── EncryptedData.cs │ ├── EncryptionRequest.cs │ ├── EncryptionSettings.cs │ ├── User.cs │ ├── EncryptionResult.cs │ └── DecryptionResult.cs ├── Services │ ├── Interfaces │ │ ├── IKeyResolverService.cs │ │ ├── IUserService.cs │ │ ├── ISecurifyService.cs │ │ └── IEncryptionService.cs │ ├── SettingsBasedKeyResolverService.cs │ ├── ExceptionHandlingMiddleware.cs │ ├── UserService.cs │ ├── SecurifyService.cs │ └── AesGcmEncryptionService.cs ├── Configuration │ ├── Constants.cs │ └── SecurifyConfig.cs ├── Authentication │ ├── AuthenticationOptions.cs │ ├── JwtSchemeSelectorMiddleware.cs │ ├── TokenIssuerCache.cs │ └── AuthenticationBuilderExtensions.cs ├── Properties │ └── launchSettings.json ├── altinn-securify.csproj ├── Authorization │ ├── AuthorizationPolicyBuilderExtensions.cs │ ├── AuthorizationOptionsSetup.cs │ └── ClaimsPrincipalExtensions.cs ├── appsettings.json ├── altinn-securify.http └── Program.cs ├── LICENSE ├── altinn-securify.sln ├── altinn-transformer.sln.DotSettings.user ├── altinn-securify.sln.DotSettings.user └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | bin/ 3 | obj/ 4 | /packages/ 5 | riderModule.iml 6 | /_ReSharper.Caches/ 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Altinn/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /altinn-securify/Models/DecryptionRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Models; 2 | 3 | public class DecryptionRequest 4 | { 5 | public string CipherText { get; set; } = null!; 6 | } -------------------------------------------------------------------------------- /altinn-securify/Models/Dto/EncyptionResultDto.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Models.Dto; 2 | 3 | public class EncyptionResultDto 4 | { 5 | public string CipherText { get; set; } = null!; 6 | } -------------------------------------------------------------------------------- /altinn-securify/Services/Interfaces/IKeyResolverService.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Services.Interfaces; 2 | 3 | public interface IKeyResolverService 4 | { 5 | public Task GetKey(string keyId); 6 | } -------------------------------------------------------------------------------- /altinn-securify/Services/Interfaces/IUserService.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Models; 2 | 3 | namespace Altinn.Securify.Services.Interfaces; 4 | 5 | public interface IUserService 6 | { 7 | User GetUser(); 8 | } -------------------------------------------------------------------------------- /altinn-securify/Models/EncryptedData.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace Altinn.Securify.Models; 4 | 5 | public record EncryptedData(EncryptionSettings Settings, DateTimeOffset At, User By, JsonElement Data); -------------------------------------------------------------------------------- /altinn-securify/Configuration/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Configuration; 2 | 3 | public static class Constants 4 | { 5 | public const string AuthorizationHeader = "Authorization"; 6 | public const string CurrentTokenIssuer = "CurrentTokenIssuer"; 7 | 8 | } -------------------------------------------------------------------------------- /altinn-securify/Models/EncryptionRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace Altinn.Securify.Models; 4 | 5 | public class EncryptionRequest 6 | { 7 | public JsonElement PlainText { get; set; } 8 | public EncryptionSettings Settings { get; set; } = new(); 9 | } -------------------------------------------------------------------------------- /altinn-securify/Models/Dto/SenderDto.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Models.Dto; 2 | 3 | public class SenderDto 4 | { 5 | public string OrgNo { get; set; } = null!; 6 | public string ClientId { get; set; } = null!; 7 | public List Scopes { get; set; } = null!; 8 | } -------------------------------------------------------------------------------- /altinn-securify/Services/Interfaces/ISecurifyService.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Models; 2 | 3 | namespace Altinn.Securify.Services.Interfaces; 4 | 5 | public interface ISecurifyService 6 | { 7 | public Task Encrypt(EncryptionRequest request); 8 | public Task Decrypt(DecryptionRequest input); 9 | } -------------------------------------------------------------------------------- /altinn-securify/Models/Dto/DecryptionResultDto.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace Altinn.Securify.Models.Dto; 4 | 5 | public class DecryptionResultDto 6 | { 7 | public DateTimeOffset At { get; set; } 8 | public SenderDto By { get; set; } = new(); 9 | public JsonElement PlainText { get; set; } = new(); 10 | } 11 | -------------------------------------------------------------------------------- /altinn-securify/Models/EncryptionSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Models; 2 | 3 | public class EncryptionSettings 4 | { 5 | public DateTimeOffset ExpiresAt { get; set; } 6 | public List? RequiresOrgNo { get; set; } 7 | public List? RequiresClientId { get; set; } 8 | public List? RequiresScope { get; set; } 9 | } -------------------------------------------------------------------------------- /altinn-securify/Services/Interfaces/IEncryptionService.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Services.Interfaces; 2 | 3 | public interface IEncryptionService 4 | { 5 | public Task Encrypt(byte[] plainText, string keyId, Func> keyResolver); 6 | public Task Decrypt(byte[] cipherText, Func> keyResolver); 7 | } -------------------------------------------------------------------------------- /altinn-securify/Models/Dto/DecryptionRequestDto.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Models.Dto; 2 | 3 | public class DecryptionRequestDto 4 | { 5 | public string CipherText { get; set; } = null!; 6 | 7 | public DecryptionRequest ToDecryptionRequest() 8 | { 9 | return new DecryptionRequest 10 | { 11 | CipherText = CipherText 12 | }; 13 | } 14 | } -------------------------------------------------------------------------------- /altinn-securify/Models/User.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Models.Dto; 2 | 3 | namespace Altinn.Securify.Models; 4 | 5 | public record User(string OrgNo, string ClientId, List Scopes) 6 | { 7 | public SenderDto ToSenderDto() 8 | { 9 | return new SenderDto 10 | { 11 | OrgNo = OrgNo, 12 | ClientId = ClientId, 13 | Scopes = Scopes 14 | }; 15 | } 16 | } -------------------------------------------------------------------------------- /altinn-securify/Models/EncryptionResult.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Models.Dto; 2 | 3 | namespace Altinn.Securify.Models; 4 | 5 | public class EncryptionResult 6 | { 7 | public string CipherText { get; set; } = null!; 8 | 9 | public EncyptionResultDto ToEncryptionResultDto() 10 | { 11 | return new EncyptionResultDto 12 | { 13 | CipherText = CipherText 14 | }; 15 | } 16 | } -------------------------------------------------------------------------------- /altinn-securify/Authentication/AuthenticationOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.Securify.Authentication; 2 | 3 | public sealed class AuthenticationOptions 4 | { 5 | public required List JwtBearerTokenSchemas { get; init; } 6 | } 7 | 8 | public sealed class JwtBearerTokenSchemasOptions 9 | { 10 | public required string Name { get; init; } 11 | public required string WellKnown { get; init; } 12 | } 13 | -------------------------------------------------------------------------------- /altinn-securify/Configuration/SecurifyConfig.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Authentication; 2 | 3 | namespace Altinn.Securify.Configuration; 4 | 5 | public class SecurifyConfig 6 | { 7 | public int MaxPlainTextSizeInBytes { get; set; } 8 | public TimeSpan DefaultLifeTime { get; set; } 9 | public TimeSpan MaxLifeTime { get; set; } 10 | public string EncryptionKeys { get; set; } = null!; 11 | public string RequiredScope { get; set; } = null!; 12 | public AuthenticationOptions Authentication { get; set; } = null!; 13 | } -------------------------------------------------------------------------------- /altinn-securify/Models/DecryptionResult.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Altinn.Securify.Models.Dto; 3 | 4 | namespace Altinn.Securify.Models; 5 | 6 | public class DecryptionResult 7 | { 8 | public DateTimeOffset Timestamp { get; set; } 9 | public User User { get; set; } = null!; 10 | public JsonElement PlainText { get; set; } 11 | public List Errors { get; set; } = new(); 12 | 13 | public DecryptionResultDto ToDecryptionResultDto() 14 | { 15 | return new DecryptionResultDto 16 | { 17 | At = Timestamp, 18 | By = User.ToSenderDto(), 19 | PlainText = PlainText 20 | }; 21 | } 22 | } -------------------------------------------------------------------------------- /altinn-securify/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "https://localhost:7157;http://localhost:5228", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | }, 13 | "http": { 14 | "commandName": "Project", 15 | "dotnetRunMessages": true, 16 | "launchBrowser": false, 17 | "applicationUrl": "http://localhost:5228", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /altinn-securify/altinn-securify.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | Altinn.Securify 8 | 56e58508-77e0-4134-bb20-28b0d4f795f9 9 | altinn-securify 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /altinn-securify/Services/SettingsBasedKeyResolverService.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Configuration; 2 | using Altinn.Securify.Services.Interfaces; 3 | using Microsoft.Extensions.Options; 4 | 5 | namespace Altinn.Securify.Services; 6 | 7 | public class SettingsBasedKeyResolverService : IKeyResolverService 8 | { 9 | private readonly Dictionary _keyStore; 10 | 11 | public SettingsBasedKeyResolverService(IOptions securifyConfig) 12 | { 13 | _keyStore = securifyConfig.Value.EncryptionKeys.Split(',', StringSplitOptions.RemoveEmptyEntries) 14 | .Select(pair => pair.Split(':')) 15 | .ToDictionary(parts => parts[0], parts => Convert.FromBase64String(parts[1])); 16 | } 17 | 18 | public async Task GetKey(string keyId) 19 | { 20 | _keyStore.TryGetValue(keyId, out var key); 21 | return await Task.FromResult(key); 22 | } 23 | } -------------------------------------------------------------------------------- /altinn-securify/Authorization/AuthorizationPolicyBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | 3 | namespace Altinn.Securify.Authorization; 4 | 5 | internal static class AuthorizationPolicyBuilderExtensions 6 | { 7 | private const string ScopeClaim = "scope"; 8 | private const char ScopeClaimSeparator = ' '; 9 | 10 | public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder builder, string scope) => 11 | builder.RequireAssertion(ctx => ctx.User.Claims 12 | .Where(x => x.Type == ScopeClaim) 13 | .Select(x => x.Value) 14 | .Any(scopeValue => scopeValue == scope || scopeValue 15 | .Split(ScopeClaimSeparator, StringSplitOptions.RemoveEmptyEntries) 16 | .Contains(scope))); 17 | 18 | public static AuthorizationPolicyBuilder RequireValidConsumerClaim(this AuthorizationPolicyBuilder builder) => 19 | builder.RequireAssertion(ctx => ctx.User.TryGetOrganizationNumber(out _)); 20 | } 21 | -------------------------------------------------------------------------------- /altinn-securify/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "SecurifyConfig": { 10 | "MaxPlainTextSizeInBytes": 2048, 11 | "DefaultLifeTime": "00:01:00", // "hh:mm:ss" 12 | "MaxLifeTime": "00:20:00", // "hh:mm:ss" 13 | "EncryptionKeys": "20241219:secretbyteshere", // keyId1:base64EncodedKey,keyId2=base64EncodedKey. Create with `openssl rand -base64 32`, `head -c 32 /dev/urandom | base64` or similar 14 | "RequiredScope": "altinn:securify", 15 | "Authentication": { 16 | "JwtBearerTokenSchemas": [ 17 | { 18 | "Name": "Maskinporten", 19 | "WellKnown": "https://test.maskinporten.no/.well-known/oauth-authorization-server/" 20 | }, 21 | { 22 | "Name": "Altinn", 23 | "WellKnown": "https://platform.tt02.altinn.no/authentication/api/v1/openid/.well-known/openid-configuration" 24 | } 25 | ] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /altinn-securify/Authorization/AuthorizationOptionsSetup.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Configuration; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.Extensions.Options; 4 | 5 | namespace Altinn.Securify.Authorization; 6 | 7 | internal sealed class AuthorizationOptionsSetup : IConfigureOptions 8 | { 9 | private readonly SecurifyConfig _options; 10 | 11 | public AuthorizationOptionsSetup(IOptions options) 12 | { 13 | _options = options.Value; 14 | } 15 | 16 | public void Configure(AuthorizationOptions options) 17 | { 18 | var authenticationSchemas = _options 19 | .Authentication 20 | .JwtBearerTokenSchemas 21 | .Select(x => x.Name) 22 | .ToArray(); 23 | 24 | options.DefaultPolicy = new AuthorizationPolicyBuilder() 25 | .RequireAuthenticatedUser() 26 | .AddAuthenticationSchemes(authenticationSchemas) 27 | .RequireValidConsumerClaim() 28 | .RequireScope(_options.RequiredScope) 29 | .Build(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Altinn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /altinn-securify.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "altinn-securify", "altinn-securify\altinn-securify.csproj", "{F0F46689-1B9C-4325-9CFF-C0B6E023AC37}" 4 | EndProject 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution files", "Solution files", "{96D5AADF-C769-434F-90AD-6F906C58A838}" 6 | ProjectSection(SolutionItems) = preProject 7 | README.md = README.md 8 | renovate.json = renovate.json 9 | EndProjectSection 10 | EndProject 11 | Global 12 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 13 | Debug|Any CPU = Debug|Any CPU 14 | Release|Any CPU = Release|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {F0F46689-1B9C-4325-9CFF-C0B6E023AC37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {F0F46689-1B9C-4325-9CFF-C0B6E023AC37}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {F0F46689-1B9C-4325-9CFF-C0B6E023AC37}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {F0F46689-1B9C-4325-9CFF-C0B6E023AC37}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /altinn-securify/Services/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | namespace Altinn.Securify.Services; 4 | 5 | public class ExceptionHandlingMiddleware 6 | { 7 | private readonly RequestDelegate _next; 8 | private readonly ILogger _logger; 9 | 10 | public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) 11 | { 12 | _next = next; 13 | _logger = logger; 14 | } 15 | 16 | public async Task InvokeAsync(HttpContext context) 17 | { 18 | try 19 | { 20 | await _next(context); 21 | } 22 | catch (Exception ex) 23 | { 24 | _logger.LogError(ex, "An unhandled exception has occurred."); 25 | await HandleExceptionAsync(context, ex); 26 | } 27 | } 28 | 29 | private static Task HandleExceptionAsync(HttpContext context, Exception exception) 30 | { 31 | context.Response.ContentType = "application/json"; 32 | context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 33 | 34 | var response = new 35 | { 36 | context.Response.StatusCode, 37 | Message = "Internal Server Error", 38 | Detailed = exception.Message 39 | }; 40 | 41 | return context.Response.WriteAsJsonAsync(response); 42 | } 43 | } -------------------------------------------------------------------------------- /altinn-securify/Authentication/JwtSchemeSelectorMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using Altinn.Securify.Configuration; 3 | 4 | namespace Altinn.Securify.Authentication; 5 | 6 | public sealed class JwtSchemeSelectorMiddleware 7 | { 8 | private readonly RequestDelegate _next; 9 | 10 | public JwtSchemeSelectorMiddleware(RequestDelegate next) 11 | { 12 | _next = next; 13 | } 14 | 15 | public Task InvokeAsync(HttpContext context) 16 | { 17 | if (!context.Request.Headers.TryGetValue(Constants.AuthorizationHeader, out var authorizationHeader)) 18 | return _next(context); 19 | 20 | var token = authorizationHeader.ToString() 21 | .Split(' ') 22 | .LastOrDefault(); 23 | 24 | if (string.IsNullOrEmpty(token)) 25 | return _next(context); 26 | 27 | var handler = new JwtSecurityTokenHandler(); 28 | if (!handler.CanReadToken(token)) 29 | return _next(context); 30 | 31 | var jwtToken = handler.ReadJwtToken(token); 32 | context.Items[Constants.CurrentTokenIssuer] = jwtToken.Issuer; 33 | return _next(context); 34 | } 35 | } 36 | 37 | public static class JwtSchemeSelectorMiddlewareExtensions 38 | { 39 | public static IApplicationBuilder UseJwtSchemeSelector(this IApplicationBuilder app) 40 | => app.UseMiddleware(); 41 | } 42 | -------------------------------------------------------------------------------- /altinn-securify/Models/Dto/EncryptionRequestDto.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Altinn.Securify.Configuration; 3 | 4 | namespace Altinn.Securify.Models.Dto; 5 | 6 | public class EncryptionRequestDto 7 | { 8 | public JsonElement PlainText { get; set; } 9 | public EncryptionSettingsDto? Settings { get; set; } = null!; 10 | 11 | public List Validate(SecurifyConfig securifyConfig) 12 | { 13 | var errors = new List(); 14 | 15 | if (string.IsNullOrEmpty(PlainText.ToString())) 16 | { 17 | errors.Add("Cannot encrypt empty plaintext"); 18 | } 19 | else if (PlainText.ToString().Length > securifyConfig.MaxPlainTextSizeInBytes) 20 | { 21 | errors.Add($"PlainText is too long. Max length is {securifyConfig.MaxPlainTextSizeInBytes} bytes."); 22 | } 23 | 24 | if (Settings != null) 25 | { 26 | errors.AddRange(Settings.Validate(securifyConfig)); 27 | } 28 | 29 | return errors; 30 | } 31 | 32 | public EncryptionRequest ToEncryptionRequest(SecurifyConfig securifyConfig) 33 | { 34 | return new EncryptionRequest 35 | { 36 | PlainText = PlainText, 37 | Settings = Settings?.ToEncryptionSettings(securifyConfig) 38 | ?? new EncryptionSettings 39 | { 40 | ExpiresAt = DateTimeOffset.UtcNow.Add(securifyConfig.DefaultLifeTime) 41 | } 42 | }; 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /altinn-securify/Models/Dto/EncryptionSettingsDto.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using Altinn.Securify.Configuration; 3 | 4 | namespace Altinn.Securify.Models.Dto; 5 | 6 | public partial class EncryptionSettingsDto 7 | { 8 | public DateTimeOffset? ExpiresAt { get; set; } 9 | public List? RequiresOrgNo { get; set; } 10 | public List? RequiresClientId { get; set; } 11 | public List? RequiresScope { get; set; } 12 | 13 | private const string ValidNorwegianOrgNoPattern = @"^\d{9}$"; 14 | 15 | [GeneratedRegex(ValidNorwegianOrgNoPattern)] 16 | private static partial Regex ValidNorwegianOrgNoRegex(); 17 | 18 | public List Validate(SecurifyConfig securifyConfig) 19 | { 20 | var errors = new List(); 21 | 22 | if (ExpiresAt.HasValue && (ExpiresAt <= DateTimeOffset.UtcNow || ExpiresAt >= DateTimeOffset.UtcNow.Add(securifyConfig.MaxLifeTime))) 23 | { 24 | errors.Add($"ExpiresAt must be within {securifyConfig.MaxLifeTime.TotalSeconds} seconds in the future."); 25 | } 26 | 27 | if (RequiresOrgNo != null && RequiresOrgNo.Count != 0) 28 | { 29 | errors.AddRange(from orgNo in RequiresOrgNo where !ValidNorwegianOrgNoRegex().IsMatch(orgNo) 30 | select $"Invalid Norwegian organization number: {orgNo}"); 31 | } 32 | 33 | return errors; 34 | } 35 | 36 | public EncryptionSettings ToEncryptionSettings(SecurifyConfig securifyConfig) 37 | { 38 | return new EncryptionSettings 39 | { 40 | ExpiresAt = ExpiresAt ?? DateTimeOffset.UtcNow.Add(securifyConfig.DefaultLifeTime), 41 | RequiresOrgNo = RequiresOrgNo ?? [], 42 | RequiresClientId = RequiresClientId ?? [], 43 | RequiresScope = RequiresScope ?? [] 44 | }; 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /altinn-securify/altinn-securify.http: -------------------------------------------------------------------------------- 1 | @HostAddress = https://localhost:7157 2 | @AccessToken = eyJhbGciOiJSUzI1NiIsImtpZCI6IkQ4RDg2N0M3RDUyMTM2MEY0RjM1Q0Q1MTU4MEM0OUEwNTE2NUQ0RTEiLCJ0eXAiOiJKV1QiLCJ4NWMiOiJEOEQ4NjdDN0Q1MjEzNjBGNEYzNUNENTE1ODBDNDlBMDUxNjVENEUxIn0.eyJzY29wZSI6ImFsdGlubjpzZWN1cmlmeSIsInRva2VuX3R5cGUiOiJCZWFyZXIiLCJleHAiOjE3Mzc2MTkxMTAsImlhdCI6MTczNzUzMjcxMCwiY2xpZW50X2lkIjoiZTMzNmQ0NWMtZmM3Ni00OTQ2LThhZjktNDI2ZWEyMjUwYTRiIiwianRpIjoiZlFwcUxIcUNOYy00U3V2b3BHQUp4NUNLV255bC13cGNtSzhXQ3didVlXbSIsImNvbnN1bWVyIjp7ImF1dGhvcml0eSI6ImlzbzY1MjMtYWN0b3JpZC11cGlzIiwiSUQiOiIwMTkyOjk5MTgyNTgyNyJ9LCJ1cm46YWx0aW5uOm9yZ051bWJlciI6Ijk5MTgyNTgyNyIsInVybjphbHRpbm46YXV0aGVudGljYXRlbWV0aG9kIjoibWFza2lucG9ydGVuIiwidXJuOmFsdGlubjphdXRobGV2ZWwiOjMsImlzcyI6Imh0dHBzOi8vcGxhdGZvcm0udHQwMi5hbHRpbm4ubm8vYXV0aGVudGljYXRpb24vYXBpL3YxL29wZW5pZC8iLCJhY3R1YWxfaXNzIjoiYWx0aW5uLXRlc3QtdG9vbHMiLCJuYmYiOjE3Mzc1MzI3MTB9.RHuXH7PMzm0fGiT6ZrRluG6JsjMOxQut09O1ETp7xDHt0P5pI4iTYgL-tM0V7pqc7u5dXcaFg9pMJIdPnYGbx_85Uh-0sF49_QjG3gpiYmMeOutQo-veuxcJ6jrqCf4-eXketjJc0R-hDlgcmpYcbjoTFCEqagbAI_BtgQ3yXPDSpXs88OZF00r-2aDtEIn1enW-yLxRpNCqZBUsZOWG1Dxw9j0QQv2aPPeKCZUNEYBsfzL3UoPI4RAKBpKPtnfVuupnD_wf4GDCIfr9F-Q-yaz0gUZfRUJHEH-yI_YFTfTsd9lpM2E5NUG5Eq3YcprAOFMklVpUgquvJd_6pGzcjw 3 | 4 | ### Encrypt 5 | POST {{HostAddress}}/encrypt 6 | Accept: application/json 7 | Authorization: Bearer {{AccessToken}} 8 | Content-Type: application/json 9 | 10 | { 11 | "plaintext": "this is a secret message", 12 | "settings": { 13 | "requiresOrgNo": [ 14 | "912345678" 15 | ] 16 | } 17 | } 18 | 19 | ### Decrypt 20 | POST {{HostAddress}}/decrypt 21 | Accept: application/json 22 | Authorization: Bearer {{AccessToken}} 23 | Content-Type: application/json 24 | 25 | { 26 | "ciphertext": "as_AQgyMDI1MDEwMgFNc79HOKOqOl8SlQwBnCxpvGx9U337DOp6HO4gDkyIMiGVFI-5-Igy3hb8Uw80jLC8Nkc1rqtKLx7KvhT7p35JCTtMEnUkIBmJUn32eH_tU6Wge3Z-yRscF20CTD5RA8E6MOvpg1dxUHiJhRssla7QdRMPin-ZBPG9r5cPo0BW9k0qMO_Blxdy8zvpVl4pA8E7RfAKwNEMTv-hjFd5xV1eqkpx6KMjAzdThge1bzAcRUUOUW8z8uHUfQp7PD_pjdYk" 27 | } 28 | -------------------------------------------------------------------------------- /altinn-securify/Authentication/TokenIssuerCache.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Securify.Configuration; 2 | using Microsoft.IdentityModel.Protocols; 3 | using Microsoft.IdentityModel.Protocols.OpenIdConnect; 4 | 5 | namespace Altinn.Securify.Authentication; 6 | 7 | public interface ITokenIssuerCache 8 | { 9 | public Task GetIssuerForScheme(string schemeName); 10 | } 11 | 12 | public sealed class TokenIssuerCache : ITokenIssuerCache, IDisposable 13 | { 14 | private readonly Dictionary _issuerMappings = new(); 15 | private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); 16 | private bool _initialized; 17 | private readonly IReadOnlyCollection _jwtTokenSchemas; 18 | 19 | public TokenIssuerCache(IConfiguration configuration) 20 | { 21 | _jwtTokenSchemas = configuration 22 | .GetSection(nameof(SecurifyConfig)) 23 | .Get() 24 | ?.Authentication 25 | ?.JwtBearerTokenSchemas 26 | ?? throw new ArgumentException("JwtBearerTokenSchemas is required."); 27 | } 28 | 29 | public async Task GetIssuerForScheme(string schemeName) 30 | { 31 | await EnsureInitializedAsync(); 32 | 33 | return _issuerMappings.TryGetValue(schemeName, out var issuer) 34 | ? issuer : null; 35 | } 36 | 37 | private async Task EnsureInitializedAsync() 38 | { 39 | if (_initialized) return; 40 | await _initializationSemaphore.WaitAsync(); 41 | if (_initialized) return; 42 | 43 | try 44 | { 45 | foreach (var schema in _jwtTokenSchemas) 46 | { 47 | var configManager = new ConfigurationManager( 48 | schema.WellKnown, new OpenIdConnectConfigurationRetriever()); 49 | var config = await configManager.GetConfigurationAsync(); 50 | _issuerMappings[schema.Name] = config.Issuer; 51 | } 52 | 53 | _initialized = true; 54 | } 55 | finally 56 | { 57 | _initializationSemaphore.Release(); 58 | } 59 | } 60 | 61 | public void Dispose() => _initializationSemaphore.Dispose(); 62 | } 63 | -------------------------------------------------------------------------------- /altinn-securify/Authentication/AuthenticationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using Microsoft.AspNetCore.Authentication.JwtBearer; 3 | using System.Diagnostics; 4 | using System.IdentityModel.Tokens.Jwt; 5 | using Altinn.Securify.Configuration; 6 | 7 | namespace Altinn.Securify.Authentication; 8 | 9 | internal static class AuthenticationBuilderExtensions 10 | { 11 | public static IServiceCollection AddSecurifyAuthentication( 12 | this IServiceCollection services, 13 | IConfiguration configuration) 14 | { 15 | var jwtTokenSchemas = configuration 16 | .GetSection(nameof(SecurifyConfig)) 17 | .Get() 18 | ?.Authentication 19 | ?.JwtBearerTokenSchemas; 20 | 21 | if (jwtTokenSchemas is null || jwtTokenSchemas.Count == 0) 22 | { 23 | throw new InvalidOperationException("No JWT token schemas found in configuration."); 24 | } 25 | 26 | services.AddSingleton(); 27 | 28 | var authenticationBuilder = services.AddAuthentication(); 29 | 30 | foreach (var schema in jwtTokenSchemas) 31 | { 32 | authenticationBuilder.AddJwtBearer(schema.Name, options => 33 | { 34 | options.MetadataAddress = schema.WellKnown; 35 | options.TokenValidationParameters = new TokenValidationParameters 36 | { 37 | ValidateIssuerSigningKey = true, 38 | ValidateIssuer = false, 39 | ValidateAudience = false, 40 | RequireExpirationTime = true, 41 | ValidateLifetime = true, 42 | ClockSkew = TimeSpan.FromSeconds(2) 43 | }; 44 | 45 | options.Events = new JwtBearerEvents 46 | { 47 | OnMessageReceived = async context => 48 | { 49 | var expectedIssuer = await context.HttpContext 50 | .RequestServices 51 | .GetRequiredService() 52 | .GetIssuerForScheme(schema.Name); 53 | 54 | if (context.HttpContext.Items.TryGetValue(Constants.CurrentTokenIssuer, out var tokenIssuer) 55 | && (string?)tokenIssuer != expectedIssuer) 56 | { 57 | context.NoResult(); 58 | } 59 | } 60 | }; 61 | }); 62 | } 63 | 64 | return services; 65 | } 66 | } -------------------------------------------------------------------------------- /altinn-transformer.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | ForceIncluded 3 | ForceIncluded 4 | ForceIncluded 5 | ForceIncluded 6 | ForceIncluded 7 | True -------------------------------------------------------------------------------- /altinn-securify/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Altinn.Securify.Authentication; 3 | using Altinn.Securify.Authorization; 4 | using Altinn.Securify.Configuration; 5 | using Altinn.Securify.Models.Dto; 6 | using Altinn.Securify.Services; 7 | using Altinn.Securify.Services.Interfaces; 8 | using Microsoft.Extensions.Options; 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | 12 | builder.Services.Configure(builder.Configuration.GetSection(nameof(SecurifyConfig))); 13 | builder.Services 14 | .ConfigureOptions() 15 | .AddSingleton() 16 | .AddSingleton() 17 | .AddSingleton() 18 | .AddSingleton() 19 | .AddHttpContextAccessor() 20 | .AddOpenApi() 21 | .AddSecurifyAuthentication(builder.Configuration) 22 | .AddAuthorization(); 23 | 24 | var app = builder.Build(); 25 | 26 | if (app.Environment.IsDevelopment()) 27 | { 28 | app.MapOpenApi(); 29 | } 30 | 31 | app.UseHttpsRedirection() 32 | .UseJwtSchemeSelector() 33 | .UseAuthentication() 34 | .UseAuthorization() 35 | .UseMiddleware(); 36 | 37 | 38 | app.MapPost("/encrypt", async (IOptions securifyConfig, ISecurifyService securifyService, EncryptionRequestDto requestDto) => 39 | { 40 | var errors = requestDto.Validate(securifyConfig.Value); 41 | if (errors.Count > 0) 42 | { 43 | return Results.Problem( 44 | title: "Encryption error", 45 | statusCode: (int) HttpStatusCode.BadRequest, 46 | detail: string.Join(", ", errors)); 47 | } 48 | 49 | var encryptionRequest = requestDto.ToEncryptionRequest(securifyConfig.Value); 50 | var encryptionResult = await securifyService.Encrypt(encryptionRequest); 51 | 52 | return Results.Ok(encryptionResult.ToEncryptionResultDto()); 53 | }).RequireAuthorization(); 54 | 55 | app.MapPost("/decrypt", async (ISecurifyService securifyService, DecryptionRequestDto requestDto) => 56 | { 57 | var decryptionRequest = requestDto.ToDecryptionRequest(); 58 | var decryptionResult = await securifyService.Decrypt(decryptionRequest); 59 | 60 | if (decryptionResult.Errors.Count > 0) 61 | { 62 | return Results.Problem( 63 | title: "Decryption error", 64 | statusCode: (int) HttpStatusCode.BadRequest, 65 | detail: string.Join(", ", decryptionResult.Errors)); 66 | } 67 | 68 | return Results.Ok(decryptionResult.ToDecryptionResultDto()); 69 | }).RequireAuthorization(); 70 | 71 | await app.RunAsync(); 72 | -------------------------------------------------------------------------------- /altinn-securify/Authorization/ClaimsPrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Security.Claims; 3 | using System.Text.Json; 4 | 5 | namespace Altinn.Securify.Authorization; 6 | 7 | public static class ClaimsPrincipalExtensions 8 | { 9 | private const string ConsumerClaim = "consumer"; 10 | private const string AuthorityClaim = "authority"; 11 | private const string AuthorityValue = "iso6523-actorid-upis"; 12 | private const string IdClaim = "ID"; 13 | private const char IdDelimiter = ':'; 14 | private const string IdPrefix = "0192"; 15 | private const string ScopeClaim = "scope"; 16 | private const char ScopeClaimSeparator = ' '; 17 | 18 | public static bool TryGetClaimValue(this ClaimsPrincipal claimsPrincipal, string claimType, 19 | [NotNullWhen(true)] out string? value) 20 | { 21 | value = claimsPrincipal.FindFirst(claimType)?.Value; 22 | return value is not null; 23 | } 24 | 25 | public static bool TryGetOrganizationNumber(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? orgNumber) 26 | => claimsPrincipal.FindFirst(ConsumerClaim).TryGetOrganizationNumber(out orgNumber); 27 | 28 | public static bool HasScope(this ClaimsPrincipal claimsPrincipal, string scope) => 29 | claimsPrincipal.TryGetClaimValue(ScopeClaim, out var scopes) && 30 | scopes.Split(ScopeClaimSeparator).Contains(scope); 31 | 32 | public static bool TryGetOrganizationNumber(this Claim? consumerClaim, [NotNullWhen(true)] out string? orgNumber) 33 | { 34 | orgNumber = null; 35 | if (consumerClaim is null || consumerClaim.Type != ConsumerClaim) 36 | { 37 | return false; 38 | } 39 | 40 | var consumerClaimJson = JsonSerializer.Deserialize>(consumerClaim.Value); 41 | 42 | if (consumerClaimJson is null) 43 | { 44 | return false; 45 | } 46 | 47 | if (!consumerClaimJson.TryGetValue(AuthorityClaim, out var authority) || 48 | !string.Equals(authority, AuthorityValue, StringComparison.OrdinalIgnoreCase)) 49 | { 50 | return false; 51 | } 52 | 53 | if (!consumerClaimJson.TryGetValue(IdClaim, out var id)) 54 | { 55 | return false; 56 | } 57 | 58 | orgNumber = id.Split(IdDelimiter) switch 59 | { 60 | [IdPrefix, var on] => NorwegianOrganizationIdentifier.IsValid(on) ? on : null, 61 | _ => null 62 | }; 63 | 64 | return orgNumber is not null; 65 | } 66 | } 67 | 68 | file sealed record NorwegianOrganizationIdentifier 69 | { 70 | public static bool IsValid(string value) 71 | { 72 | return value.Length == 9; 73 | } 74 | } -------------------------------------------------------------------------------- /altinn-securify/Services/UserService.cs: -------------------------------------------------------------------------------- 1 | 2 | using System.Security.Claims; 3 | using System.Text.Json; 4 | using Altinn.Securify.Models; 5 | using Altinn.Securify.Services.Interfaces; 6 | 7 | namespace Altinn.Securify.Services; 8 | 9 | public class UserService : IUserService 10 | { 11 | private const string AuthorityClaim = "authority"; 12 | private const string AuthorityValue = "iso6523-actorid-upis"; 13 | private const string IdClaim = "ID"; 14 | private const char IdDelimiter = ':'; 15 | private const string IdPrefix = "0192"; 16 | 17 | private readonly IHttpContextAccessor _httpContextAccessor; 18 | 19 | public UserService(IHttpContextAccessor httpContextAccessor) 20 | { 21 | _httpContextAccessor = httpContextAccessor; 22 | } 23 | 24 | public User GetUser() 25 | { 26 | ArgumentNullException.ThrowIfNull(_httpContextAccessor.HttpContext, nameof(_httpContextAccessor.HttpContext)); 27 | var claims = _httpContextAccessor.HttpContext.User.Claims.ToList(); 28 | 29 | return new User( 30 | GetOrganizationNumber(claims), 31 | GetClientId(claims), 32 | GetScopes(claims)); 33 | } 34 | 35 | private static List GetScopes(IEnumerable claims) 36 | { 37 | var claim = claims.FirstOrDefault(c => c.Type == "scope"); 38 | ArgumentNullException.ThrowIfNull(claim, nameof(claim)); 39 | return claim.Value.Split(' ').ToList(); 40 | } 41 | 42 | private static string GetClientId(IEnumerable claims) 43 | { 44 | var claim = claims.FirstOrDefault(c => c.Type == "client_id"); 45 | ArgumentNullException.ThrowIfNull(claim, nameof(claim)); 46 | return claim.Value; 47 | } 48 | 49 | private static string GetOrganizationNumber(IEnumerable claims) 50 | { 51 | var claim = claims.FirstOrDefault(c => c.Type == "consumer"); 52 | ArgumentNullException.ThrowIfNull(claim, nameof(claim)); 53 | 54 | var consumerClaimJson = JsonSerializer.Deserialize>(claim.Value); 55 | ArgumentNullException.ThrowIfNull(consumerClaimJson, nameof(consumerClaimJson)); 56 | 57 | if (!consumerClaimJson.TryGetValue(AuthorityClaim, out var authority) || 58 | !string.Equals(authority, AuthorityValue, StringComparison.OrdinalIgnoreCase)) 59 | { 60 | throw new InvalidOperationException(nameof(AuthorityClaim)); 61 | } 62 | 63 | if (!consumerClaimJson.TryGetValue(IdClaim, out var id)) 64 | { 65 | throw new InvalidOperationException(nameof(IdClaim)); 66 | } 67 | 68 | var orgNumber = id.Split(IdDelimiter) switch 69 | { 70 | [IdPrefix, var orgNo] => orgNo, 71 | _ => throw new InvalidOperationException(nameof(IdDelimiter)) 72 | }; 73 | 74 | return orgNumber; 75 | } 76 | } -------------------------------------------------------------------------------- /altinn-securify.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | ForceIncluded 3 | ForceIncluded 4 | ForceIncluded 5 | ForceIncluded 6 | ForceIncluded 7 | ForceIncluded 8 | ForceIncluded 9 | True -------------------------------------------------------------------------------- /altinn-securify/Services/SecurifyService.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Buffers.Text; 3 | using System.Text; 4 | using Altinn.Securify.Models; 5 | using Altinn.Securify.Services.Interfaces; 6 | 7 | namespace Altinn.Securify.Services; 8 | 9 | public class SecurifyService : ISecurifyService 10 | { 11 | private const string CurrentKeyId = "20250102"; 12 | private const string CipherTextPrefix = "as_"; 13 | 14 | private readonly IEncryptionService _encryptionService; 15 | private readonly IKeyResolverService _keyResolverService; 16 | private readonly IUserService _userService; 17 | 18 | 19 | public SecurifyService(IEncryptionService encryptionService, IKeyResolverService keyResolverService, IUserService userService) 20 | { 21 | _encryptionService = encryptionService; 22 | _keyResolverService = keyResolverService; 23 | _userService = userService; 24 | } 25 | 26 | public async Task Encrypt(EncryptionRequest request) => await GetEncryptionResult(request); 27 | 28 | public async Task Decrypt(DecryptionRequest request) => await GetDecryptionResult(request); 29 | 30 | private async Task GetDecryptionResult(DecryptionRequest request) 31 | { 32 | var securedData = await GetDecodedAndDecryptedData(request); 33 | var errors = ValidateSecuredData(securedData); 34 | 35 | if (errors.Count > 0) 36 | { 37 | return new DecryptionResult 38 | { 39 | Errors = errors 40 | }; 41 | } 42 | 43 | return new DecryptionResult 44 | { 45 | Timestamp = securedData!.At, 46 | User = securedData.By, 47 | PlainText = securedData.Data 48 | }; 49 | } 50 | 51 | private async Task GetEncryptionResult(EncryptionRequest request) 52 | { 53 | var encodedAndEncryptedData = await GetEncodedAndEncryptedData(request); 54 | 55 | return new EncryptionResult 56 | { 57 | CipherText = encodedAndEncryptedData 58 | }; 59 | } 60 | 61 | private List ValidateSecuredData(EncryptedData? securedData) 62 | { 63 | if (securedData is null) 64 | { 65 | return ["Unable to decrypt supplied cipher text"]; 66 | } 67 | 68 | var errors = new List(); 69 | if (securedData.Settings.ExpiresAt < DateTimeOffset.UtcNow) 70 | { 71 | errors.Add("Secured data has expired"); 72 | } 73 | 74 | var user = _userService.GetUser(); 75 | if (securedData.Settings.RequiresOrgNo != null && !securedData.Settings.RequiresOrgNo.Contains(user.OrgNo)) 76 | { 77 | errors.Add("The currently authenticated organization number is not allowed to decrypt this data"); 78 | } 79 | 80 | if (securedData.Settings.RequiresClientId != null && !securedData.Settings.RequiresClientId.Contains(user.ClientId)) 81 | { 82 | errors.Add("The currently authenticated client ID is not allowed to decrypt this data"); 83 | } 84 | 85 | if (securedData.Settings.RequiresScope != null && 86 | !securedData.Settings.RequiresScope.All(x => user.Scopes.Contains(x))) 87 | { 88 | errors.Add("The currently authenticated scopes are not allowed to decrypt this data"); 89 | } 90 | 91 | return errors; 92 | } 93 | 94 | private async Task GetEncodedAndEncryptedData(EncryptionRequest request) 95 | { 96 | var plaintext = JsonSerializer.SerializeToUtf8Bytes(new EncryptedData(request.Settings, DateTimeOffset.Now, _userService.GetUser(), request.PlainText)); 97 | var ciphertext = await _encryptionService.Encrypt(plaintext, CurrentKeyId, _keyResolverService.GetKey); 98 | return CipherTextPrefix + Base64Url.EncodeToString(ciphertext); 99 | } 100 | 101 | private async Task GetDecodedAndDecryptedData(DecryptionRequest request) 102 | { 103 | var ciphertext = Base64Url.DecodeFromUtf8(Encoding.UTF8.GetBytes(request.CipherText.Substring(CipherTextPrefix.Length))); 104 | var plaintext = await _encryptionService.Decrypt(ciphertext, _keyResolverService.GetKey); 105 | 106 | return JsonSerializer.Deserialize(plaintext); 107 | } 108 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Altinn Securify 2 | 3 | > NOTE! This is work-in-progress PoC, and not released/supported in any way at this time 4 | 5 | The transformer is a Maskinporten-secured REST API that allows authorized parties to create secure (encrypted) representations of any plaintext small string (<2KB), and control which parties are allowed to decrypt it using the same API. 6 | 7 | The principal application is transferring sensitive data (session information, personal identifiers, etc.) in URLs as query params, where the data must be protected from tampering and unauthorized access and storage in eg. access logs. 8 | 9 | ## Local installation 10 | 11 | 1. Clone the repo 12 | 2. Navigate to the project directory 13 | 3. Set up an encryption key: `dotnet user-secrets set "TransformerConfig:EncryptionKeys" "20250102:$(head -c 32 /dev/urandom | base64)"` 14 | 4. Run the application (`dotnet run`) 15 | 16 | See `altinn-securify.http` for an example request 17 | 18 | ## Quickstart 19 | 20 | ### Encrypting data 21 | 22 | ```http 23 | POST /api/v1/encrypt 24 | { 25 | "plaintext": "somesecretvalue", // or any arbitrary valid JSON 26 | "settings": { // all are optional 27 | "expires": "2024-12-18T23:00:00Z", // default 1 minute (max 20 minutes) 28 | "requireOrgNo": ["991825827"], // default any org number 29 | "requireClientId": ["042fbe5d-bbfb-41cf-bada-9b9b52073a9b"], // default any client id 30 | "requireScope": ["some:arbitrary-scope"] // default no additional scope (besides altinn:securify) 31 | } 32 | } 33 | ``` 34 | Response: 35 | ```http 36 | { 37 | "ciphertext": "as_2ZXJzaW9uIjoxLCJrZXlJRCI6IjIwMjUwMTAyOjJkZjM..." 38 | } 39 | ``` 40 | 41 | ### Decrypting data 42 | 43 | ```http 44 | POST /api/v1/decrypt 45 | { 46 | "ciphertext": "as_2ZXJzaW9uIjoxLCJrZXlJRCI6IjIwMjUwMTAyOjJkZjM..." 47 | } 48 | ``` 49 | Reponse: 50 | ```jsonc 51 | { 52 | "at": "2025-03-30T23:42:55.206517+02:00", 53 | "by": { 54 | "orgNo": "991825827", 55 | "clientId": "cf991714-976c-47eb-88c6-dbbc7f65832d", 56 | "scopes": [ 57 | "altinn:securify" 58 | ] 59 | }, 60 | "plaintext": "somesecretvalue" 61 | } 62 | ``` 63 | 64 | ## How it works 65 | 66 | When requesting encryption of any arbitrary data, the returned string will be Base64URL encoded, which ensures that it can be safely used as query strings in URLs. 67 | 68 | The encrypted data is *not* designed to be persisted, but must be exchanged to its plain text variant before it is stored. Any requirement for securing of this data at-rest is out of scope for this solution. 69 | 70 | The encryption used is AES-GCM, which includes nonces and authentication tags to ensure that a given plaintext will not produce the same ciphertext twice, and to detect tampering. A version identifier is also prepended for future-proofing, allowing for multiple encryption algorithms to be supported in the future. 71 | 72 | Along the nonce and authentication tag, a key ID is also embedded. This allows for runtime key resolution when decrypting, allowing for key server side rotation and revocation. 73 | 74 | **The entire output value (when encrypting) should be considered opaque by consumers**, and should not be parsed or assumed having any particular form (besides being URL safe). This allows the API to change encodings, encryption algorithms, modes, key/nonce/tag sizes on a per version or even per key-ID basis. This means no key material should be handled by consumers, and no assumptions should be made about the output format (other than being URL safe). 75 | 76 | ## Security settings 77 | 78 | When encrypting data, additional metadata can be supplied that adds restrictions to consumers wanting to exchange the encrypted data back to its plain text representations. If any of the restrictions is not met, the API will refuse to return the decrypted plaintext and will instead produce an error. 79 | 80 | By default, no restrictions are added except for a expiry time, which is capped at 20 minutes. This is to discourage persisting secured-data, which will potentially break as keys will be rotated. 81 | 82 | | Restriction | Description | 83 | |-------------|-------------| 84 | | `expires` | UTC timestamp after which the secured data can no longer be decrypted | 85 | | `requireOrgNo` | Array of norwegian organization numbers. The consumer claim of the Maskinporten-token provided must match one of the supplied organization numbers. | 86 | | `requireClientId` | Array of client ids (strings, usually UUIDs). The client_id claim of the Maskinporten-token provided must match one of the supplied client ids. | 87 | | `requireScope` | Array of scopes (strings). The scope claim of the Maskinporten-token must contain at least one of the supplied scopes. | 88 | 89 | ## Maskinporten 90 | 91 | The API is secured using [Maskinporten](https://docs.digdir.no/docs/Maskinporten/maskinporten_summary), which is a OAuth2-based authorization server that allows for fine-grained access control to APIs. In order to use the API, a Maskinporten token needs be supplied in the `Authorization` header, having the scope `altinn:securify`. 92 | 93 | ## TODO 94 | 95 | * Deployment (authorization cluster?) 96 | -------------------------------------------------------------------------------- /altinn-securify/Services/AesGcmEncryptionService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using Altinn.Securify.Services.Interfaces; 3 | 4 | namespace Altinn.Securify.Services; 5 | 6 | /// 7 | /// Provides methods for encrypting and decrypting data using AES-GCM with key rotation support. 8 | /// 9 | public class AesGcmEncryptionService : IEncryptionService 10 | { 11 | private const int NonceSize = 12; // 96-bit nonce 12 | private const int TagSize = 16; // 128-bit authentication tag 13 | private const int KeySize = 32; // 256-bit key for AES-GCM 14 | private const byte CurrentVersion = 1; // Format version 15 | private const int MaxPlaintextSize = 1024 * 2; // 2KB limit 16 | private const int MaxKeyIdLength = 16; // Maximum length for key ID 17 | 18 | /// 19 | /// Encrypts data using AES-GCM with key rotation support. 20 | /// 21 | /// The data to encrypt. 22 | /// Identifier for the key to use for encryption. 23 | /// Function to retrieve the encryption key based on key ID. 24 | /// Encrypted data including version, keyId, nonce, and authentication tag. 25 | public async Task Encrypt(byte[] plainText, string keyId, Func> keyResolver) 26 | { 27 | // Validate inputs 28 | ArgumentNullException.ThrowIfNull(plainText); 29 | if (string.IsNullOrEmpty(keyId)) throw new ArgumentException("Invalid key ID."); 30 | ArgumentNullException.ThrowIfNull(keyResolver); 31 | if (plainText.Length > MaxPlaintextSize) throw new ArgumentException($"Plaintext exceeds maximum size of {MaxPlaintextSize} bytes."); 32 | 33 | // Convert keyId to bytes and validate length 34 | var keyIdBytes = System.Text.Encoding.ASCII.GetBytes(keyId); 35 | if (keyIdBytes.Length > MaxKeyIdLength) 36 | throw new ArgumentException($"Key ID exceeds maximum length of {MaxKeyIdLength} bytes."); 37 | 38 | // Get the key 39 | var key = await keyResolver(keyId); 40 | if (key is not { Length: KeySize }) 41 | throw new ArgumentException("Invalid or missing encryption key."); 42 | 43 | using var aesGcm = new AesGcm(key, TagSize); 44 | 45 | // Generate random nonce 46 | var nonce = new byte[NonceSize]; 47 | RandomNumberGenerator.Fill(nonce); 48 | 49 | // Calculate total size: version + keyIdLength + keyId + nonce + tag + ciphertext 50 | var totalSize = 1 + 1 + keyIdBytes.Length + NonceSize + TagSize + plainText.Length; 51 | var cipherText = new byte[totalSize]; 52 | var position = 0; 53 | 54 | // Write version 55 | cipherText[position++] = CurrentVersion; 56 | 57 | // Write keyId length and keyId 58 | cipherText[position++] = (byte)keyIdBytes.Length; 59 | keyIdBytes.CopyTo(cipherText, position); 60 | position += keyIdBytes.Length; 61 | 62 | // Write nonce 63 | nonce.CopyTo(cipherText, position); 64 | position += NonceSize; 65 | 66 | // Encrypt the data 67 | aesGcm.Encrypt( 68 | nonce, 69 | plainText, 70 | cipherText.AsSpan(position + TagSize), // Ciphertext position 71 | cipherText.AsSpan(position, TagSize) // Tag position 72 | ); 73 | 74 | return cipherText; 75 | } 76 | 77 | /// 78 | /// Decrypts data that was encrypted using the Encrypt method. 79 | /// 80 | /// The encrypted data to decrypt. 81 | /// Function to retrieve the decryption key based on key ID. 82 | /// The decrypted data. 83 | public async Task Decrypt(byte[] cipherText, Func> keyResolver) 84 | { 85 | // Validate inputs 86 | ArgumentNullException.ThrowIfNull(cipherText); 87 | ArgumentNullException.ThrowIfNull(keyResolver); 88 | if (cipherText.Length < 1 + 1 + NonceSize + TagSize) // Minimum size check 89 | throw new ArgumentException("Invalid ciphertext format."); 90 | 91 | var position = 0; 92 | 93 | // Verify version 94 | var version = cipherText[position++]; 95 | if (version != CurrentVersion) 96 | throw new ArgumentException($"Unsupported format version: {version}"); 97 | 98 | // Extract keyId 99 | int keyIdLength = cipherText[position++]; 100 | if (keyIdLength > MaxKeyIdLength || cipherText.Length < position + keyIdLength + NonceSize + TagSize) 101 | throw new ArgumentException("Invalid ciphertext format."); 102 | 103 | var keyId = System.Text.Encoding.ASCII.GetString( 104 | cipherText.AsSpan(position, keyIdLength)); 105 | position += keyIdLength; 106 | 107 | // Get the key 108 | var key = await keyResolver(keyId); 109 | if (key is not { Length: KeySize }) 110 | throw new ArgumentException("Invalid or missing encryption key."); 111 | 112 | // Extract nonce 113 | var nonce = cipherText.AsSpan(position, NonceSize).ToArray(); 114 | position += NonceSize; 115 | 116 | // Extract tag and encrypted data 117 | ReadOnlySpan tag = cipherText.AsSpan(position, TagSize); 118 | position += TagSize; 119 | ReadOnlySpan encryptedData = cipherText.AsSpan(position); 120 | 121 | // Decrypt 122 | var plainText = new byte[encryptedData.Length]; 123 | using var aesGcm = new AesGcm(key, TagSize); 124 | aesGcm.Decrypt(nonce, encryptedData, tag, plainText); 125 | 126 | return plainText; 127 | } 128 | } --------------------------------------------------------------------------------