diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7402011..8613e67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Test certificate setup without Azure access + shell: pwsh + run: ./setup/tests/Certificates.Tests.ps1 - name: Compile Bicep without deploying shell: pwsh run: | diff --git a/README.md b/README.md index 6736c0c..4340103 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ from the same commit. Customers select a language, provider, SMS or voice, Globa and a resource prefix, then approve one complete plan. Manual Step 1 only creates the dedicated app registration; PowerShell configures its service principals, `Epp.Invoke`, Microsoft caller access, Graph `Application.Read.All`, the provider-tenant allowlist preview, encryption certificate, and -Easy Auth. The home tenant remains allowed by Entra. Policy activation remains manual. +Easy Auth. The encryption certificate is issued inside Key Vault after infrastructure deployment; +setup downloads only its public certificate and pins the Function to its PEM secret version. +Certificate renewal and policy activation remain manual. The home tenant remains allowed by Entra. ## Download a Function ZIP @@ -148,7 +150,7 @@ how code accesses configuration, not the environment-variable names. |---|---|---| | `AzureWebJobsStorage` | Functions host storage | Local sample: `UseDevelopmentStorage=true` with Azurite running. Configure Azure host storage separately for the selected plan. | | `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`. Choose the value matching your implementation. | -| `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. | +| `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. Guided setup pins the PEM backing secret of its Key Vault certificate. | | `EPP_ENCRYPTION_KEY_ID` | Optional | Expected encryption key ID; mismatch only produces an advisory warning. | | `EPP_PROVIDER_NAME` | Live delivery | Selected adapter's manifest ID. No default provider. | | `EPP_PROVIDER_ENDPOINT` | Live delivery | Complete provider-approved HTTPS request URL selected from the provider profile. | @@ -188,6 +190,10 @@ work without credential acquisition. No extra refresh app settings are required. Core Tools does not resolve Azure Key Vault reference expressions locally. Supply the local test PEM or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=https://.vault.azure.net/secrets//)` for `EPP_DECRYPTION_KEY_PEM` in Azure app settings, where the platform resolves it. +Guided setup instead uses a **versioned** reference to +`secrets/phone-provider-encryption/`, containing a certificate and its exportable RSA private +key in PEM format. A new Key Vault certificate version does not automatically switch the Function +or update Entra. See [certificate lifecycle](setup/docs/README.md#encryption-certificate-lifecycle). Configure inbound issuer/audience/caller trust in **Easy Auth**, not these application variables. Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data and never override the diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index bada50b..5c19213 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -67,6 +67,12 @@ verified before any plaintext is used. Decrypted plaintext = `DeliveryContext`: The original compact JWE is passed unchanged to the JOSE library. Parsing header fields for the advisory key-ID check must not replace the original protected-header bytes used for authentication. +`EPP_DECRYPTION_KEY_PEM` accepts a private-key PEM alone or a PEM certificate bundle containing the +private key, in plain text or base64 form. Guided setup issues the certificate inside Key Vault and +pins a reference to its PEM backing secret version. Certificate renewal is manual and does not +automatically update Entra or select another decryption key; see the +[certificate lifecycle](../setup/docs/README.md#encryption-certificate-lifecycle). + All three HTTP-handler suites use [shared policy cases](../tests/fixtures/contract.json): the allowed pair succeeds, while `RSA-OAEP`, `A128GCM` and `A256CBC-HS512` alternatives return `400 decryption_failed` without provider I/O. Decryption uses the same policy before live/evaluation branching, so the matrix diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 8462181..0258118 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -25,6 +25,10 @@ Use [CONTRACT.md](CONTRACT.md) for the full request contract and production limi preview to its home tenant plus the selected provider tenant. It then deploys the Function and configures Easy Auth. It does not purchase the provider offer, grant provider API consent/roles, or activate the EPP policy. + The encryption certificate is issued inside Key Vault, not on the setup workstation. Setup + registers its public certificate in Entra and pins the Function to its PEM backing secret version. + Renewal remains manual; see the [certificate lifecycle](../setup/docs/README.md#encryption-certificate-lifecycle) + for same-key renewal and the separate Entra update. 3. **Complete provider authentication and settings.** diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 88e001c..155965a 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using Xunit; @@ -7,6 +8,31 @@ namespace Epp.Otp.Tests; public class EnvelopeTests { + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void KeyVaultPemCertificateBundleDecrypts(bool certificateFirst, bool base64Encoded) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=EPP-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1)); + var publicPem = certificate.ExportCertificatePem(); + var privatePem = rsa.ExportPkcs8PrivateKeyPem(); + var bundle = certificateFirst ? $"{publicPem}\n{privatePem}" : $"{privatePem}\n{publicPem}"; + var env = new TestEnv + { + ["EPP_DECRYPTION_KEY_PEM"] = base64Encoded ? Convert.ToBase64String(Encoding.UTF8.GetBytes(bundle)) : bundle + }; + var provider = new EnvJweKeyProvider(env); + using var imported = provider.GetPrivateKey("test-key"); + var compact = Jose.JWT.Encode("{\"nonce\":\"test-nonce\"}", rsa, + Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); + Assert.Equal("test-nonce", new JweDecryptor(provider).Decrypt(compact).Context.Nonce); + Assert.Same(imported, provider.GetPrivateKey("test-key")); + } + [Theory] [InlineData("\"channel\":1,\"mode\":2,\"ttlSeconds\":60", "sms", 2, 60)] [InlineData("\"channel\":\"VOICE\",\"mode\":\"Live\"", "voice", 1, null)] diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 5836d9c..661c2a4 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -230,6 +230,21 @@ test('JWE authenticates the original protected-header bytes, not reserialized JS assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); }); +test('evaluation accepts a Key Vault PEM certificate bundle in either order and base64 form', async () => { + const certificate = require('node:tls').rootCertificates[0]; + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + for (const bundle of [`${certificate}\n${pem}`, `${pem}\n${certificate}`]) { + for (const value of [bundle, Buffer.from(bundle).toString('base64')]) { + process.env.EPP_DECRYPTION_KEY_PEM = value; + const result = await invoke(await envelope({ mode: 'evaluation' })); + assert.equal(result.status, 200); + assert.equal(result.jsonBody.nonce, delivery.nonce); + } + } + assert.equal(getSecret.mock.callCount(), 0); + assert.equal(fetchMock.mock.callCount(), 0); +}); + test('evaluation decrypts without provider config or I/O and checks the advisory key ID', async () => { for (const key of ['EPP_PROVIDER_NAME', 'EPP_PROVIDER_ENDPOINT', 'KEY_VAULT_URL']) delete process.env[key]; for (const expectedKeyId of ['', 'PRIVATE-KID', 'private-kid']) { diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index fe5e50a..2ef50e2 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -1,6 +1,7 @@ import base64 import json import logging +from datetime import datetime, timedelta, timezone from concurrent.futures import ThreadPoolExecutor from pathlib import Path from threading import Event @@ -10,6 +11,9 @@ import azure.functions as func import pytest from jwcrypto import jwe, jwk +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.x509.oid import NameOID import function_app import src.dispatch as dispatch_module @@ -161,6 +165,28 @@ def test_jwe_authenticates_original_protected_header_bytes(): dispatch_module.requests.request.assert_not_called() +@pytest.mark.parametrize("certificate_first", [True, False]) +@pytest.mark.parametrize("base64_encoded", [True, False]) +def test_evaluation_accepts_key_vault_pem_bundle(monkeypatch, certificate_first, base64_encoded): + private_pem = _PRIVATE_PEM.encode() + private_key = serialization.load_pem_private_key(private_pem, password=None) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "EPP-test")]) + certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject) + .public_key(private_key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=1)) + .sign(private_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM)) + bundle = certificate + private_pem if certificate_first else private_pem + certificate + value = base64.b64encode(bundle).decode() if base64_encoded else bundle.decode() + monkeypatch.setattr(function_app, "_key_provider", + dispatch_module.make_key_provider({"EPP_DECRYPTION_KEY_PEM": value})) + response = _HANDLER(_request(_envelope(mode="evaluation"))) + assert response.status_code == 200 + assert json.loads(response.get_body())["nonce"] == _NONCE + dispatch_module.requests.request.assert_not_called() + function_app._engine._resolve_credential.assert_not_called() + + def test_evaluation_decrypts_without_provider_configuration_or_work(monkeypatch, caplog): caplog.set_level(logging.INFO) monkeypatch.setenv("EPP_ENCRYPTION_KEY_ID", "configured-key-id") diff --git a/setup/docs/README.md b/setup/docs/README.md index 6c015da..3a2bf88 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -51,8 +51,9 @@ disclosed outbound managed-identity federated credential. ## Prerequisites for Step 2 -- **Windows with PowerShell 7+**. Certificate generation/reuse uses the current user's Windows - certificate store; this is not an Azure Cloud Shell or Linux customer deployment script. +- **Windows with PowerShell 7+** is the supported customer deployment environment. Certificate + issuance now happens inside Key Vault, without Windows certificate cmdlets or the local certificate + store. End-to-end deployment from Linux or Azure Cloud Shell has not been validated. - Azure CLI **2.48.1+** on `PATH`, with access to GitHub, Azure, Microsoft Graph, and Key Vault. Setup installs the Azure CLI Bicep component after confirmation when it is missing. Azure CLI itself must be installed before running the script. Python additionally needs network access to SCM. @@ -60,7 +61,9 @@ disclosed outbound managed-identity federated credential. `Microsoft.Graph.Applications`. Setup installs missing 2.x+ modules from PSGallery for CurrentUser after a separate confirmation. - An Azure **user** account permitted to deploy at subscription scope, create the listed resources, - and create the scoped Azure role assignments. + and create the scoped Azure role assignments. Bicep grants the operator **Key Vault Certificates + Officer** for issuance and **Key Vault Secrets Officer** for provider credentials, + scoped to this deployment's vault. - A Microsoft Entra **Privileged Role Administrator** for granting the Microsoft first-party service principal Graph `Application.Read.All`, plus delegated Graph scopes `User.Read`, `Application.ReadWrite.All`, `Application.Read.All`, and `AppRoleAssignment.ReadWrite.All`. @@ -186,23 +189,65 @@ outbound managed identity, diagnostics, Easy Auth, and scoped role assignments. access uses managed identity, not account keys or SAS. Telemetry uses the system identity; the outbound identity is selected explicitly, not through a global `AZURE_CLIENT_ID`. -The Function starts with public ingress disabled. Setup stores the private key in Key Vault and -configures application trust. It **reads back and verifies Easy Auth before enabling ingress**. +The Function starts with public ingress disabled. After Bicep creates the vault and permissions, +setup asks Key Vault to issue or reuse `phone-provider-encryption`, a self-signed, exportable RSA-2048 +certificate. Its subject and Entra certificate display name are both **`CN=ExternalPhoneProvider`**, +without an application ID, resource prefix, or thumbprint in the name. Setup registers the public +certificate in Entra with `Usage=Encrypt` and pins `EPP_DECRYPTION_KEY_PEM` to its **versioned PEM +backing secret**. It **reads back and verifies Easy Auth before enabling ingress**. Python requires this access for its Entra-authenticated SCM remote build; SCM basic authentication stays disabled. Setup validates the built Python payload, stores it in private Blob storage, and switches to managed-identity run-from-package. It never mounts the unbuilt Python source ZIP. For every language, setup restarts, synchronizes triggers, and verifies that `SendOtp` is registered. On publication/startup failure it disables public ingress again; failure to close ingress is reported explicitly rather than hidden. +App-setting changes refresh Key Vault references through App Service. Setup does not separately poll +secret-resolution status; the required deployed evaluation request verifies decryption before policy activation. The public certificate and a timestamped identifier summary are saved to `epp-output` beside the downloaded script, or to `-OutputDirectory`. -Private keys remain in the user's certificate store and Key Vault, not in that summary. +The summary includes certificate/secret version identifiers, thumbprint, expiry, and manual renewal +mode. Setup never downloads, writes, or imports the private key locally: only the Function receives +it through its managed-identity Key Vault reference. Certificate creation automatically supplies the +backing secret; setup no longer writes a separate `phone-provider-decryption-key` secret. For unattended runs, supply every input, authenticate both clients first, and explicitly authorize the whole displayed plan with **both** `-NonInteractive -ApproveDeployment`. `-NonInteractive` alone never approves changes. There is no `-Stage`, `-Resume`, `-ConfigPath`, or policy-approval switch. +### Encryption certificate lifecycle + +The issuance policy uses **12-month validity, key reuse, and manual renewal**. It specifies +`EmailContacts` 30 days before expiry, **not `AutoRenew`**. Email is sent only if the customer +separately configures Key Vault certificate contacts; setup does not create contacts or guarantee +notifications. Track the saved expiry and arrange renewal before the certificate expires. + +Reruns reuse a valid matching cloud certificate. Only a certificate-not-found response triggers +`az keyvault certificate create`; Azure CLI waits for self-signed issuance. Other errors, including +pending-operation conflicts, stop setup rather than starting a custom recovery workflow. Disabled, +incompatible, or near-expiry certificates also stop setup. Keep more than 30 days of validity remaining. +Certificates issued with an earlier per-application subject require a coordinated manual reissuance +with `CN=ExternalPhoneProvider`, retaining the same RSA key. Renaming the Entra display name alone +does not change the signed certificate's subject. + +For a planned renewal, coordinate with the EPP owner, create a new version in Key Vault using the same +policy with **reuse key enabled**, then rerun setup before the old certificate expires. Setup verifies +that the RSA public key still matches every registered encryption credential, pins the Function to the +new secret version, and adds the renewed public certificate to Entra while preserving existing +credentials. It does not automatically remove old versions or Entra credentials. Validate evaluation +requests before an administrator retires old credentials through the supported EPP procedure. + +Key Vault renewal alone does **not** update the uploaded Entra certificate or its expiration. +Version-pinning deliberately prevents an unattended secret switch. Do not enable `AutoRenew` or +generate a different RSA key without implementing coordinated Entra updates and overlapping +decryption-key support. The Function still decrypts in-process with a single private key. + +**Existing local-certificate deployments are not automatically migrated.** Coordinate migration with +the EPP owner before running this setup on an active endpoint. After infrastructure deployment, +setup refuses to update Entra or the Function's encryption settings if the public key differs from +an existing encryption credential. This is not a pre-deployment migration check: resources may already +be updated and ingress disabled when it stops. Do not delete encryption credentials to bypass it. + ### Source versioning `-SourceRepository` defaults to `Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample`. @@ -217,6 +262,20 @@ redirect execution to another script. Download failures stop setup, and temporar removed on completion or failure. Select only a repository whose code you trust: its supporting PowerShell is executed locally. +### Offline setup checks + +From the repository root, run the certificate regression suite without Azure sign-in or resource +changes, then compile the infrastructure: + +```powershell +pwsh -NoProfile -File .\setup\tests\Certificates.Tests.ps1 +az bicep build --file .\setup\infra\main.bicep --outfile "$env:TEMP\epp-main.json" +``` + +The focused tests replace certificate/Graph calls and verify creation, reuse, errors, naming, key +mismatch, and versioned settings. CI runs them on Windows and Linux. They do not simulate the full +deployment or certify live RBAC propagation, Key Vault issuance, Entra behavior, or provider delivery. + ## Step 3 - manually validate and activate policy 1. Save the Step 2 summary and confirm its tenant, application client ID, endpoint URL, encryption diff --git a/setup/infra/resources.bicep b/setup/infra/resources.bicep index 72dbd7c..b106260 100644 --- a/setup/infra/resources.bicep +++ b/setup/infra/resources.bicep @@ -37,6 +37,7 @@ var queueDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization var tableDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') var keyVaultSecretsUserRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') var keyVaultSecretsOfficerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') +var keyVaultCertificatesOfficerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a4417e6f-fecd-4de8-b567-7b0420556985') var monitoringMetricsPublisherRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { @@ -180,7 +181,7 @@ resource appSettings 'Microsoft.Web/sites/config@2024-04-01' = { APPLICATIONINSIGHTS_CONNECTION_STRING: insights.properties.ConnectionString APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'Authorization=AAD' KEY_VAULT_URL: vault.properties.vaultUri - EPP_DECRYPTION_KEY_PEM: '@Microsoft.KeyVault(SecretUri=${vault.properties.vaultUri}secrets/phone-provider-decryption-key)' + // Setup pins the encryption settings after Key Vault issues the certificate. EPP_OUTBOUND_CLIENT_ID: applicationId EPP_OUTBOUND_MI_CLIENT_ID: outboundIdentity.properties.clientId EPP_EXPECTED_AUDIENCE: audience @@ -280,6 +281,16 @@ resource vaultWriteRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { } } +resource vaultCertificateRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, deployerObjectId, keyVaultCertificatesOfficerRoleId) + scope: vault + properties: { + principalId: deployerObjectId + principalType: 'User' + roleDefinitionId: keyVaultCertificatesOfficerRoleId + } +} + resource metricsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(insights.id, functionApp.id, monitoringMetricsPublisherRoleId) scope: insights diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 index 08ed495..f2dd15d 100644 --- a/setup/support/Epp.Setup.psm1 +++ b/setup/support/Epp.Setup.psm1 @@ -6,6 +6,8 @@ $script:MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' $script:MicrosoftGraphApplicationReadAllRoleId = '9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30' $script:EppInvokeAppRoleId = 'ddf32018-9212-41c7-b73c-f5dfe73a2f24' $script:EppInvokeAppRoleValue = 'Epp.Invoke' +$script:EppCertificateName = 'phone-provider-encryption' +$script:EppCertificateSubject = 'CN=ExternalPhoneProvider' $script:GraphRequiredScopes = @('User.Read', 'Application.ReadWrite.All', 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All') . (Join-Path $PSScriptRoot 'Epp.Packages.ps1') @@ -733,13 +735,8 @@ function Connect-EppContext { if ($NonInteractive -and $ForceAuthentication) { throw '-ForceAuthentication requires interactive device-code sign-in and cannot be combined with -NonInteractive.' } - foreach ($command in @('az', 'New-SelfSignedCertificate', 'Export-Certificate')) { - if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { - if ($command -eq 'az') { - throw "Azure CLI is not installed or not on PATH. Install Azure CLI, open a new PowerShell 7 window, and rerun setup." - } - throw "Missing Windows certificate command '$command'. Run setup in PowerShell 7 on Windows." - } + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw "Azure CLI is not installed or not on PATH. Install Azure CLI, open a new PowerShell 7 window, and rerun setup." } $cliVersion = Invoke-EppAz version --output json | ConvertFrom-Json if ([Version]$cliVersion.'azure-cli' -lt [Version]'2.48.1') { @@ -883,6 +880,9 @@ function Show-EppPlan { Write-Host ' Storage Queue Data Contributor and Storage Table Data Contributor - use Azure Functions host storage.' Write-Host ' Key Vault Secrets User - read provider credentials and the encryption private key.' Write-Host ' Monitoring Metrics Publisher - publish platform metrics.' + Write-Host ' - Setup operator, scoped to this Key Vault:' + Write-Host ' Key Vault Certificates Officer - issue and inspect the encryption certificate.' + Write-Host ' Key Vault Secrets Officer - manage provider credentials.' if ($Context.Application.SignInAudience -ne 'AzureADMultipleOrgs') { Write-Host ' - Change the endpoint application from single-tenant to organizational multi-tenant.' } @@ -899,7 +899,10 @@ function Show-EppPlan { Write-Host ' - Restrict the multi-tenant endpoint application to the customer tenant and selected provider tenant.' } Write-Host ' - Configure Easy Auth to require HTTPS authentication and allow only the Microsoft phone-provider enterprise application.' - Write-Host ' - Create an encryption certificate and store its private key in the new Key Vault.' + Write-Host ' - Issue or reuse an RSA-2048 encryption certificate inside Key Vault; no private key is downloaded.' + Write-Host ' - Use a 12-month certificate with manual renewal and key reuse; automatic renewal is disabled.' + Write-Host ' - Pin the Function to the certificate secret version and register only the public certificate in Entra.' + Write-Host ' - Renewal and Entra certificate synchronization remain administrator-owned operations.' -ForegroundColor Yellow if ($Inputs.ProviderAuthentication -eq 'oauth') { Write-Host ' - Soprano OAuth: add a federated credential so the outbound managed identity can authenticate without a client secret.' } @@ -943,6 +946,7 @@ function Show-EppDeploymentResult { Format-Table Resource, Name -AutoSize | Out-String -Width 160 | Write-Host Write-Host "Function endpoint: $EndpointUrl" -ForegroundColor Green Write-Host "Deployment details: $ResultPath" + Write-Host 'Certificate renewal is manual. Track the expiry in the deployment details and coordinate the Entra certificate update before expiry.' -ForegroundColor Yellow if ($ProviderConfiguration.Id -eq 'telesign') { $authentication = $ProviderConfiguration.Manifest.deployment.authentication @@ -1090,20 +1094,59 @@ function Initialize-EppGraphAccess { } function Get-EppEncryptionCertificate { - param([hashtable] $Inputs, [string] $OutputDirectory) - - $subject = "CN=EPP-$($Inputs.ApplicationId)-$($Inputs.ResourcePrefix)" - $certificate = Get-ChildItem Cert:\CurrentUser\My | - Where-Object { $_.Subject -eq $subject -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date).AddDays(30) } | - Sort-Object NotAfter -Descending | Select-Object -First 1 - if (-not $certificate) { - $certificate = New-SelfSignedCertificate -Subject $subject -CertStoreLocation 'Cert:\CurrentUser\My' ` - -KeyAlgorithm RSA -KeyLength 2048 -KeyExportPolicy Exportable -KeyUsage KeyEncipherment, DataEncipherment ` - -NotAfter (Get-Date).AddYears(1) - } - $publicPath = Join-Path $OutputDirectory "$($certificate.Thumbprint).cer" - Export-Certificate -Cert $certificate -FilePath $publicPath -Force | Out-Null - return $certificate + param([hashtable] $Inputs, [string] $VaultName, [string] $OutputDirectory, [string] $Directory) + + $readCertificate = { + Invoke-EppAz keyvault certificate show --vault-name $VaultName --name $script:EppCertificateName ` + --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json -AsHashtable + } + try { $bundle = Invoke-EppDataOperation $readCertificate } + catch { + if ($_.Exception.Message -notmatch '\(CertificateNotFound\)') { throw } + $policy = @{ + issuerParameters = @{ name = 'Self' } + keyProperties = @{ exportable = $true; keyType = 'RSA'; keySize = 2048; reuseKey = $true } + secretProperties = @{ contentType = 'application/x-pem-file' } + x509CertificateProperties = @{ + subject = $script:EppCertificateSubject + validityInMonths = 12 + keyUsage = @('keyEncipherment', 'dataEncipherment') + } + lifetimeActions = @(@{ action = @{ actionType = 'EmailContacts' }; trigger = @{ daysBeforeExpiry = 30 } }) + } + $policyPath = Join-Path $Directory 'certificate-policy.json' + try { + $policy | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $policyPath -Encoding utf8NoBOM + # Azure CLI waits for self-signed issuance; no separate polling loop is needed. + Invoke-EppDataOperation { + Invoke-EppAz keyvault certificate create --vault-name $VaultName --name $script:EppCertificateName ` + --policy "@$policyPath" --subscription $Inputs.SubscriptionId --output none + } | Out-Null + } + finally { if (Test-Path -LiteralPath $policyPath) { Remove-Item -LiteralPath $policyPath -Force } } + $bundle = Invoke-EppDataOperation $readCertificate + } + if (-not $bundle -or -not $bundle['cer'] -or -not $bundle['sid']) { + throw 'Key Vault returned incomplete certificate metadata.' + } + $policy = $bundle['policy'] + $actions = @($policy['lifetimeActions']) + if ($bundle['attributes']['enabled'] -ne $true -or + $policy['keyProperties']['keyType'] -cne 'RSA' -or + $policy['keyProperties']['exportable'] -ne $true -or $policy['keyProperties']['reuseKey'] -ne $true -or + $policy['secretProperties']['contentType'] -cne 'application/x-pem-file' -or + $actions.Count -ne 1 -or $actions[0]['action']['actionType'] -cne 'EmailContacts') { + throw 'Use an enabled certificate with exportable RSA, PEM, key reuse, and manual renewal. Setup will not overwrite an incompatible certificate.' + } + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($bundle['cer'])) + if ($certificate.Subject -cne $script:EppCertificateSubject -or $certificate.HasPrivateKey -or + $certificate.NotBefore.ToUniversalTime() -gt [DateTime]::UtcNow -or + $certificate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow.AddDays(30)) { + $certificate.Dispose() + throw "The certificate must have subject '$script:EppCertificateSubject', be valid now, and have more than 30 days remaining. Renew manually before rerunning setup." + } + [IO.File]::WriteAllBytes((Join-Path $OutputDirectory "$($certificate.Thumbprint).cer"), $certificate.RawData) + return [pscustomobject]@{ Certificate = $certificate; CertificateId = $bundle['id']; SecretId = $bundle['sid'] } } function Assert-EppGraphAccess { @@ -1120,6 +1163,7 @@ function Assert-EppGraphAccess { }) $keys = @($application.KeyCredentials | Where-Object { $_.KeyId -eq $KeyId -and $_.Usage -eq 'Encrypt' -and $_.CustomKeyIdentifier -and + $_.DisplayName -ceq $Certificate.Subject -and -not (Compare-Object $_.CustomKeyIdentifier $Certificate.GetCertHash()) }) if ($application.SignInAudience -ne 'AzureADMultipleOrgs' -or $application.TokenEncryptionKeyId -or @@ -1154,53 +1198,18 @@ function Assert-EppGraphAccess { } } -function Set-EppPrivateKey { - param($Certificate, [string] $KeyId, [string] $VaultName, [string] $SubscriptionId, [string] $Directory) - - $existing = @(Invoke-EppDataOperation { - Invoke-EppAz keyvault secret list --vault-name $VaultName --subscription $SubscriptionId --output json - } | ConvertFrom-Json -AsHashtable) - $match = @($existing | Where-Object { $_['name'] -eq 'phone-provider-decryption-key' }) - if ($match.Count -and $match[0]['tags'] -and - $match[0]['tags']['certificateThumbprint'] -eq $Certificate.Thumbprint -and - $match[0]['tags']['encryptionKeyId'] -eq $KeyId) { - if (-not $match[0]['attributes']['enabled'] -or - ($match[0]['attributes']['expires'] -and [DateTimeOffset]::Parse($match[0]['attributes']['expires']) -le [DateTimeOffset]::UtcNow)) { - throw 'The existing decryption secret is disabled or expired. Correct its state before rerunning setup.' - } - return - } +function Set-EppEncryptionSettings { + param([hashtable] $Inputs, [Collections.IDictionary] $Names, [string] $SecretId, [string] $KeyId, [string] $Directory) - $rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) - if (-not $rsa) { throw 'The encryption certificate must have an exportable RSA private key.' } - $privatePath = Join-Path $Directory 'private-key.txt' + $reference = "@Microsoft.KeyVault(SecretUri=$SecretId)" + $path = Join-Path $Directory 'encryption-appsettings.json' try { - $pem = "-----BEGIN PRIVATE KEY-----`n$([Convert]::ToBase64String($rsa.ExportPkcs8PrivateKey(), [Base64FormattingOptions]::InsertLineBreaks))`n-----END PRIVATE KEY-----" - [IO.File]::WriteAllText($privatePath, [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pem)), [Text.UTF8Encoding]::new($false)) - Invoke-EppDataOperation { - Invoke-EppAz keyvault secret set --vault-name $VaultName --subscription $SubscriptionId ` - --name phone-provider-decryption-key --file $privatePath --encoding utf-8 ` - --tags "certificateThumbprint=$($Certificate.Thumbprint)" "encryptionKeyId=$KeyId" --output none - } | Out-Null - } - finally { - $rsa.Dispose() - if (Test-Path -LiteralPath $privatePath) { Remove-Item -LiteralPath $privatePath -Force } - } -} - -function Assert-EppPrivateKey { - param([string] $VaultName, [string] $SubscriptionId, $Certificate, [string] $KeyId) - - $secret = Invoke-EppDataOperation { - Invoke-EppAz keyvault secret show --vault-name $VaultName --subscription $SubscriptionId ` - --name phone-provider-decryption-key --output json - } | ConvertFrom-Json -AsHashtable - if (-not $secret -or -not $secret['attributes']['enabled'] -or - $secret['tags']['certificateThumbprint'] -ne $Certificate.Thumbprint -or - $secret['tags']['encryptionKeyId'] -ne $KeyId) { - throw 'Key Vault readback does not match the approved encryption certificate and key ID.' + @{ EPP_DECRYPTION_KEY_PEM = $reference; EPP_ENCRYPTION_KEY_ID = $KeyId } | + ConvertTo-Json | Set-Content -LiteralPath $path -Encoding utf8NoBOM + Invoke-EppAz functionapp config appsettings set --resource-group $Names.resourceGroup --name $Names.functionApp ` + --subscription $Inputs.SubscriptionId --settings "@$path" --output none | Out-Null } + finally { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force } } } function Set-EppApplicationEndpoint { @@ -1211,9 +1220,20 @@ function Set-EppApplicationEndpoint { if ($application.AppId -ne $Inputs.ApplicationId -or $application.SignInAudience -ne 'AzureADMultipleOrgs' -or $application.TokenEncryptionKeyId) { throw 'The application changed after preflight. Its identity, audience, and signed-token configuration must still match.' } + foreach ($existingKey in @($application.KeyCredentials | Where-Object { $_ -and $_.Usage -eq 'Encrypt' })) { + if (-not $existingKey.Key) { throw 'The existing encryption certificate is not readable. Setup cannot verify key continuity.' } + $existingCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new([byte[]]$existingKey.Key) + try { + if ($existingCertificate.GetKeyAlgorithm() -cne $Certificate.GetKeyAlgorithm() -or + $existingCertificate.GetPublicKeyString() -cne $Certificate.GetPublicKeyString()) { + throw 'The app registration has a different encryption key. Coordinate migration with the EPP owner; setup will not replace it.' + } + } + finally { $existingCertificate.Dispose() } + } $key = @{ CustomKeyIdentifier = $Certificate.GetCertHash() - DisplayName = "EPP encryption $($Certificate.Thumbprint)" + DisplayName = $Certificate.Subject Key = $Certificate.GetRawCertData() KeyId = $KeyId Type = 'AsymmetricX509Cert' @@ -1224,7 +1244,11 @@ function Set-EppApplicationEndpoint { $uris = @($application.IdentifierUris | Where-Object { $_ }) if ($uris -notcontains $Outputs.identifierUri.value) { $uris += $Outputs.identifierUri.value } $keys = @($application.KeyCredentials | Where-Object { $null -ne $_ }) - if (-not @($keys | Where-Object { $_.KeyId -eq $KeyId }).Count) { $keys += $key } + $existingKeys = @($keys | Where-Object { $_.KeyId -eq $KeyId }) + if ($existingKeys.Count) { + foreach ($existingKey in $existingKeys) { $existingKey.DisplayName = $Certificate.Subject } + } + else { $keys += $key } # Do not set tokenEncryptionKeyId: JWE payload encryption is separate from bearer-token encryption. Update-MgApplication -ApplicationId $application.Id -IdentifierUris $uris -KeyCredentials $keys -ErrorAction Stop if (-not $ConfigureFederation) { return } @@ -1376,20 +1400,12 @@ function Invoke-EppDeployment { if (-not $graphOperator -or $graphOperator.Id -ne $Context.GraphOperatorId) { throw 'The Graph session changed after the plan was reviewed. Rerun setup.' } - # The single setup approval covers these planned writes, including SDK/certificate cmdlets. + # The single setup approval covers these planned writes, including certificate issuance. $ConfirmPreference = 'None' $graphAccess = Initialize-EppGraphAccess -Inputs $Inputs -Context $Context Initialize-EppResourceProviders -Inputs $Inputs New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null - $certificate = Get-EppEncryptionCertificate -Inputs $Inputs -OutputDirectory $OutputDirectory - $existingKeys = @($Context.Application.KeyCredentials | Where-Object { - $_ -and $_.Usage -eq 'Encrypt' -and $_.CustomKeyIdentifier -and - -not (Compare-Object $_.CustomKeyIdentifier $certificate.GetCertHash()) - }) - if ($existingKeys.Count -gt 1) { throw 'Multiple encryption credentials match this certificate. Resolve the duplicate credentials manually.' } - $keyId = if ($existingKeys.Count) { [string]$existingKeys[0].KeyId } else { [Guid]::NewGuid().ToString() } $settings = @{} + $ProviderConfiguration.Settings - $settings.EPP_ENCRYPTION_KEY_ID = $keyId $settings.EPP_PROVIDER_AUTH_MODE = $Inputs.ProviderAuthentication $parameters = @{ resourceNames = @{ value = $Names } @@ -1420,12 +1436,19 @@ function Invoke-EppDeployment { $null = ConvertTo-EppGuid $outputs.outboundPrincipalId.value Assert-EppHttpsUrl $outputs.endpointUrl.value if ([Text.Encoding]::UTF8.GetByteCount($outputs.endpointUrl.value) -gt 100) { throw 'The deployed endpoint URL exceeds the EPP 100-byte limit.' } - Set-EppPrivateKey -Certificate $certificate -KeyId $keyId -VaultName $Names.keyVault ` - -SubscriptionId $Inputs.SubscriptionId -Directory $AssetDirectory - Assert-EppPrivateKey -VaultName $Names.keyVault -SubscriptionId $Inputs.SubscriptionId ` - -Certificate $certificate -KeyId $keyId + $issued = Get-EppEncryptionCertificate -Inputs $Inputs -VaultName $Names.keyVault ` + -OutputDirectory $OutputDirectory -Directory $AssetDirectory + $certificate = $issued.Certificate + $application = Get-MgApplication -ApplicationId $Context.Application.Id -Property Id,KeyCredentials -ErrorAction Stop + $existingKeys = @($application.KeyCredentials | Where-Object { + $_ -and $_.Usage -eq 'Encrypt' -and $_.CustomKeyIdentifier -and + -not (Compare-Object $_.CustomKeyIdentifier $certificate.GetCertHash()) + }) + if ($existingKeys.Count -gt 1) { throw 'Multiple encryption credentials match this certificate. Resolve the duplicate credentials manually.' } + $keyId = if ($existingKeys.Count) { [string]$existingKeys[0].KeyId } else { [Guid]::NewGuid().ToString() } Set-EppApplicationEndpoint -Inputs $Inputs -Context $Context -Outputs $outputs -Certificate $certificate -KeyId $keyId ` -ConfigureFederation:($Inputs.ProviderAuthentication -eq 'oauth') + Set-EppEncryptionSettings -Inputs $Inputs -Names $Names -SecretId $issued.SecretId -KeyId $keyId -Directory $AssetDirectory $graphAccess = Assert-EppGraphAccess -Inputs $Inputs -Certificate $certificate -KeyId $keyId ` -IdentifierUri $outputs.identifierUri.value $siteId = "/subscriptions/$($Inputs.SubscriptionId)/resourceGroups/$($Names.resourceGroup)/providers/Microsoft.Web/sites/$($Names.functionApp)" @@ -1479,6 +1502,9 @@ function Invoke-EppDeployment { language = $Inputs.Language endpointUrl = $outputs.endpointUrl.value; identifierUri = $outputs.identifierUri.value encryptionKeyId = $keyId; certificateThumbprint = $certificate.Thumbprint + certificateId = $issued.CertificateId; certificateSecretId = $issued.SecretId + certificateExpiresUtc = $certificate.NotAfter.ToUniversalTime().ToString('o') + certificateRenewal = 'manual' endpointServicePrincipalId = $graphAccess.EndpointPrincipalId microsoftPhoneProviderServicePrincipalId = $graphAccess.CallerPrincipalId eppInvokeAppRoleId = $script:EppInvokeAppRoleId diff --git a/setup/tests/Certificates.Tests.ps1 b/setup/tests/Certificates.Tests.ps1 new file mode 100644 index 0000000..74c0160 --- /dev/null +++ b/setup/tests/Certificates.Tests.ps1 @@ -0,0 +1,165 @@ +#Requires -Version 7.0 +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$module = Import-Module (Join-Path $PSScriptRoot '../support/Epp.Setup.psm1') -Force -PassThru +$directory = Join-Path ([IO.Path]::GetTempPath()) "epp-certificate-tests-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $directory | Out-Null +try { + & $module { + param($Directory) + + function script:Assert($Condition, [string] $Message) { + if (-not $Condition) { throw $Message } + } + function script:Assert-Throws([scriptblock] $Action, [string] $Pattern) { + try { $null = & $Action } + catch { + Assert ($_.Exception.Message -match $Pattern) "Unexpected failure: $($_.Exception.Message)" + return + } + throw "Expected failure matching '$Pattern'." + } + $script:Exists = $false + $script:ReadError = '' + $script:CreateError = '' + $script:SettingsError = '' + $script:Calls = [Collections.Generic.List[string]]::new() + $script:GraphWrites = 0 + function script:Invoke-EppAz { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + $command = $Arguments -join ' ' + $script:Calls.Add($command) + switch -Regex ($command) { + '^keyvault certificate show ' { + if ($script:ReadError) { throw $script:ReadError } + if (-not $script:Exists) { throw '(CertificateNotFound) absent' } + return $script:Bundle | ConvertTo-Json -Depth 8 + } + '^keyvault certificate create ' { + if ($script:CreateError) { throw $script:CreateError } + $path = $Arguments[[Array]::IndexOf($Arguments, '--policy') + 1].Substring(1) + $script:Bundle.policy = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable + $script:Exists = $true + } + '^functionapp config appsettings set ' { + if ($script:SettingsError) { throw $script:SettingsError } + $path = $Arguments[[Array]::IndexOf($Arguments, '--settings') + 1].Substring(1) + $script:Settings = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable + } + default { throw "Unexpected Azure call (no network allowed): $command" } + } + } + function script:Get-MgApplication { param($ApplicationId, $Property, $ErrorAction); return $script:Application } + function script:Update-MgApplication { + param($ApplicationId, $IdentifierUris, $KeyCredentials, $ErrorAction) + $script:GraphWrites++ + $script:Application.IdentifierUris = $IdentifierUris + $script:Application.KeyCredentials = $KeyCredentials + } + + $rsa = [Security.Cryptography.RSA]::Create(2048) + $request = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=ExternalPhoneProvider', $rsa, [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $certificate = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-5), [DateTimeOffset]::UtcNow.AddYears(1)) + $issued = $null + try { + $script:Bundle = @{ + id = 'https://epptestvault.vault.azure.net/certificates/phone-provider-encryption/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + sid = 'https://epptestvault.vault.azure.net/secrets/phone-provider-encryption/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + cer = [Convert]::ToBase64String($certificate.RawData); attributes = @{ enabled = $true } + } + $inputs = @{ SubscriptionId = '33333333-3333-3333-3333-333333333333'; ApplicationId = '22222222-2222-2222-2222-222222222222' } + $parameters = @{ Inputs = $inputs; VaultName = 'epptestvault'; OutputDirectory = $Directory; Directory = $Directory } + $issued = Get-EppEncryptionCertificate @parameters + $policy = $script:Bundle.policy + Assert ($script:Calls.Count -eq 3 -and $script:Calls[1] -like 'keyvault certificate create *') 'Expected show/create/show only.' + Assert ($policy.x509CertificateProperties.subject -ceq 'CN=ExternalPhoneProvider') 'Subject must not be personalized.' + Assert ($policy.issuerParameters.name -ceq 'Self' -and $policy.x509CertificateProperties.validityInMonths -eq 12) 'Expected 12-month self-signed issuance.' + Assert ($policy.keyProperties.keyType -ceq 'RSA' -and $policy.keyProperties.keySize -eq 2048) 'Expected RSA-2048.' + Assert ($policy.keyProperties.exportable -eq $true -and $policy.keyProperties.reuseKey -eq $true) 'Expected exportable, reusable key.' + Assert ($policy.secretProperties.contentType -ceq 'application/x-pem-file') 'Expected PEM backing secret.' + Assert ($policy.lifetimeActions[0].action.actionType -ceq 'EmailContacts') 'Renewal must remain manual.' + Assert ($issued.SecretId -ceq $script:Bundle.sid -and -not $issued.Certificate.HasPrivateKey) 'Return secret ID and public certificate only.' + Assert (-not (Test-Path (Join-Path $Directory 'certificate-policy.json'))) 'Temporary policy was not removed.' + $publicBytes = [IO.File]::ReadAllBytes((Join-Path $Directory "$($certificate.Thumbprint).cer")) + Assert ([Convert]::ToBase64String($publicBytes) -ceq $script:Bundle.cer) 'Saved output must contain only public DER.' + $script:Calls.Clear() + $reused = Get-EppEncryptionCertificate @parameters + Assert ($script:Calls.Count -eq 1 -and $reused.Certificate.Thumbprint -ceq $certificate.Thumbprint) 'Rerun must reuse the existing certificate.' + $reused.Certificate.Dispose() + + foreach ($errorText in @('(Forbidden) denied', '(VaultNotFound) absent', '(ServiceUnavailable) unavailable')) { + $script:ReadError = $errorText; $script:Calls.Clear() + Assert-Throws { Get-EppEncryptionCertificate @parameters } ([regex]::Escape($errorText)) + Assert ($script:Calls.Count -eq 1) 'Read errors must not trigger creation.' + } + $script:ReadError = ''; $script:Exists = $false; $script:CreateError = '(Conflict) pending operation exists' + Assert-Throws { Get-EppEncryptionCertificate @parameters } '\(Conflict\)' + Assert (-not (Test-Path (Join-Path $Directory 'certificate-policy.json'))) 'Failed issuance left its temporary policy.' + $script:CreateError = ''; $script:Exists = $true + foreach ($mutation in @( + { $script:Bundle.attributes.enabled = $false }, + { $script:Bundle.policy.keyProperties.exportable = $false }, + { $script:Bundle.policy.secretProperties.contentType = 'application/x-pkcs12' }, + { $script:Bundle.policy.lifetimeActions[0].action.actionType = 'AutoRenew' } + )) { + $saved = $script:Bundle | ConvertTo-Json -Depth 8 + & $mutation + Assert-Throws { Get-EppEncryptionCertificate @parameters } 'incompatible certificate' + $script:Bundle = $saved | ConvertFrom-Json -AsHashtable + } + + $script:Application = [pscustomobject]@{ + Id = 'application-object'; AppId = $inputs.ApplicationId; SignInAudience = 'AzureADMultipleOrgs' + TokenEncryptionKeyId = $null; IdentifierUris = @(); KeyCredentials = @() + } + $keyId = [Guid]::NewGuid().ToString() + $registration = @{ + Inputs = $inputs; Context = @{ Application = $script:Application } + Outputs = @{ identifierUri = @{ value = 'api://epp-test' } } + Certificate = $issued.Certificate; KeyId = $keyId; ConfigureFederation = $false + } + Set-EppApplicationEndpoint @registration + $script:Application.KeyCredentials[0].DisplayName = 'Old display name' + Set-EppApplicationEndpoint @registration + Assert ($script:Application.KeyCredentials.Count -eq 1) 'Rerun must preserve the credential ID.' + Assert ($script:Application.KeyCredentials[0].DisplayName -ceq 'CN=ExternalPhoneProvider') 'Display name must match the subject on create and reuse.' + Assert ($script:Application.KeyCredentials[0].Usage -ceq 'Encrypt' -and $null -eq $script:Application.TokenEncryptionKeyId) 'Preserve JWE usage and signed bearer tokens.' + $renewed = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-1), [DateTimeOffset]::UtcNow.AddYears(1)) + try { + $registration.Certificate = $renewed; $registration.KeyId = [Guid]::NewGuid().ToString() + Set-EppApplicationEndpoint @registration + Assert ($script:Application.KeyCredentials.Count -eq 2 -and $script:Application.KeyCredentials[0].KeyId -eq $keyId) 'Same-key renewal must preserve the old credential.' + } + finally { $renewed.Dispose() } + $otherRsa = [Security.Cryptography.RSA]::Create(2048) + $otherRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=ExternalPhoneProvider', $otherRsa, [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $other = $otherRequest.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-1), [DateTimeOffset]::UtcNow.AddYears(1)) + try { + $registration.Certificate = $other + Assert-Throws { Set-EppApplicationEndpoint @registration } 'different encryption key' + Assert ($script:GraphWrites -eq 3) 'Key mismatch must stop before updating Entra.' + } + finally { $other.Dispose(); $otherRsa.Dispose() } + + $settings = @{ Inputs = $inputs; Names = @{ resourceGroup = 'epp-test-rg'; functionApp = 'epp-test-app' }; SecretId = $issued.SecretId; KeyId = $keyId; Directory = $Directory } + Set-EppEncryptionSettings @settings + Assert ($script:Settings.EPP_DECRYPTION_KEY_PEM -ceq "@Microsoft.KeyVault(SecretUri=$($issued.SecretId))" -and $script:Settings.EPP_ENCRYPTION_KEY_ID -eq $keyId) 'Use the selected version and Entra key ID.' + $script:SettingsError = 'App settings write failed' + Assert-Throws { Set-EppEncryptionSettings @settings } 'App settings write failed' + Assert (-not (Test-Path (Join-Path $Directory 'encryption-appsettings.json'))) 'Failed settings update left its temporary file.' + Write-Host 'Certificate creation, reuse, errors, naming, key continuity, and settings checks passed.' + } + finally { + if ($issued) { $issued.Certificate.Dispose() } + $certificate.Dispose(); $rsa.Dispose() + } + } $directory +} +finally { + Remove-Module -ModuleInfo $module -Force + Remove-Item -LiteralPath $directory -Recurse -Force +}