From b6e518a394ac781bf4758fd0c81fd6edb2cc2fbc Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Wed, 1 Jul 2026 19:18:03 +0100 Subject: [PATCH] Add confidential client-side encryption sample --- .../Deploy-ClientSideEncryption.ps1 | 361 ++++++++++++ client-side-encryption/Dockerfile | 25 + client-side-encryption/README.md | 167 ++++++ client-side-encryption/app.py | 525 ++++++++++++++++++ .../deployment-template-original.json | 168 ++++++ .../deployment-template.json | 168 ++++++ client-side-encryption/requirements.txt | 7 + client-side-encryption/supervisord.conf | 26 + client-side-encryption/templates/index.html | 236 ++++++++ 9 files changed, 1683 insertions(+) create mode 100644 client-side-encryption/Deploy-ClientSideEncryption.ps1 create mode 100644 client-side-encryption/Dockerfile create mode 100644 client-side-encryption/README.md create mode 100644 client-side-encryption/app.py create mode 100644 client-side-encryption/deployment-template-original.json create mode 100644 client-side-encryption/deployment-template.json create mode 100644 client-side-encryption/requirements.txt create mode 100644 client-side-encryption/supervisord.conf create mode 100644 client-side-encryption/templates/index.html diff --git a/client-side-encryption/Deploy-ClientSideEncryption.ps1 b/client-side-encryption/Deploy-ClientSideEncryption.ps1 new file mode 100644 index 0000000..ab298e3 --- /dev/null +++ b/client-side-encryption/Deploy-ClientSideEncryption.ps1 @@ -0,0 +1,361 @@ +<# +.SYNOPSIS +Deploy a confidential ACI demo that uses Secure Key Release for client-side encryption. + +.DESCRIPTION +Creates a new resource group named <5-random-letters>, then deploys: +- User-assigned managed identity +- Azure Container Registry +- Storage account (Blob/Table/Queue) +- Key Vault Premium with key: customer-secret-key +- Confidential ACI running a Flask UI + SKR service + +The web app: +- Shows the secure key release process +- Encrypts an initial string supplied on the command line +- Stores encrypted records in Blob, Table, and Queue +- Shows encrypted and decrypted values side by side + +.PARAMETER SecretString +Initial string to encrypt and store. + +.PARAMETER Prefix +Prefix used for naming resources. The resource group will be named <5 letters>. + +.PARAMETER Location +Azure region. Default: eastus. + +.PARAMETER SubscriptionId +Optional subscription ID. If omitted, uses current az account. + +.PARAMETER SkipBrowser +Do not auto-open browser after deployment. +#> + +param( + [Parameter(Mandatory = $true)] + [string]$SecretString, + + [Parameter(Mandatory = $true)] + [string]$Prefix, + + [string]$Location = "eastus", + [string]$SubscriptionId, + [string]$SubnetId = "", + [switch]$SkipBrowser +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONIOENCODING = "utf-8" + +function Write-Header { + param([string]$Message) + Write-Host "" + Write-Host "=== $Message ===" -ForegroundColor Cyan +} + +function New-RandomLetters { + param([int]$Count = 5) + -join ((97..122) | Get-Random -Count $Count | ForEach-Object { [char]$_ }) +} + +function New-SafeName { + param( + [string]$Raw, + [int]$MaxLength, + [string]$Fallback = "sample" + ) + + $value = ($Raw.ToLower() -replace "[^a-z0-9]", "") + if ([string]::IsNullOrWhiteSpace($value)) { + $value = $Fallback + } + if ($value.Length -gt $MaxLength) { + $value = $value.Substring(0, $MaxLength) + } + return $value +} + +function Invoke-Az { + param([string]$CommandText) + Write-Host "-> $CommandText" -ForegroundColor DarkGray + $result = Invoke-Expression $CommandText + if ($LASTEXITCODE -ne 0) { + throw "Command failed: $CommandText" + } + return $result +} + +function Test-RoleAssignmentPermission { + param( + [string]$SubscriptionId, + [string]$Upn + ) + + $scope = "/subscriptions/$SubscriptionId" + $allowedRoles = @( + "Owner", + "User Access Administrator", + "Role Based Access Control Administrator" + ) + + try { + $assignments = Invoke-Az "az role assignment list --assignee `"$Upn`" --scope $scope --include-inherited --output json" | ConvertFrom-Json + } catch { + throw "Unable to query role assignments for $Upn at $scope. Re-run after refreshing Azure credentials if needed." + } + + try { + $permissionsUri = "https://management.azure.com$scope/providers/Microsoft.Authorization/permissions?api-version=2015-07-01" + $permissionsResponse = Invoke-Az "az rest --method get --uri $permissionsUri --output json" | ConvertFrom-Json + } catch { + throw "Unable to query effective Azure permissions for $scope. Re-run after refreshing Azure credentials if needed." + } + + $matches = @($assignments | Where-Object { $allowedRoles -contains $_.roleDefinitionName }) + $grantsRoleAssignmentWrite = $false + + foreach ($entry in @($permissionsResponse.value)) { + $actions = @($entry.actions) + $notActions = @($entry.notActions) + $allowsWrite = ($actions -contains "*") -or ($actions -contains "Microsoft.Authorization/*") -or ($actions -contains "Microsoft.Authorization/roleAssignments/*") -or ($actions -contains "Microsoft.Authorization/roleAssignments/write") + $deniesWrite = ($notActions -contains "Microsoft.Authorization/*") -or ($notActions -contains "Microsoft.Authorization/roleAssignments/*") -or ($notActions -contains "Microsoft.Authorization/roleAssignments/write") + + if ($allowsWrite -and -not $deniesWrite) { + $grantsRoleAssignmentWrite = $true + break + } + } + + return [pscustomobject]@{ + Scope = $scope + AllowedRoles = $allowedRoles + MatchingAssignments = $matches + CanAssignRoles = $matches.Count -gt 0 -and $grantsRoleAssignmentWrite + HasMatchingRole = $matches.Count -gt 0 + HasEffectiveWritePermission = $grantsRoleAssignmentWrite + } +} + +function Assert-RoleAssignmentPermission { + param( + [string]$SubscriptionId, + [string]$Upn + ) + + Write-Header "Checking required Azure roles" + $permission = Test-RoleAssignmentPermission -SubscriptionId $SubscriptionId -Upn $Upn + + if ($permission.CanAssignRoles) { + $roles = ($permission.MatchingAssignments | Select-Object -ExpandProperty roleDefinitionName -Unique) -join ", " + Write-Host "Verified role-assignment capability at $($permission.Scope)" -ForegroundColor Green + Write-Host "Active role(s): $roles" + return + } + + $expected = $permission.AllowedRoles -join ", " + $detail = if ($permission.HasMatchingRole -and -not $permission.HasEffectiveWritePermission) { + "A matching role assignment was found, but the current token does not have effective Microsoft.Authorization/roleAssignments/write permission yet. If you just activated the role through PIM, refresh Azure CLI authentication and open a new shell before re-running." + } else { + "No active assignment was found that can grant roleAssignments/write at the required scope." + } + $message = @( + "Missing required Azure RBAC permissions before deployment starts.", + "This script needs one of these active roles at subscription scope: $expected.", + "Checked scope: $($permission.Scope)", + $detail, + "If your organization uses Microsoft Entra Privileged Identity Management (PIM), activate one of those roles and then re-run the script.", + "No resources were created because the check ran before provisioning." + ) -join " `n" + + throw $message +} + +Write-Header "Preparing deployment" + +if ($SubscriptionId) { + Invoke-Az "az account set --subscription $SubscriptionId" +} + +$account = Invoke-Az "az account show --output json" | ConvertFrom-Json +$upn = $account.user.name + +if ([string]::IsNullOrWhiteSpace($upn)) { + throw "Could not resolve current user UPN from 'az account show'." +} + +Assert-RoleAssignmentPermission -SubscriptionId $account.id -Upn $upn + +$prefixSafe = New-SafeName -Raw $Prefix -MaxLength 10 -Fallback "cse" +$random5 = New-RandomLetters -Count 5 + +$resourceGroupName = "$prefixSafe$random5" +$acrName = New-SafeName -Raw ("$prefixSafe" + "acr" + (New-RandomLetters -Count 8)) -MaxLength 50 -Fallback "cseacr" +$storageName = New-SafeName -Raw ("$prefixSafe" + "st" + (New-RandomLetters -Count 8)) -MaxLength 24 -Fallback "csestorage" +$keyVaultName = New-SafeName -Raw ("$prefixSafe" + "kv" + (New-RandomLetters -Count 10)) -MaxLength 24 -Fallback "csekv" +$identityName = "$prefixSafe-id" +$containerGroupName = "$prefixSafe-cse-aci" +$dnsLabel = New-SafeName -Raw ("$prefixSafe" + (New-RandomLetters -Count 8)) -MaxLength 60 -Fallback "csedemo" +$imageName = "client-side-encryption-demo" +$imageTag = "latest" +$maaEndpoint = "sharedeus.eus.attest.azure.net" +$keyName = "customer-secret-key" + +$tags = @{ + ownerUpn = $upn + scenario = "client-side-encryption" + createdBy = "Deploy-ClientSideEncryption.ps1" +} +$tagArgs = ($tags.GetEnumerator() | ForEach-Object { "{0}={1}" -f $_.Key, $_.Value }) -join " " + +Write-Host "Subscription: $($account.id)" +Write-Host "UPN: $upn" +Write-Host "ResourceGroup:$resourceGroupName" +Write-Host "ACR: $acrName" +Write-Host "Storage: $storageName" +Write-Host "Key Vault: $keyVaultName" +Write-Host "Identity: $identityName" +Write-Host "" + +Write-Header "Creating resource group" +Invoke-Az "az group create --name $resourceGroupName --location $Location --tags $tagArgs --output table" + +Write-Header "Creating managed identity" +Invoke-Az "az identity create --resource-group $resourceGroupName --name $identityName --tags $tagArgs --output table" +$identityJson = Invoke-Az "az identity show --resource-group $resourceGroupName --name $identityName --output json" | ConvertFrom-Json +$identityResourceId = $identityJson.id +$identityPrincipalId = $identityJson.principalId +$identityClientId = $identityJson.clientId + +Write-Header "Creating Azure Container Registry" +Invoke-Az "az acr create --resource-group $resourceGroupName --name $acrName --sku Basic --admin-enabled false --tags $tagArgs --output table" +$resourceGroupScope = "/subscriptions/$($account.id)/resourceGroups/$resourceGroupName" +$acrId = (Invoke-Az "az acr show --resource-group $resourceGroupName --name $acrName --query id --output tsv").Trim() +$acrLoginServer = (Invoke-Az "az acr show --resource-group $resourceGroupName --name $acrName --query loginServer --output tsv").Trim() + +Write-Header "Creating policy-aware storage account" +try { + Invoke-Az "az storage account create --name $storageName --resource-group $resourceGroupName --location $Location --sku Standard_LRS --kind StorageV2 --https-only true --min-tls-version TLS1_2 --allow-blob-public-access false --allow-shared-key-access false --public-network-access Enabled --tags $tagArgs --output table" +} catch { + Write-Host "Secure create options were restricted; retrying with baseline compliant options..." -ForegroundColor Yellow + Invoke-Az "az storage account create --name $storageName --resource-group $resourceGroupName --location $Location --sku Standard_LRS --kind StorageV2 --https-only true --min-tls-version TLS1_2 --allow-blob-public-access false --public-network-access Enabled --tags $tagArgs --output table" +} + +$storageId = (Invoke-Az "az storage account show --resource-group $resourceGroupName --name $storageName --query id --output tsv").Trim() + +# Enforce secure settings post-create to align with common Azure Policy baselines. +Invoke-Az "az storage account update --resource-group $resourceGroupName --name $storageName --https-only true --min-tls-version TLS1_2 --allow-blob-public-access false --public-network-access Enabled --output none" + +Write-Header "Creating Key Vault and SKR key" +Invoke-Az "az keyvault create --name $keyVaultName --resource-group $resourceGroupName --location $Location --sku premium --enable-rbac-authorization false --enable-purge-protection true --tags $tagArgs --output table" + +$akvEndpoint = (Invoke-Az "az keyvault show --name $keyVaultName --query properties.vaultUri --output tsv").Trim() + +Invoke-Az "az keyvault set-policy --name $keyVaultName --object-id $identityPrincipalId --key-permissions get release --output none" + +$policyPath = Join-Path $PSScriptRoot "skr-release-policy.json" +$policyObject = @{ + version = "1.0.0" + anyOf = @( + @{ + authority = "https://$maaEndpoint" + allOf = @( + @{ + claim = "x-ms-attestation-type" + equals = "sevsnpvm" + } + ) + } + ) +} +$policyObject | ConvertTo-Json -Depth 10 | Set-Content -Path $policyPath -Encoding UTF8 + +Invoke-Az "az keyvault key create --vault-name $keyVaultName --name $keyName --kty RSA-HSM --size 2048 --ops encrypt decrypt wrapKey unwrapKey --exportable true --policy $policyPath --output table" +Remove-Item $policyPath -Force + +Write-Header "Assigning RBAC roles" +$roles = @( + @{ Name = "AcrPull"; Scope = $resourceGroupScope }, + @{ Name = "Storage Blob Data Contributor"; Scope = $resourceGroupScope }, + @{ Name = "Storage Queue Data Contributor"; Scope = $resourceGroupScope }, + @{ Name = "Storage Table Data Contributor"; Scope = $resourceGroupScope } +) + +foreach ($role in $roles) { + try { + Invoke-Az "az role assignment create --assignee-object-id $identityPrincipalId --assignee-principal-type ServicePrincipal --role '$($role.Name)' --scope $($role.Scope) --output none" + } catch { + throw "Failed to assign role '$($role.Name)' at scope '$($role.Scope)'. If you activated access through PIM, confirm the role is active in the current shell and re-run the script." + } +} +$registryUsername = "" +$registryPassword = "" +$storageConnectionString = "" + +Write-Header "Building and pushing container image" +Invoke-Az "az acr build --registry $acrName --image $imageName`:$imageTag $PSScriptRoot --no-logs --output none" + +Write-Header "Refreshing local image cache for confcom" +& docker pull "$acrLoginServer/$imageName`:$imageTag" +if ($LASTEXITCODE -ne 0) { + throw "Failed to pull the freshly built image from ACR into the local Docker cache for confcom policy generation." +} + +Write-Header "Preparing confidential container deployment" +Set-Location $PSScriptRoot + +Copy-Item -Path "deployment-template-original.json" -Destination "deployment-template.json" -Force + +$deploymentParams = @{ + '$schema' = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#" + contentVersion = "1.0.0.0" + parameters = @{ + containerGroupName = @{ value = $containerGroupName } + location = @{ value = $Location } + appImage = @{ value = "$acrLoginServer/$imageName`:$imageTag" } + registryServer = @{ value = $acrLoginServer } + registryUsername = @{ value = $registryUsername } + registryPassword = @{ value = $registryPassword } + dnsNameLabel = @{ value = $dnsLabel } + subnetId = @{ value = $SubnetId } + identityResourceId = @{ value = $identityResourceId } + identityClientId = @{ value = $identityClientId } + skrKeyName = @{ value = $keyName } + skrMaaEndpoint = @{ value = $maaEndpoint } + skrAkvEndpoint = @{ value = $akvEndpoint } + storageAccount = @{ value = $storageName } + storageConnectionString = @{ value = $storageConnectionString } + secretString = @{ value = $SecretString } + } +} +$deploymentParams | ConvertTo-Json -Depth 20 | Set-Content -Path "deployment-params.json" -Encoding UTF8 + +Write-Header "Authenticating Docker to ACR" +try { + Invoke-Az "az acr login --name $acrName --output none" +} catch { + throw "Failed to authenticate Docker to ACR '$acrName'. Ensure Docker Desktop is running and your Azure token can access the registry." +} + +Invoke-Az "az extension add --name confcom --upgrade --output none" +Invoke-Az "az confcom acipolicygen -a deployment-template.json --parameters deployment-params.json --disable-stdio --approve-wildcards" + +Write-Header "Deploying confidential ACI" +Invoke-Az "az deployment group create --resource-group $resourceGroupName --template-file deployment-template.json --parameters @deployment-params.json --output table" + +$fqdn = (Invoke-Az "az container show --resource-group $resourceGroupName --name $containerGroupName --query ipAddress.fqdn --output tsv").Trim() +$url = "http://$fqdn" + +Write-Header "Deployment complete" +Write-Host "Resource Group: $resourceGroupName" -ForegroundColor Green +Write-Host "Container URL: $url" -ForegroundColor Green +Write-Host "Storage account: $storageName" -ForegroundColor Green +Write-Host "Key Vault: $keyVaultName" -ForegroundColor Green +Write-Host "SKR key: $keyName" -ForegroundColor Green +Write-Host "" +Write-Host "Open the page, click 'get secret data', then use 'add record'." -ForegroundColor Cyan + +if (-not $SkipBrowser) { + Start-Process $url | Out-Null +} diff --git a/client-side-encryption/Dockerfile b/client-side-encryption/Dockerfile new file mode 100644 index 0000000..c3b0624 --- /dev/null +++ b/client-side-encryption/Dockerfile @@ -0,0 +1,25 @@ +FROM mcr.microsoft.com/aci/skr:2.13 AS skr-source + +FROM python:3.13-slim-bookworm + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + supervisor \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=skr-source /bin/skr /usr/local/bin/skr +RUN chmod +x /usr/local/bin/skr + +COPY requirements.txt requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY templates/ templates/ + +RUN mkdir -p /var/log/supervisor + +EXPOSE 80 + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/client-side-encryption/README.md b/client-side-encryption/README.md new file mode 100644 index 0000000..0343604 --- /dev/null +++ b/client-side-encryption/README.md @@ -0,0 +1,167 @@ +# Storing Confidential Data on Azure Storage Accounts using Client-Side Encryption + +This sample creates a confidential Azure Container Instance (ACI) that performs Secure Key Release (SKR) from Azure Key Vault, encrypts data with the released key inside the Trusted Execution Environment (TEE), stores encrypted records in Blob/Table/Queue, and shows encrypted and decrypted results in a web page for comparison. + +This is a useful pattern when you need to store sensitive data in a PaaS or SaaS service that does not yet directly support confidential computing. By encrypting the data before it leaves the TEE, the backing service can persist the protected payload without needing access to the plaintext. + +The tradeoff is that because the service never sees the decrypted contents, its ability to provide features such as full-text search, indexing, analytics, or other server-side inspection of the data is reduced or unavailable unless you build additional privacy-preserving mechanisms around it. + +This example uses ACI to provide the TEE for the encryption/decryption of sensitive data but the same principle applies to other IaaS services in the ACC portfolio (CVMs, AKS Confidential Worker Nodes). + +## What this creates + +Running the deployment script creates a **new resource group** named: + +- `<5 random letters>` + +Inside that resource group it creates: + +- User-assigned managed identity +- Azure Container Registry (ACR) +- One Azure Storage account used for Blob, Table, and Queue +- Key Vault Premium with key `customer-secret-key` +- Confidential ACI (AMD SEV-SNP) running the web app + SKR service + +All resources are tagged with the signed-in user's UPN. + +## Deployment model for secure real-world environments + +This sample is designed around a common enterprise pattern where backend data services are private-only and workloads must run inside a Virtual Network. + +- Confidential ACI runs in a delegated private subnet. +- Storage account access is via private endpoints (Blob, Queue, Table). +- Private DNS zones resolve storage endpoints to private IPs. +- The managed identity is used for both ACR pull and storage data-plane access. +- Optional public ingress is provided through Application Gateway, which forwards traffic to the private ACI address. + +This allows internet users to reach the app through a controlled edge while keeping data plane connectivity private within the VNet. + +```mermaid +flowchart LR + U[User Browser / Public Internet] --> AGW[Application Gateway
Public IP] + + subgraph VNET[Virtual Network] + subgraph APP[App Subnet] + ACI[Confidential ACI
Flask UI + SKR] + end + + subgraph PE[Private Endpoint Subnet] + PEB[Private Endpoint
Blob] + PEQ[Private Endpoint
Queue] + PET[Private Endpoint
Table] + end + end + + subgraph PAA[Azure PaaS Services] + SA[Storage Account
Blob + Queue + Table] + KV[Key Vault Premium
SKR key] + ATT[Microsoft Azure Attestation] + ACR[Azure Container Registry] + end + + subgraph DNS[Private DNS Zones] + ZB[privatelink.blob.core.windows.net] + ZQ[privatelink.queue.core.windows.net] + ZT[privatelink.table.core.windows.net] + end + + AGW --> ACI + ACI -->|SKR release| KV + ACI -->|attestation trust path| ATT + ACI -->|image pull via MI| ACR + + ACI --> PEB --> SA + ACI --> PEQ --> SA + ACI --> PET --> SA + + ACI -.DNS lookup.-> ZB + ACI -.DNS lookup.-> ZQ + ACI -.DNS lookup.-> ZT +``` + +## Prerequisites + +- Azure CLI 2.60+ (`az`) +- Logged in to Azure CLI: `az login` +- Permissions to create resource groups/resources and assign RBAC roles +- One of these active roles at subscription scope: `Owner`, `User Access Administrator`, or `Role Based Access Control Administrator` +- A subscription/region with confidential ACI availability (default: `eastus`) + +If your organization uses Microsoft Entra Privileged Identity Management (PIM), activate one of the required role-assignment roles before running the script. The script now checks this up front and exits before creating resources if the required role is not active. + +## Deploy + +From this folder, run: + +```powershell +.\Deploy-ClientSideEncryption.ps1 -Prefix sgall -SecretString "my first secret" +``` + +Optional parameters: + +- `-Location ` (default `eastus`) +- `-SubscriptionId ` +- `-SkipBrowser` + +## End-to-end flow + +1. The script creates resources in a new RG named from `-Prefix` + 5 random letters. +2. The script creates Key Vault key `customer-secret-key` with an SKR release policy. +3. The confidential container starts and exposes a web UI. +4. Click **get secret data**: +- App performs Secure Key Release (`/key/release` via local SKR service). +- App encrypts the original command-line secret. +- App writes encrypted payloads to Blob, Table, and Queue. +- App reads back encrypted data and decrypts it in-memory to display side-by-side. +5. Click **add record** to submit another string from the page: +- App encrypts it with the same released key. +- App stores it in Blob/Table/Queue. +- View updates with the new encrypted and decrypted entries. + +## Notes on policy compliance + +The script applies secure defaults commonly required by Azure Policy: + +- Storage HTTPS only +- Minimum TLS 1.2 +- Blob public access disabled +- Managed identity for storage data-plane access +- Managed identity for ACR image pull +- Key Vault Premium for SKR key release support + +If your tenant has stricter policy, deployment may require additional controls (private networking, restricted SKUs, approved regions). + +## Files + +- `Deploy-ClientSideEncryption.ps1` - deploys all Azure resources +- `app.py` - Flask app handling SKR, encryption, and storage operations +- `templates/index.html` - UI with **get secret data** and **add record** +- `deployment-template-original.json` - confidential ACI ARM template (policy injected at deploy time) +- `Dockerfile` - combined app + SKR runtime image +- `supervisord.conf` - starts SKR and Flask in one container +- `requirements.txt` - Python dependencies + +## Cleanup + +Delete the resource group shown at the end of deployment: + +```powershell +az group delete --name --yes --no-wait +``` + +## Further documentation + +- Confidential containers on ACI: +https://learn.microsoft.com/azure/container-instances/container-instances-confidential-overview + +- Azure CLI `confcom` extension: +https://learn.microsoft.com/cli/azure/confcom + +- Secure Key Release with Azure Key Vault: +https://learn.microsoft.com/azure/key-vault/keys/secure-key-release-overview + +- Azure Storage data-plane RBAC: +https://learn.microsoft.com/azure/storage/common/storage-auth-aad-rbac-portal + +- Microsoft Azure Attestation: +https://learn.microsoft.com/azure/attestation/overview diff --git a/client-side-encryption/app.py b/client-side-encryption/app.py new file mode 100644 index 0000000..4bc7aab --- /dev/null +++ b/client-side-encryption/app.py @@ -0,0 +1,525 @@ +import base64 +import json +import os +import time +import uuid +from datetime import datetime, timezone + +import requests +from flask import Flask, jsonify, render_template, request +from werkzeug.exceptions import HTTPException + +from azure.core.exceptions import ResourceExistsError +from azure.data.tables import TableClient +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient +from azure.storage.queue import QueueServiceClient +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +app = Flask(__name__) + +DEFAULT_MAA_ENDPOINT = os.environ.get("SKR_MAA_ENDPOINT", "sharedeus.eus.attest.azure.net") +DEFAULT_AKV_ENDPOINT = os.environ.get("SKR_AKV_ENDPOINT", "") +DEFAULT_KEY_NAME = os.environ.get("SKR_KEY_NAME", "customer-secret-key") +DEFAULT_STORAGE_ACCOUNT = os.environ.get("STORAGE_ACCOUNT", "") +DEFAULT_STORAGE_CONNECTION_STRING = os.environ.get("STORAGE_CONNECTION_STRING", "") +DEFAULT_SECRET_STRING = os.environ.get("SECRET_STRING", "") +DEFAULT_AZURE_CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "") +BLOB_CONTAINER = os.environ.get("BLOB_CONTAINER", "secretdata") +TABLE_NAME = os.environ.get("TABLE_NAME", "secretrecords") +QUEUE_NAME = os.environ.get("QUEUE_NAME", "secretrecords") + +_released_key = None +_key_metadata = {} +_storage_clients = None +_initial_seed_done = False + + +def _storage_access_error_message(exc: Exception) -> str: + detail = str(exc).strip() or exc.__class__.__name__ + return ( + "Storage access failed while reading or writing Blob/Table/Queue data. " + "This deployment currently depends on network reachability from the confidential container " + "to the storage account. In this tenant, the storage account may be blocked by network policy. " + f"Detail: {detail}" + ) + + +@app.errorhandler(Exception) +def handle_unexpected_exception(exc: Exception): + if isinstance(exc, HTTPException): + response = exc.get_response() + response.data = json.dumps({"ok": False, "error": exc.description}) + response.content_type = "application/json" + return response + + app.logger.exception("Unhandled application error") + return ( + jsonify( + { + "ok": False, + "error": str(exc).strip() or "Internal server error.", + } + ), + 500, + ) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _normalize_endpoint(host: str) -> str: + if not host: + return "" + host = host.strip() + host = host.replace("https://", "").replace("http://", "") + return host.strip("/") + + +def _b64url_decode_to_int(value: str) -> int: + pad = "=" * ((4 - len(value) % 4) % 4) + raw = base64.urlsafe_b64decode(value + pad) + return int.from_bytes(raw, byteorder="big") + + +def _jwk_to_private_key(jwk: dict): + required = ["n", "e", "d", "p", "q", "dp", "dq", "qi"] + if not all(k in jwk for k in required): + return None + + numbers = rsa.RSAPrivateNumbers( + p=_b64url_decode_to_int(jwk["p"]), + q=_b64url_decode_to_int(jwk["q"]), + d=_b64url_decode_to_int(jwk["d"]), + dmp1=_b64url_decode_to_int(jwk["dp"]), + dmq1=_b64url_decode_to_int(jwk["dq"]), + iqmp=_b64url_decode_to_int(jwk["qi"]), + public_numbers=rsa.RSAPublicNumbers( + e=_b64url_decode_to_int(jwk["e"]), + n=_b64url_decode_to_int(jwk["n"]), + ), + ) + return numbers.private_key(default_backend()) + + +def _jwk_to_public_key(jwk: dict): + if "n" not in jwk or "e" not in jwk: + return None + numbers = rsa.RSAPublicNumbers( + e=_b64url_decode_to_int(jwk["e"]), + n=_b64url_decode_to_int(jwk["n"]), + ) + return numbers.public_key(default_backend()) + + +def _release_key(maa_endpoint: str, akv_endpoint: str, key_name: str): + global _released_key, _key_metadata + + maa_endpoint = _normalize_endpoint(maa_endpoint) + akv_endpoint = _normalize_endpoint(akv_endpoint) + + start = time.time() + response = requests.post( + "http://localhost:8080/key/release", + json={"maa_endpoint": maa_endpoint, "akv_endpoint": akv_endpoint, "kid": key_name}, + timeout=90, + ) + elapsed_ms = int((time.time() - start) * 1000) + + if response.status_code != 200: + detail = response.text[:4000] if response.text else "No response body" + return None, { + "ok": False, + "status_code": response.status_code, + "elapsed_ms": elapsed_ms, + "detail": detail, + "request": { + "maa_endpoint": maa_endpoint, + "akv_endpoint": akv_endpoint, + "kid": key_name, + }, + } + + payload = response.json() + key_payload = payload.get("key", payload) + if isinstance(key_payload, str): + key_payload = json.loads(key_payload) + if isinstance(key_payload, dict) and "key" in key_payload: + key_payload = key_payload["key"] + + _released_key = key_payload + _key_metadata = { + "kid": key_payload.get("kid", key_name) if isinstance(key_payload, dict) else key_name, + "kty": key_payload.get("kty", "unknown") if isinstance(key_payload, dict) else "unknown", + "released_at": _utc_now_iso(), + "elapsed_ms": elapsed_ms, + } + + return key_payload, { + "ok": True, + "elapsed_ms": elapsed_ms, + "request": { + "maa_endpoint": maa_endpoint, + "akv_endpoint": akv_endpoint, + "kid": key_name, + }, + "key_info": { + "kid": _key_metadata["kid"], + "kty": _key_metadata["kty"], + "has_private_components": all( + k in key_payload for k in ["d", "p", "q", "dp", "dq", "qi"] + ) + if isinstance(key_payload, dict) + else False, + }, + } + + +def _encrypt_with_released_key(plaintext: str): + if not _released_key or not isinstance(_released_key, dict): + raise RuntimeError("No released key is available.") + + public_key = _jwk_to_public_key(_released_key) + if not public_key: + raise RuntimeError("Released key does not contain RSA public components.") + + plaintext_bytes = plaintext.encode("utf-8") + ciphertext = public_key.encrypt( + plaintext_bytes, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + + return { + "ciphertext": base64.b64encode(ciphertext).decode("ascii"), + "algorithm": "RSA-OAEP-SHA256", + "key_id": _key_metadata.get("kid", "unknown"), + } + + +def _decrypt_with_released_key(ciphertext_b64: str): + if not _released_key or not isinstance(_released_key, dict): + return None, "No released key is available." + + private_key = _jwk_to_private_key(_released_key) + if not private_key: + return None, "Released key does not include private key material for local decryption." + + ciphertext = base64.b64decode(ciphertext_b64) + plaintext = private_key.decrypt( + ciphertext, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return plaintext.decode("utf-8"), None + + +def _get_storage_clients(): + global _storage_clients + if _storage_clients: + return _storage_clients + + if DEFAULT_STORAGE_CONNECTION_STRING: + blob_service = BlobServiceClient.from_connection_string(DEFAULT_STORAGE_CONNECTION_STRING) + queue_service = QueueServiceClient.from_connection_string(DEFAULT_STORAGE_CONNECTION_STRING) + table_client = TableClient.from_connection_string( + conn_str=DEFAULT_STORAGE_CONNECTION_STRING, + table_name=TABLE_NAME, + ) + else: + if not DEFAULT_STORAGE_ACCOUNT: + raise RuntimeError("STORAGE_ACCOUNT is not configured.") + + cred = DefaultAzureCredential( + managed_identity_client_id=DEFAULT_AZURE_CLIENT_ID or None, + ) + blob_service = BlobServiceClient( + account_url=f"https://{DEFAULT_STORAGE_ACCOUNT}.blob.core.windows.net", + credential=cred, + ) + queue_service = QueueServiceClient( + account_url=f"https://{DEFAULT_STORAGE_ACCOUNT}.queue.core.windows.net", + credential=cred, + ) + table_client = TableClient( + endpoint=f"https://{DEFAULT_STORAGE_ACCOUNT}.table.core.windows.net", + table_name=TABLE_NAME, + credential=cred, + ) + + _storage_clients = { + "blob_service": blob_service, + "queue_service": queue_service, + "table_client": table_client, + } + return _storage_clients + + +def _ensure_storage_entities(): + clients = _get_storage_clients() + + blob_container = clients["blob_service"].get_container_client(BLOB_CONTAINER) + try: + blob_container.create_container() + except ResourceExistsError: + pass + + queue_client = clients["queue_service"].get_queue_client(QUEUE_NAME) + try: + queue_client.create_queue() + except ResourceExistsError: + pass + + try: + clients["table_client"].create_table() + except ResourceExistsError: + pass + + return { + "blob_container": blob_container, + "queue_client": queue_client, + "table_client": clients["table_client"], + } + + +def _store_record(plaintext: str): + stores = _ensure_storage_entities() + enc = _encrypt_with_released_key(plaintext) + + record_id = str(uuid.uuid4()) + created_at = _utc_now_iso() + base_record = { + "id": record_id, + "createdAt": created_at, + "ciphertext": enc["ciphertext"], + "algorithm": enc["algorithm"], + "keyId": enc["key_id"], + } + + blob_name = f"record-{record_id}.json" + stores["blob_container"].upload_blob(blob_name, json.dumps(base_record), overwrite=True) + + entity = { + "PartitionKey": "records", + "RowKey": record_id, + "Ciphertext": enc["ciphertext"], + "Algorithm": enc["algorithm"], + "KeyId": enc["key_id"], + "CreatedAt": created_at, + } + stores["table_client"].upsert_entity(entity=entity) + + queue_message = json.dumps(base_record) + stores["queue_client"].send_message(queue_message) + + return base_record + + +def _collect_records_for_view(): + stores = _ensure_storage_entities() + rows = [] + + blob_items = stores["blob_container"].list_blobs(name_starts_with="record-") + for item in blob_items: + payload = stores["blob_container"].download_blob(item.name).readall().decode("utf-8") + data = json.loads(payload) + plain, err = _decrypt_with_released_key(data.get("ciphertext", "")) + rows.append( + { + "store": "blob", + "id": data.get("id"), + "createdAt": data.get("createdAt"), + "encrypted": payload, + "decrypted": plain, + "decryptError": err, + "keyId": data.get("keyId", "unknown"), + } + ) + + entities = stores["table_client"].query_entities("PartitionKey eq 'records'") + for ent in entities: + encrypted_payload = json.dumps( + { + "PartitionKey": ent.get("PartitionKey"), + "RowKey": ent.get("RowKey"), + "Ciphertext": ent.get("Ciphertext"), + "Algorithm": ent.get("Algorithm"), + "KeyId": ent.get("KeyId"), + "CreatedAt": ent.get("CreatedAt"), + } + ) + plain, err = _decrypt_with_released_key(ent.get("Ciphertext", "")) + rows.append( + { + "store": "table", + "id": ent.get("RowKey"), + "createdAt": ent.get("CreatedAt"), + "encrypted": encrypted_payload, + "decrypted": plain, + "decryptError": err, + "keyId": ent.get("KeyId", "unknown"), + } + ) + + queue_messages = stores["queue_client"].peek_messages(max_messages=32) + for msg in queue_messages: + body = msg.content + decoded = body + try: + payload = json.loads(decoded) + except Exception: + # Some queue configurations may return base64-encoded content. + decoded = base64.b64decode(body).decode("utf-8") + payload = json.loads(decoded) + plain, err = _decrypt_with_released_key(payload.get("ciphertext", "")) + rows.append( + { + "store": "queue", + "id": payload.get("id"), + "createdAt": payload.get("createdAt"), + "encrypted": decoded, + "decrypted": plain, + "decryptError": err, + "keyId": payload.get("keyId", "unknown"), + } + ) + + rows.sort(key=lambda r: (r.get("createdAt") or "", r.get("store") or ""), reverse=True) + return rows + + +def _seed_initial_record_once(process_log: list): + global _initial_seed_done + if _initial_seed_done: + return + if not DEFAULT_SECRET_STRING: + process_log.append({"step": "seed", "status": "skipped", "detail": "No SECRET_STRING configured."}) + _initial_seed_done = True + return + + rec = _store_record(DEFAULT_SECRET_STRING) + process_log.append( + { + "step": "seed", + "status": "ok", + "detail": "Seeded initial command-line secret string.", + "record": rec, + } + ) + _initial_seed_done = True + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/get-secret-data", methods=["POST"]) +def api_get_secret_data(): + process = [] + + body = request.get_json(silent=True) or {} + maa = body.get("maa_endpoint", DEFAULT_MAA_ENDPOINT) + akv = body.get("akv_endpoint", DEFAULT_AKV_ENDPOINT) + kid = body.get("key_name", DEFAULT_KEY_NAME) + + if not akv: + return jsonify( + { + "ok": False, + "error": "SKR_AKV_ENDPOINT is not configured.", + "process": process, + } + ), 400 + + _, release_info = _release_key(maa, akv, kid) + process.append({"step": "secure-key-release", **release_info}) + if not release_info.get("ok"): + return jsonify({"ok": False, "error": "Secure Key Release failed.", "process": process}), 502 + + try: + _seed_initial_record_once(process) + records = _collect_records_for_view() + except Exception as exc: + app.logger.exception("Storage access failed during get-secret-data") + process.append( + { + "step": "storage", + "status": "error", + "detail": _storage_access_error_message(exc), + } + ) + return jsonify({"ok": False, "error": _storage_access_error_message(exc), "process": process}), 502 + + process.append( + { + "step": "read-stores", + "status": "ok", + "detail": "Read encrypted records from Blob, Table, and Queue stores.", + "counts": { + "total": len(records), + "blob": len([r for r in records if r["store"] == "blob"]), + "table": len([r for r in records if r["store"] == "table"]), + "queue": len([r for r in records if r["store"] == "queue"]), + }, + } + ) + + return jsonify( + { + "ok": True, + "process": process, + "key": _key_metadata, + "records": records, + } + ) + + +@app.route("/api/add-record", methods=["POST"]) +def api_add_record(): + body = request.get_json(silent=True) or {} + value = (body.get("value") or "").strip() + if not value: + return jsonify({"ok": False, "error": "value is required."}), 400 + + if not _released_key: + return jsonify( + { + "ok": False, + "error": "No released key available. Click 'get secret data' first.", + } + ), 400 + + try: + rec = _store_record(value) + records = _collect_records_for_view() + except Exception as exc: + app.logger.exception("Storage access failed during add-record") + return jsonify({"ok": False, "error": _storage_access_error_message(exc)}), 502 + + return jsonify( + { + "ok": True, + "added": rec, + "records": records, + "key": _key_metadata, + } + ) + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=80, debug=False) diff --git a/client-side-encryption/deployment-template-original.json b/client-side-encryption/deployment-template-original.json new file mode 100644 index 0000000..cd4c6a6 --- /dev/null +++ b/client-side-encryption/deployment-template-original.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "containerGroupName": { + "type": "string" + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "appImage": { + "type": "string" + }, + "registryServer": { + "type": "string" + }, + "registryUsername": { + "type": "string", + "defaultValue": "" + }, + "registryPassword": { + "type": "securestring", + "defaultValue": "" + }, + "dnsNameLabel": { + "type": "string" + }, + "subnetId": { + "type": "string", + "defaultValue": "" + }, + "identityResourceId": { + "type": "string" + }, + "identityClientId": { + "type": "string" + }, + "skrKeyName": { + "type": "string", + "defaultValue": "customer-secret-key" + }, + "skrMaaEndpoint": { + "type": "string", + "defaultValue": "sharedeus.eus.attest.azure.net" + }, + "skrAkvEndpoint": { + "type": "string" + }, + "storageAccount": { + "type": "string" + }, + "storageConnectionString": { + "type": "securestring", + "defaultValue": "" + }, + "secretString": { + "type": "string" + }, + "blobContainer": { + "type": "string", + "defaultValue": "secretdata" + }, + "tableName": { + "type": "string", + "defaultValue": "secretrecords" + }, + "queueName": { + "type": "string", + "defaultValue": "secretrecords" + } + }, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[parameters('containerGroupName')]", + "location": "[parameters('location')]", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[parameters('identityResourceId')]": {} + } + }, + "properties": { + "sku": "Confidential", + "confidentialComputeProperties": { + "ccePolicy": "" + }, + "containers": [ + { + "name": "client-side-encryption-app", + "properties": { + "image": "[parameters('appImage')]", + "ports": [ + { + "protocol": "TCP", + "port": 80 + } + ], + "environmentVariables": [ + { + "name": "SKR_KEY_NAME", + "value": "[parameters('skrKeyName')]" + }, + { + "name": "SKR_MAA_ENDPOINT", + "value": "[parameters('skrMaaEndpoint')]" + }, + { + "name": "SKR_AKV_ENDPOINT", + "value": "[parameters('skrAkvEndpoint')]" + }, + { + "name": "STORAGE_ACCOUNT", + "value": "[parameters('storageAccount')]" + }, + { + "name": "AZURE_CLIENT_ID", + "value": "[parameters('identityClientId')]" + }, + { + "name": "STORAGE_CONNECTION_STRING", + "secureValue": "[parameters('storageConnectionString')]" + }, + { + "name": "SECRET_STRING", + "value": "[parameters('secretString')]" + }, + { + "name": "BLOB_CONTAINER", + "value": "[parameters('blobContainer')]" + }, + { + "name": "TABLE_NAME", + "value": "[parameters('tableName')]" + }, + { + "name": "QUEUE_NAME", + "value": "[parameters('queueName')]" + } + ], + "resources": { + "requests": { + "cpu": 1, + "memoryInGB": 2 + } + } + } + } + ], + "imageRegistryCredentials": [ + "[if(empty(parameters('registryUsername')), createObject('server', parameters('registryServer'), 'identity', parameters('identityResourceId')), createObject('server', parameters('registryServer'), 'username', parameters('registryUsername'), 'password', parameters('registryPassword')))]" + ], + "subnetIds": "[if(empty(parameters('subnetId')), json('[]'), createArray(createObject('id', parameters('subnetId'))))]", + "ipAddress": "[if(empty(parameters('subnetId')), createObject('ports', createArray(createObject('protocol', 'TCP', 'port', 80)), 'type', 'Public', 'dnsNameLabel', parameters('dnsNameLabel')), createObject('ports', createArray(createObject('protocol', 'TCP', 'port', 80)), 'type', 'Private'))]", + "osType": "Linux", + "restartPolicy": "Always" + } + } + ], + "outputs": { + "fqdn": { + "type": "string", + "value": "[if(empty(parameters('subnetId')), reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn, '')]" + } + } +} diff --git a/client-side-encryption/deployment-template.json b/client-side-encryption/deployment-template.json new file mode 100644 index 0000000..b9992e1 --- /dev/null +++ b/client-side-encryption/deployment-template.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "containerGroupName": { + "type": "string" + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "appImage": { + "type": "string" + }, + "registryServer": { + "type": "string" + }, + "registryUsername": { + "type": "string", + "defaultValue": "" + }, + "registryPassword": { + "type": "securestring", + "defaultValue": "" + }, + "dnsNameLabel": { + "type": "string" + }, + "subnetId": { + "type": "string", + "defaultValue": "" + }, + "identityResourceId": { + "type": "string" + }, + "identityClientId": { + "type": "string" + }, + "skrKeyName": { + "type": "string", + "defaultValue": "customer-secret-key" + }, + "skrMaaEndpoint": { + "type": "string", + "defaultValue": "sharedeus.eus.attest.azure.net" + }, + "skrAkvEndpoint": { + "type": "string" + }, + "storageAccount": { + "type": "string" + }, + "storageConnectionString": { + "type": "securestring", + "defaultValue": "" + }, + "secretString": { + "type": "string" + }, + "blobContainer": { + "type": "string", + "defaultValue": "secretdata" + }, + "tableName": { + "type": "string", + "defaultValue": "secretrecords" + }, + "queueName": { + "type": "string", + "defaultValue": "secretrecords" + } + }, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[parameters('containerGroupName')]", + "location": "[parameters('location')]", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[parameters('identityResourceId')]": {} + } + }, + "properties": { + "sku": "Confidential", + "confidentialComputeProperties": { + "ccePolicy": "cGFja2FnZSBwb2xpY3kKCmltcG9ydCBmdXR1cmUua2V5d29yZHMuZXZlcnkKaW1wb3J0IGZ1dHVyZS5rZXl3b3Jkcy5pbgoKYXBpX3ZlcnNpb24gOj0gIjAuMTEuMCIKZnJhbWV3b3JrX3ZlcnNpb24gOj0gIjAuMi4zIgoKZnJhZ21lbnRzIDo9IFsKICB7CiAgICAiZmVlZCI6ICJtY3IubWljcm9zb2Z0LmNvbS9hY2kvYWNpLWNjLWluZnJhLWZyYWdtZW50IiwKICAgICJpbmNsdWRlcyI6IFsKICAgICAgImNvbnRhaW5lcnMiLAogICAgICAiZnJhZ21lbnRzIgogICAgXSwKICAgICJpc3N1ZXIiOiAiZGlkOng1MDk6MDpzaGEyNTY6SV9faXVMMjVvWEVWRmRUUF9hQkx4X2VUMVJQSGJDUV9FQ0JRZllacHQ5czo6ZWt1OjEuMy42LjEuNC4xLjMxMS43Ni41OS4xLjMiLAogICAgIm1pbmltdW1fc3ZuIjogIjQiCiAgfQpdCgpjb250YWluZXJzIDo9IFt7ImFsbG93X2VsZXZhdGVkIjpmYWxzZSwiYWxsb3dfc3RkaW9fYWNjZXNzIjpmYWxzZSwiY2FwYWJpbGl0aWVzIjp7ImFtYmllbnQiOltdLCJib3VuZGluZyI6WyJDQVBfQVVESVRfV1JJVEUiLCJDQVBfQ0hPV04iLCJDQVBfREFDX09WRVJSSURFIiwiQ0FQX0ZPV05FUiIsIkNBUF9GU0VUSUQiLCJDQVBfS0lMTCIsIkNBUF9NS05PRCIsIkNBUF9ORVRfQklORF9TRVJWSUNFIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRHSUQiLCJDQVBfU0VUUENBUCIsIkNBUF9TRVRVSUQiLCJDQVBfU1lTX0NIUk9PVCJdLCJlZmZlY3RpdmUiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXSwiaW5oZXJpdGFibGUiOltdLCJwZXJtaXR0ZWQiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXX0sImNvbW1hbmQiOlsiL3Vzci9iaW4vc3VwZXJ2aXNvcmQiLCItYyIsIi9ldGMvc3VwZXJ2aXNvci9jb25mLmQvc3VwZXJ2aXNvcmQuY29uZiJdLCJlbnZfcnVsZXMiOlt7InBhdHRlcm4iOiJTS1JfS0VZX05BTUU9Y3VzdG9tZXItc2VjcmV0LWtleSIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJTS1JfTUFBX0VORFBPSU5UPXNoYXJlZGV1cy5ldXMuYXR0ZXN0LmF6dXJlLm5ldCIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJTS1JfQUtWX0VORFBPSU5UPWh0dHBzOi8vc2dhbGxrdmJ6bHBxbXdzbmoudmF1bHQuYXp1cmUubmV0LyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJTVE9SQUdFX0FDQ09VTlQ9c2dhbGxzdHhsYmdvenV5IiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IkFaVVJFX0NMSUVOVF9JRD02YmE0Mzc4Zi1iODEyLTRiZDQtYWNmYS04YWMxYzBkZjA0NjQiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiU1RPUkFHRV9DT05ORUNUSU9OX1NUUklORz0uKiIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifSx7InBhdHRlcm4iOiJTRUNSRVRfU1RSSU5HPXRoaXMgaXMgc2ltb25zIGV4cGVyaW1lbnQgbnVtYmVyIG9uZSIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJCTE9CX0NPTlRBSU5FUj1zZWNyZXRkYXRhIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlRBQkxFX05BTUU9c2VjcmV0cmVjb3JkcyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJRVUVVRV9OQU1FPXNlY3JldHJlY29yZHMiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUEFUSD0vdXNyL2xvY2FsL2JpbjovdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiR1BHX0tFWT03MTY5NjA1RjYyQzc1MTM1NkQwNTRBMjZBODIxRTY4MEU1RkE2MzA1IiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTl9WRVJTSU9OPTMuMTMuMTQiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OX1NIQTI1Nj02MzllNDMyNDNjNjIwYTMwOGY5NjgyMTNkZjllMDBmMmY4ZjYyMzMyZjdhZGJhYTdhN2VlYjk3ODMwNTdjNjkwIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlRFUk09eHRlcm0iLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiKD9pKShGQUJSSUMpXy4rPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IkhPU1ROQU1FPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IlQoRSk/TVA9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiRmFicmljUGFja2FnZUZpbGVOYW1lPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6Ikhvc3RlZFNlcnZpY2VOYW1lPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IklERU5USVRZX0FQSV9WRVJTSU9OPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IklERU5USVRZX0hFQURFUj0uKyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifSx7InBhdHRlcm4iOiJJREVOVElUWV9TRVJWRVJfVEhVTUJQUklOVD0uKyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifSx7InBhdHRlcm4iOiJhenVyZWNvbnRhaW5lcmluc3RhbmNlX3Jlc3RhcnRlZF9ieT0uKyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifV0sImV4ZWNfcHJvY2Vzc2VzIjpbXSwiaWQiOiJzZ2FsbGFjcml5bHpqdGR2LmF6dXJlY3IuaW8vY2xpZW50LXNpZGUtZW5jcnlwdGlvbi1kZW1vOmxhdGVzdCIsImxheWVycyI6WyI0YTRmNDFiZDg5N2U4ZWQ3MmU2MmQwYzE5OTE1MmFjMDdjMTY4Mjk0YzUwMzA4MzI0NDgwOTNhN2I4ZWI4NWI3IiwiNjM3N2JlNTdiN2NiZTE2OWY5MWY5ZTI1Y2EzM2Y5Nzk3OGI4NzRjMmVlODRlNzQ5M2EzNjMyYzY2ZjY5Zjk2NCIsImE2ODMzYTY2NmFkNWJkMzdmZjUzZjkyMzYzOWRiZTY1YmEzMmEwN2JmOGJmOWNiMGNkYmFkYzMwYjg0ZGU3NjUiLCIwYzRiMWM3YjQwODJiOWZmOWM0OGEyZjRjMzViODkyMTg3MTVhNzM1ZGJiNDdkNjcwN2JlYzFmNjI2NDgyNTZmIiwiODVkOTQ5OTlkN2MyZjc3MjMxMGQ2ZGRjNzNkZmYyZmYyZjliMzU5MDNjYTI0MjBmZjQxNDA1OGI1MzcwMjdkNSIsIjE2MzZhM2Y0MjIxMjY4NGYyODQwMzRmYzEwMTc5OWE2NDRjMzI0NzczZWIyM2E0MmE0MzdkOTdkYjA4MTI3MDIiLCJlYzM2N2M2YjhjMThmN2I0MTUxZTliMjc5NTZmMGVkMjI1ODE5ZWNkODdhMDNiMGI2ZWU5M2VlYjI3NTI1YjIwIiwiOWEwMDc1Mjg1NDg2NjJjYzU0ZGNkMzNhOTlmNjNmMzJlZTEwZDVhZDI1ZjYzYzViMWRmMWYyMzkwNDJiODI5NCIsIjgxY2ZjYzRjZmVmNGM3OWQ4NjcyYTU5Nzk1M2JjZGI1ZDQ0NzUwNTdjY2E0NzU0ZGRiYTBjMzM0MTBhYTljZjMiLCI0MjczMmY0OTZkMjg5N2I4ZWUxYjUwNTIwODQ1ZGQ4NmQwYmViY2IzOTU2YTE0ZmJiMzFjOGQwN2Q1ZWY4MzkxIiwiZTQ3ZjA4ZTA4ZmZjNDYwOWZjZTVhYmI0NmE3OTgwMWI2NTg5MzNhODc2ZGU1NmIzZDVjOTVkMzE2M2E4NWNjMCIsIjFkMjA1NDZkMjA1MGE4MjAzNDdjOTU2NjFkOTY3MjQ1NzVkZGQ0NWY1OWQ5ZmVmYjVjNjMyOGI4MjU4NDY2NmYiXSwibW91bnRzIjpbeyJkZXN0aW5hdGlvbiI6Ii9ldGMvcmVzb2x2LmNvbmYiLCJvcHRpb25zIjpbInJiaW5kIiwicnNoYXJlZCIsInJ3Il0sInNvdXJjZSI6InNhbmRib3g6Ly8vdG1wL2F0bGFzL3Jlc29sdmNvbmYvLisiLCJ0eXBlIjoiYmluZCJ9XSwibmFtZSI6ImNsaWVudC1zaWRlLWVuY3J5cHRpb24tYXBwIiwibm9fbmV3X3ByaXZpbGVnZXMiOmZhbHNlLCJzZWNjb21wX3Byb2ZpbGVfc2hhMjU2IjoiIiwic2lnbmFscyI6W10sInVzZXIiOnsiZ3JvdXBfaWRuYW1lcyI6W3sicGF0dGVybiI6IiIsInN0cmF0ZWd5IjoiYW55In1dLCJ1bWFzayI6IjAwMjIiLCJ1c2VyX2lkbmFtZSI6eyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifX0sIndvcmtpbmdfZGlyIjoiL2FwcCJ9LHsiYWxsb3dfZWxldmF0ZWQiOmZhbHNlLCJhbGxvd19zdGRpb19hY2Nlc3MiOmZhbHNlLCJjYXBhYmlsaXRpZXMiOnsiYW1iaWVudCI6W10sImJvdW5kaW5nIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl0sImVmZmVjdGl2ZSI6WyJDQVBfQ0hPV04iLCJDQVBfREFDX09WRVJSSURFIiwiQ0FQX0ZTRVRJRCIsIkNBUF9GT1dORVIiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX1JBVyIsIkNBUF9TRVRHSUQiLCJDQVBfU0VUVUlEIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUUENBUCIsIkNBUF9ORVRfQklORF9TRVJWSUNFIiwiQ0FQX1NZU19DSFJPT1QiLCJDQVBfS0lMTCIsIkNBUF9BVURJVF9XUklURSJdLCJpbmhlcml0YWJsZSI6W10sInBlcm1pdHRlZCI6WyJDQVBfQ0hPV04iLCJDQVBfREFDX09WRVJSSURFIiwiQ0FQX0ZTRVRJRCIsIkNBUF9GT1dORVIiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX1JBVyIsIkNBUF9TRVRHSUQiLCJDQVBfU0VUVUlEIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUUENBUCIsIkNBUF9ORVRfQklORF9TRVJWSUNFIiwiQ0FQX1NZU19DSFJPT1QiLCJDQVBfS0lMTCIsIkNBUF9BVURJVF9XUklURSJdfSwiY29tbWFuZCI6WyIvcGF1c2UiXSwiZW52X3J1bGVzIjpbeyJwYXR0ZXJuIjoiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iLCJyZXF1aXJlZCI6dHJ1ZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJURVJNPXh0ZXJtIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9XSwiZXhlY19wcm9jZXNzZXMiOltdLCJsYXllcnMiOlsiMTZiNTE0MDU3YTA2YWQ2NjVmOTJjMDI4NjNhY2EwNzRmZDU5NzZjNzU1ZDI2YmZmMTYzNjUyOTkxNjllODQxNSJdLCJtb3VudHMiOltdLCJuYW1lIjoicGF1c2UtY29udGFpbmVyIiwibm9fbmV3X3ByaXZpbGVnZXMiOmZhbHNlLCJzZWNjb21wX3Byb2ZpbGVfc2hhMjU2IjoiIiwic2lnbmFscyI6W10sInVzZXIiOnsiZ3JvdXBfaWRuYW1lcyI6W3sicGF0dGVybiI6IiIsInN0cmF0ZWd5IjoiYW55In1dLCJ1bWFzayI6IjAwMjIiLCJ1c2VyX2lkbmFtZSI6eyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifX0sIndvcmtpbmdfZGlyIjoiLyJ9XQoKYWxsb3dfcHJvcGVydGllc19hY2Nlc3MgOj0gdHJ1ZQphbGxvd19kdW1wX3N0YWNrcyA6PSBmYWxzZQphbGxvd19ydW50aW1lX2xvZ2dpbmcgOj0gZmFsc2UKYWxsb3dfZW52aXJvbm1lbnRfdmFyaWFibGVfZHJvcHBpbmcgOj0gdHJ1ZQphbGxvd191bmVuY3J5cHRlZF9zY3JhdGNoIDo9IGZhbHNlCmFsbG93X2NhcGFiaWxpdHlfZHJvcHBpbmcgOj0gdHJ1ZQoKbW91bnRfZGV2aWNlIDo9IGRhdGEuZnJhbWV3b3JrLm1vdW50X2RldmljZQp1bm1vdW50X2RldmljZSA6PSBkYXRhLmZyYW1ld29yay51bm1vdW50X2RldmljZQptb3VudF9vdmVybGF5IDo9IGRhdGEuZnJhbWV3b3JrLm1vdW50X292ZXJsYXkKdW5tb3VudF9vdmVybGF5IDo9IGRhdGEuZnJhbWV3b3JrLnVubW91bnRfb3ZlcmxheQpjcmVhdGVfY29udGFpbmVyIDo9IGRhdGEuZnJhbWV3b3JrLmNyZWF0ZV9jb250YWluZXIKZXhlY19pbl9jb250YWluZXIgOj0gZGF0YS5mcmFtZXdvcmsuZXhlY19pbl9jb250YWluZXIKZXhlY19leHRlcm5hbCA6PSBkYXRhLmZyYW1ld29yay5leGVjX2V4dGVybmFsCnNodXRkb3duX2NvbnRhaW5lciA6PSBkYXRhLmZyYW1ld29yay5zaHV0ZG93bl9jb250YWluZXIKc2lnbmFsX2NvbnRhaW5lcl9wcm9jZXNzIDo9IGRhdGEuZnJhbWV3b3JrLnNpZ25hbF9jb250YWluZXJfcHJvY2VzcwpwbGFuOV9tb3VudCA6PSBkYXRhLmZyYW1ld29yay5wbGFuOV9tb3VudApwbGFuOV91bm1vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnBsYW45X3VubW91bnQKZ2V0X3Byb3BlcnRpZXMgOj0gZGF0YS5mcmFtZXdvcmsuZ2V0X3Byb3BlcnRpZXMKZHVtcF9zdGFja3MgOj0gZGF0YS5mcmFtZXdvcmsuZHVtcF9zdGFja3MKcnVudGltZV9sb2dnaW5nIDo9IGRhdGEuZnJhbWV3b3JrLnJ1bnRpbWVfbG9nZ2luZwpsb2FkX2ZyYWdtZW50IDo9IGRhdGEuZnJhbWV3b3JrLmxvYWRfZnJhZ21lbnQKc2NyYXRjaF9tb3VudCA6PSBkYXRhLmZyYW1ld29yay5zY3JhdGNoX21vdW50CnNjcmF0Y2hfdW5tb3VudCA6PSBkYXRhLmZyYW1ld29yay5zY3JhdGNoX3VubW91bnQKcndfbW91bnRfZGV2aWNlIDo9IGRhdGEuZnJhbWV3b3JrLnJ3X21vdW50X2RldmljZQoKcmVhc29uIDo9IHsiZXJyb3JzIjogZGF0YS5mcmFtZXdvcmsuZXJyb3JzfQ==" + }, + "containers": [ + { + "name": "client-side-encryption-app", + "properties": { + "image": "[parameters('appImage')]", + "ports": [ + { + "protocol": "TCP", + "port": 80 + } + ], + "environmentVariables": [ + { + "name": "SKR_KEY_NAME", + "value": "[parameters('skrKeyName')]" + }, + { + "name": "SKR_MAA_ENDPOINT", + "value": "[parameters('skrMaaEndpoint')]" + }, + { + "name": "SKR_AKV_ENDPOINT", + "value": "[parameters('skrAkvEndpoint')]" + }, + { + "name": "STORAGE_ACCOUNT", + "value": "[parameters('storageAccount')]" + }, + { + "name": "AZURE_CLIENT_ID", + "value": "[parameters('identityClientId')]" + }, + { + "name": "STORAGE_CONNECTION_STRING", + "secureValue": "[parameters('storageConnectionString')]" + }, + { + "name": "SECRET_STRING", + "value": "[parameters('secretString')]" + }, + { + "name": "BLOB_CONTAINER", + "value": "[parameters('blobContainer')]" + }, + { + "name": "TABLE_NAME", + "value": "[parameters('tableName')]" + }, + { + "name": "QUEUE_NAME", + "value": "[parameters('queueName')]" + } + ], + "resources": { + "requests": { + "cpu": 1, + "memoryInGB": 2 + } + } + } + } + ], + "imageRegistryCredentials": [ + "[if(empty(parameters('registryUsername')), createObject('server', parameters('registryServer'), 'identity', parameters('identityResourceId')), createObject('server', parameters('registryServer'), 'username', parameters('registryUsername'), 'password', parameters('registryPassword')))]" + ], + "subnetIds": "[if(empty(parameters('subnetId')), json('[]'), createArray(createObject('id', parameters('subnetId'))))]", + "ipAddress": "[if(empty(parameters('subnetId')), createObject('ports', createArray(createObject('protocol', 'TCP', 'port', 80)), 'type', 'Public', 'dnsNameLabel', parameters('dnsNameLabel')), createObject('ports', createArray(createObject('protocol', 'TCP', 'port', 80)), 'type', 'Private'))]", + "osType": "Linux", + "restartPolicy": "Always" + } + } + ], + "outputs": { + "fqdn": { + "type": "string", + "value": "[if(empty(parameters('subnetId')), reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn, '')]" + } + } +} \ No newline at end of file diff --git a/client-side-encryption/requirements.txt b/client-side-encryption/requirements.txt new file mode 100644 index 0000000..0dd39c0 --- /dev/null +++ b/client-side-encryption/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.1.1 +requests==2.32.4 +azure-identity==1.25.1 +azure-storage-blob==12.26.0 +azure-storage-queue==12.13.0 +azure-data-tables==12.7.0 +cryptography==45.0.4 diff --git a/client-side-encryption/supervisord.conf b/client-side-encryption/supervisord.conf new file mode 100644 index 0000000..1e8f64d --- /dev/null +++ b/client-side-encryption/supervisord.conf @@ -0,0 +1,26 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid +loglevel=info + +[program:skr] +command=/usr/local/bin/skr +directory=/app +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/skr.log +stderr_logfile=/var/log/supervisor/skr_error.log +priority=1 +startsecs=2 + +[program:flask] +command=python3 app.py +directory=/app +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/flask.log +stderr_logfile=/var/log/supervisor/flask_error.log +priority=10 +startsecs=5 diff --git a/client-side-encryption/templates/index.html b/client-side-encryption/templates/index.html new file mode 100644 index 0000000..9ad072c --- /dev/null +++ b/client-side-encryption/templates/index.html @@ -0,0 +1,236 @@ + + + + + + Client-side Encryption Demo + + + +
+

Client-side Encryption on Confidential ACI

+

Secure Key Release -> Encrypt data -> Store in Blob/Table/Queue -> Decrypt for verification.

+ +
+
+ + + +
+
Ready.
+
+ +
+

Process Trace

+
{}
+
+ +
+

Key Info

+
{}
+
+ +
+

Encrypted vs Decrypted Records

+
+ + + + + + + + + + + +
StoreRecord IdEncrypted Raw PayloadDecrypted ValueKey Used
+
+
+
+ + + +