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-finger delete is 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.py says so loudly. Never ship it.

What you build

  • Managed-identity token flowIdentity (system-assigned vs user-assigned selectors), InstanceMetadataService.acquire_token(resource, identity, now) returning an AccessToken with expires_on = now + ttl, scoped to a resource (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 encryptionkeystream(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 centerpiece rotate_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 modelKeyVault with versioned secrets (set_secret appends a version; get_secret returns latest or a pinned version), RBAC default-deny (get_secret(..., principal, role_assignments) raises unless the principal holds a data-plane read role — Owner does not count), and the soft-delete state machine: delete_secret → recoverable until now + retention; recover_secret restores; purge_secret permanently removes but raises when purge protection is on inside the retention window.

Key concepts

ConceptWhat to understand
Managed Identityan Entra service principal Azure manages; code holds no secret and gets a token from IMDS
System vs user-assignedsystem-assigned is tied to one resource's lifecycle (no client_id); user-assigned is standalone and attachable to many (selected by client_id)
IMDSthe 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 + skewrefetch only near expiry (a refresh_skew_s before expires_on) — a hot loop must not DoS IMDS
Control plane vs data planemanaging the vault (Owner) is control plane; reading a secret is data plane and needs a data-plane role
RBAC default-denyno matching role assignment → deny, not error; least privilege is the default posture
Versioned secretseach set creates an immutable version; old versions stay readable so in-flight clients survive a rotation
Envelope encryptionencrypt data with a fast DEK; wrap the DEK with a KEK in the vault/HSM; store (ciphertext, wrapped_dek)
Rewrap-on-rotaterotating the KEK only unwraps+rewraps the DEK — O(#DEKs), not O(bytes); the data ciphertext is untouched
Soft-deletea delete is recoverable for a retention window; the bytes are not gone yet
Purge protectioninside retention, nobody (not even an admin) can force-purge — the anti-ransomware/insider guarantee

Files

FilePurpose
lab.pyskeleton with # TODO markers and full signatures/docstrings
solution.pycomplete reference; python solution.py runs a worked example (token caching, envelope + rotation, RBAC deny→allow, soft-delete→recover, blocked purge)
test_lab.pythe proof — run it red, make it green (29 tests)
requirements.txtpytest 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_token is the line that keeps a hot loop from DoS-ing IMDS: the SDK serves the cached token until it is near expiry; only test_token_refreshed_after_expiry_new_token and test_token_refreshed_within_skew_window actually hit the endpoint again.
  • You can explain why test_token_audience_scoping matters: a token is scoped to one resource; reusing a vault.azure.net token against storage.azure.com returns 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_unchanged is 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_allow denies even an Owner: 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_protection is 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 labReal Azure
InstanceMetadataServicethe 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 skewIMDS 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 rolesAzure 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 secretsthe 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 SecretVersionevery 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_dekKey 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_dekin real envelope encryption the DEK is fresh CSPRNG bytes per object; here it's seed-derived only so tests are deterministic
soft-delete / recover / purgeKey Vault soft-delete (now always on; default 90-day retention) and purge (az keyvault secret purge)
purge_protection blocks purgepurge protection — when on, soft-deleted objects (and the vault) cannot be purged until retention elapses; irreversible once enabled
Managed HSM framingfor 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 FederatedCredential path — 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_valid checks iss/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 Certificate object with issue, 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 with DefaultAzureCredential + SecretClientno secret in the code. Then az keyvault secret delete / recover / try purge with 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 Owner of 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.