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 retainedimport 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
- Key Revocation Validation — focused revocation drill.
- RSA-3072 Sign, then Rapid Revocation — same idea for signing keys.
markKeyForDestruction— final step after archive, with cooling-off period.
Updated 10 days ago