Lab 01 — Key Vault & Managed Identity: Token Acquisition + Envelope Encryption
Phase: 12 — Secrets, Key Vault & Managed Identity | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
The most secure secret is the one you never store. A Managed Identity is how code proves who it is to Azure with no credential in the code — it asks the non-routable IMDS endpoint (
169.254.169.254) for a token, audience-scoped and cached. A Key Vault then stores the secrets you can't avoid (third-party API keys, connection strings) as versioned objects behind RBAC, with soft-delete so a fat-fingerdeleteis recoverable and purge-protection so it can't be made permanent on demand. And envelope encryption is the trick that lets you rotate the key protecting terabytes of data by re-encrypting only a few bytes. This lab builds all three as a runnable, deterministic miniature: an IMDS token cache, the DEK/KEK envelope with rewrap-on- rotate, and the vault's versioning + RBAC default-deny + soft-delete state machine.
⚠️ The Part-2 cipher is a TEACHING cipher (SHA-256 counter-mode XOR), not for production — it has no authentication, no nonce, and reuses a keystream. It exists to make the envelope-encryption mechanism (DEK, KEK, wrap, unwrap, rewrap-on-rotate) legible without an external crypto dependency. Real Azure uses AES-GCM / RSA-OAEP in FIPS 140-2 validated HSMs. The header in
lab.py/solution.pysays so loudly. Never ship it.
What you build
- Managed-identity token flow —
Identity(system-assigned vs user-assigned selectors),InstanceMetadataService.acquire_token(resource, identity, now)returning anAccessTokenwithexpires_on = now + ttl, scoped to aresource(audience), and a per-(identity, resource)cache so a second call before expiry returns the same token and a call after expiry (or within the refresh skew) mints a new one.token_is_valid(token, resource, now)checks audience and expiry — the two ways a token silently fails. - Envelope encryption —
keystream(key, nbytes)(SHA-256 counter mode),enc/dec(XOR, symmetric),generate_dek(seed),wrap_dek/unwrap_dek(encrypt the DEK under the KEK),envelope_encrypt/envelope_decrypt, and the centerpiecerotate_kek(old, new, wrapped_dek)— unwrap with the old KEK, rewrap with the new, and the data ciphertext is never touched. A test asserts the ciphertext bytes are identical before and after rotation and still decrypt. - The Key Vault data model —
KeyVaultwith versioned secrets (set_secretappends a version;get_secretreturns latest or a pinned version), RBAC default-deny (get_secret(..., principal, role_assignments)raises unless the principal holds a data-plane read role —Ownerdoes not count), and the soft-delete state machine:delete_secret→ recoverable untilnow + retention;recover_secretrestores;purge_secretpermanently removes but raises when purge protection is on inside the retention window.
Key concepts
| Concept | What to understand |
|---|---|
| Managed Identity | an Entra service principal Azure manages; code holds no secret and gets a token from IMDS |
| System vs user-assigned | system-assigned is tied to one resource's lifecycle (no client_id); user-assigned is standalone and attachable to many (selected by client_id) |
| IMDS | the link-local 169.254.169.254 metadata endpoint that issues the token; the SDK caches it per (identity, resource) |
| Audience (resource) | a token is scoped to one target service; a vault.azure.net token is rejected by storage.azure.com |
| Token cache + skew | refetch only near expiry (a refresh_skew_s before expires_on) — a hot loop must not DoS IMDS |
| Control plane vs data plane | managing the vault (Owner) is control plane; reading a secret is data plane and needs a data-plane role |
| RBAC default-deny | no matching role assignment → deny, not error; least privilege is the default posture |
| Versioned secrets | each set creates an immutable version; old versions stay readable so in-flight clients survive a rotation |
| Envelope encryption | encrypt data with a fast DEK; wrap the DEK with a KEK in the vault/HSM; store (ciphertext, wrapped_dek) |
| Rewrap-on-rotate | rotating the KEK only unwraps+rewraps the DEK — O(#DEKs), not O(bytes); the data ciphertext is untouched |
| Soft-delete | a delete is recoverable for a retention window; the bytes are not gone yet |
| Purge protection | inside retention, nobody (not even an admin) can force-purge — the anti-ransomware/insider guarantee |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a worked example (token caching, envelope + rotation, RBAC deny→allow, soft-delete→recover, blocked purge) |
test_lab.py | the proof — run it red, make it green (29 tests) |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 29 tests pass against your implementation.
- You can explain why
test_token_cached_before_expiry_same_tokenis the line that keeps a hot loop from DoS-ing IMDS: the SDK serves the cached token until it is near expiry; onlytest_token_refreshed_after_expiry_new_tokenandtest_token_refreshed_within_skew_windowactually hit the endpoint again. - You can explain why
test_token_audience_scopingmatters: a token is scoped to one resource; reusing avault.azure.nettoken againststorage.azure.comreturns a token the target service rejects — a silent failure that looks like a 401 with no obvious cause. - You can explain why
test_kek_rotation_data_ciphertext_object_unchangedis the whole point of envelope encryption: rotating the KEK rewraps only the DEK, so key rotation costs the same whether you are protecting 1 KB or 1 PB. - You can explain why
test_rbac_default_deny_then_allowdenies even anOwner: reading a secret is a data-plane action, and control-plane roles do not grant it. - You can explain why
test_purge_blocked_under_purge_protectionis a security feature, not a bug: purge protection means a compromised admin (or ransomware) cannot make a deletion permanent until the retention window elapses.
How this maps to real Azure (Managed Identity + Key Vault)
| The lab | Real Azure |
|---|---|
InstanceMetadataService | the Azure Instance Metadata Service at http://169.254.169.254/metadata/identity/oauth2/token (a non-routable link-local address reachable only from the resource) |
Identity(kind="system") | a system-assigned managed identity — created with the resource, deleted with it, one per resource |
Identity(kind="user", client_id=…) | a user-assigned managed identity — a standalone Azure resource you attach to one or many resources; selected by client_id (or object_id/mi_res_id) |
acquire_token(resource, identity, now) | DefaultAzureCredential / ManagedIdentityCredential.get_token(scope) → the SDK calls IMDS and caches the token |
resource (audience) | the ?resource= / scope, e.g. https://vault.azure.net, https://storage.azure.com, https://management.azure.com — the token's aud claim |
expires_on + refresh skew | IMDS tokens live ~24h; the SDK refreshes before expiry to avoid a race (the AAD MSI extension also caches at the host) |
token (opaque hash) | a real JWT access token; the target service validates its signature, iss, aud, and exp (you built that validator in P03) |
KeyVault + RBAC roles | Azure Key Vault with Azure RBAC authorization (recommended over legacy access policies); Key Vault Secrets User = read, Secrets Officer = read/write, Administrator = full data-plane |
Owner does not read secrets | the recurring control-plane vs data-plane split: Owner/Contributor manage the vault resource; a data-plane role is required to read a secret's value |
versioned SecretVersion | every secret/key/cert is versioned; a version has its own immutable id and URL (.../secrets/{name}/{version}); omitting the version returns current |
wrap_dek / unwrap_dek | Key Vault wrapKey/unwrapKey (and encrypt/decrypt) — the KEK private key never leaves the vault/HSM; you send the DEK, get back the wrapped DEK |
rotate_kek (rewrap only) | key rotation with a new key version; Azure Storage/SQL/Disk encryption (CMK) rewrap their DEK against the new KEK version — terabytes of data are never re-encrypted |
generate_dek | in real envelope encryption the DEK is fresh CSPRNG bytes per object; here it's seed-derived only so tests are deterministic |
soft-delete / recover / purge | Key Vault soft-delete (now always on; default 90-day retention) and purge (az keyvault secret purge) |
purge_protection blocks purge | purge protection — when on, soft-deleted objects (and the vault) cannot be purged until retention elapses; irreversible once enabled |
| Managed HSM framing | for FIPS 140-2 Level 3 single-tenant HSMs, Azure Key Vault Managed HSM; certs have their own lifecycle (issuance, auto-rotation, CA integration) |
What the miniature leaves out (and why it's fine): real IMDS returns a signed JWT
validated against Entra's JWKS; the token has a full claim set (tid, oid, xms_mirid);
workload identity federation (AKS/GitHub Actions) swaps a federated OIDC token for an
Entra token with no secret and no IMDS at all; Key Vault enforces network ACLs/private
endpoints, throttles the data plane, and runs keys inside HSMs with attestation; and
certificates add CSR/CA/auto-rotation flows. None of that changes the three mechanisms
this lab isolates — (1) a managed identity gets an audience-scoped, cached token from
IMDS with no stored secret, (2) envelope encryption makes KEK rotation cost
O(#DEKs) by rewrapping the DEK and never the data, and (3) Key Vault is a
versioned, RBAC-default-deny, soft-deletable store where control plane ≠ data plane.
Those are exactly the parts interviewers probe and on-call incidents turn on.
Extensions (build these in your own subscription)
- Workload identity federation: add a
FederatedCredentialpath — exchange an external OIDC token (subject =repo:org/repo:ref:refs/heads/main) for an Entra token with no secret and no IMDS. This is how GitHub Actions and AKS pods authenticate (you touched the OIDC side in P03/P08). - JWT-real tokens: replace the opaque hash with a real signed JWT (HMAC or the RSA
validator from P03) so
token_is_validchecksiss/aud/exp/signature, not just a string compare. - Key Vault references in config: model App Service / Functions Key Vault
references (
@Microsoft.KeyVault(SecretUri=...)) — the platform resolves the secret using the app's managed identity and injects it as an env var; add a resolver that needs the MI token and the data-plane role. - Certificate lifecycle: add a
Certificateobject withissue,auto_rotate(issue a new version N days before expiry), and CA-policy validation. - Wire it to real Azure:
az identity create(user-assigned), assign it to a Function App,az role assignment create --role "Key Vault Secrets User"at the vault scope, and read a secret withDefaultAzureCredential+SecretClient— no secret in the code. Thenaz keyvault secret delete/recover/ trypurgewith purge protection on and watch it refuse.
Interview / resume
- Talking points: "How does a Managed Identity get a token with no stored secret — walk
me through IMDS, the audience, and the cache." / "System-assigned vs user-assigned —
when do you pick which?" / "Why does an
Ownerof a Key Vault get a 403 reading a secret?" / "Explain envelope encryption and why rotating the KEK doesn't re-encrypt the data." / "What does purge protection actually protect you from?" - Resume bullet: Built a runnable model of Azure's secret-zero security stack — managed- identity IMDS token acquisition with audience scoping and per-identity caching, DEK/KEK envelope encryption with O(#DEKs) key rotation (rewrap-only, data untouched), and a Key Vault data model with RBAC data-plane default-deny, secret versioning, and a soft-delete / purge-protection state machine.