diff --git a/README.md b/README.md index 8c84639..e2d8a4c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Confidential computing is the protection of data-in-use through isolating comput | Addition | Description | |---|---| +| **[Visual Attestation Demo v2 (ACI)](/aci-samples/visual-attestation-demo-v2/README.md)** 🆕 | Simplified ACI port of the AKS visual attestation sample. Flask app calls Microsoft Azure Attestation **directly** via the upstream `get-snp-report` tool — **no SKR sidecar**, single container. Side-by-side Confidential vs Standard SKU deployment demonstrates falsifiability: the same image fails deterministically on Standard (no `/dev/sev-guest`), proving the success case really came from AMD silicon. | | **[Federated Multi-Party Demo](/multi-party-samples/advanced-app-federated/README.md)** ⭐ | New 4-party (Contoso, Fabrikam, Wingtip Toys, Woodgrove Bank) **federated** analytics demo. Each partner decrypts its own data inside its own AMD SEV-SNP TEE and returns only aggregates — no raw PII ever crosses the trust boundary. Includes a 3-minute [`DEMO-SCRIPT.md`](/multi-party-samples/advanced-app-federated/DEMO-SCRIPT.md). | | **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | | **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | @@ -92,7 +93,8 @@ This repository is organized by Azure service type and deployment method: ### [ACI Samples](/aci-samples/README.md) Azure Container Instances with AMD SEV-SNP confidential computing: - **BuildRandomACI.ps1** - Create confidential ACI with hello-world container -- **Visual Attestation Demo** - Interactive web demo with remote attestation via Microsoft Azure Attestation (MAA) +- **Visual Attestation Demo** - Interactive web demo with remote attestation via Microsoft Azure Attestation (MAA) using the SKR sidecar +- **[Visual Attestation Demo v2](/aci-samples/visual-attestation-demo-v2/README.md)** 🆕 - Simplified single-container port that calls MAA **directly** (no SKR sidecar) via the upstream `get-snp-report` tool; `-Compare` mode deploys Confidential + Standard SKUs side-by-side to demonstrate falsifiability - **[App + PostgreSQL Finance Demo](/aci-samples/app-and-postgreSQL-demo/README.md)** 🆕 - Confidential ACI with DCa/ECa AMD PostgreSQL, 5,000 financial transactions, Application Gateway, and 9 documented threat scenarios - Side-by-side comparison mode (Confidential vs Standard SKU) - Real-time encryption with SKR-released keys diff --git a/aci-samples/README.md b/aci-samples/README.md index 61df877..1a0991d 100644 --- a/aci-samples/README.md +++ b/aci-samples/README.md @@ -1,6 +1,6 @@ # Azure Container Instances (ACI) Confidential Computing Samples -**Last Updated:** May 2026 +**Last Updated:** June 2026 ## Overview @@ -45,7 +45,8 @@ Scripts and samples for creating Confidential Azure Container Instances (ACIs) u | Sample | Description | |--------|-------------| | `BuildRandomACI.ps1` | PowerShell script to create a confidential ACI with a hello-world container | -| [Visual Attestation Demo](visual-attestation-demo/README.md) | Interactive web application demonstrating remote attestation via Microsoft Azure Attestation (MAA) | +| [Visual Attestation Demo](visual-attestation-demo/README.md) | Interactive web application demonstrating remote attestation via Microsoft Azure Attestation (MAA) using the SKR sidecar | +| [Visual Attestation Demo v2](visual-attestation-demo-v2/README.md) 🆕 | Simplified ACI port that calls MAA **directly** from the Flask app via the upstream `get-snp-report` tool (no SKR sidecar). Side-by-side Confidential vs Standard SKU deployment to demonstrate falsifiability. | | [App + PostgreSQL Finance Demo](app-and-postgreSQL-demo/README.md) 🆕 | Confidential ACI with DCa/ECa AMD PostgreSQL, 5,000 financial transactions, Application Gateway, and 9 threat scenarios | --- @@ -107,3 +108,34 @@ cd visual-attestation-demo ``` See [visual-attestation-demo/README.md](visual-attestation-demo/README.md) for complete documentation. + +--- + +## Visual Attestation Demo v2 (direct MAA, no SKR sidecar) + +A simpler ACI port of the AKS visual attestation sample. The Flask app calls Microsoft Azure Attestation **directly** from inside a single container — no SKR sidecar — by invoking the upstream [`get-snp-report`](https://github.com/microsoft/confidential-sidecar-containers/tree/main/tools/get-snp-report) tool against `/dev/sev-guest`, then POSTing the SNP report plus THIM cert chain and UVM endorsements to `https:///attest/SevSnpVm`. + +### Features +- **No SKR sidecar** — single container, single process tree +- **Direct MAA call** from Python with `get-snp-report` baked into the image (multi-stage build) +- **Side-by-side Confidential vs Standard SKU** via `-Compare` — same image, deterministic Standard-SKU failure (`/dev/sev-guest` absent) proves the success case really came from AMD silicon +- **CCE policy** auto-generated with `az confcom acipolicygen` on Confidential deploys +- Renders the decoded MAA JWT with `x-ms-sevsnpvm-*` claims and the raw hardware evidence + +### Quick Start +```powershell +cd visual-attestation-demo-v2 + +# Build, deploy both SKUs side-by-side, and open both URLs in the browser +.\Deploy-VisualAttestationV2.ps1 -Compare + +# Or step-by-step +.\Deploy-VisualAttestationV2.ps1 -Build +.\Deploy-VisualAttestationV2.ps1 -Deploy # Confidential SKU +.\Deploy-VisualAttestationV2.ps1 -Deploy -NoAcc # Standard SKU + +# Tear everything down +.\Deploy-VisualAttestationV2.ps1 -Cleanup +``` + +See [visual-attestation-demo-v2/README.md](visual-attestation-demo-v2/README.md) for the full flow, screenshots of both SKUs, and a feature comparison with the AKS sample. diff --git a/aci-samples/visual-attestation-demo-v2/Deploy-VisualAttestationV2.ps1 b/aci-samples/visual-attestation-demo-v2/Deploy-VisualAttestationV2.ps1 new file mode 100644 index 0000000..dfaabf1 --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/Deploy-VisualAttestationV2.ps1 @@ -0,0 +1,413 @@ +<# +.SYNOPSIS + Build and deploy the Visual Attestation Demo v2 web UI to Azure Container Instances. + +.DESCRIPTION + Builds the container image SERVER-SIDE in Azure Container Registry via + `az acr build` (no local Docker required), then deploys the resulting image + to Azure Container Instances on either the Confidential SKU (AMD SEV-SNP, + attestation succeeds) or Standard SKU (no TEE, attestation fails - shown + for educational comparison). + +.PARAMETER Build + Create resource group, ACR, and run `az acr build` to produce the image. + +.PARAMETER Deploy + Deploy a single container group. Confidential by default. Pass -NoAcc to + deploy on Standard SKU instead (so you can see attestation fail). + +.PARAMETER Compare + Deploy BOTH a confidential and a standard container side-by-side using + the already-built image. Confidential requires Docker + the confcom CLI + extension on the local machine for CCE policy generation. + +.PARAMETER Cleanup + Delete the entire resource group created by -Build. + +.PARAMETER NoAcc + With -Deploy: use the Standard SKU template (attestation will fail). + +.PARAMETER SkipBrowser + Don't auto-open Edge after a successful deploy. + +.PARAMETER RegistryName + Optional ACR name to reuse. If omitted, a random name is generated and + persisted to acr-config.json. + +.PARAMETER Location + Azure region. Defaults to eastus (matches confidential ACI availability). + +.EXAMPLE + # Build the image once, deploy to Confidential ACI (attestation succeeds) + ./Deploy-VisualAttestationV2.ps1 -Build + ./Deploy-VisualAttestationV2.ps1 -Deploy + +.EXAMPLE + # Same image, deploy to Standard ACI (attestation FAILS - educational) + ./Deploy-VisualAttestationV2.ps1 -Deploy -NoAcc + +.EXAMPLE + # Side-by-side comparison + ./Deploy-VisualAttestationV2.ps1 -Compare + +.EXAMPLE + ./Deploy-VisualAttestationV2.ps1 -Cleanup +#> +[CmdletBinding(DefaultParameterSetName='Help')] +param( + [Parameter(ParameterSetName='Build')] [switch]$Build, + [Parameter(ParameterSetName='Deploy')] [switch]$Deploy, + [Parameter(ParameterSetName='Compare')] [switch]$Compare, + [Parameter(ParameterSetName='Cleanup')] [switch]$Cleanup, + + [Parameter(ParameterSetName='Deploy')] [switch]$NoAcc, + [Parameter(ParameterSetName='Deploy')] + [Parameter(ParameterSetName='Compare')] [switch]$SkipBrowser, + + [Parameter(ParameterSetName='Build')] [string]$RegistryName, + [Parameter(ParameterSetName='Build')] [string]$Prefix = 'sgall', + [string]$Location = 'eastus' +) + +# UTF-8 console for nicer output on Windows +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$ErrorActionPreference = 'Stop' + +$ImageName = 'cc-attest' +$ImageTag = '1.0' +$ConfigPath = Join-Path $PSScriptRoot 'acr-config.json' + +# ============================================================================ +# Helpers +# ============================================================================ +function Write-Header { param($m) Write-Host ""; Write-Host ("=" * 72) -ForegroundColor Cyan; Write-Host $m -ForegroundColor Cyan; Write-Host ("=" * 72) -ForegroundColor Cyan } +function Write-Success { param($m) Write-Host "[OK] $m" -ForegroundColor Green } +function Write-Warn2 { param($m) Write-Host "[WARN] $m" -ForegroundColor Yellow } +function Write-Err2 { param($m) Write-Host "[ERROR] $m" -ForegroundColor Red } + +function Get-Config { + if (-not (Test-Path $ConfigPath)) { return $null } + return Get-Content $ConfigPath -Raw | ConvertFrom-Json +} +function Save-Config { param($cfg) $cfg | ConvertTo-Json -Depth 10 | Set-Content $ConfigPath -Encoding UTF8 } + +function Test-AzCli { + $v = az version 2>$null + if ($LASTEXITCODE -ne 0) { throw "Azure CLI not found. Install from https://aka.ms/azcli" } + $acct = az account show 2>$null | ConvertFrom-Json + if (-not $acct) { throw "Not logged in. Run: az login" } + Write-Host "Subscription: $($acct.name) ($($acct.id))" +} + +# ============================================================================ +# Build phase +# ============================================================================ +function Invoke-Build { + Write-Header "Build phase - creating ACR and building image server-side" + Test-AzCli + + if (-not $RegistryName) { + $rand = -join ((97..122) | Get-Random -Count 8 | ForEach-Object { [char]$_ }) + $RegistryName = "acr$rand" + Write-Warn2 "No -RegistryName given. Generated: $RegistryName" + } + $ResourceGroup = "$Prefix-$RegistryName-rg" + + Write-Host "Resource Group: $ResourceGroup" + Write-Host "Registry : $RegistryName" + Write-Host "Location : $Location" + Write-Host "Image : ${ImageName}:${ImageTag}" + Write-Host "" + + Write-Host "Creating resource group..." + az group create --name $ResourceGroup --location $Location | Out-Null + if ($LASTEXITCODE -ne 0) { throw "az group create failed" } + + Write-Host "Creating ACR (Basic, admin user enabled)..." + az acr create --resource-group $ResourceGroup --name $RegistryName --sku Basic --admin-enabled true | Out-Null + if ($LASTEXITCODE -ne 0) { throw "az acr create failed" } + + Write-Host "Running az acr build (server-side Docker build, no local daemon needed)..." + Write-Host "This typically takes 4-6 minutes (apt-get + cvm-attestation-tools clone + pip install)..." + az acr build --registry $RegistryName --image "${ImageName}:${ImageTag}" --file Dockerfile --no-logs $PSScriptRoot + if ($LASTEXITCODE -ne 0) { + # Verify the image actually exists - some az acr build calls return non-zero on warnings + az acr repository show --name $RegistryName --image "${ImageName}:${ImageTag}" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "az acr build failed" } + } + Write-Success "Image built and pushed to ACR" + + $loginServer = az acr show --name $RegistryName --query loginServer -o tsv + $cfg = [pscustomobject]@{ + registryName = $RegistryName + resourceGroup = $ResourceGroup + location = $Location + loginServer = $loginServer + imageName = $ImageName + imageTag = $ImageTag + fullImage = "$loginServer/${ImageName}:${ImageTag}" + } + Save-Config $cfg + + Write-Header "Build complete" + Write-Host "Image: $($cfg.fullImage)" + Write-Host "Config saved to: $ConfigPath" + Write-Host "" + Write-Host "Next step:" + Write-Host " ./Deploy-VisualAttestationV2.ps1 -Deploy # Confidential SKU" + Write-Host " ./Deploy-VisualAttestationV2.ps1 -Deploy -NoAcc # Standard SKU" + Write-Host " ./Deploy-VisualAttestationV2.ps1 -Compare # both side-by-side" +} + +# ============================================================================ +# Deploy helpers +# ============================================================================ +function Get-AcrCreds { + param($cfg) + $u = az acr credential show --name $cfg.registryName --query username -o tsv + $p = az acr credential show --name $cfg.registryName --query "passwords[0].value" -o tsv + if (-not $u -or -not $p) { throw "Failed to read ACR credentials" } + return @{ Username = $u; Password = $p } +} + +function New-ParamsFile { + param($Path, $Name, $Image, $Server, $User, $Pass, $Dns) + $obj = @{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + 'contentVersion' = '1.0.0.0' + 'parameters' = @{ + containerGroupName = @{ value = $Name } + location = @{ value = $Location } + appImage = @{ value = $Image } + registryServer = @{ value = $Server } + registryUsername = @{ value = $User } + registryPassword = @{ value = $Pass } + dnsNameLabel = @{ value = $Dns } + } + } + $obj | ConvertTo-Json -Depth 10 | Set-Content $Path -Encoding UTF8 +} + +function Invoke-AcrLoginForConfcom { + param($cfg, $creds) + Write-Host "Logging into ACR (required for confcom CCE policy generation)..." + az acr login --name $cfg.loginServer --username $creds.Username --password $creds.Password 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + docker login $cfg.loginServer -u $creds.Username -p $creds.Password 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "ACR login failed" } + } +} + +function Test-ConfidentialPrereqs { + Write-Host "Checking prerequisites for Confidential ACI deploy..." + docker info 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Docker is not running. Required by 'az confcom acipolicygen' for CCE policy generation. Start Docker Desktop, or pass -NoAcc to deploy on Standard SKU." + } + az extension show -n confcom 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host "Installing 'confcom' Azure CLI extension..." + az extension add -n confcom | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to install confcom extension" } + } + Write-Success "Docker running, confcom extension installed" +} + +function Wait-ForContainer { + param($Fqdn, $TimeoutSec = 240) + Write-Host "Waiting for http://$Fqdn (timeout ${TimeoutSec}s)..." + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt $TimeoutSec) { + try { + $r = Invoke-WebRequest -Uri "http://$Fqdn" -Method Head -TimeoutSec 5 -ErrorAction SilentlyContinue + if ($r.StatusCode -eq 200) { Write-Success "Container responding"; return $true } + } catch { } + Start-Sleep -Seconds 5 + Write-Host (" ... {0:n0}s elapsed" -f $sw.Elapsed.TotalSeconds) + } + Write-Warn2 "Container did not respond within ${TimeoutSec}s. It may still be pulling the image - try the URL in a browser." + return $false +} + +# ============================================================================ +# Deploy phase (single container group) +# ============================================================================ +function Invoke-Deploy { + if ($NoAcc) { Write-Header "Deploy phase - STANDARD SKU (attestation will FAIL)" } + else { Write-Header "Deploy phase - CONFIDENTIAL SKU (AMD SEV-SNP)" } + Test-AzCli + + $cfg = Get-Config + if (-not $cfg) { throw "acr-config.json not found. Run with -Build first." } + + $creds = Get-AcrCreds $cfg + $stamp = Get-Date -Format 'MMddHHmm' + $rand = -join ((97..122) | Get-Random -Count 4 | ForEach-Object { [char]$_ }) + + if ($NoAcc) { + Write-Warn2 "*** Standard SKU mode - no TEE, no vTPM, attestation WILL fail ***" + $name = "cc-attest-std-$stamp$rand" + $dns = "cc-attest-std-$stamp$rand" + $template = Join-Path $PSScriptRoot 'deployment-template-standard.json' + $params = Join-Path $PSScriptRoot 'deployment-params-standard.json' + New-ParamsFile -Path $params -Name $name -Image $cfg.fullImage ` + -Server $cfg.loginServer -User $creds.Username -Pass $creds.Password -Dns $dns + } + else { + Test-ConfidentialPrereqs + Invoke-AcrLoginForConfcom $cfg $creds + + $name = "cc-attest-conf-$stamp$rand" + $dns = "cc-attest-conf-$stamp$rand" + $template = Join-Path $PSScriptRoot 'deployment-template-confidential.json' + $params = Join-Path $PSScriptRoot 'deployment-params-confidential.json' + New-ParamsFile -Path $params -Name $name -Image $cfg.fullImage ` + -Server $cfg.loginServer -User $creds.Username -Pass $creds.Password -Dns $dns + + Write-Host "Generating CCE policy via 'az confcom acipolicygen' (this can take a few minutes)..." + az confcom acipolicygen -a $template --parameters $params --disable-stdio + if ($LASTEXITCODE -ne 0) { throw "az confcom acipolicygen failed" } + Write-Success "CCE policy injected into $template" + } + + Write-Host "Container group: $name" + Write-Host "DNS label : $dns" + Write-Host "Image : $($cfg.fullImage)" + Write-Host "" + + Write-Host "Submitting ARM deployment..." + az deployment group create ` + --resource-group $cfg.resourceGroup ` + --template-file $template ` + --parameters "@$params" ` + --query "properties.outputs.fqdn.value" -o tsv | Tee-Object -Variable fqdn | Out-Null + if ($LASTEXITCODE -ne 0) { throw "az deployment group create failed" } + Write-Success "Deployed: http://$fqdn" + + Wait-ForContainer -Fqdn $fqdn | Out-Null + + if ($NoAcc) { + Write-Header "Diagnostics (Standard SKU - attestation expected to fail)" + Write-Host "Last 25 lines of container logs:" + az container logs --resource-group $cfg.resourceGroup --name $name --container-name cc-attest 2>&1 | + Select-Object -Last 25 | ForEach-Object { Write-Host " $_" } + Write-Host "" + Write-Host "Click the 'Run Attestation' button in the UI - it will return an error explaining" + Write-Host "that no vTPM / no SEV-SNP hardware report is available on Standard SKU." + } + + if (-not $SkipBrowser) { + Start-Process "http://$fqdn" + } + + Write-Host "" + Write-Host "To view logs: az container logs -g $($cfg.resourceGroup) -n $name --container-name cc-attest" + Write-Host "To delete: az container delete -g $($cfg.resourceGroup) -n $name --yes" +} + +# ============================================================================ +# Compare phase (both side-by-side) +# ============================================================================ +function Invoke-Compare { + Write-Header "Compare phase - Confidential AND Standard side-by-side" + Test-AzCli + + $cfg = Get-Config + if (-not $cfg) { throw "acr-config.json not found. Run with -Build first." } + + Test-ConfidentialPrereqs + $creds = Get-AcrCreds $cfg + Invoke-AcrLoginForConfcom $cfg $creds + + $stamp = Get-Date -Format 'MMddHHmm' + $name_conf = "cc-attest-conf-$stamp" + $name_std = "cc-attest-std-$stamp" + $dns_conf = "cc-attest-conf-$stamp" + $dns_std = "cc-attest-std-$stamp" + + $tpl_conf = Join-Path $PSScriptRoot 'deployment-template-confidential.json' + $tpl_std = Join-Path $PSScriptRoot 'deployment-template-standard.json' + $par_conf = Join-Path $PSScriptRoot 'deployment-params-confidential.json' + $par_std = Join-Path $PSScriptRoot 'deployment-params-standard.json' + + New-ParamsFile -Path $par_conf -Name $name_conf -Image $cfg.fullImage ` + -Server $cfg.loginServer -User $creds.Username -Pass $creds.Password -Dns $dns_conf + New-ParamsFile -Path $par_std -Name $name_std -Image $cfg.fullImage ` + -Server $cfg.loginServer -User $creds.Username -Pass $creds.Password -Dns $dns_std + + Write-Host "Generating CCE policy for confidential container..." + az confcom acipolicygen -a $tpl_conf --parameters $par_conf --disable-stdio + if ($LASTEXITCODE -ne 0) { throw "az confcom acipolicygen failed" } + Write-Success "CCE policy generated" + + Write-Header "Deploying CONFIDENTIAL container ($name_conf)" + az deployment group create --resource-group $cfg.resourceGroup ` + --template-file $tpl_conf --parameters "@$par_conf" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Confidential deploy failed" } + $fqdn_conf = az container show -g $cfg.resourceGroup -n $name_conf --query "ipAddress.fqdn" -o tsv + Write-Success "Confidential: http://$fqdn_conf" + + Write-Header "Deploying STANDARD container ($name_std)" + az deployment group create --resource-group $cfg.resourceGroup ` + --template-file $tpl_std --parameters "@$par_std" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Standard deploy failed" } + $fqdn_std = az container show -g $cfg.resourceGroup -n $name_std --query "ipAddress.fqdn" -o tsv + Write-Success "Standard : http://$fqdn_std" + + Write-Host "" + Write-Host "Waiting for both containers to come up..." + Wait-ForContainer -Fqdn $fqdn_conf | Out-Null + Wait-ForContainer -Fqdn $fqdn_std | Out-Null + + Write-Header "Both containers deployed" + Write-Host "Confidential (attestation succeeds): http://$fqdn_conf" + Write-Host "Standard (attestation FAILS) : http://$fqdn_std" + Write-Host "" + Write-Host "Open both side-by-side, click 'Run Attestation' on each, and compare." + + if (-not $SkipBrowser) { + Start-Process "http://$fqdn_conf" + Start-Sleep -Seconds 1 + Start-Process "http://$fqdn_std" + } +} + +# ============================================================================ +# Cleanup phase +# ============================================================================ +function Invoke-Cleanup { + Write-Header "Cleanup phase" + $cfg = Get-Config + if (-not $cfg) { Write-Warn2 "No acr-config.json - nothing to delete."; return } + + Write-Warn2 "About to DELETE resource group: $($cfg.resourceGroup)" + Write-Warn2 "This removes the ACR, the image, and all container groups in that RG." + $confirm = Read-Host "Type the resource group name to confirm" + if ($confirm -ne $cfg.resourceGroup) { + Write-Host "Cancelled." + return + } + az group delete --name $cfg.resourceGroup --yes --no-wait + if ($LASTEXITCODE -eq 0) { + Write-Success "Delete submitted (running in background)." + Remove-Item $ConfigPath -Force -ErrorAction SilentlyContinue + Get-ChildItem $PSScriptRoot -Filter 'deployment-params-*.json' -ErrorAction SilentlyContinue | Remove-Item -Force + } else { + throw "az group delete failed" + } +} + +# ============================================================================ +# Main +# ============================================================================ +switch ($PSCmdlet.ParameterSetName) { + 'Build' { Invoke-Build } + 'Deploy' { Invoke-Deploy } + 'Compare' { Invoke-Compare } + 'Cleanup' { Invoke-Cleanup } + default { + Get-Help $PSCommandPath -Detailed + } +} diff --git a/aci-samples/visual-attestation-demo-v2/Dockerfile b/aci-samples/visual-attestation-demo-v2/Dockerfile new file mode 100644 index 0000000..0279fd0 --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/Dockerfile @@ -0,0 +1,42 @@ +# Visual Attestation Demo v2 image for Azure Container Instances. +# +# Single-container demo - no SKR sidecar. The Flask app: +# 1. runs the upstream `get-snp-report` tool (from +# microsoft/confidential-sidecar-containers, compiled in stage 1) against +# /dev/sev-guest to obtain a fresh AMD SEV-SNP attestation report, +# 2. loads the THIM cert chain + UVM endorsements from the +# UVM_SECURITY_CONTEXT_DIR mount that the ACI control plane provides, +# 3. POSTs the SNP report + cert chain + runtime data to MAA's +# /attest/SevSnpVm endpoint and renders the returned JWT claims. +# +# - ACI Confidential SKU -> /dev/sev-guest exists, MAA returns sevsnpvm token +# - ACI Standard SKU -> /dev/sev-guest is absent, fails before MAA call +# (educational failure mode) +# +# Built server-side by `az acr build`, no local Docker required. + +FROM debian:bookworm-slim AS build +ARG SIDECAR_TAG=v2.14 +RUN apt-get update \ + && apt-get install -y --no-install-recommends git make gcc libc6-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch ${SIDECAR_TAG} \ + https://github.com/microsoft/confidential-sidecar-containers.git /src +WORKDIR /src/tools/get-snp-report +RUN make bin/get-snp-report + +FROM python:3.11-slim +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PORT=80 + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY --from=build /src/tools/get-snp-report/bin/get-snp-report /usr/local/bin/get-snp-report +COPY app.py /app/app.py +COPY templates/ /app/templates/ + +EXPOSE 80 +CMD ["python", "/app/app.py"] diff --git a/aci-samples/visual-attestation-demo-v2/README.md b/aci-samples/visual-attestation-demo-v2/README.md new file mode 100644 index 0000000..79c931e --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/README.md @@ -0,0 +1,221 @@ +# Visual Attestation Demo v2 on Azure Container Instances + +A self-contained ACI port of the AKS confidential-node attestation web UI from +`aks-samples/azure-voting-app/attestation/`. This is the **v2** of the original +[`visual-attestation-demo`](../visual-attestation-demo/) - same goal, simpler +footprint, and adds a one-shot `-Compare` mode that deploys both Confidential +and Standard SKUs side-by-side. + +It demonstrates **runtime guest attestation** of an AMD SEV-SNP TEE via +Microsoft Azure Attestation (MAA), and shows how the **same image fails to +attest** when run on a non-confidential ACI SKU - the educational point of the +sample. + +The container image is built **server-side in Azure Container Registry** via +`az acr build`, so you do **not** need a local Docker daemon to ship the image. +You only need Docker for the Confidential SKU deploy, where the +`az confcom acipolicygen` extension uses Docker to compute the CCE policy hash. + +## How attestation works in this sample + +There is **no SKR sidecar**. The Flask app fetches the SNP report itself, then +talks directly to MAA: + +1. The app builds a small JSON `runtimeData = {nonce, client}` and sets + `REPORT_DATA = SHA-256(runtimeData) || 0x00...` (64 bytes total). +2. It runs `get-snp-report ` (the upstream tool from + [microsoft/confidential-sidecar-containers](https://github.com/microsoft/confidential-sidecar-containers/tree/main/tools/get-snp-report), + baked into the image), which opens `/dev/sev-guest` and issues + `SNP_GUEST_REQUEST`. The AMD Secure Processor returns the 1184-byte + attestation report signed by the chip's VCEK. +3. It loads the THIM cert chain (`host-amd-cert-base64`) and UVM endorsements + (`reference-info-base64`) from the `UVM_SECURITY_CONTEXT_DIR` directory the + ACI control plane mounts into Confidential container groups. +4. It POSTs `{report, runtimeData, nonce}` to + `https:///attest/SevSnpVm?api-version=2022-08-01`. MAA verifies the + SNP report against the AMD root CA chain and the UVM endorsements, then + returns a signed JWT whose `x-ms-sevsnpvm-*` claims describe the TEE state + at this instant. +5. The app decodes the JWT and renders every claim with a human-readable + explanation. + +On ACI **Standard** SKU there is no `/dev/sev-guest` - step 2 fails before +MAA is ever called, which is the deterministic, hardware-rooted failure the +demo is built to show. + +## What's in here + +| File | Purpose | +|------|---------| +| [Dockerfile](Dockerfile) | Multi-stage build. Stage 1 compiles `get-snp-report` from microsoft/confidential-sidecar-containers; stage 2 is `python:3.11-slim` with Flask + the static binary. | +| [app.py](app.py) | Flask app - runs `get-snp-report` against `/dev/sev-guest`, posts the report + UVM evidence to MAA, and renders the returned JWT. | +| [templates/index.html](templates/index.html) | UI with dark mode, claim explanations, single-layer `sevsnpvm` token rendering. | +| [requirements.txt](requirements.txt) | `flask` + `requests`. | +| [deployment-template-confidential.json](deployment-template-confidential.json) | Single-container ACI template (`cc-attest`), `sku=Confidential`, `ccePolicy` filled by `confcom` at deploy time. | +| [deployment-template-standard.json](deployment-template-standard.json) | Single-container ACI template, `sku=Standard` (attestation fails by design). | +| [Deploy-VisualAttestationV2.ps1](Deploy-VisualAttestationV2.ps1) | One script for `-Build`, `-Deploy`, `-Compare`, `-Cleanup`. | + +## Architecture + +``` + Azure subscription + +---------------------------+ + | --rg | + | | + | +----------------------+ | ++-----------+ | | Azure Container | | +| | | | Registry (Basic, | | +| az acr +---+->| admin enabled) | | +| build . | | | cc-attest:1.0 | | +| | | +----------+-----------+ | ++-----------+ | | | + | v | + | +----------+-----------+ | MAA + | | ACI Confidential SKU |--+--HTTPS-->/attest/SevSnpVm + | | (AMD SEV-SNP) | | (sharedeus.eus...) + | | /dev/sev-guest | | + | +----------------------+ | + | +----------------------+ | + | | ACI Standard SKU | | <- no /dev/sev-guest, fails + | | (no TEE) | | + | +----------------------+ | + +---------------------------+ +``` + +## Prerequisites + +- Azure CLI logged in: `az login` +- Subscription with quota for **Confidential ACI** (AMD SEV-SNP) in your + region. `eastus` is the default; `northeurope`, `westeurope`, + `southcentralus`, `eastus2` also work. +- For confidential deploys only: **Docker Desktop** running locally, plus the + `confcom` Azure CLI extension. The script auto-installs `confcom` for you. + +The sample does **not** require any role assignments +(`Microsoft.Authorization/*`) - ACR pulls happen via admin user credentials +embedded in `imageRegistryCredentials`. + +## Quick start + +### 1. Build the image (server-side, ~5 minutes) + +```powershell +./Deploy-VisualAttestationV2.ps1 -Build +``` + +This creates a resource group, an ACR, runs `az acr build` on the Dockerfile +in this directory, and persists the image coordinates to `acr-config.json`. + +### 2. Deploy on Confidential SKU (attestation will succeed) + +```powershell +./Deploy-VisualAttestationV2.ps1 -Deploy +``` + +Requires Docker running locally so `confcom` can compute the CCE policy. +Opens the UI in your browser - click **Attest** and you'll get a fully +populated MAA token with `x-ms-attestation-type=sevsnpvm` and +`x-ms-compliance-status=azure-compliant-uvm`. + +### 3. Deploy on Standard SKU (attestation will fail) + +```powershell +./Deploy-VisualAttestationV2.ps1 -Deploy -NoAcc +``` + +No Docker required. The same image runs unmodified, but with no SEV-SNP +hardware `/dev/sev-guest` is absent and `get-snp-report` fails before MAA is +ever called. The `/api/attest` endpoint returns that error and the script +prints the last 25 lines of container logs for inspection. + +### 4. Side-by-side comparison + +```powershell +./Deploy-VisualAttestationV2.ps1 -Compare +``` + +Deploys both flavors in the same resource group and opens both URLs. Same +image, same code path, opposite result - the cleanest way to demo why +attestation matters. + +### 5. Cleanup + +```powershell +./Deploy-VisualAttestationV2.ps1 -Cleanup +``` + +Confirms by re-typing the resource group name, then deletes everything +(`--no-wait`). + +## Demo screenshots + +The screenshots below were captured against a live `-Compare` deployment +(`cc-attest-conf-*` and `cc-attest-std-*` running side-by-side in the same +resource group, same image digest) by clicking **Attest** on each instance. + +### Confidential SKU - attestation succeeds + +`get-snp-report` reads the AMD SEV-SNP report from `/dev/sev-guest`, the app +posts it with the THIM cert chain and UVM endorsements to MAA, and the page +renders the decoded JWT. Notice `x-ms-attestation-type = sevsnpvm` and +`x-ms-compliance-status = azure-compliant-uvm`, plus the per-chip +`x-ms-sevsnpvm-chipid`, launch measurement, and TCB version - all rooted in +silicon. + +![Confidential SKU - successful SEV-SNP attestation via direct MAA call](images/screenshot-confidential.png) + +### Standard SKU - attestation fails (by design) + +Same image, no SEV-SNP hardware, no `/dev/sev-guest`. `get-snp-report` errors +out before MAA is ever called. That deterministic failure is the educational +contrast - it proves the success case really did need confidential hardware. + +![Standard SKU - attestation fails - /dev/sev-guest absent on non-CC SKU](images/screenshot-standard.png) + +## Why two SKUs? + +The point of this sample is **falsifiability**. A demo that only ever shows +attestation succeeding doesn't prove much - the same JSON response could be +mocked by any web server. By running the *exact same image* on a non-TEE host +and watching it fail in a specific, hardware-rooted way (no `/dev/sev-guest`, +no SEV-SNP guest report), you demonstrate that: + +1. The success case on Confidential SKU really did come from AMD silicon. +2. A relying party that pins `x-ms-attestation-type=sevsnpvm` and a specific + `x-ms-policy-hash` cannot be spoofed by a Standard-SKU deployment. + +## Differences vs. the AKS sample + +| Aspect | AKS sample | This sample | +|--------|------------|-------------| +| Bootstrap | ConfigMap mounts source at pod start | Source baked into image at build time | +| Attestation path | App opens `/dev/tpmrm0` directly via `cvm-attestation-tools` | App runs `get-snp-report` against `/dev/sev-guest`, then POSTs SNP report + THIM cert chain + UVM endorsements directly to MAA `/attest/SevSnpVm` (no SKR sidecar; ACI CC UVM does not expose a vTPM to the workload) | +| Token shape | Nested - outer with `x-ms-isolation-tee.x-ms-runtime` | Single-layer `sevsnpvm` token (claims at top level) | +| Privileges | `privileged: true`, `/dev/tpmrm0` host mount | None - `/dev/sev-guest` is auto-exposed in ACI Confidential SKU | +| Build | `az acr build` | `az acr build` | +| TEE | Per-node SEV-SNP (`Standard_DC2as_v5`) | Per-container-group SEV-SNP (Confidential ACI) | +| Failure demo | Add a non-CC nodepool | `-NoAcc` switch -> Standard SKU (no `/dev/sev-guest`, no TEE) | + +## Troubleshooting + +**`az confcom acipolicygen` complains Docker isn't running** +Start Docker Desktop, or use `-NoAcc` to skip confidential mode. + +**Container is `CrashLoopBackOff` on Confidential SKU** +View logs: `az container logs -g -n --container-name cc-attest`. +Two common causes: (a) `get-snp-report` failed because `/dev/sev-guest` is +not yet accessible (race with container start - retry once); (b) the THIM +cert chain at `UVM_SECURITY_CONTEXT_DIR/host-amd-cert-base64` was missing, +which means the ACI control plane did not provision it - confirm the +container group really came up as `sku=Confidential` and that +`confidentialComputeProperties.ccePolicy` was supplied. + +**MAA returns HTTP 400 / `Failed validation`** +The CCE policy hash MAA computed from `host-amd-cert-base64` and the report's +`HOST_DATA` did not match the `ccePolicy` stamped into the container group. +Re-run `Deploy-VisualAttestationV2.ps1 -Compare` so `confcom acipolicygen` +regenerates the policy for the current image. + +**HTTP timeout waiting for the container** +First-time pulls of the image can take 2-3 minutes on a cold ACI host. +Re-open the URL after a minute. diff --git a/aci-samples/visual-attestation-demo-v2/app.py b/aci-samples/visual-attestation-demo-v2/app.py new file mode 100644 index 0000000..9308ffb --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/app.py @@ -0,0 +1,342 @@ +""" +SEV-SNP runtime attestation web UI for ACI Confidential Containers. + +This app skips the SKR sidecar and talks to MAA itself: + + 1. `get-snp-report` (from microsoft/confidential-sidecar-containers, baked + into the image) opens /dev/sev-guest and asks the AMD Secure Processor + for a fresh 1184-byte attestation report bound to a caller-supplied + REPORT_DATA (sha256 of a per-request runtime data blob). + 2. The app reads the THIM cert chain and UVM reference info that the ACI + control plane drops into UVM_SECURITY_CONTEXT_DIR (or the equivalent + environment variables). + 3. The app POSTs report + cert chain + endorsements + runtime data to MAA's + /attest/SevSnpVm endpoint, which validates the hardware evidence, + measures the runtime data into REPORT_DATA, and returns a signed JWT. + 4. The app decodes the JWT and renders every claim with a human-readable + explanation. + + - ACI Confidential SKU -> /dev/sev-guest + UVM context, MAA returns sevsnpvm + - ACI Standard SKU -> no /dev/sev-guest, get-snp-report fails by design + +References: + https://github.com/microsoft/confidential-sidecar-containers/tree/main/tools/get-snp-report + https://learn.microsoft.com/rest/api/attestation/attestation/attest-sev-snp-vm + AMD SEV-SNP ABI specification (publication 56860) +""" + +import base64 +import hashlib +import json +import os +import secrets +import struct +import subprocess +import traceback +from datetime import datetime, timezone +from pathlib import Path + +import requests +from flask import Flask, jsonify, render_template, request + + +GET_SNP_REPORT = os.environ.get("GET_SNP_REPORT", "/usr/local/bin/get-snp-report") +DEFAULT_MAA = os.environ.get("MAA_ENDPOINT", "sharedeus.eus.attest.azure.net") +MAA_API_VERSION = os.environ.get("MAA_API_VERSION", "2022-08-01") + + +# --------------------------------------------------------------------------- +# Claim explanations for MAA SEV-SNP tokens. +# --------------------------------------------------------------------------- +CLAIM_EXPLANATIONS = { + "iss": "Issuer - the MAA endpoint that signed this token. Verifying the issuer URL pins the token to a specific Azure Attestation provider.", + "iat": "Issued At (Unix epoch seconds) - when MAA produced the token.", + "exp": "Expiration (Unix epoch seconds) - after this point the token must not be trusted.", + "nbf": "Not Before (Unix epoch seconds) - the token is not valid earlier than this time.", + "jti": "JWT ID - a unique identifier MAA assigns to this token; useful for replay detection.", + + "x-ms-ver": "MAA token schema version.", + "x-ms-attestation-type": "Type of TEE that produced the evidence ('sevsnpvm', 'tdxvm', 'azurevm', 'tpm', etc.).", + "x-ms-compliance-status": "MAA's overall verdict against its policy. 'azure-compliant-uvm' means the platform passed all Azure CC UVM policy checks.", + "x-ms-policy-hash": "SHA-256 of the MAA policy (base64url) that evaluated this evidence. Pin this in your relying party to detect policy drift.", + "x-ms-policy-signer": "If a custom JWS-signed MAA policy was used, this is the signer's certificate chain.", + "x-ms-runtime": "Caller-supplied runtime data that was bound into the hardware report's REPORT_DATA field.", + "x-ms-inittime": "Init-time data bound into the report (the CCE policy hash on ACI CC).", + "nonce": "Caller-supplied nonce echoed back inside x-ms-runtime to prove freshness.", + + "x-ms-sevsnpvm-authorkeydigest": "SHA-384 of the SEV-SNP author key (AKD). Zero on Azure-managed CVMs unless you bring your own ID/Author key.", + "x-ms-sevsnpvm-bootloader-svn": "SVN of the AMD SEV-SNP guest bootloader at launch.", + "x-ms-sevsnpvm-familyId": "Family ID supplied at SNP_LAUNCH_FINISH (16 bytes). On Azure CVMs this identifies the Azure VM family.", + "x-ms-sevsnpvm-guestsvn": "Guest Security Version Number stamped into the report. Allows monotonic anti-rollback in your policy.", + "x-ms-sevsnpvm-hostdata": "Host data the hypervisor injected at launch (sha256 of the CCE policy on ACI CC). Lets you bind the guest to a specific host configuration.", + "x-ms-sevsnpvm-idkeydigest": "SHA-384 of the SEV-SNP ID Key (IDK). On Azure CC nodes this is the Azure-managed launch ID key digest.", + "x-ms-sevsnpvm-imageId": "Image ID supplied at SNP_LAUNCH_FINISH (16 bytes).", + "x-ms-sevsnpvm-is-debuggable": "true means the guest was launched with the SNP debug policy bit set. For production CVMs this MUST be false.", + "x-ms-sevsnpvm-launchmeasurement": "SHA-384 of the initial guest memory contents measured by AMD-SP at launch. This is the cryptographic identity of the boot image.", + "x-ms-sevsnpvm-microcode-svn": "Microcode SVN of the AMD CPU at attestation time.", + "x-ms-sevsnpvm-migration-allowed": "true if the SNP guest policy permits migration between machines. Azure CVMs report false.", + "x-ms-sevsnpvm-reportdata": "Hex of REPORT_DATA - 64 bytes the guest itself supplied when requesting the report. MAA stuffs the SHA-256 of x-ms-runtime here so you can cryptographically link the token to caller-supplied data.", + "x-ms-sevsnpvm-reportid": "Per-launch report ID assigned by AMD-SP. Different across reboots.", + "x-ms-sevsnpvm-smt-allowed": "true means simultaneous multithreading was allowed at launch (per SNP guest policy).", + "x-ms-sevsnpvm-snpfw-svn": "SVN of the AMD SEV-SNP firmware (PSP) at attestation time.", + "x-ms-sevsnpvm-tee-svn": "SVN of the TEE component (always 0 for SEV-SNP today; reserved).", + "x-ms-sevsnpvm-vmpl": "Virtual Machine Privilege Level the report was generated at. Azure CVMs run the OS at VMPL0.", +} + + +def _explain(key: str) -> str: + if key in CLAIM_EXPLANATIONS: + return CLAIM_EXPLANATIONS[key] + if key.startswith("x-ms-sevsnpvm-"): + return "SEV-SNP attestation report field surfaced by MAA. See AMD SEV-SNP ABI spec for the underlying bit layout." + if key.startswith("x-ms-"): + return "MAA-issued claim. Refer to the Microsoft Azure Attestation claim-set documentation." + return "Standard JWT or caller-supplied claim." + + +# --------------------------------------------------------------------------- +# JWT helpers (display only). +# --------------------------------------------------------------------------- +def _b64url_decode(segment: str) -> bytes: + pad = "=" * (-len(segment) % 4) + return base64.urlsafe_b64decode(segment + pad) + + +def decode_jwt(token: str): + if isinstance(token, bytes): + token = token.decode("utf-8") + parts = token.split(".") + if len(parts) != 3: + raise ValueError(f"Token does not have 3 segments (got {len(parts)})") + return json.loads(_b64url_decode(parts[0])), json.loads(_b64url_decode(parts[1])) + + +def _format_timestamp(value): + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc).isoformat() + except Exception: + return None + + +def annotate_claims(payload: dict): + rows = [] + for key, value in payload.items(): + row = {"key": key, "value": value, "explanation": _explain(key)} + if key in {"iat", "exp", "nbf"}: + row["timestamp"] = _format_timestamp(value) + rows.append(row) + rows.sort(key=lambda r: (not r["key"].startswith("x-ms-"), r["key"])) + return rows + + +# --------------------------------------------------------------------------- +# UVM information - THIM certs and reference info supplied by the ACI control +# plane. Same lookup order as the SKR sidecar: +# 1. UVM_SECURITY_CONTEXT_DIR (or auto-discovered /security-context-*) +# 2. UVM_HOST_AMD_CERTIFICATE / UVM_REFERENCE_INFO env vars (legacy). +# --------------------------------------------------------------------------- +def _find_security_context_dir() -> str | None: + explicit = os.environ.get("UVM_SECURITY_CONTEXT_DIR") + if explicit and os.path.isdir(explicit): + return explicit + try: + for entry in os.listdir("/"): + if entry.startswith("security-context-"): + full = os.path.join("/", entry) + if os.path.isdir(full): + return full + except OSError: + pass + return None + + +def load_uvm_information() -> dict: + """Returns dict with keys: host_amd_cert_b64, reference_info_b64, source.""" + ctx_dir = _find_security_context_dir() + if ctx_dir: + def _read(name): + p = Path(ctx_dir) / name + return p.read_text().strip() if p.exists() else "" + return { + "host_amd_cert_b64": _read("host-amd-cert-base64"), + "reference_info_b64": _read("reference-info-base64"), + "source": ctx_dir, + } + return { + "host_amd_cert_b64": os.environ.get("UVM_HOST_AMD_CERTIFICATE", ""), + "reference_info_b64": os.environ.get("UVM_REFERENCE_INFO", ""), + "source": "env", + } + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii") + + +def _build_maa_report(snp_report: bytes, vcek_cert_chain: bytes, endorsements_json: bytes | None) -> str: + """Pack hardware report + cert chain + endorsements into MAA's `report` blob.""" + inner = { + "SnpReport": _b64url(snp_report), + "VcekCertChain": _b64url(vcek_cert_chain), + } + if endorsements_json: + inner["Endorsements"] = _b64url(endorsements_json) + return _b64url(json.dumps(inner, separators=(",", ":")).encode("utf-8")) + + +# --------------------------------------------------------------------------- +# get-snp-report invocation. +# --------------------------------------------------------------------------- +REPORT_LEN = 0x4A0 # 1184 bytes + + +def fetch_snp_report(report_data: bytes) -> bytes: + if not (os.path.exists("/dev/sev-guest") or os.path.exists("/dev/sev")): + raise RuntimeError( + "Neither /dev/sev-guest nor /dev/sev is present in this container. " + "On ACI Standard SKU there is no AMD SEV-SNP hardware - this is the " + "expected failure mode for the demo." + ) + proc = subprocess.run( + [GET_SNP_REPORT, report_data.hex()], + capture_output=True, + timeout=15, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"get-snp-report exited {proc.returncode}. " + f"stdout: {proc.stdout.decode('utf-8', 'replace')[:500]} " + f"stderr: {proc.stderr.decode('utf-8', 'replace')[:500]}" + ) + hex_output = "".join(c for c in proc.stdout.decode("ascii", "replace") if c in "0123456789abcdefABCDEF") + report = bytes.fromhex(hex_output) + if len(report) < REPORT_LEN: + raise RuntimeError(f"SNP report too short: {len(report)} bytes") + return report[:REPORT_LEN] + + +def parse_snp_report_summary(buf: bytes) -> dict: + """Just enough fields for a side-panel summary; MAA does the real validation.""" + version = struct.unpack_from(" dict: + nonce = user_nonce or secrets.token_hex(16) + + # Runtime data: arbitrary JSON the caller wants bound into REPORT_DATA. + runtime_obj = {"nonce": nonce, "client": "visual-attestation-demo-v2"} + runtime_bytes = json.dumps(runtime_obj, separators=(",", ":")).encode("utf-8") + + # MAA hashes runtime data into REPORT_DATA, but only after we hand it the + # report. So we must fetch the report with REPORT_DATA = sha256(runtime). + report_data = hashlib.sha256(runtime_bytes).digest() + b"\x00" * 32 + + snp_report = fetch_snp_report(report_data) + summary = parse_snp_report_summary(snp_report) + + # Load THIM cert chain + UVM endorsements from the security context dir. + uvm = load_uvm_information() + if not uvm["host_amd_cert_b64"]: + raise RuntimeError( + "UVM host AMD certificate not found. Expected security-context-*/host-amd-cert-base64 " + "or UVM_HOST_AMD_CERTIFICATE env var. ACI control plane usually injects this on Confidential SKU." + ) + thim_certs_raw = base64.b64decode(uvm["host_amd_cert_b64"]) + thim_certs = json.loads(thim_certs_raw) + vcek_chain = (thim_certs.get("vcekCert", "") + thim_certs.get("certificateChain", "")).encode("utf-8") + + endorsements_json: bytes | None = None + if uvm["reference_info_b64"]: + ref_info = base64.b64decode(uvm["reference_info_b64"]) + endorsements_json = json.dumps( + {"Uvm": [_b64url(ref_info)]}, separators=(",", ":") + ).encode("utf-8") + + body = { + "report": _build_maa_report(snp_report, vcek_chain, endorsements_json), + "runtimeData": {"data": _b64url(runtime_bytes), "dataType": "JSON"}, + "nonce": secrets.randbits(63), + } + + url = f"https://{DEFAULT_MAA}/attest/SevSnpVm?api-version={MAA_API_VERSION}" + resp = requests.post(url, json=body, timeout=30, headers={"User-Agent": "visual-attestation-demo-v2"}) + if resp.status_code != 200: + raise RuntimeError(f"MAA POST {url} returned HTTP {resp.status_code}: {resp.text[:600]}") + token = resp.json().get("token") + if not token: + raise RuntimeError(f"MAA response missing 'token': {resp.text[:600]}") + + header, payload = decode_jwt(token) + + return { + "endpoint": f"https://{DEFAULT_MAA}", + "region": DEFAULT_MAA.split(".")[0], + "isolation_type": "SEV_SNP", + "nonce": nonce, + "token": token, + "header": header, + "payload": payload, + "claims": annotate_claims(payload), + "hardware_evidence": { + "maa_endpoint": DEFAULT_MAA, + "uvm_source": uvm["source"], + "snp_report_size": len(snp_report), + "snp_report_hex": snp_report.hex(), + "snp_summary": summary, + "runtime_data": runtime_obj, + "runtime_data_sha256": hashlib.sha256(runtime_bytes).hexdigest(), + }, + } + + +# --------------------------------------------------------------------------- +# Flask app +# --------------------------------------------------------------------------- +app = Flask(__name__) + + +@app.route("/", methods=["GET"]) +def index(): + is_confidential = os.environ.get("ACI_SKU", "").lower() == "confidential" + return render_template("index.html", is_confidential=is_confidential) + + +@app.route("/healthz", methods=["GET"]) +def healthz(): + return "ok", 200 + + +@app.route("/api/attest", methods=["POST"]) +def api_attest(): + user_nonce = (request.json or {}).get("nonce") if request.is_json else request.form.get("nonce") + try: + result = perform_attestation(user_nonce or None) + except Exception as exc: + return ( + jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}), + 500, + ) + return jsonify({"ok": True, **result}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80"))) diff --git a/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json b/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json new file mode 100644 index 0000000..12f3b61 --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json @@ -0,0 +1,98 @@ +{ + "$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" + }, + "registryPassword": { + "type": "securestring" + }, + "dnsNameLabel": { + "type": "string" + } + }, + "variables": {}, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[parameters('containerGroupName')]", + "location": "[parameters('location')]", + "properties": { + "sku": "Confidential", + "confidentialComputeProperties": { + "ccePolicy": "cGFja2FnZSBwb2xpY3kKCmltcG9ydCBmdXR1cmUua2V5d29yZHMuZXZlcnkKaW1wb3J0IGZ1dHVyZS5rZXl3b3Jkcy5pbgoKYXBpX3ZlcnNpb24gOj0gIjAuMTAuMCIKZnJhbWV3b3JrX3ZlcnNpb24gOj0gIjAuMi4zIgoKZnJhZ21lbnRzIDo9IFsKICB7CiAgICAiZmVlZCI6ICJtY3IubWljcm9zb2Z0LmNvbS9hY2kvYWNpLWNjLWluZnJhLWZyYWdtZW50IiwKICAgICJpbmNsdWRlcyI6IFsKICAgICAgImNvbnRhaW5lcnMiLAogICAgICAiZnJhZ21lbnRzIgogICAgXSwKICAgICJpc3N1ZXIiOiAiZGlkOng1MDk6MDpzaGEyNTY6SV9faXVMMjVvWEVWRmRUUF9hQkx4X2VUMVJQSGJDUV9FQ0JRZllacHQ5czo6ZWt1OjEuMy42LjEuNC4xLjMxMS43Ni41OS4xLjMiLAogICAgIm1pbmltdW1fc3ZuIjogIjQiCiAgfQpdCgpjb250YWluZXJzIDo9IFt7ImFsbG93X2VsZXZhdGVkIjpmYWxzZSwiYWxsb3dfc3RkaW9fYWNjZXNzIjpmYWxzZSwiY2FwYWJpbGl0aWVzIjp7ImFtYmllbnQiOltdLCJib3VuZGluZyI6WyJDQVBfQVVESVRfV1JJVEUiLCJDQVBfQ0hPV04iLCJDQVBfREFDX09WRVJSSURFIiwiQ0FQX0ZPV05FUiIsIkNBUF9GU0VUSUQiLCJDQVBfS0lMTCIsIkNBUF9NS05PRCIsIkNBUF9ORVRfQklORF9TRVJWSUNFIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRHSUQiLCJDQVBfU0VUUENBUCIsIkNBUF9TRVRVSUQiLCJDQVBfU1lTX0NIUk9PVCJdLCJlZmZlY3RpdmUiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXSwiaW5oZXJpdGFibGUiOltdLCJwZXJtaXR0ZWQiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXX0sImNvbW1hbmQiOlsicHl0aG9uIiwiL2FwcC9hcHAucHkiXSwiZW52X3J1bGVzIjpbeyJwYXR0ZXJuIjoiQUNJX1NLVT1Db25maWRlbnRpYWwiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiTUFBX0VORFBPSU5UPXNoYXJlZGV1cy5ldXMuYXR0ZXN0LmF6dXJlLm5ldCIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJQQVRIPS91c3IvbG9jYWwvYmluOi91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJMQU5HPUMuVVRGLTgiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiR1BHX0tFWT1BMDM1QzhDMTkyMTlCQTgyMUVDRUE4NkI2NEU2MjhGOEQ2ODQ2OTZEIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTl9WRVJTSU9OPTMuMTEuMTUiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OX1NIQTI1Nj0yNzIxNzlkZGQ5YTJlNDFhMGZjOGU0MmUzM2RmYmRjYTBiMzcxMWFhNWFiZjM3MmQzZjJkNTE1NDNkMDliNjI1IiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTkRPTlRXUklURUJZVEVDT0RFPTEiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OVU5CVUZGRVJFRD0xIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBPUlQ9ODAiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiVEVSTT14dGVybSIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiIoP2kpKEZBQlJJQylfLis9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSE9TVE5BTUU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiVChFKT9NUD0uKyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifSx7InBhdHRlcm4iOiJGYWJyaWNQYWNrYWdlRmlsZU5hbWU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSG9zdGVkU2VydmljZU5hbWU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSURFTlRJVFlfQVBJX1ZFUlNJT049LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSURFTlRJVFlfSEVBREVSPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IklERU5USVRZX1NFUlZFUl9USFVNQlBSSU5UPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6ImF6dXJlY29udGFpbmVyaW5zdGFuY2VfcmVzdGFydGVkX2J5PS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9XSwiZXhlY19wcm9jZXNzZXMiOltdLCJpZCI6ImFjcmxpZm1jZHdqLmF6dXJlY3IuaW8vY2MtYXR0ZXN0OjEuMCIsImxheWVycyI6WyI4NzkxODg2YWY4MDg2MmQyNDk3YzcwNTM4NWE1NGQ5MDIwMzI2MGY5NGVjYTI3ZjhiYjgyMWIzMGQ5YzM0Y2U1IiwiNTIwN2NhNGZkNDk3NGEzMGFkODdmOGUyMGE0Nzg1ODVhNDk2MjY1ZmQ3YjllN2Q3Njg3Y2I5MzZhM2FkYzYyNyIsIjM3OWM1NTQxZjVmMzIwZTMyNDViNjg1NmQxYzZlODk3OWJjMTA0NDc4N2NmZjU3OTVkMjdmYzM2MGFhYTBhZjIiLCJiYmI2NWQ1OTMyNWJjMDRiNWRhYWM5MTMzYmE3OGVkZjE3Nzk2ZWYyNGI2NzAxM2RlZjliNDhlZmM4ZjBjZTA0IiwiNzYwMDY0OGMxYWU3MzFkNzI2OTg5ZTk3YzQ2ZWU0NzYzZDcxNmRmMTkyNDdlNDg4ZGRlMGQ2MDhiMjBlOGIyYyIsIjM4MjBhNGU2YjcxYmFmMjBjMzRjZmY3NmM0NGQ1MDlmYjU1MGMxMDlmZDAwZjA4NjBiMGZjNDkxMjIzMzc2ZjkiLCIzNmJiY2U0MWQzZjIxNGNmZWJjMDJjYzE2ZWY2OWE4ODM2ZmZjYjAyMDAxOGNlNDZkODJmNTRhNGE2NmFmMjg5IiwiZDE3YjQ1ODk0NDg4OTkwMjMxNzk5Y2ExMDZiMmJjN2IxNjg3MzQ1NmZhMzJmOGNlMDAxNDRiOWU1NGFmMjI5ZSIsIjgzMTE0ZTdjOWQ1ODE3NWEwNDM5ZjFkYWJhNGRlNTZlMjNjOTcyZjA2ZGJjNmQ1YWE2NGRjNjc5YjY2OTI1ZjciLCJiZjI2MzNhOTBiMThiOGQyYjg0MDkwYmZjYmQzYTBiZWRmNTkwODdkYTY1Y2JhOGIzMDZjOWQ1YmI2ZmZkZjVmIl0sIm1vdW50cyI6W3siZGVzdGluYXRpb24iOiIvZXRjL3Jlc29sdi5jb25mIiwib3B0aW9ucyI6WyJyYmluZCIsInJzaGFyZWQiLCJydyJdLCJzb3VyY2UiOiJzYW5kYm94Oi8vL3RtcC9hdGxhcy9yZXNvbHZjb25mLy4rIiwidHlwZSI6ImJpbmQifV0sIm5hbWUiOiJjYy1hdHRlc3QiLCJub19uZXdfcHJpdmlsZWdlcyI6ZmFsc2UsInNlY2NvbXBfcHJvZmlsZV9zaGEyNTYiOiIiLCJzaWduYWxzIjpbXSwidXNlciI6eyJncm91cF9pZG5hbWVzIjpbeyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifV0sInVtYXNrIjoiMDAyMiIsInVzZXJfaWRuYW1lIjp7InBhdHRlcm4iOiIiLCJzdHJhdGVneSI6ImFueSJ9fSwid29ya2luZ19kaXIiOiIvYXBwIn0seyJhbGxvd19lbGV2YXRlZCI6ZmFsc2UsImFsbG93X3N0ZGlvX2FjY2VzcyI6ZmFsc2UsImNhcGFiaWxpdGllcyI6eyJhbWJpZW50IjpbXSwiYm91bmRpbmciOlsiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GU0VUSUQiLCJDQVBfRk9XTkVSIiwiQ0FQX01LTk9EIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFVJRCIsIkNBUF9TRVRGQ0FQIiwiQ0FQX1NFVFBDQVAiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9TWVNfQ0hST09UIiwiQ0FQX0tJTEwiLCJDQVBfQVVESVRfV1JJVEUiXSwiZWZmZWN0aXZlIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl0sImluaGVyaXRhYmxlIjpbXSwicGVybWl0dGVkIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl19LCJjb21tYW5kIjpbIi9wYXVzZSJdLCJlbnZfcnVsZXMiOlt7InBhdHRlcm4iOiJQQVRIPS91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsInJlcXVpcmVkIjp0cnVlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlRFUk09eHRlcm0iLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn1dLCJleGVjX3Byb2Nlc3NlcyI6W10sImxheWVycyI6WyIxNmI1MTQwNTdhMDZhZDY2NWY5MmMwMjg2M2FjYTA3NGZkNTk3NmM3NTVkMjZiZmYxNjM2NTI5OTE2OWU4NDE1Il0sIm1vdW50cyI6W10sIm5hbWUiOiJwYXVzZS1jb250YWluZXIiLCJub19uZXdfcHJpdmlsZWdlcyI6ZmFsc2UsInNlY2NvbXBfcHJvZmlsZV9zaGEyNTYiOiIiLCJzaWduYWxzIjpbXSwidXNlciI6eyJncm91cF9pZG5hbWVzIjpbeyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifV0sInVtYXNrIjoiMDAyMiIsInVzZXJfaWRuYW1lIjp7InBhdHRlcm4iOiIiLCJzdHJhdGVneSI6ImFueSJ9fSwid29ya2luZ19kaXIiOiIvIn1dCgphbGxvd19wcm9wZXJ0aWVzX2FjY2VzcyA6PSB0cnVlCmFsbG93X2R1bXBfc3RhY2tzIDo9IGZhbHNlCmFsbG93X3J1bnRpbWVfbG9nZ2luZyA6PSBmYWxzZQphbGxvd19lbnZpcm9ubWVudF92YXJpYWJsZV9kcm9wcGluZyA6PSB0cnVlCmFsbG93X3VuZW5jcnlwdGVkX3NjcmF0Y2ggOj0gZmFsc2UKYWxsb3dfY2FwYWJpbGl0eV9kcm9wcGluZyA6PSB0cnVlCgptb3VudF9kZXZpY2UgOj0gZGF0YS5mcmFtZXdvcmsubW91bnRfZGV2aWNlCnVubW91bnRfZGV2aWNlIDo9IGRhdGEuZnJhbWV3b3JrLnVubW91bnRfZGV2aWNlCm1vdW50X292ZXJsYXkgOj0gZGF0YS5mcmFtZXdvcmsubW91bnRfb3ZlcmxheQp1bm1vdW50X292ZXJsYXkgOj0gZGF0YS5mcmFtZXdvcmsudW5tb3VudF9vdmVybGF5CmNyZWF0ZV9jb250YWluZXIgOj0gZGF0YS5mcmFtZXdvcmsuY3JlYXRlX2NvbnRhaW5lcgpleGVjX2luX2NvbnRhaW5lciA6PSBkYXRhLmZyYW1ld29yay5leGVjX2luX2NvbnRhaW5lcgpleGVjX2V4dGVybmFsIDo9IGRhdGEuZnJhbWV3b3JrLmV4ZWNfZXh0ZXJuYWwKc2h1dGRvd25fY29udGFpbmVyIDo9IGRhdGEuZnJhbWV3b3JrLnNodXRkb3duX2NvbnRhaW5lcgpzaWduYWxfY29udGFpbmVyX3Byb2Nlc3MgOj0gZGF0YS5mcmFtZXdvcmsuc2lnbmFsX2NvbnRhaW5lcl9wcm9jZXNzCnBsYW45X21vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnBsYW45X21vdW50CnBsYW45X3VubW91bnQgOj0gZGF0YS5mcmFtZXdvcmsucGxhbjlfdW5tb3VudApnZXRfcHJvcGVydGllcyA6PSBkYXRhLmZyYW1ld29yay5nZXRfcHJvcGVydGllcwpkdW1wX3N0YWNrcyA6PSBkYXRhLmZyYW1ld29yay5kdW1wX3N0YWNrcwpydW50aW1lX2xvZ2dpbmcgOj0gZGF0YS5mcmFtZXdvcmsucnVudGltZV9sb2dnaW5nCmxvYWRfZnJhZ21lbnQgOj0gZGF0YS5mcmFtZXdvcmsubG9hZF9mcmFnbWVudApzY3JhdGNoX21vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnNjcmF0Y2hfbW91bnQKc2NyYXRjaF91bm1vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnNjcmF0Y2hfdW5tb3VudAoKcmVhc29uIDo9IHsiZXJyb3JzIjogZGF0YS5mcmFtZXdvcmsuZXJyb3JzfQ==" + }, + "containers": [ + { + "name": "cc-attest", + "properties": { + "image": "[parameters('appImage')]", + "ports": [ + { + "protocol": "TCP", + "port": 80 + } + ], + "environmentVariables": [ + { + "name": "ACI_SKU", + "value": "Confidential" + }, + { + "name": "MAA_ENDPOINT", + "value": "sharedeus.eus.attest.azure.net" + } + ], + "resources": { + "requests": { + "memoryInGB": 2, + "cpu": 1 + } + } + } + } + ], + "imageRegistryCredentials": [ + { + "server": "[parameters('registryServer')]", + "username": "[parameters('registryUsername')]", + "password": "[parameters('registryPassword')]" + } + ], + "ipAddress": { + "ports": [ + { + "protocol": "TCP", + "port": 80 + } + ], + "type": "Public", + "dnsNameLabel": "[parameters('dnsNameLabel')]" + }, + "osType": "Linux", + "restartPolicy": "Always" + } + } + ], + "outputs": { + "fqdn": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn]" + } + } +} \ No newline at end of file diff --git a/aci-samples/visual-attestation-demo-v2/deployment-template-standard.json b/aci-samples/visual-attestation-demo-v2/deployment-template-standard.json new file mode 100644 index 0000000..9c036f2 --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/deployment-template-standard.json @@ -0,0 +1,56 @@ +{ + "$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" }, + "registryPassword": { "type": "securestring" }, + "dnsNameLabel": { "type": "string" } + }, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[parameters('containerGroupName')]", + "location": "[parameters('location')]", + "properties": { + "sku": "Standard", + "containers": [ + { + "name": "cc-attest", + "properties": { + "image": "[parameters('appImage')]", + "ports": [ { "protocol": "TCP", "port": 80 } ], + "resources": { + "requests": { "memoryInGB": 2, "cpu": 1 } + } + } + } + ], + "imageRegistryCredentials": [ + { + "server": "[parameters('registryServer')]", + "username": "[parameters('registryUsername')]", + "password": "[parameters('registryPassword')]" + } + ], + "ipAddress": { + "ports": [ { "protocol": "TCP", "port": 80 } ], + "type": "Public", + "dnsNameLabel": "[parameters('dnsNameLabel')]" + }, + "osType": "Linux", + "restartPolicy": "Always" + } + } + ], + "outputs": { + "fqdn": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn]" + } + } +} diff --git a/aci-samples/visual-attestation-demo-v2/images/screenshot-confidential.png b/aci-samples/visual-attestation-demo-v2/images/screenshot-confidential.png new file mode 100644 index 0000000..115b6a3 Binary files /dev/null and b/aci-samples/visual-attestation-demo-v2/images/screenshot-confidential.png differ diff --git a/aci-samples/visual-attestation-demo-v2/images/screenshot-standard.png b/aci-samples/visual-attestation-demo-v2/images/screenshot-standard.png new file mode 100644 index 0000000..5feca13 Binary files /dev/null and b/aci-samples/visual-attestation-demo-v2/images/screenshot-standard.png differ diff --git a/aci-samples/visual-attestation-demo-v2/requirements.txt b/aci-samples/visual-attestation-demo-v2/requirements.txt new file mode 100644 index 0000000..3c01733 --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/requirements.txt @@ -0,0 +1,3 @@ +flask>=2.3 +requests + diff --git a/aci-samples/visual-attestation-demo-v2/templates/index.html b/aci-samples/visual-attestation-demo-v2/templates/index.html new file mode 100644 index 0000000..3946f42 --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/templates/index.html @@ -0,0 +1,205 @@ + + + + + ACI CC - Runtime Attestation + + + + +
+ +

ACI {% if is_confidential %}Confidential{% else %}Standard{% endif %} Containers - Runtime Attestation

+ {% if is_confidential %} +

This container is running in an ACI Confidential Container group on AMD SEV-SNP hardware. Click Attest to fetch a fresh hardware-rooted attestation token from Microsoft Azure Attestation (MAA) and inspect the claims.

+ {% else %} +

This container is running in a Standard ACI container group - not on AMD SEV-SNP hardware and with no /dev/sev-guest device. Click Attest to see the expected hardware-rooted attestation failure.

+ {% endif %} +
+
+
+

The attestation flow runs inside this single container - no SKR sidecar - using the upstream get-snp-report tool baked into the image:

+
    +
  1. The app builds a runtime data blob ({nonce, client}) and computes REPORT_DATA = SHA-256(runtime_data) || 0×32.
  2. +
  3. get-snp-report opens /dev/sev-guest and issues SNP_GUEST_REQUEST; the AMD Secure Processor returns a 1184-byte report signed by this chip's VCEK and bound to the supplied REPORT_DATA.
  4. +
  5. The app reads the THIM cert chain and UVM endorsements from UVM_SECURITY_CONTEXT_DIR (injected by the ACI control plane) and POSTs {report, runtimeData, nonce} to https://<maa>/attest/SevSnpVm.
  6. +
  7. MAA verifies the SNP report against the AMD root CA chain and the UVM endorsements, then returns a signed JWT whose x-ms-sevsnpvm-* claims describe the TEE state at this moment.
  8. +
+

On ACI Standard SKU there is no SEV-SNP hardware and no /dev/sev-guest device, so step 2 fails before MAA is ever called - that is the educational contrast.

+

+ + +

+
+ +
+
+ + + + diff --git a/aks-samples/azure-voting-app/Deploy-VotingAppCC.ps1 b/aks-samples/azure-voting-app/Deploy-VotingAppCC.ps1 new file mode 100644 index 0000000..b422346 --- /dev/null +++ b/aks-samples/azure-voting-app/Deploy-VotingAppCC.ps1 @@ -0,0 +1,570 @@ +# Hands-off script to build a small AKS cluster with an AMD SEV-SNP Confidential Computing node pool +# and deploy the public Microsoft "Azure Voting App" multi-container sample to it, exposed via a public +# LoadBalancer. Modeled on BuildRandomCVM.ps1 (random naming, tagging, smoketest, preflight). +# +# Simon Gallagher, ACC Product Group +# Use at your own risk, no warranties implied, test in a non-production environment first +# +# References: +# - https://learn.microsoft.com/azure/aks/use-cvm (CVM node pools on AKS) +# - https://learn.microsoft.com/azure/aks/confidential-computing-overview +# - https://learn.microsoft.com/azure/aks/auto-upgrade-cluster (auto-upgrade channel) +# - https://learn.microsoft.com/azure/aks/auto-upgrade-node-image (node OS auto-upgrade) +# - https://github.com/Azure-Samples/azure-voting-app-redis (the demo app) +# +# Usage: +# ./Deploy-VotingAppCC.ps1 -subsID -basename [-region ] ` +# [-description ] [-smoketest] [-SkipSkuPreflight] +# +# Requirements: +# - Azure PowerShell (Az) module + Azure CLI (az), both logged in to the same subscription +# - kubectl on PATH (az aks install-cli will install it if missing) + +param ( + [Parameter(Mandatory)]$subsID, + [Parameter(Mandatory)]$basename, + [Parameter(Mandatory=$false)]$description = "", + [Parameter(Mandatory=$false)][switch]$smoketest, + [Parameter(Mandatory=$false)]$region = "northeurope", + [Parameter(Mandatory=$false)]$ccVmSize = "Standard_DC2as_v5", # smallest AMD SEV-SNP CVM (2 vCPU / 8 GiB) + [Parameter(Mandatory=$false)]$systemVmSize = "Standard_D2as_v6", # tiny non-CC system pool (AMD, allowed by typical Allowed-VM-SKUs policies) + [Parameter(Mandatory=$false)][int]$ccNodeCount = 2, + [Parameter(Mandatory=$false)][switch]$SkipSkuPreflight +) + +if ($subsID -eq "" -or $basename -eq "") { + write-host "You must enter a subscription ID and a basename" + exit 1 +} + +$startTime = Get-Date +$scriptName = $MyInvocation.MyCommand.Name + +# Get GitHub repository URL from git remote (used as a tag) +$gitRemoteUrl = "" +try { $gitRemoteUrl = (git remote get-url origin) -replace "\.git$","" } catch {} +if (-not $gitRemoteUrl) { $gitRemoteUrl = "[Originally from] https://github.com/Microsoft/confidential-computing" } + +# ACR names cannot contain hyphens, AKS cluster names should be conservative as well. +if ($basename -match '[^a-z0-9]') { + write-host "basename must contain only lowercase letters and digits (no hyphens, no uppercase). ACR does not allow hyphens." -ForegroundColor Red + exit 1 +} + +# Random suffix in the same style as BuildRandomCVM.ps1 +$basename = $basename + -join ((97..122) | Get-Random -Count 5 | % {[char]$_}) +$resgrp = $basename +$aksName = $basename + "aks" +$acrName = $basename + "acr" +$ccPoolName = "ccpool" # 12-char max, lowercase, AMD SEV-SNP node pool +$systemPool = "syspool" + +write-host "----------------------------------------------------------------------------------------------------------------" +write-host "Building AKS cluster '$aksName' with AMD SEV-SNP CC node pool in '$region' (subscription $subsID)" +write-host " System pool : $systemPool 1x $systemVmSize" +write-host " CC pool : $ccPoolName ${ccNodeCount}x $ccVmSize (AMD SEV-SNP)" +write-host " Resource Gp : $resgrp" +if ($smoketest) { write-host "SMOKETEST MODE: Resources will be auto-deleted after the front-end is verified" -ForegroundColor Yellow } +write-host "Script: $scriptName" +write-host "Repository URL: $gitRemoteUrl" +write-host "----------------------------------------------------------------------------------------------------------------" + +# Set subscription context for both Az and az CLI +Set-AzContext -SubscriptionId $subsID | Out-Null +if (!$?) { write-host "Failed to Set-AzContext to $subsID" -ForegroundColor Red; exit 1 } +az account set --subscription $subsID | Out-Null +if (!$?) { write-host "Failed to az account set --subscription $subsID" -ForegroundColor Red; exit 1 } + +$tmp = Get-AzContext +$ownername = $tmp.Account.Id + +# ---------- Pre-flight: SKU + quota check for the CC pool ----------------------------------------- +if ($SkipSkuPreflight) { + write-host "Pre-flight check SKIPPED (-SkipSkuPreflight)." -ForegroundColor Yellow +} else { + write-host "Pre-flight: confirming '$ccVmSize' is available in '$region' with sufficient AMD CVM vCPU quota..." -ForegroundColor Cyan + + # Hard-fail on Intel SGX SKUs - this script targets full-VM CC (SEV-SNP) + if ($ccVmSize -match '^Standard_DC\d+s_v[23]$') { + write-host "ERROR: '$ccVmSize' is an Intel SGX SKU; this script targets AMD SEV-SNP Confidential VM nodes." -ForegroundColor Red + exit 1 + } + if ($ccVmSize -notmatch '^Standard_(DC|EC)\d+a[a-z]*_v\d+$') { + write-host "Warning: '$ccVmSize' does not look like an AMD SEV-SNP CVM SKU (expected DCa*/ECa*v5 family)." -ForegroundColor Yellow + } + + $skuInfo = $null + try { + $skuInfo = Get-AzComputeResourceSku -Location $region -ErrorAction Stop | + Where-Object { $_.ResourceType -eq 'virtualMachines' -and $_.Name -eq $ccVmSize } | + Select-Object -First 1 + } catch { + write-host "Warning: Get-AzComputeResourceSku failed: $($_.Exception.Message)" -ForegroundColor Yellow + } + + if ($null -eq $skuInfo) { + write-host "ERROR: '$ccVmSize' is not offered in '$region'." -ForegroundColor Red + write-host "Find available regions: Get-AzComputeResourceSku | ? { `$_.Name -eq '$ccVmSize' -and -not `$_.Restrictions } | Select Locations" -ForegroundColor Gray + exit 1 + } + + $subRestriction = $skuInfo.Restrictions | Where-Object { + $_.ReasonCode -eq 'NotAvailableForSubscription' -or + ($_.RestrictionInfo -and $_.RestrictionInfo.Locations -contains $region) + } + if ($subRestriction) { + $reason = ($skuInfo.Restrictions | ForEach-Object { $_.ReasonCode }) -join ', ' + write-host "ERROR: '$ccVmSize' is restricted for this subscription in '$region' (reason: $reason)." -ForegroundColor Red + exit 1 + } + + $skuVCpus = ($skuInfo.Capabilities | Where-Object Name -eq 'vCPUs' | Select-Object -First 1).Value -as [int] + if (-not $skuVCpus) { $skuVCpus = 2 } + $needed = $skuVCpus * $ccNodeCount + $skuFamily = $skuInfo.Family + try { + $usage = Get-AzVMUsage -Location $region -ErrorAction Stop | + Where-Object { $_.Name.Value -eq $skuFamily } | Select-Object -First 1 + if ($usage) { + $available = [int]$usage.Limit - [int]$usage.CurrentValue + write-host ("Quota for {0} in {1}: {2}/{3} used, {4} vCPUs available, this pool needs {5}." -f ` + $skuFamily, $region, $usage.CurrentValue, $usage.Limit, $available, $needed) -ForegroundColor Cyan + if ($available -lt $needed) { + write-host "ERROR: Insufficient AMD CVM vCPU quota in '$skuFamily' / '$region' ($needed needed, $available available)." -ForegroundColor Red + exit 1 + } + } + } catch { + write-host "Warning: Get-AzVMUsage failed: $($_.Exception.Message). Continuing." -ForegroundColor Yellow + } + write-host "Pre-flight passed: '$ccVmSize' available with quota in '$region'." -ForegroundColor Green +} + +# ---------- Resource group ------------------------------------------------------------------------ +$rgTags = @{ + owner = $ownername + BuiltBy = $scriptName + GitRepo = $gitRemoteUrl + Workload = "azure-voting-app" + CCType = "AMD-SEV-SNP" +} +if ($description -ne "") { $rgTags.Add("description", $description) } +if ($smoketest) { $rgTags.Add("smoketest", "true") } + +New-AzResourceGroup -Name $resgrp -Location $region -Tag $rgTags -Force | Out-Null + +# ---------- AKS cluster --------------------------------------------------------------------------- +# Auto-patching strategy (no preview features required, safe defaults that reflect the recommended +# Azure Policies "Kubernetes clusters should have auto-upgrade enabled" and node-image auto-upgrade): +# --auto-upgrade-channel stable cluster K8s version auto-upgrades to stable +# --node-os-upgrade-channel NodeImage node OS images auto-upgrade weekly +# --enable-managed-identity system-assigned MI for the cluster +# --tier standard uptime SLA + financially-backed (cheap insurance) +# We deliberately keep local accounts enabled so 'az aks get-credentials' just works for the demo. +write-host "Creating AKS cluster '$aksName' (this takes ~5 minutes)..." -ForegroundColor Cyan +az aks create ` + --resource-group $resgrp ` + --name $aksName ` + --location $region ` + --node-count 1 ` + --nodepool-name $systemPool ` + --node-vm-size $systemVmSize ` + --os-sku Ubuntu ` + --enable-managed-identity ` + --generate-ssh-keys ` + --auto-upgrade-channel stable ` + --node-os-upgrade-channel NodeImage ` + --tier standard ` + --network-plugin azure ` + --tags owner=$ownername BuiltBy=$scriptName Workload=azure-voting-app ` + --only-show-errors +if ($LASTEXITCODE -ne 0) { write-host "az aks create failed" -ForegroundColor Red; exit 1 } + +# ---------- AMD SEV-SNP Confidential Computing node pool ------------------------------------------ +# AMD SEV-SNP CVM node pools require Ubuntu and a DCa*/ECa* v5 SKU. Secure Boot + vTPM are enabled +# implicitly by the platform when a CVM SKU is selected; no extra flags are needed. +write-host "Adding AMD SEV-SNP CC node pool '$ccPoolName' (${ccNodeCount}x $ccVmSize)..." -ForegroundColor Cyan +az aks nodepool add ` + --resource-group $resgrp ` + --cluster-name $aksName ` + --name $ccPoolName ` + --node-count $ccNodeCount ` + --node-vm-size $ccVmSize ` + --os-sku Ubuntu ` + --mode User ` + --labels workload=confidential sku=amd-sev-snp ` + --tags owner=$ownername CCType=AMD-SEV-SNP ` + --only-show-errors +if ($LASTEXITCODE -ne 0) { write-host "az aks nodepool add failed for CC pool" -ForegroundColor Red; exit 1 } + +# ---------- kubectl access ------------------------------------------------------------------------ +if (-not (Get-Command kubectl -ErrorAction SilentlyContinue)) { + write-host "kubectl not found - installing via 'az aks install-cli'" -ForegroundColor Yellow + az aks install-cli --only-show-errors | Out-Null +} +write-host "Fetching cluster credentials..." -ForegroundColor Cyan +az aks get-credentials --resource-group $resgrp --name $aksName --overwrite-existing --only-show-errors | Out-Null +if ($LASTEXITCODE -ne 0) { write-host "az aks get-credentials failed" -ForegroundColor Red; exit 1 } + +# Sanity: list nodes +kubectl get nodes -o wide +if ($LASTEXITCODE -ne 0) { write-host "kubectl get nodes failed - cluster not reachable" -ForegroundColor Red; exit 1 } + +# ---------- Deploy public Azure Voting App (multi-container) -------------------------------------- +# Source: https://github.com/Azure-Samples/azure-voting-app-redis - public images on mcr.microsoft.com +# We pin the front-end to the CC node pool via nodeSelector so the app actually runs inside SEV-SNP. +$votingManifest = @' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: azure-vote-back + labels: + app: azure-vote-back +spec: + replicas: 1 + selector: + matchLabels: + app: azure-vote-back + template: + metadata: + labels: + app: azure-vote-back + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: azure-vote-back + image: mcr.microsoft.com/oss/bitnami/redis:6.0.8 + env: + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + ports: + - containerPort: 6379 + name: redis +--- +apiVersion: v1 +kind: Service +metadata: + name: azure-vote-back +spec: + ports: + - port: 6379 + selector: + app: azure-vote-back +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: azure-vote-front + labels: + app: azure-vote-front +spec: + replicas: 2 + selector: + matchLabels: + app: azure-vote-front + template: + metadata: + labels: + app: azure-vote-front + spec: + nodeSelector: + kubernetes.io/os: linux + workload: confidential + containers: + - name: azure-vote-front + # The previously-published mcr.microsoft.com/azuredocs/azure-vote-front:v1 image was + # removed from MCR. We bootstrap the same Flask app at runtime from the public source + # repo so the sample works first-time without requiring an attached ACR. + image: docker.io/library/python:3.9-slim + command: ["bash","-c"] + args: + - | + set -e + apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates >/dev/null + rm -rf /src + git clone --depth 1 https://github.com/Azure-Samples/azure-voting-app-redis /src + rm -rf /app && mkdir -p /app + cp -r /src/azure-vote/azure-vote/. /app/ + cd /app + pip install --no-cache-dir flask redis >/dev/null + exec python -c "import sys; sys.path.insert(0,'.'); from main import app; app.run(host='0.0.0.0', port=80)" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + ports: + - containerPort: 80 + env: + - name: REDIS + value: "azure-vote-back" + startupProbe: + httpGet: { path: /, port: 80 } + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: { path: /, port: 80 } + periodSeconds: 10 + failureThreshold: 6 +--- +apiVersion: v1 +kind: Service +metadata: + name: azure-vote-front +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: 80 + selector: + app: azure-vote-front +'@ + +$manifestFile = Join-Path $env:TEMP "azure-vote-$basename.yaml" +$votingManifest | Out-File -FilePath $manifestFile -Encoding utf8 -Force +write-host "Applying voting-app manifest from $manifestFile..." -ForegroundColor Cyan +kubectl apply -f $manifestFile +if ($LASTEXITCODE -ne 0) { write-host "kubectl apply failed" -ForegroundColor Red; exit 1 } + +# ---------- Wait for rollouts --------------------------------------------------------------------- +write-host "Waiting for deployments to become available..." -ForegroundColor Cyan +kubectl rollout status deployment/azure-vote-back --timeout=5m +if ($LASTEXITCODE -ne 0) { write-host "azure-vote-back rollout failed" -ForegroundColor Red; kubectl describe deployment azure-vote-back; exit 1 } +kubectl rollout status deployment/azure-vote-front --timeout=10m +if ($LASTEXITCODE -ne 0) { write-host "azure-vote-front rollout failed" -ForegroundColor Red; kubectl describe deployment azure-vote-front; kubectl get pods -l app=azure-vote-front -o wide; exit 1 } + +# ---------- Wait for LoadBalancer external IP ----------------------------------------------------- +write-host "Waiting for LoadBalancer to allocate a public IP (up to 5 minutes)..." -ForegroundColor Cyan +$externalIP = $null +for ($i = 1; $i -le 30; $i++) { + $externalIP = kubectl get service azure-vote-front -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>$null + if ($externalIP) { break } + Start-Sleep -Seconds 10 + write-host " ...still waiting for external IP (attempt $i/30)" +} +if (-not $externalIP) { + write-host "Timed out waiting for external IP" -ForegroundColor Red + kubectl describe service azure-vote-front + exit 1 +} +write-host "Voting app external IP: $externalIP" -ForegroundColor Green + +# ---------- Smoke test the front-end -------------------------------------------------------------- +write-host "Smoke testing http://$externalIP/ ..." -ForegroundColor Cyan +$ok = $false +for ($i = 1; $i -le 30; $i++) { + try { + $resp = Invoke-WebRequest -Uri "http://$externalIP/" -UseBasicParsing -TimeoutSec 10 + if ($resp.StatusCode -eq 200 -and $resp.Content -match 'Cats|Dogs|Azure Voting App') { + $ok = $true; break + } + } catch { } + Start-Sleep -Seconds 10 + write-host " ...front-end not responding yet (attempt $i/30)" +} +if (-not $ok) { + write-host "Front-end did not respond with expected content within 5 minutes" -ForegroundColor Red + kubectl get pods -o wide + kubectl logs -l app=azure-vote-front --tail=50 + exit 1 +} + +write-host "----------------------------------------------------------------------------------------------------------------" +write-host "SUCCESS: Azure Voting App is live at http://$externalIP/" -ForegroundColor Green +write-host "Cluster : $aksName" +write-host "Resource group : $resgrp" +write-host "CC node pool : $ccPoolName (${ccNodeCount}x $ccVmSize - AMD SEV-SNP)" +write-host "Auto-upgrade : cluster=stable node-os=NodeImage" +write-host "----------------------------------------------------------------------------------------------------------------" + +# ---------- Deploy the runtime-attestation web UI ------------------------------------------------- +# Wraps Azure/cvm-attestation-tools so a user can click "Attest" and see a fresh MAA-signed +# SEV-SNP attestation token with every claim explained. Bootstrapped at pod startup from a +# ConfigMap built from the local ./attestation/ folder (no ACR needed). +write-host "Deploying CC runtime-attestation web UI..." -ForegroundColor Cyan +$attestDir = Join-Path $PSScriptRoot 'attestation' +foreach ($f in @('app.py','config_snp.json','templates/index.html')) { + if (-not (Test-Path (Join-Path $attestDir $f))) { + write-host "Missing $f under $attestDir - skipping attestation deployment." -ForegroundColor Yellow + $attestDir = $null + break + } +} + +if ($attestDir) { + # Build a single ConfigMap with three flat keys (app.py, config_snp.json, index.html). + $cmYaml = kubectl create configmap cc-attest-app ` + --from-file=app.py=(Join-Path $attestDir 'app.py') ` + --from-file=config_snp.json=(Join-Path $attestDir 'config_snp.json') ` + --from-file=index.html=(Join-Path $attestDir 'templates/index.html') ` + --dry-run=client -o yaml + if ($LASTEXITCODE -ne 0) { write-host "Failed to build attestation ConfigMap" -ForegroundColor Red; exit 1 } + $cmFile = Join-Path $env:TEMP "cc-attest-cm-$basename.yaml" + $cmYaml | Out-File -FilePath $cmFile -Encoding utf8 -Force + kubectl apply -f $cmFile | Out-Null + + $attestManifest = @' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cc-attest + labels: { app: cc-attest } +spec: + replicas: 1 + selector: { matchLabels: { app: cc-attest } } + template: + metadata: + labels: { app: cc-attest } + spec: + nodeSelector: + kubernetes.io/os: linux + workload: confidential + containers: + - name: cc-attest + image: docker.io/library/python:3.11-slim + # Privileged + host TPM device passthrough so the upstream tool can read the + # SEV-SNP HCL report from the node vTPM. + securityContext: + privileged: true + env: + - name: POD_NAME + valueFrom: { fieldRef: { fieldPath: metadata.name } } + - name: NODE_NAME + valueFrom: { fieldRef: { fieldPath: spec.nodeName } } + - name: CVM_TOOLS_DIR + value: /opt/cvm-tools/cvm-attestation + ports: + - containerPort: 80 + command: ["bash","-c"] + args: + - | + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends git ca-certificates tpm2-tools >/dev/null + rm -rf /opt/cvm-tools + git clone --depth 1 https://github.com/Azure/cvm-attestation-tools.git /opt/cvm-tools + cd /opt/cvm-tools/cvm-attestation + pip install --no-cache-dir -r requirements.txt >/dev/null + pip install --no-cache-dir flask >/dev/null + mkdir -p /app/templates + cp /etc/attest-app/app.py /app/app.py + cp /etc/attest-app/config_snp.json /app/config_snp.json + cp /etc/attest-app/index.html /app/templates/index.html + exec python /app/app.py + volumeMounts: + - { name: tpmrm, mountPath: /dev/tpmrm0 } + - { name: tpm0, mountPath: /dev/tpm0 } + - { name: securityfs, mountPath: /sys/kernel/security, readOnly: true } + - { name: app, mountPath: /etc/attest-app } + startupProbe: + httpGet: { path: /healthz, port: 80 } + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: { path: /healthz, port: 80 } + periodSeconds: 10 + failureThreshold: 6 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { cpu: 1, memory: 1Gi } + volumes: + - name: tpmrm + hostPath: { path: /dev/tpmrm0, type: CharDevice } + - name: tpm0 + hostPath: { path: /dev/tpm0, type: CharDevice } + - name: securityfs + hostPath: { path: /sys/kernel/security, type: Directory } + - name: app + configMap: + name: cc-attest-app +--- +apiVersion: v1 +kind: Service +metadata: + name: cc-attest +spec: + type: LoadBalancer + selector: { app: cc-attest } + ports: + - port: 80 + targetPort: 80 +'@ + + $attestManifestFile = Join-Path $env:TEMP "cc-attest-$basename.yaml" + $attestManifest | Out-File -FilePath $attestManifestFile -Encoding utf8 -Force + kubectl apply -f $attestManifestFile + if ($LASTEXITCODE -ne 0) { write-host "kubectl apply failed for attestation manifest" -ForegroundColor Red; exit 1 } + + # Restart deployment so any ConfigMap changes from re-runs are picked up. + kubectl rollout restart deployment/cc-attest | Out-Null + write-host "Waiting for cc-attest rollout (first run installs tpm2-tools + clones upstream)..." -ForegroundColor Cyan + kubectl rollout status deployment/cc-attest --timeout=10m + if ($LASTEXITCODE -ne 0) { + write-host "cc-attest rollout failed" -ForegroundColor Red + kubectl describe deployment cc-attest + kubectl logs -l app=cc-attest --tail=80 + } else { + # Wait for the attestation LoadBalancer. + $attestIP = $null + for ($i = 1; $i -le 30; $i++) { + $attestIP = kubectl get service cc-attest -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>$null + if ($attestIP) { break } + Start-Sleep -Seconds 10 + write-host " ...still waiting for cc-attest external IP (attempt $i/30)" + } + if ($attestIP) { + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "CC Attestation UI live at http://$attestIP/" -ForegroundColor Green + write-host " Click 'Attest' to fetch a fresh MAA-signed SEV-SNP token with every claim explained." + write-host "----------------------------------------------------------------------------------------------------------------" + } else { + write-host "cc-attest LoadBalancer did not get an external IP in time." -ForegroundColor Yellow + } + } +} + +# ---------- Smoketest cleanup -------------------------------------------------------------------- +if ($smoketest) { + write-host "SMOKETEST MODE: Automatically removing all created resources..." -ForegroundColor Yellow + write-host "Resource group: $resgrp" + write-host "WARNING: RESOURCES ARE NOT RECOVERABLE." -ForegroundColor Red + write-host "Press ANY KEY to cancel deletion, or wait 10 seconds to proceed..." -ForegroundColor Yellow + + $timeout = 10 + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $cancelled = $false + while ($timer.Elapsed.TotalSeconds -lt $timeout) { + if ([Console]::KeyAvailable) { [Console]::ReadKey($true) | Out-Null; $cancelled = $true; break } + Start-Sleep -Milliseconds 100 + $remaining = [math]::Ceiling($timeout - $timer.Elapsed.TotalSeconds) + Write-Host "`rDeletion in $remaining seconds... (Press any key to cancel)" -NoNewline -ForegroundColor Yellow + } + $timer.Stop() + if ($cancelled) { + write-host "`nDeletion cancelled. To clean up later: Remove-AzResourceGroup -Name $resgrp -Force" -ForegroundColor Green + } else { + write-host "`nProceeding with resource deletion..." + Remove-AzResourceGroup -Name $resgrp -Force -AsJob | Out-Null + write-host "Resource group deletion initiated in background." -ForegroundColor Green + } +} else { + write-host "" + write-host "Resources created in resource group: $resgrp" + write-host "To clean up: Remove-AzResourceGroup -Name $resgrp -Force" +} + +$myTimeSpan = New-TimeSpan -Start $startTime -End (Get-Date) +Write-Output ("Execution time was {0} minutes and {1} seconds." -f $myTimeSpan.Minutes, $myTimeSpan.Seconds) diff --git a/aks-samples/azure-voting-app/README.md b/aks-samples/azure-voting-app/README.md new file mode 100644 index 0000000..f7dc2c4 --- /dev/null +++ b/aks-samples/azure-voting-app/README.md @@ -0,0 +1,84 @@ +# Azure Voting App on AKS with AMD SEV-SNP Confidential Computing nodes + +`Deploy-VotingAppCC.ps1` builds a randomly-named AKS cluster with the **smallest possible AMD +SEV-SNP confidential computing node pool** (2 nodes, `Standard_DC2as_v5`) and deploys the public +multi-container [Azure Voting App](https://github.com/Azure-Samples/azure-voting-app-redis) sample +to it, exposed via a public LoadBalancer. + +The script follows the same conventions as [`vm-samples/BuildRandomCVM.ps1`](../../vm-samples/BuildRandomCVM.ps1): +random 5-letter suffix on the basename, full resource-group tagging (owner, BuiltBy, GitRepo, +description, smoketest), CC SKU + AMD CVM vCPU quota preflight, and an optional `-smoketest` flag +that auto-deletes everything once the front-end is verified. + +## What gets created + +| Resource | Detail | +|----------|--------| +| Resource group | `<5 random letters>` with full tagging | +| AKS cluster | 1x `Standard_D2as_v6` system pool, Azure CNI, managed identity, Standard tier | +| CC node pool | `ccpool` - 2x `Standard_DC2as_v5` (AMD SEV-SNP), labelled `workload=confidential` | +| Auto-patching | `--auto-upgrade-channel stable` + `--node-os-upgrade-channel NodeImage` | +| Voting app | `azure-vote-back` (Redis) + `azure-vote-front` (Flask) pinned to CC nodes | +| Public ingress | `azure-vote-front` Service of type `LoadBalancer` on port 80 | + +The front-end pods are pinned to the CC pool via `nodeSelector: workload=confidential`, so the user- +facing workload actually runs inside AMD SEV-SNP TEEs. + +> **Note on the front-end image.** The previously-published image +> `mcr.microsoft.com/azuredocs/azure-vote-front:v1` was removed from MCR. To keep the sample +> self-contained (no ACR / no role assignments / no `--attach-acr` permissions required), the +> script bootstraps the same Flask app at pod startup from the public +> [`Azure-Samples/azure-voting-app-redis`](https://github.com/Azure-Samples/azure-voting-app-redis) +> repo using the public `python:3.9-slim` image. First rollout takes ~2 minutes per pod +> (apt + pip install); subsequent restarts re-bootstrap the same way. + +## Auto-patching policy choices + +The script reflects the recommended Azure Policies for AKS: + +- **`Kubernetes clusters should have auto-upgrade enabled`** → `--auto-upgrade-channel stable` +- **AKS node OS image auto-upgrade** → `--node-os-upgrade-channel NodeImage` (weekly node-image + refresh, no version drift) +- **`Azure Kubernetes Service Clusters should use managed identities`** → `--enable-managed-identity` +- **Standard tier** for the financially-backed uptime SLA on the API server + +These are all GA features and require no preview registrations, so the deployment works first time +in any standard subscription with quota for the AMD CVM family. + +## Usage + +```powershell +# Smoke test - auto-cleans up after success (10s cancel window) +./Deploy-VotingAppCC.ps1 -subsID -basename sgall -smoketest + +# Persistent deployment in a non-default region +./Deploy-VotingAppCC.ps1 -subsID -basename sgall -region westeurope -description "demo" +``` + +### Parameters + +| Parameter | Default | Notes | +|-----------|---------|-------| +| `-subsID` | _required_ | Target subscription ID | +| `-basename` | _required_ | Lowercase letters / digits only (ACR forbids hyphens). 5 random letters are appended. | +| `-region` | `northeurope` | Must support `Standard_DC2as_v5` | +| `-ccVmSize` | `Standard_DC2as_v5` | Smallest AMD SEV-SNP CVM (2 vCPU / 8 GiB) | +| `-systemVmSize` | `Standard_D2as_v6` | Tiny non-CC system pool | +| `-ccNodeCount` | `2` | Minimum the user requested | +| `-description` | _empty_ | Added as a tag on the resource group | +| `-smoketest` | _off_ | Auto-deletes the resource group after the front-end is verified | +| `-SkipSkuPreflight` | _off_ | Skip SKU/quota check (ARM will validate at deploy time) | + +## Prerequisites + +- Azure PowerShell (`Az`) and Azure CLI (`az`), both signed in to the same subscription +- `kubectl` on PATH (the script will install it via `az aks install-cli` if missing) +- AMD CVM (`standardDCASv5Family`) vCPU quota of **at least 4** in the chosen region + +## Cleanup + +If you didn't use `-smoketest`: + +```powershell +Remove-AzResourceGroup -Name <5 random letters> -Force +``` diff --git a/aks-samples/azure-voting-app/attestation/Dockerfile b/aks-samples/azure-voting-app/attestation/Dockerfile new file mode 100644 index 0000000..2455d02 --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/Dockerfile @@ -0,0 +1,36 @@ +# Optional pre-built image for the attestation web UI. +# +# This image is NOT required by Deploy-VotingAppCC.ps1 - that script bootstraps +# the same code at pod startup from a ConfigMap so the sample works without an +# attached ACR. Use this Dockerfile if you want to bake the app into a real +# image and push it to your own registry. +# +# Build: docker build -t /cc-attestation-web:1.0 . +# Run: docker run --rm -p 8080:80 --device=/dev/tpmrm0 +FROM python:3.11-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + CVM_TOOLS_DIR=/opt/cvm-tools/cvm-attestation + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git ca-certificates tpm2-tools \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 https://github.com/Azure/cvm-attestation-tools.git /opt/cvm-tools + +WORKDIR /opt/cvm-tools/cvm-attestation +RUN pip install --no-cache-dir -r requirements.txt + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY app.py /app/app.py +COPY config_snp.json /app/config_snp.json +COPY templates/ /app/templates/ + +EXPOSE 80 +CMD ["python", "/app/app.py"] diff --git a/aks-samples/azure-voting-app/attestation/README.md b/aks-samples/azure-voting-app/attestation/README.md new file mode 100644 index 0000000..c605b3b --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/README.md @@ -0,0 +1,55 @@ +# Runtime attestation web UI for AKS CC nodes + +A Flask web app that wraps [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools) +to perform a **runtime SEV-SNP attestation** against Microsoft Azure Attestation (MAA) and +present the resulting signed token claims with human-readable explanations. + +Deployed automatically by [`../Deploy-VotingAppCC.ps1`](../Deploy-VotingAppCC.ps1) alongside +the voting app, on the same AMD SEV-SNP node pool. + +## What it does + +When the user clicks **Attest**, the pod: + +1. Reads the AMD SEV-SNP attestation report from the node's vTPM (HCL report) via `/dev/tpmrm0`. +2. POSTs the hardware evidence + a freshly generated nonce to the regional MAA endpoint + (auto-discovered from IMDS). +3. Receives a signed JWT and decodes it. +4. Renders every claim in a table with a plain-English explanation of what it means - SEV-SNP + measurement registers, TCB SVNs, VMPL, REPORT_DATA binding, MAA policy hash, etc. + +Because the JWT is signed by MAA after MAA itself verified the hardware evidence, a green +`x-ms-compliance-status: azure-compliant-cvm` is your end-to-end proof that the workload is +running inside a genuine, policy-compliant AMD SEV-SNP TEE in Azure right now. + +## Requirements at runtime + +| Requirement | Why | +|-------------|-----| +| Pod scheduled on an SEV-SNP node | Otherwise the vTPM doesn't expose an SNP HCL report | +| `/dev/tpmrm0` mounted (privileged) | Upstream tool reads the HCL report via tpm2-tools | +| Egress to `*.attest.azure.net` + `169.254.169.254` (IMDS) | MAA endpoint discovery and call | + +The Deploy script handles all of the above. + +## Building a real image (optional) + +The default deployment uses runtime bootstrap (no registry needed). To bake an image instead: + +```bash +docker build -t /cc-attestation-web:1.0 . +docker push /cc-attestation-web:1.0 +``` + +Then edit the Deployment in `Deploy-VotingAppCC.ps1` to use that image and drop the bootstrap +`command`/`args`. + +## Files + +| File | Purpose | +|------|---------| +| `app.py` | Flask app + JWT decode + claim-explanation dictionary | +| `templates/index.html` | Single-page UI with the **Attest** button and result rendering | +| `config_snp.json` | MAA SEV-SNP config consumed by the upstream client (nonce injected per request) | +| `requirements.txt` | Python deps for the Flask app + upstream client | +| `Dockerfile` | Optional pre-built image | diff --git a/aks-samples/azure-voting-app/attestation/app.py b/aks-samples/azure-voting-app/attestation/app.py new file mode 100644 index 0000000..425f7e5 --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/app.py @@ -0,0 +1,332 @@ +""" +SEV-SNP / TDX runtime attestation web UI for AKS Confidential Computing nodes. + +Wraps Azure/cvm-attestation-tools (https://github.com/Azure/cvm-attestation-tools) +to perform a guest attestation against Microsoft Azure Attestation (MAA) and +present the resulting JWT claims with human-readable explanations. + +Expected runtime layout (matches Dockerfile and the in-cluster ConfigMap bootstrap): + + /opt/cvm-tools/cvm-attestation/ <- upstream cvm-attestation-tools clone + /app/app.py <- this file + /app/templates/ <- index.html / result.html + /app/config_snp.json <- MAA SEV-SNP config copied here at startup + +The pod must run with access to /dev/tpmrm0 (TPM Resource Manager) so the upstream +client can fetch the HCL report from the vTPM. +""" + +import base64 +import json +import os +import secrets +import sys +import traceback +from datetime import datetime, timezone + +from flask import Flask, jsonify, render_template, request + +# Make the upstream cvm-attestation package importable. +CVM_TOOLS_DIR = os.environ.get("CVM_TOOLS_DIR", "/opt/cvm-tools/cvm-attestation") +if CVM_TOOLS_DIR not in sys.path: + sys.path.insert(0, CVM_TOOLS_DIR) + +# The upstream tool reads its endpoint table from the current working dir. +os.chdir(CVM_TOOLS_DIR) + +from src.attestation_client import ( # noqa: E402 (import after sys.path tweak) + AttestationClient, + AttestationClientParameters, + Verifier, +) +from src.endpoint_selector import EndpointSelector # noqa: E402 +from src.imds_client import ImdsClient # noqa: E402 +from src.isolation import IsolationType # noqa: E402 +from src.logger import Logger # noqa: E402 + + +# --------------------------------------------------------------------------- +# Claim explanations (MAA SEV-SNP and TDX tokens) +# --------------------------------------------------------------------------- +# References: +# https://learn.microsoft.com/azure/attestation/claim-sets +# https://learn.microsoft.com/azure/attestation/claim-rule-grammar +# AMD SEV-SNP ABI specification (publication 56860) +CLAIM_EXPLANATIONS = { + # Standard JWT + "iss": "Issuer - the MAA endpoint that signed this token. Verifying the issuer URL pins the token to a specific Azure Attestation provider.", + "iat": "Issued At (Unix epoch seconds) - when MAA produced the token.", + "exp": "Expiration (Unix epoch seconds) - after this point the token must not be trusted.", + "nbf": "Not Before (Unix epoch seconds) - the token is not valid earlier than this time.", + "jti": "JWT ID - a unique identifier MAA assigns to this token; useful for replay detection.", + + # MAA generic + "x-ms-ver": "MAA token schema version.", + "x-ms-attestation-type": "Type of TEE that produced the evidence ('sevsnpvm', 'tdxvm', 'azurevm', 'tpm', etc.). On AMD CC AKS nodes the OUTER token is 'azurevm' (Azure HCL envelope) and the INNER x-ms-isolation-tee block reports 'sevsnpvm'.", + "x-ms-compliance-status": "MAA's overall verdict against its policy. 'azure-compliant-cvm' means the platform passed all Azure CVM policy checks.", + "x-ms-policy-hash": "SHA-256 of the MAA policy (base64url) that evaluated this evidence. Pin this in your relying party to detect policy drift.", + "x-ms-policy-signer": "If a custom JWS-signed MAA policy was used, this is the signer's certificate chain.", + "x-ms-runtime": "Caller-supplied runtime data that was bound into the hardware report's REPORT_DATA field. For Azure CVMs this also carries the HCL-generated vTPM keys (HCLAkPub, HCLEkPub) and the VM configuration snapshot.", + "x-ms-inittime": "Init-time data bound into the report (rarely used for IaaS CVMs).", + "x-ms-isolation-tee": "Nested sub-token containing the hardware TEE evidence MAA verified. For SEV-SNP this holds every x-ms-sevsnpvm-* field plus its own x-ms-attestation-type and x-ms-compliance-status. This is the cryptographic root of trust for the whole token.", + "nonce": "Caller-supplied nonce echoed back inside x-ms-runtime to prove freshness.", + "secureboot": "true if UEFI Secure Boot was enabled on the guest at attest time.", + + # SEV-SNP specific + "x-ms-sevsnpvm-authorkeydigest": "SHA-384 of the SEV-SNP author key (AKD). Zero on Azure-managed CVMs unless you bring your own ID/Author key.", + "x-ms-sevsnpvm-bootloader-svn": "SVN of the AMD SEV-SNP guest bootloader at launch.", + "x-ms-sevsnpvm-chip-family": "AMD CPU family that produced this report (e.g. 'Milan' = 3rd-gen EPYC, 'Genoa' = 4th-gen EPYC).", + "x-ms-sevsnpvm-chipid": "Per-chip unique identifier (CHIP_ID) burned into the AMD SP. Lets you identify the exact physical socket the CVM is running on.", + "x-ms-sevsnpvm-ciphertext-hiding-dram-enabled": "true if AMD ciphertext-hiding (CT-Hide) DRAM feature is active (mitigates ciphertext side channels).", + "x-ms-sevsnpvm-cxl-allowed": "Whether CXL.mem devices were permitted for this guest at launch.", + "x-ms-sevsnpvm-familyId": "Family ID supplied at SNP_LAUNCH_FINISH (16 bytes). On Azure CVMs this identifies the Azure VM family.", + "x-ms-sevsnpvm-guestsvn": "Guest Security Version Number stamped into the report. Allows monotonic anti-rollback in your policy.", + "x-ms-sevsnpvm-hostdata": "Host data the hypervisor injected at launch (e.g. a SHA-256 of an external policy). Lets you bind the guest to a specific host configuration.", + "x-ms-sevsnpvm-idkeydigest": "SHA-384 of the SEV-SNP ID Key (IDK). On Azure CC nodes this is the Azure-managed launch ID key digest.", + "x-ms-sevsnpvm-imageId": "Image ID supplied at SNP_LAUNCH_FINISH (16 bytes).", + "x-ms-sevsnpvm-is-debuggable": "true means the guest was launched with the SNP debug policy bit set. For production CVMs this MUST be false.", + "x-ms-sevsnpvm-launchmeasurement": "SHA-384 of the initial guest memory contents measured by AMD-SP at launch. This is the cryptographic identity of the boot image that came up; a relying party pins expected values here.", + "x-ms-sevsnpvm-mem-aes256-xts-required": "Whether AES-256-XTS memory encryption was required (vs the older AES-128 mode).", + "x-ms-sevsnpvm-microcode-svn": "Microcode SVN of the AMD CPU at attestation time.", + "x-ms-sevsnpvm-migration-allowed": "true if the SNP guest policy permits migration between machines. Azure CVMs report false.", + "x-ms-sevsnpvm-page-swap-disabled": "Whether host-initiated page swapping of CVM memory is disabled.", + "x-ms-sevsnpvm-rapl-disabled": "Whether the AMD RAPL (Running Average Power Limit) interface is disabled for this guest (mitigates power side-channels).", + "x-ms-sevsnpvm-reportdata": "Hex of REPORT_DATA - 64 bytes the guest itself supplied when requesting the report. The Azure HCL stuffs a TPM-backed runtime hash here so you can cryptographically link the MAA token to a vTPM nonce.", + "x-ms-sevsnpvm-reportid": "Per-launch report ID assigned by AMD-SP. Different across reboots.", + "x-ms-sevsnpvm-singlesocket": "true if the SNP guest policy required a single-socket host.", + "x-ms-sevsnpvm-smt-allowed": "true means simultaneous multithreading was allowed at launch (per SNP guest policy).", + "x-ms-sevsnpvm-snpfw-svn": "SVN of the AMD SEV-SNP firmware (PSP) at attestation time.", + "x-ms-sevsnpvm-tee-svn": "SVN of the TEE component (always 0 for SEV-SNP today; reserved).", + "x-ms-sevsnpvm-vmpl": "Virtual Machine Privilege Level the report was generated at. Azure CVMs run the OS at VMPL0; reports about the OS itself therefore come from VMPL0.", + + # vTPM / HCL + "x-ms-azurevm-attestation-protocol-ver": "Version of the Azure HCL attestation protocol used to build this token.", + "x-ms-azurevm-attested-pcr-values": "Values of the vTPM PCRs that were quoted and signed inside the HCL report. The relying party can match these against known-good measurements.", + "x-ms-azurevm-attested-pcrs": "List of vTPM PCR indices that contributed to the quote.", + "x-ms-azurevm-bootdebug-enabled": "true if the Windows boot debugger was enabled at boot.", + "x-ms-azurevm-dbvalidated": "true if the UEFI Secure Boot 'db' (allowed signers) database is intact and validated.", + "x-ms-azurevm-dbxvalidated": "true if the UEFI Secure Boot 'dbx' (revoked signers) database is intact and validated.", + "x-ms-azurevm-default-securebootkeysvalidated": "MAA confirmed the default Azure Secure Boot keys are present and validated by the HCL.", + "x-ms-azurevm-debuggersdisabled": "Kernel debuggers are disabled.", + "x-ms-azurevm-elam-enabled": "Early Launch Anti-Malware was active during boot (Windows guests).", + "x-ms-azurevm-flightsigning-enabled": "Whether Windows test/flight signing was permitted.", + "x-ms-azurevm-hvci-policy": "Numeric HVCI (Hypervisor-protected Code Integrity) policy state.", + "x-ms-azurevm-hypervisordebug-enabled": "Whether the hypervisor debugger was enabled.", + "x-ms-azurevm-is-windows": "true if the guest was identified as Windows by the HCL.", + "x-ms-azurevm-kerneldebug-enabled": "Whether kernel debugging was enabled at attest time.", + "x-ms-azurevm-osbuild": "Reported guest OS build string.", + "x-ms-azurevm-osdistro": "Reported Linux distribution (e.g. 'Ubuntu', 'Mariner').", + "x-ms-azurevm-ostype": "Linux or Windows.", + "x-ms-azurevm-osversion-major": "Guest OS major version.", + "x-ms-azurevm-osversion-minor": "Guest OS minor version.", + "x-ms-azurevm-signingdisabled": "Whether driver signing enforcement was disabled.", + "x-ms-azurevm-testsigning-enabled": "Whether unsigned/test-signed binaries were permitted.", + "x-ms-azurevm-vmid": "Azure VM ID (GUID) reported by IMDS at attest time. Useful for correlating with control-plane logs.", + + # TDX (in case the workload runs on Intel TDX nodes) + "x-ms-tdxvm-tdreport": "Raw Intel TD-Report fields measured by Intel TDX Module.", + "x-ms-tdxvm-mrtd": "Measurement of the initial TD - the TDX equivalent of LAUNCH_MEASUREMENT.", + "x-ms-tdxvm-rtmrs": "Runtime extendable measurement registers (RTMR0..RTMR3).", +} + + +def _explain(claim_key: str) -> str: + """Return a human explanation for a claim key, or a generic fallback.""" + if claim_key in CLAIM_EXPLANATIONS: + return CLAIM_EXPLANATIONS[claim_key] + if claim_key.startswith("x-ms-sevsnpvm-"): + return "SEV-SNP attestation report field surfaced by MAA. See AMD SEV-SNP ABI spec for the underlying bit layout." + if claim_key.startswith("x-ms-tdxvm-"): + return "Intel TDX attestation field surfaced by MAA." + if claim_key.startswith("x-ms-azurevm-"): + return "Azure HCL / VM-level claim derived from the vTPM-anchored runtime report." + if claim_key.startswith("x-ms-"): + return "MAA-issued claim. Refer to the Microsoft Azure Attestation claim-set documentation." + return "Standard JWT or caller-supplied claim." + + +# --------------------------------------------------------------------------- +# JWT helpers (display only - no signature verification here; MAA already +# verified the hardware evidence and signed the token) +# --------------------------------------------------------------------------- +def _b64url_decode(segment: str) -> bytes: + pad = "=" * (-len(segment) % 4) + return base64.urlsafe_b64decode(segment + pad) + + +def decode_jwt(token): + if isinstance(token, bytes): + token = token.decode("utf-8") + parts = token.split(".") + if len(parts) != 3: + raise ValueError(f"Token does not have 3 segments (got {len(parts)})") + header = json.loads(_b64url_decode(parts[0])) + payload = json.loads(_b64url_decode(parts[1])) + return header, payload + + +def _format_timestamp(value): + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc).isoformat() + except Exception: + return None + + +def annotate_claims(payload: dict): + """Flatten the payload into [{key, value, explanation, timestamp?, section}] rows. + + The MAA 'guest' attestation token for AMD CC AKS nodes nests every SEV-SNP claim + under x-ms-isolation-tee. We surface that subtree as its own section so each + sevsnpvm-* field gets its own explained row instead of being one giant JSON blob. + """ + rows = [] + + def add_row(key, value, section): + row = { + "key": key, + "value": value, + "explanation": _explain(key), + "section": section, + } + if key in {"iat", "exp", "nbf"}: + row["timestamp"] = _format_timestamp(value) + rows.append(row) + + isolation_tee = None + for key, value in payload.items(): + if key == "x-ms-isolation-tee" and isinstance(value, dict): + isolation_tee = value + # Still surface the parent so its purpose is explained. + add_row(key, "(see SEV-SNP / Hardware TEE section below)", "Outer MAA token") + continue + add_row(key, value, "Outer MAA token") + + if isolation_tee is not None: + for key, value in isolation_tee.items(): + add_row(key, value, "x-ms-isolation-tee (SEV-SNP hardware TEE evidence)") + + # Sort: hardware TEE block first, then outer; within each, x-ms-* first. + section_order = { + "x-ms-isolation-tee (SEV-SNP hardware TEE evidence)": 0, + "Outer MAA token": 1, + } + rows.sort(key=lambda r: (section_order.get(r["section"], 2), not r["key"].startswith("x-ms-"), r["key"])) + return rows + + +# --------------------------------------------------------------------------- +# Attestation invocation +# --------------------------------------------------------------------------- +def perform_attestation(user_nonce: str | None): + """Run a guest attestation and return (jwt_token, header, payload, hw_evidence_summary).""" + logger = Logger("cc-attestation-web").get_logger() + + config_path = os.environ.get("ATTEST_CONFIG", "/app/config_snp.json") + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + nonce = user_nonce or secrets.token_hex(16) + config.setdefault("claims", {}).setdefault("user-claims", {})["nonce"] = nonce + + isolation_type = { + "maa_snp": IsolationType.SEV_SNP, + "maa_tdx": IsolationType.TDX, + "maa_trusted_launch": IsolationType.TRUSTED_LAUNCH, + }.get(config.get("attestation_provider"), IsolationType.UNDEFINED) + + # Resolve the regional MAA endpoint from IMDS (matches the upstream CLI). + imds_client = ImdsClient(logger) + region = imds_client.get_region_from_compute_metadata() + if not region: + raise RuntimeError("Unable to read Azure region from IMDS - is this pod on an Azure VM?") + region = region.replace(" ", "").lower() + table = "attestation_uri_table_usgov.json" if "usgov" in region else "attestation_uri_table.json" + endpoint = EndpointSelector(os.path.join(CVM_TOOLS_DIR, table), logger).get_attestation_endpoint( + isolation_type, "guest", region + ) + + params = AttestationClientParameters( + endpoint=endpoint, + verifier=Verifier.MAA, + isolation_type=isolation_type, + claims=config.get("claims"), + api_key=config.get("api_key", ""), + ) + client = AttestationClient(logger, params) + token = client.attest_guest() + if isinstance(token, bytes): + token = token.decode("utf-8") + + header, payload = decode_jwt(token) + + hw_summary = {} + try: + evidence = client.get_hardware_evidence() + if evidence is not None and getattr(evidence, "hardware_report", None): + hw_summary["hardware_report_bytes"] = len(evidence.hardware_report) + hw_summary["hardware_report_sha256"] = __import__("hashlib").sha256( + evidence.hardware_report + ).hexdigest() + if evidence is not None and getattr(evidence, "runtime_data", None): + try: + hw_summary["runtime_data"] = json.loads(evidence.runtime_data) + except Exception: + hw_summary["runtime_data_raw_bytes"] = len(evidence.runtime_data) + except Exception: + # Hardware evidence is best-effort; the token is the source of truth. + pass + + return { + "endpoint": endpoint, + "region": region, + "isolation_type": isolation_type.name if hasattr(isolation_type, "name") else str(isolation_type), + "nonce": nonce, + "token": token, + "header": header, + "payload": payload, + "claims": annotate_claims(payload), + "hardware_evidence": hw_summary, + } + + +# --------------------------------------------------------------------------- +# Flask app +# --------------------------------------------------------------------------- +app = Flask(__name__) + + +@app.route("/", methods=["GET"]) +def index(): + return render_template( + "index.html", + node_name=os.environ.get("NODE_NAME", ""), + pod_name=os.environ.get("POD_NAME", ""), + ) + + +@app.route("/healthz", methods=["GET"]) +def healthz(): + return "ok", 200 + + +@app.route("/api/attest", methods=["POST"]) +def api_attest(): + user_nonce = (request.json or {}).get("nonce") if request.is_json else request.form.get("nonce") + try: + result = perform_attestation(user_nonce or None) + except Exception as exc: + return ( + jsonify( + { + "ok": False, + "error": str(exc), + "trace": traceback.format_exc(), + } + ), + 500, + ) + return jsonify({"ok": True, **result}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80"))) diff --git a/aks-samples/azure-voting-app/attestation/config_snp.json b/aks-samples/azure-voting-app/attestation/config_snp.json new file mode 100644 index 0000000..dfbb734 --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/config_snp.json @@ -0,0 +1,11 @@ +{ + "attestation_url": "", + "attestation_provider": "maa_snp", + "api_key": "", + "enable_metrics": false, + "claims": { + "user-claims": { + "nonce": "placeholder-replaced-per-request" + } + } +} diff --git a/aks-samples/azure-voting-app/attestation/manifest.yaml b/aks-samples/azure-voting-app/attestation/manifest.yaml new file mode 100644 index 0000000..858a293 --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/manifest.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cc-attest + labels: { app: cc-attest } +spec: + replicas: 1 + selector: { matchLabels: { app: cc-attest } } + template: + metadata: + labels: { app: cc-attest } + spec: + nodeSelector: + kubernetes.io/os: linux + workload: confidential + containers: + - name: cc-attest + image: docker.io/library/python:3.11-slim + securityContext: + privileged: true + env: + - name: POD_NAME + valueFrom: { fieldRef: { fieldPath: metadata.name } } + - name: NODE_NAME + valueFrom: { fieldRef: { fieldPath: spec.nodeName } } + - name: CVM_TOOLS_DIR + value: /opt/cvm-tools/cvm-attestation + ports: + - containerPort: 80 + command: ["bash","-c"] + args: + - | + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends git ca-certificates tpm2-tools >/dev/null + rm -rf /opt/cvm-tools + git clone --depth 1 https://github.com/Azure/cvm-attestation-tools.git /opt/cvm-tools + cd /opt/cvm-tools/cvm-attestation + pip install --no-cache-dir -r requirements.txt >/dev/null + pip install --no-cache-dir flask >/dev/null + mkdir -p /app/templates + cp /etc/attest-app/app.py /app/app.py + cp /etc/attest-app/config_snp.json /app/config_snp.json + cp /etc/attest-app/index.html /app/templates/index.html + exec python /app/app.py + volumeMounts: + - { name: tpmrm, mountPath: /dev/tpmrm0 } + - { name: tpm0, mountPath: /dev/tpm0 } + - { name: securityfs, mountPath: /sys/kernel/security, readOnly: true } + - { name: app, mountPath: /etc/attest-app } + startupProbe: + httpGet: { path: /healthz, port: 80 } + initialDelaySeconds: 15 + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: { path: /healthz, port: 80 } + periodSeconds: 10 + failureThreshold: 6 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { cpu: 1, memory: 1Gi } + volumes: + - name: tpmrm + hostPath: { path: /dev/tpmrm0, type: CharDevice } + - name: tpm0 + hostPath: { path: /dev/tpm0, type: CharDevice } + - name: securityfs + hostPath: { path: /sys/kernel/security, type: Directory } + - name: app + configMap: + name: cc-attest-app +--- +apiVersion: v1 +kind: Service +metadata: + name: cc-attest +spec: + type: LoadBalancer + selector: { app: cc-attest } + ports: + - port: 80 + targetPort: 80 diff --git a/aks-samples/azure-voting-app/attestation/requirements.txt b/aks-samples/azure-voting-app/attestation/requirements.txt new file mode 100644 index 0000000..6cf3941 --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/requirements.txt @@ -0,0 +1,6 @@ +flask>=2.3 +requests +click +pyjwt +cryptography +construct diff --git a/aks-samples/azure-voting-app/attestation/templates/index.html b/aks-samples/azure-voting-app/attestation/templates/index.html new file mode 100644 index 0000000..b6a8ef0 --- /dev/null +++ b/aks-samples/azure-voting-app/attestation/templates/index.html @@ -0,0 +1,203 @@ + + + + + AKS CC - Runtime Attestation + + + + +
+ +

AKS Confidential Computing - Runtime Attestation

+

This pod is running on an AMD SEV-SNP confidential node. Click Attest to fetch a fresh hardware-rooted attestation token from Microsoft Azure Attestation (MAA) and inspect the claims.

+
+
+
+

Pod: {{ pod_name or 'unknown' }}   Node: {{ node_name or 'unknown' }}

+

The attestation flow uses Azure/cvm-attestation-tools to:

+
    +
  1. Read the AMD SEV-SNP attestation report from the node's vTPM (HCL report).
  2. +
  3. Send the hardware evidence + a fresh nonce to the regional MAA endpoint.
  4. +
  5. Receive a signed JWT whose claims describe the TEE state at this exact moment.
  6. +
+

+ + +

+
+ +
+
+ + + + diff --git a/aks-samples/azure-voting-app/vote-front-patch.yaml b/aks-samples/azure-voting-app/vote-front-patch.yaml new file mode 100644 index 0000000..b9f710b --- /dev/null +++ b/aks-samples/azure-voting-app/vote-front-patch.yaml @@ -0,0 +1,36 @@ +spec: + replicas: 2 + template: + spec: + containers: + - name: azure-vote-front + image: docker.io/library/python:3.9-slim + command: ["bash","-c"] + args: + - | + set -e + apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates >/dev/null + rm -rf /src + git clone --depth 1 https://github.com/Azure-Samples/azure-voting-app-redis /src + rm -rf /app && mkdir -p /app + cp -r /src/azure-vote/azure-vote/. /app/ + cd /app + pip install --no-cache-dir flask redis >/dev/null + exec python -c "import sys; sys.path.insert(0,'.'); from main import app; app.run(host='0.0.0.0', port=80)" + env: + - name: REDIS + value: azure-vote-back + ports: + - containerPort: 80 + startupProbe: + httpGet: { path: /, port: 80 } + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: { path: /, port: 80 } + periodSeconds: 10 + failureThreshold: 6 + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 500m, memory: 512Mi } diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index fc99adc..ff11259 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -10,7 +10,7 @@ # # Clone this repo to a folder (relies on the WindowsAttest.ps1 script being in the same folder as this script) # -# Usage: ./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-policyFilePath ] [-DisableBastion] +# Usage: ./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-policyFilePath ] [-DisableBastion] [-SkipSkuPreflight] # # Basename is a prefix for all resources created, it's used to create unique names for the resources # osType specifies which OS to deploy: Windows (Server 2022), Windows11 (Windows 11 Enterprise), Ubuntu (24.04), or RHEL (9.5) @@ -38,7 +38,8 @@ param ( [Parameter(Mandatory=$false)]$region = "northeurope", [Parameter(Mandatory=$false)]$vmsize = "Standard_DC2as_v5", [Parameter(Mandatory=$false)]$policyFilePath = "", - [Parameter(Mandatory=$false)][switch]$DisableBastion + [Parameter(Mandatory=$false)][switch]$DisableBastion, + [Parameter(Mandatory=$false)][switch]$SkipSkuPreflight ) if ($subsID -eq "" -or $basename -eq "" -or $osType -eq "") { @@ -130,6 +131,14 @@ $ownername = $tmp.Account.Id # a different isolation model and not supported by this script), is offered in the chosen region and not # restricted for this subscription, and that there's enough vCPU quota left in the SKU's family before # we start creating resources. +# Note: Get-AzComputeResourceSku and Get-AzVMUsage have been observed to misreport NotAvailableForSubscription / 0 quota +# even when ARM accepts the deployment (e.g. Standard_DC*as_v6 in koreacentral). Use -SkipSkuPreflight to bypass. +if ($SkipSkuPreflight) { + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "Pre-flight check SKIPPED (-SkipSkuPreflight). ARM will validate '$vmSize' in '$region' at deploy time." -ForegroundColor Yellow + write-host "----------------------------------------------------------------------------------------------------------------" +} +else { write-host "----------------------------------------------------------------------------------------------------------------" write-host "Pre-flight check: confirming '$vmSize' is available in '$region' with sufficient quota..." -ForegroundColor Cyan @@ -219,6 +228,7 @@ try { write-host "Pre-flight check passed: '$vmSize' is available and within quota in '$region'." -ForegroundColor Green write-host "----------------------------------------------------------------------------------------------------------------" +} # Create Resource Group $resourceGroupTags = @{ diff --git a/vm-samples/README.md b/vm-samples/README.md index 28c91f3..8d4008c 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -75,6 +75,16 @@ Get-AzComputeResourceSku -Location '' | Where-Object { $_.ResourceType - Get-AzVMUsage -Location '' | Where-Object { $_.Name.Value -match 'DCa|DCe|ECa|ECe|cores' } | Format-Table -AutoSize ``` +#### Bypassing the pre-flight check: `-SkipSkuPreflight` + +`Get-AzComputeResourceSku` and `Get-AzVMUsage` have been observed to return false negatives in some subscription/region combinations — for example, `Standard_DC2as_v6` in `koreacentral` is reported as `NotAvailableForSubscription` (Restriction Type=Zone, all zones) with `standardDCasv6Family` quota shown as `0/10`, yet a raw `New-AzVM` deployment in that region succeeds and the VM runs normally. When you have evidence (or a deployment from another tool) that the SKU works, pass `-SkipSkuPreflight` to skip the entire SKU + quota block and let ARM validate at deploy time: + +```powershell +./BuildRandomCVM.ps1 -subsID "" -basename "kc" -osType "Windows" -region "koreacentral" -vmsize "Standard_DC2as_v6" -DisableBastion -SkipSkuPreflight +``` + +The script prints a yellow notice when the pre-flight is skipped so it's clear in logs that ARM (not the script) is the source of truth for SKU availability on that run. + Use at your own risk, no warranties implied. ## Usage