Key Lifecycle Management (ACTIVE → SUSPENDED → REVOKED → ARCHIVED)

Full walkthrough of every state transition: create → suspend → reactivate → revoke → archive. Understand which operations are allowed in each state.

🔐 Key Lifecycle Management

Scenario: exercise every state transition of a key's lifecycle — from ACTIVE at creation, through SUSPENDED, back to ACTIVE, then to REVOKED, then to ARCHIVED. At each step, verify which operations are allowed and which return HTTP 412 Precondition Failed.

This is the compliance drill your auditor asks for. Once every state, from cradle to archive.

Prerequisites

Same two identities as the AES-256 recipe. Admin needs admin.keys.suspend, admin.keys.reactivate, admin.keys.revoke, admin.keys.archive.

State machine

                    ┌──────────┐
                    │  ACTIVE  │◀──────────┐
                    └─────┬────┘           │
                          │                │
                    suspend│         reactivate
                          ▼                │
                    ┌───────────┐          │
                    │ SUSPENDED │──────────┘
                    └─────┬─────┘
                          │
                     revoke│  (from ACTIVE or SUSPENDED)
                          ▼
                    ┌───────────┐
                    │  REVOKED  │
                    └─────┬─────┘
                          │
                    archive│
                          ▼
                    ┌───────────┐
                    │ ARCHIVED  │
                    └───────────┘

Only ARCHIVED can be marked for destruction — see markKeyForDestruction with the cooling-off period.


Step-by-step

export BASE_URL="https://staging.ankatech.co"
export TENANT_ID="00000000-0000-0000-0000-000000000003"
export KID="lifecycle-drill-key"
# ADMIN_TOKEN + APP_TOKEN as in AES-256 recipe

# ── Step 1: create ML-KEM-512 key (starts ACTIVE)
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"kid\":\"$KID\", \"kty\":\"ML-KEM\", \"algorithm\":\"ML-KEM-512\",
        \"purpose\":\"ENCRYPT_DECRYPT\", \"keyOps\":[\"encrypt\",\"decrypt\"] }" | jq

# ── Step 2: ACTIVE — encrypt works
export DATA_B64=$(printf "%s" "step 2" | base64 -w0)
curl -s -X POST "$BASE_URL/api/v3/crypto/encrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"kid\":\"$KID\", \"data\":\"$DATA_B64\" }" | jq .algorithmUsed
# ✅ ok

# ── Step 3: SUSPEND — new encrypts blocked (412), decrypts also blocked
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID/suspend" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq .status   # → "SUSPENDED"

curl -s -o /dev/null -w "encrypt while SUSPENDED: %{http_code}\n" \
  -X POST "$BASE_URL/api/v3/crypto/encrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"kid\":\"$KID\", \"data\":\"$DATA_B64\" }"   # → 412

# ── Step 4: REACTIVATE — back to ACTIVE
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID/reactivate" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq .status   # → "ACTIVE"

# Encrypt one more time — for the archive-phase decrypt test
export FINAL_JWE=$(curl -s -X POST "$BASE_URL/api/v3/crypto/encrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"kid\":\"$KID\", \"data\":\"$DATA_B64\" }" | jq -c .jweToken)

# ── Step 5: REVOKE — new encrypts blocked; old jweTokens still decrypt
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID/revoke" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq .status   # → "REVOKED"

curl -s -o /dev/null -w "encrypt while REVOKED: %{http_code}\n" \
  -X POST "$BASE_URL/api/v3/crypto/encrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"kid\":\"$KID\", \"data\":\"$DATA_B64\" }"   # → 412

curl -s -X POST "$BASE_URL/api/v3/crypto/decrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"jweToken\": $FINAL_JWE }" \
  | jq '{decryptedData, warnings}'   # ✅ still decrypts, warning about REVOKED

# ── Step 6: ARCHIVE — read-only from here on
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID/archive" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq .status   # → "ARCHIVED"

curl -s -X POST "$BASE_URL/api/v3/crypto/decrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"jweToken\": $FINAL_JWE }" \
  | jq .decryptedData   # ✅ still decrypts while material is retained
import base64, requests, time

BASE = "https://staging.ankatech.co"
TENANT_ID = "00000000-0000-0000-0000-000000000003"
KID = "lifecycle-drill-key"
# admin / app tokens as in AES-256

# ACTIVE
requests.post(f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys", headers=admin,
    json={"kid": KID, "kty": "ML-KEM", "algorithm": "ML-KEM-512",
          "purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"]}).raise_for_status()

data_b64 = base64.b64encode(b"lifecycle").decode()

def encrypt(): return requests.post(f"{BASE}/api/v3/crypto/encrypt", headers=app,
                                    json={"kid": KID, "data": data_b64})
def admin_op(op): return requests.post(
    f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}/{op}", headers=admin)

assert encrypt().status_code == 200, "ACTIVE should allow encrypt"

# SUSPEND
assert admin_op("suspend").json()["status"] == "SUSPENDED"
assert encrypt().status_code == 412, "SUSPENDED should block encrypt"

# REACTIVATE
assert admin_op("reactivate").json()["status"] == "ACTIVE"
final = encrypt().json()
final_jwe = final["jweToken"]

# REVOKE
assert admin_op("revoke").json()["status"] == "REVOKED"
assert encrypt().status_code == 412, "REVOKED should block encrypt"

# Old jweToken still decrypts
dec = requests.post(f"{BASE}/api/v3/crypto/decrypt", headers=app,
                    json={"jweToken": final_jwe}).json()
assert base64.b64decode(dec["decryptedData"]) == b"lifecycle", "REVOKED should still decrypt historic"

# ARCHIVE
assert admin_op("archive").json()["status"] == "ARCHIVED"
# ARCHIVED still allows decrypt while material is retained
dec2 = requests.post(f"{BASE}/api/v3/crypto/decrypt", headers=app,
                     json={"jweToken": final_jwe}).json()
assert base64.b64decode(dec2["decryptedData"]) == b"lifecycle"
print("Full lifecycle drill PASSED")
import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import com.fasterxml.jackson.databind.*;

public class KeyLifecycleDrill {
    static final String BASE_URL  = "https://staging.ankatech.co";
    static final String TENANT_ID = "00000000-0000-0000-0000-000000000003";
    static final String KID       = "lifecycle-drill-key";

    static final HttpClient   http = HttpClient.newHttpClient();
    static final ObjectMapper json = new ObjectMapper();

    static int encrypt(String appAuth, String dataB64) throws Exception {
        return http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/encrypt"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(
                "{ \"kid\": \"" + KID + "\", \"data\": \"" + dataB64 + "\" }")).build(),
            HttpResponse.BodyHandlers.ofString()).statusCode();
    }

    static String adminOp(String adminAuth, String op) throws Exception {
        var r = http.send(HttpRequest.newBuilder(URI.create(
            BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID + "/keys/" + KID + "/" + op))
            .header("Authorization", adminAuth)
            .POST(HttpRequest.BodyPublishers.noBody()).build(),
            HttpResponse.BodyHandlers.ofString());
        return json.readTree(r.body()).get("status").asText();
    }

    public static void main(String[] args) throws Exception {
        String adminAuth = "Bearer " + System.getenv("ADMIN_TOKEN");
        String appAuth   = "Bearer " + System.getenv("APP_TOKEN");
        String dataB64   = Base64.getEncoder().encodeToString("lifecycle".getBytes());

        // Create (ACTIVE)
        http.send(HttpRequest.newBuilder(URI.create(
            BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID + "/keys"))
            .header("Authorization", adminAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString("""
                { "kid": "%s", "kty": "ML-KEM", "algorithm": "ML-KEM-512",
                  "purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"] }""".formatted(KID))).build(),
            HttpResponse.BodyHandlers.ofString());
        if (encrypt(appAuth, dataB64) != 200) throw new RuntimeException("ACTIVE should allow encrypt");

        // SUSPEND
        if (!"SUSPENDED".equals(adminOp(adminAuth, "suspend"))) throw new RuntimeException("suspend failed");
        if (encrypt(appAuth, dataB64) != 412) throw new RuntimeException("SUSPENDED should block");

        // REACTIVATE
        if (!"ACTIVE".equals(adminOp(adminAuth, "reactivate"))) throw new RuntimeException("reactivate failed");
        // Save a jweToken for later
        var enc = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/encrypt"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(
                "{ \"kid\": \"" + KID + "\", \"data\": \"" + dataB64 + "\" }")).build(),
            HttpResponse.BodyHandlers.ofString());
        String finalJwe = json.readTree(enc.body()).get("jweToken").toString();

        // REVOKE
        if (!"REVOKED".equals(adminOp(adminAuth, "revoke"))) throw new RuntimeException("revoke failed");
        if (encrypt(appAuth, dataB64) != 412) throw new RuntimeException("REVOKED should block encrypt");

        // Old decrypt still works
        var dec = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/decrypt"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString("{ \"jweToken\": " + finalJwe + " }")).build(),
            HttpResponse.BodyHandlers.ofString());
        if (dec.statusCode() != 200) throw new RuntimeException("REVOKED should allow historic decrypt");

        // ARCHIVE
        if (!"ARCHIVED".equals(adminOp(adminAuth, "archive"))) throw new RuntimeException("archive failed");
        System.out.println("Full lifecycle drill PASSED");
    }
}
// Same 2-token pattern. Sequence:
//   POST /keys → ACTIVE
//   POST /keys/{kid}/suspend    → SUSPENDED   (encrypt/decrypt 412)
//   POST /keys/{kid}/reactivate → ACTIVE
//   POST /keys/{kid}/revoke     → REVOKED     (encrypt 412; decrypt still ok w/ warning)
//   POST /keys/{kid}/archive    → ARCHIVED    (decrypt still ok while material retained)
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

class KeyLifecycleDrill
{
    const string BASE_URL  = "https://staging.ankatech.co";
    const string TENANT_ID = "00000000-0000-0000-0000-000000000003";
    const string KID       = "lifecycle-drill-key";
    static HttpClient adminHttp, appHttp;
    static string dataB64;

    static async Task<int> Encrypt() {
        var r = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/encrypt",
            new StringContent(JsonSerializer.Serialize(new { kid = KID, data = dataB64 }),
                              Encoding.UTF8, "application/json"));
        return (int)r.StatusCode;
    }

    static async Task<string> AdminOp(string op) {
        var r = await adminHttp.PostAsync(
            $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}/{op}",
            new StringContent(""));
        var body = JsonDocument.Parse(await r.Content.ReadAsStringAsync()).RootElement;
        return body.GetProperty("status").GetString();
    }

    static async Task Main()
    {
        adminHttp = new HttpClient();
        adminHttp.DefaultRequestHeaders.Add("Authorization",
            $"Bearer {Environment.GetEnvironmentVariable("ADMIN_TOKEN")}");
        appHttp = new HttpClient();
        appHttp.DefaultRequestHeaders.Add("Authorization",
            $"Bearer {Environment.GetEnvironmentVariable("APP_TOKEN")}");
        dataB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("lifecycle"));

        // Create (ACTIVE)
        await adminHttp.PostAsync(
            $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys",
            new StringContent(JsonSerializer.Serialize(new {
                kid = KID, kty = "ML-KEM", algorithm = "ML-KEM-512",
                purpose = "ENCRYPT_DECRYPT", keyOps = new[] { "encrypt", "decrypt" },
            }), Encoding.UTF8, "application/json"));
        if (await Encrypt() != 200) throw new Exception("ACTIVE should allow encrypt");

        // SUSPEND
        if (await AdminOp("suspend") != "SUSPENDED") throw new Exception("suspend failed");
        if (await Encrypt() != 412) throw new Exception("SUSPENDED should block");

        // REACTIVATE
        if (await AdminOp("reactivate") != "ACTIVE") throw new Exception("reactivate failed");
        var encResp = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/encrypt",
            new StringContent(JsonSerializer.Serialize(new { kid = KID, data = dataB64 }),
                              Encoding.UTF8, "application/json"));
        var finalJwe = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync())
                                   .RootElement.GetProperty("jweToken").GetRawText();

        // REVOKE
        if (await AdminOp("revoke") != "REVOKED") throw new Exception("revoke failed");
        if (await Encrypt() != 412) throw new Exception("REVOKED should block encrypt");
        var dec = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/decrypt",
            new StringContent($"{{ \"jweToken\": {finalJwe} }}",
                              Encoding.UTF8, "application/json"));
        if ((int)dec.StatusCode != 200) throw new Exception("REVOKED should allow historic decrypt");

        // ARCHIVE
        if (await AdminOp("archive") != "ARCHIVED") throw new Exception("archive failed");
        Console.WriteLine("Full lifecycle drill PASSED");
    }
}

Where to go next


Did this page help you?