Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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>.vault.azure.net/secrets/<private-key-secret>/)`
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/<version>`, 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
Expand Down
6 changes: 6 additions & 0 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

Expand Down
26 changes: 26 additions & 0 deletions dotnet/tests/EnvelopeTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Xunit;
Expand All @@ -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)]
Expand Down
15 changes: 15 additions & 0 deletions javascript/test/sendotp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand Down
26 changes: 26 additions & 0 deletions python/tests/test_function_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
71 changes: 65 additions & 6 deletions setup/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,19 @@ 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.
- Microsoft Graph PowerShell modules `Microsoft.Graph.Authentication` and
`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`.
Expand Down Expand Up @@ -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`.
Expand All @@ -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
Expand Down
Loading
Loading