diff --git a/.gitignore b/.gitignore index a1a35c5..d5994a2 100644 --- a/.gitignore +++ b/.gitignore @@ -263,4 +263,5 @@ paket-files/ __pycache__/ *.pyc -.env +".env" +.DS_Store 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/EXAMPLES.md b/EXAMPLES.md index 5b06bba..b5ddb6b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -12,34 +12,33 @@ 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) +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 -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). 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 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 +75,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. @@ -99,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 4ba9796..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 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)). -* [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 the quickstarts are built from 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 4b80baf..cb5ebcb 100644 --- a/README.md +++ b/README.md @@ -1,226 +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`. -First, setup the [Approov CLI](https://approov.io/docs/latest/approov-installation/index.html#initializing-the-approov-cli). +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. -Now, register the API domain for which Approov will issues tokens: -```bash -approov api -add api.example.com -``` +## Getting Started -> **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. +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. -Next, enable your Approov `admin` role with: +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`. -```bash -eval `approov role admin` -```` +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. -For the Windows powershell: -```bash -set APPROOV_ROLE=admin:___YOUR_APPROOV_ACCOUNT_NAME_HERE___ -``` +## Implementing Approov in Your Project -Now, get your Approov Secret with the [Approov CLI](https://approov.io/docs/latest/approov-installation/index.html#initializing-the-approov-cli): +Follow the detailed quickstarts to bring the same protections into your own API: -```bash -approov secret -get base64 -``` +- [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. -Next, add the [Approov secret](https://approov.io/docs/latest/approov-usage-documentation/#account-secret-key-export) to your project `.env` file: +Each guide includes package requirements, configuration snippets, and testing instructions that match the code in this repository. -```env -APPROOV_BASE64_SECRET=approov_base64_secret_here -``` -Now, add to your `appname.csproj` file the dependencies: +## Testing and Examples -```xml - - +- [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, in `Program.cs` load the secrets from the `.env` file and inject it into `AppSettiongs`: +## Additional Resources -```c# -using AppName.Helpers; +- [Approov Overview](OVERVIEW.md) +- [Approov Quickstarts](QUICKSTARTS.md) +- [Approov Integration Examples](EXAMPLES.md) -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/docker-compose.yml b/docker-compose.yml index 4f3508d..9f6ac51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,37 +1,26 @@ -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:8111 ports: - - ${HOST_IP:-127.0.0.1}:${HTTP_PORT:-8002}:${HTTP_PORT:-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: - image: approov/dotnet:6.0 - build: ./ - networks: - - default - command: bash -c "dotnet run" - ports: - - ${HOST_IP:-127.0.0.1}:${HTTP_PORT:-8002}:${HTTP_PORT:-8002} - volumes: - - ./servers/hello/src/approov-protected-server/token-binding-check:/home/developer/workspace - 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 febc077..1b4eb3d 100644 --- a/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md +++ b/docs/APPROOV_TOKEN_BINDING_QUICKSTART.md @@ -1,421 +1,196 @@ # 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-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. - -[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 -``` - -[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; +- 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; - - 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, 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 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) - { - _next = next; - _appSettings = appSettings.Value; - _logger = logger; - } - - public async Task Invoke(HttpContext context) - { - var tokenBinding = context.Items["ApproovTokenBinding"]?.ToString(); + missingHeaders.Add(header); + continue; + } - if (tokenBinding == null) { - _logger.LogInformation("The pay claim is missing in the Approov token."); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; + builder.Append(valueToHash.Trim()); } - 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 (missingHeaders.Count > 0) + { + _logger.LogInformation("Token binding header(s) missing: {Headers}", string.Join(", ", missingHeaders)); context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } - if (verifyApproovTokenBinding(authorizationToken, tokenBinding)) { - await _next(context); + var concatenated = builder.ToString(); + var computedHash = Sha256Base64(concatenated); + + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(payClaim), + Encoding.UTF8.GetBytes(computedHash))) + { + _logger.LogInformation("Token binding verification failed"); + 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 string Sha256Base64(string input) { - 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); - } + using var sha256 = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha256.ComputeHash(bytes); + return Convert.ToBase64String(hash); } } ``` -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 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(); -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`. +Register the middleware in `Program.cs` after the token middleware: -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' +```csharp +app.UseMiddleware(); +app.UseMiddleware(); ``` -The above request should fail with an Unauthorized error. For example: - -```text -HTTP/2 401 - -... - -{} -``` - -##### 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/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/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..dc8317e --- /dev/null +++ b/scripts/run-local.sh @@ -0,0 +1,120 @@ +#!/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|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" ;; + *) return 1 ;; + esac +} + +project_port_for() { + case "$1" in + unprotected) printf '8001\n' ;; + token-check) printf '8111\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; 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) + run_project "$1" + ;; + all) + run_all + ;; + -h|--help) + print_usage + ;; + *) + print_usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/servers/archived/postman/approov-dotnet-example.postman_collection.json b/servers/archived/postman/approov-dotnet-example.postman_collection.json index f1bea6a..e47d9df 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:8111/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8111", + "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-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 0c208ae..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": "Warning" - } - } -} 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 10f68b8..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": "Warning" - } - }, - "AllowedHosts": "*" -} 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 5fca7a5..d5712b1 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,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. 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 +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. +# 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/Controllers/ApproovController.cs b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs new file mode 100644 index 0000000..e4f3875 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Controllers/ApproovController.cs @@ -0,0 +1,189 @@ +using Hello.Helpers; +using Microsoft.AspNetCore.Mvc; +using StructuredFieldValues; +using System.Security.Cryptography; + +namespace Hello.Controllers; + +[ApiController] +[Produces("text/plain")] +public class ApproovController : ControllerBase +{ + private readonly ILogger _logger; + + public ApproovController(ILogger logger) + { + _logger = 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) + ? value as string + : null; + + var bindingVerified = HttpContext.Items.TryGetValue(ApproovTokenContextKeys.TokenBindingVerified, out var verifiedValue) + && verifiedValue is bool verifiedFlag + && verifiedFlag; + + if (string.IsNullOrWhiteSpace(payClaim) || !bindingVerified) + { + return Unauthorized(); + } + + 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(); + 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.LogDebug("Generated EC key pair for testing. Private DER (b64)={Private} Public DER (b64)={Public}", privateKeyBase64, publicKeyBase64); + + return Content("No IPK header provided", "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.LogWarning("Failed to import IPK header - {Message}", ex.Message); + Response.StatusCode = StatusCodes.Status401Unauthorized; + 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")] + // 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(); + 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.LogWarning("Invalid input for signing - {Message}", ex.Message); + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content("Failed to generate signature", "text/plain"); + } + catch (CryptographicException ex) + { + _logger.LogError(ex, "Cryptographic failure during signing"); + Response.StatusCode = StatusCodes.Status400BadRequest; + return Content("Failed to generate signature", "text/plain"); + } + } + + [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(); + var sfvHeader = StructuredFieldFormatter.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); // 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); + } + 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: {serialized} != {sfvHeader}"); + } + + 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); + Response.StatusCode = StatusCodes.Status401Unauthorized; + return Content("Failed SFV roundtrip", "text/plain"); + } + + +} 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..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,16 +1,17 @@ - net6.0 + net8.0 enable enable - - + + + - + 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..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,6 +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 byte[]? ApproovSecretBytes { 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 new file mode 100644 index 0000000..b90afc1 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovMessageSignatureVerifier.cs @@ -0,0 +1,595 @@ +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 Microsoft.Extensions.Options; +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) + { + "sha-256", + "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, + 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 = 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); + + 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"}"); + } + + 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"); + } + + 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"); + } + + // Parse and validate signature parameters such as algorithm, created and expires. + var metadataResult = TryExtractSignatureMetadata(signatureInputItem.Parameters); + if (!metadataResult.Success) + { + return MessageSignatureResult.Failure(metadataResult.Error!); + } + + 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($"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!); + } + + var canonicalBase = await BuildCanonicalMessageAsync(context, componentIdentifiers, signatureInputItem.Parameters); + if (!canonicalBase.Success) + { + return MessageSignatureResult.Failure(canonicalBase.Error!); + } + + 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) + { + return MessageSignatureResult.Failure(contentDigestValidation.Error!); + } + + if (!TryVerifySignature(publicKeyBase64, signatureBytes, canonicalPayloadBytes)) + { + return MessageSignatureResult.Failure("Signature verification failed"); + } + + 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(); + 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) + { + _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}"); + } + + 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)); + } + + // Ensures any Content-Digest headers align with the current request body bytes. + private async Task<(bool Success, string? Error)> VerifyContentDigestAsync(HttpContext context) + { + var contentDigestHeader = StructuredFieldFormatter.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); + _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))) + { + return (false, $"Content digest verification failed for algorithm '{entry.Key}'"); + } + } + + 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) + ? ":" + Convert.ToBase64String(SHA512.HashData(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 + { + var publicKey = Convert.FromBase64String(publicKeyBase64); + using var ecdsa = ECDsa.Create(); + ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _); + + var signature = signatureBytes.ToArray(); + 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) + { + _logger.LogWarning("Invalid public key format - {Message}", ex.Message); + return false; + } + catch (CryptographicException ex) + { + _logger.LogError(ex, "Cryptographic failure during signature verification"); + 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) + { + 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 BuildRequestTarget(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(); + } + + // 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) + { + 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 StructuredFieldFormatter.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 = StructuredFieldFormatter.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 StructuredFieldFormatter.CombineHeaderValues(values); + } + + private static string SerializeStructuredFieldHeader(string headerName, IReadOnlyList values) + { + var raw = StructuredFieldFormatter.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/ApproovTokenContextKeys.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs new file mode 100644 index 0000000..88cec9c --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/ApproovTokenContextKeys.cs @@ -0,0 +1,12 @@ +namespace Hello.Helpers; + +// Centralises the HttpContext.Items keys used by the Approov middleware chain. +public static class ApproovTokenContextKeys +{ + public const string ApproovToken = "ApproovToken"; + public const string DeviceId = "ApproovDeviceId"; + 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/Helpers/StructuredFieldFormatter.cs b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs new file mode 100644 index 0000000..887ef35 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Helpers/StructuredFieldFormatter.cs @@ -0,0 +1,251 @@ +namespace Hello.Helpers; + +using System; +using System.Collections.Generic; +using System.Globalization; +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) + { + var builder = new StringBuilder(); + builder.Append(SerializeBareItem(item.Value)); + + AppendParameters(builder, item.Parameters); + + 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(')'); + AppendParameters(builder, parameters); + + 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); + + if (entry.Value.Value is bool boolean && boolean) + { + AppendParameters(builder, entry.Value.Parameters); + 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: + { + 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: + return token.ToString(); + case DisplayString display: + 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: + 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 < 0x20 || ch > 0x7E) + { + throw new FormatException($"Invalid character U+{((int)ch):X4} in structured field string."); + } + + 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)); + } + } + + 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); + } + + // 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 }) + { + 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/ApproovTokenBindingMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs new file mode 100644 index 0000000..3b5aca1 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenBindingMiddleware.cs @@ -0,0 +1,113 @@ +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 values before continuing the request. +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 tokenBindingClaim = context.Items.TryGetValue(ApproovTokenContextKeys.TokenBinding, out var bindingObject) + ? bindingObject as string + : null; + if (string.IsNullOrWhiteSpace(tokenBindingClaim)) + { + _logger.LogDebug("Approov token binding: skipping because pay claim is missing"); + await _next(context); + return; + } + + 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; + } + + // 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(s) '{Headers}' are missing or empty.", + string.Join(", ", missingHeaders)); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var bindingValue = concatenatedBinding.ToString(); + + if (string.IsNullOrWhiteSpace(bindingValue)) + { + _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); + } + + // 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); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(tokenBinding), + 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(); + 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/Middleware/ApproovTokenMiddleware.cs b/servers/hello/src/approov-protected-server/token-check/Middleware/ApproovTokenMiddleware.cs index 6b8e380..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 @@ -3,9 +3,10 @@ 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; +// Enforces Approov JWT validation before the application pipeline sees the request. public class ApproovTokenMiddleware { private readonly RequestDelegate _next; @@ -21,10 +22,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; } @@ -38,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 @@ -54,13 +63,65 @@ 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; + // 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"); + } + } + 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; + } + } + + // Skips token enforcement for internal test endpoints from /test-scripts. + 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 new file mode 100644 index 0000000..46b64f9 --- /dev/null +++ b/servers/hello/src/approov-protected-server/token-check/Middleware/MessageSigningMiddleware.cs @@ -0,0 +1,70 @@ +namespace Hello.Middleware; + +using System; +using System.IdentityModel.Tokens.Jwt; +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; + 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; + } + + // 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)) + { + 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; + } + + _logger.LogDebug("Message signature verified"); + await _next(context); + } + + // Extracts the ipk claim from the JWT token + private static string? ExtractInstallationPublicKey(string token) + { + try + { + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); + var ipkClaim = jwt.Claims.FirstOrDefault(claim => string.Equals(claim.Type, "ipk", StringComparison.OrdinalIgnoreCase)); + return ipkClaim?.Value; + } + catch (ArgumentException) + { + return null; + } + catch (Exception) + { + return null; + } + } +} 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..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 @@ -26,9 +29,25 @@ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.Configure(appSettings => { +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.TokenBindingHeaders = tokenBindingHeaders.ToList(); +}); +builder.Services.Configure(options => +{ + options.RequireCreated = signatureRequireCreated; + options.RequireExpires = signatureRequireExpires; + options.MaximumSignatureAge = signatureMaxAge; + options.AllowedClockSkew = signatureClockSkew; }); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -47,9 +66,55 @@ // app.UseHttpsRedirection(); app.UseMiddleware(); +app.UseMiddleware(); +app.UseMiddleware(); app.UseAuthorization(); 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(); +} 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 3280f61..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. @@ -20,15 +20,18 @@ 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 exposes the following endpoints: -```json -{"message": "Hello, World!"} -``` +* `/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`. -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. +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. -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. +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. @@ -40,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) @@ -54,37 +57,46 @@ 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). +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) ## 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 the sample backends (unprotected and Approov-protected) is: ```bash -dotnet run +./scripts/run-local.sh all ``` -Next, you can test that it works with: +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 -curl -iX GET 'http://localhost:8002' +./test-scripts/request_tests_approov_msg.sh 8111 +./test-scripts/request_tests_sfv.sh 8111 ``` -The response will be a `401` unauthorized request: +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) +curl -H "Approov-Token: " http://localhost:8111/token + +# generate a deterministic signature for a canonical message +curl -H "private-key: " \ + -H "msg: " \ + http://localhost:8111/ipk_message_sign_test -```text -HTTP/1.1 401 Unauthorized -Content-Length: 0 -Date: Wed, 01 Jun 2022 11:42:42 GMT -Server: Kestrel +# verify Structured Field Value parsing +curl -H "sfv:?1;param=123" -H "sfvt:ITEM" http://localhost:8111/sfv_test ``` -The reason you got a `401` is because the Approoov token isn't provided in the headers of the request. +Run the automated unit tests with: -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). +```bash +dotnet test ../../../../tests/Hello.Tests/Hello.Tests.csproj +``` [TOC](#toc---table-of-contents) @@ -101,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) 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..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", - "Microsoft.AspNetCore": "Warning" + "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 10f68b8..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,8 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Debug", + "Microsoft.AspNetCore": "Information" } }, "AllowedHosts": "*" 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/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 < + + + 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..cb84ff1 --- /dev/null +++ b/tests/Hello.Tests/UnitTest1.cs @@ -0,0 +1,167 @@ +namespace Hello.Tests; + +using System; +using System.Collections.Generic; +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; +using StructuredFieldValues; + +public class MessageSigningVerifierTests +{ + 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 async Task VerifyAsync_WithValidSignature_ReturnsSuccess() + { + var context = BuildRequest(); + var canonicalMessage = BuildCanonicalMessage(context); + var signature = SignCanonicalMessage(canonicalMessage); + + context.Request.Headers["Signature"] = $"install=:{signature}:"; + + var verifier = CreateVerifier(); + var result = await verifier.VerifyAsync(context, TestPublicKey); + + Assert.True(result.Success); + } + + [Fact] + public async Task VerifyAsync_WithInvalidSignature_ReturnsFailure() + { + 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 = CreateVerifier(); + var result = await verifier.VerifyAsync(context, TestPublicKey); + + Assert.False(result.Success); + } + + private static DefaultHttpContext BuildRequest() + { + 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"; + context.Request.Scheme = "http"; + 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; + context.Request.Headers["Signature-Input"] = $"install={signatureInput}"; + + return context; + } + + private static string BuildCanonicalMessage(DefaultHttpContext context) + { + 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) + { + 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); + } + + 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 +{ + [Fact] + public void IpkTest_GeneratesKeysWhenHeaderMissing() + { + var controller = new ApproovController(NullLogger.Instance); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + + var result = controller.IpkTest() as ContentResult; + + Assert.NotNull(result); + Assert.Equal("No IPK header provided", result!.Content); + } + + [Fact] + public void IpKMessageSign_ReturnsSignature() + { + var context = new DefaultHttpContext(); + context.Request.Headers["private-key"] = MessageSigningVerifierTests.TestPrivateKey; + context.Request.Headers["msg"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("payload")); + + var controller = new ApproovController(NullLogger.Instance) + { + ControllerContext = new ControllerContext { HttpContext = context } + }; + + var actionResult = controller.IpkMessageSignTest() as ContentResult; + + Assert.NotNull(actionResult); + Assert.False(string.IsNullOrEmpty(actionResult!.Content)); + } +}