Key Revocation Validation (ML-KEM-768)
Prove that a revoked key is unusable for new operations, but that existing ciphertexts remain decryptable while the key material is retained.
🚫 Key Revocation Validation (ML-KEM-768)
Scenario: provision an ML-KEM-768 key, encrypt data, revoke the key, then observe that (a) new encrypts are blocked with HTTP 412, but (b) old ciphertexts are still decryptable as long as the material version is retained (not yet archived / destroyed). This is the validation drill your compliance team asks for.
Prerequisites
Same two identities as the AES-256 recipe.
Step-by-step
export BASE_URL="https://staging.ankatech.co"
export TENANT_ID="00000000-0000-0000-0000-000000000003"
export KID="revocation-drill-key"
# ADMIN_TOKEN + APP_TOKEN as in AES-256 recipe
# Step 2: admin creates ML-KEM-768 key
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-768\",
\"purpose\":\"ENCRYPT_DECRYPT\", \"keyOps\":[\"encrypt\",\"decrypt\"] }" | jq
# Step 3: app encrypts BEFORE revocation — save the jweToken
export DATA_B64=$(printf "%s" "Before revoke" | base64 -w0)
export OLD_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 4: admin revokes the key
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID/revoke" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{status, revokedAt}'
# → { "status": "REVOKED", ... }
# Step 5: attempt a NEW encrypt — must return 412
curl -s -o /tmp/enc-after.json -w "HTTP %{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\" }"
# → HTTP 412 { "type": ".../key-lifecycle-blocked", "status": 412, ... }
# Step 6: DECRYPT the pre-revoke jweToken — should still work
curl -s -X POST "$BASE_URL/api/v3/crypto/decrypt" \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
-d "{ \"jweToken\": $OLD_JWE }" \
| tee /tmp/dec.json | jq '{decryptedData, warnings}'
# → decryptedData: "QmVmb3JlIHJldm9rZQ==" (Base64 of "Before revoke")
# → warnings: ["key kid=... is REVOKED; material retained for historic decrypts only"]
echo "$(jq -r .decryptedData /tmp/dec.json)" | base64 -d
# → Before revokeimport base64, requests
BASE = "https://staging.ankatech.co"
TENANT_ID = "00000000-0000-0000-0000-000000000003"
KID = "revocation-drill-key"
# admin / app tokens as in AES-256
# Create key
requests.post(f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys", headers=admin, json={
"kid": KID, "kty": "ML-KEM", "algorithm": "ML-KEM-768",
"purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"],
}).raise_for_status()
# Encrypt BEFORE revocation
enc_before = requests.post(f"{BASE}/api/v3/crypto/encrypt", headers=app,
json={"kid": KID, "data": base64.b64encode(b"Before revoke").decode()}).json()
old_jwe = enc_before["jweToken"]
# Revoke
requests.post(f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}/revoke",
headers=admin).raise_for_status()
print("Key revoked")
# Try a NEW encrypt — expect 412
r = requests.post(f"{BASE}/api/v3/crypto/encrypt", headers=app,
json={"kid": KID, "data": "aGVsbG8="})
assert r.status_code == 412, f"Expected 412, got {r.status_code}"
print("New encrypt blocked (412) — correct")
# Decrypt the OLD jweToken — should still work
dec = requests.post(f"{BASE}/api/v3/crypto/decrypt", headers=app,
json={"jweToken": old_jwe}).json()
assert base64.b64decode(dec["decryptedData"]) == b"Before revoke"
print(f"Old ciphertext still decryptable — warnings: {dec.get('warnings', [])}")import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import com.fasterxml.jackson.databind.*;
public class RevocationValidation {
static final String BASE_URL = "https://staging.ankatech.co";
static final String TENANT_ID = "00000000-0000-0000-0000-000000000003";
static final String KID = "revocation-drill-key";
static final HttpClient http = HttpClient.newHttpClient();
static final ObjectMapper json = new ObjectMapper();
public static void main(String[] args) throws Exception {
// ADMIN_TOKEN + APP_TOKEN acquired as in the AES-256 recipe
String adminAuth = "Bearer " + System.getenv("ADMIN_TOKEN");
String appAuth = "Bearer " + System.getenv("APP_TOKEN");
// Admin creates ML-KEM-768 key
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-768",
"purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"] }""".formatted(KID))).build(),
HttpResponse.BodyHandlers.ofString());
// Encrypt BEFORE revocation
var dataB64 = Base64.getEncoder().encodeToString("Before revoke".getBytes());
var encResp = 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 oldJwe = json.readTree(encResp.body()).get("jweToken").toString();
// Admin revokes
http.send(HttpRequest.newBuilder(URI.create(
BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID + "/keys/" + KID + "/revoke"))
.header("Authorization", adminAuth)
.POST(HttpRequest.BodyPublishers.noBody()).build(),
HttpResponse.BodyHandlers.ofString());
// New encrypt → expect 412
var enc2 = 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\": \"aGVsbG8=\" }")).build(),
HttpResponse.BodyHandlers.ofString());
if (enc2.statusCode() != 412) throw new RuntimeException("Expected 412, got " + enc2.statusCode());
System.out.println("New encrypt blocked (412) — correct");
// Decrypt OLD jweToken — still works
var decResp = 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\": " + oldJwe + " }")).build(),
HttpResponse.BodyHandlers.ofString());
JsonNode dec = json.readTree(decResp.body());
String plaintext = new String(Base64.getDecoder().decode(dec.get("decryptedData").asText()));
if (!"Before revoke".equals(plaintext)) throw new RuntimeException("Mismatch");
System.out.println("Old ciphertext still decryptable — warnings=" + dec.get("warnings"));
}
}const BASE_URL = "https://staging.ankatech.co";
const TENANT_ID = "00000000-0000-0000-0000-000000000003";
const KID = "revocation-drill-key";
// Create ML-KEM-768 key
await fetch(`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys`, {
method: "POST", headers: jsonH(admin),
body: JSON.stringify({
kid: KID, kty: "ML-KEM", algorithm: "ML-KEM-768",
purpose: "ENCRYPT_DECRYPT", keyOps: ["encrypt","decrypt"],
}),
});
// Encrypt BEFORE revocation
const encBefore = await (await fetch(`${BASE_URL}/api/v3/crypto/encrypt`, {
method: "POST", headers: jsonH(app),
body: JSON.stringify({ kid: KID, data: Buffer.from("Before revoke").toString("base64") }),
})).json();
const oldJwe = encBefore.jweToken;
// Revoke
await fetch(`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys/${KID}/revoke`, {
method: "POST", headers: admin,
});
// Try NEW encrypt — expect 412
const r = await fetch(`${BASE_URL}/api/v3/crypto/encrypt`, {
method: "POST", headers: jsonH(app),
body: JSON.stringify({ kid: KID, data: "aGVsbG8=" }),
});
if (r.status !== 412) throw new Error(`Expected 412, got ${r.status}`);
console.log("New encrypt blocked (412)");
// Decrypt OLD jweToken — should still work
const dec = await (await fetch(`${BASE_URL}/api/v3/crypto/decrypt`, {
method: "POST", headers: jsonH(app),
body: JSON.stringify({ jweToken: oldJwe }),
})).json();
if (Buffer.from(dec.decryptedData, "base64").toString() !== "Before revoke")
throw new Error("Fail");
console.log(`Old ciphertext still decryptable — warnings: ${JSON.stringify(dec.warnings || [])}`);using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class RevocationValidation
{
const string BASE_URL = "https://staging.ankatech.co";
const string TENANT_ID = "00000000-0000-0000-0000-000000000003";
const string KID = "revocation-drill-key";
static async Task Main()
{
// ADMIN_TOKEN + APP_TOKEN acquired as in the AES-256 recipe
var adminHttp = new HttpClient();
adminHttp.DefaultRequestHeaders.Add("Authorization",
$"Bearer {Environment.GetEnvironmentVariable("ADMIN_TOKEN")}");
var appHttp = new HttpClient();
appHttp.DefaultRequestHeaders.Add("Authorization",
$"Bearer {Environment.GetEnvironmentVariable("APP_TOKEN")}");
// Admin creates ML-KEM-768 key
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-768",
purpose = "ENCRYPT_DECRYPT", keyOps = new[] { "encrypt", "decrypt" },
}), Encoding.UTF8, "application/json"));
// Encrypt BEFORE revocation
var dataB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("Before revoke"));
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 enc = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement;
var oldJwe = enc.GetProperty("jweToken").GetRawText();
// Admin revokes
await adminHttp.PostAsync(
$"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}/revoke",
new StringContent(""));
// New encrypt → expect 412
var enc2 = await appHttp.PostAsync(
$"{BASE_URL}/api/v3/crypto/encrypt",
new StringContent(JsonSerializer.Serialize(new { kid = KID, data = "aGVsbG8=" }),
Encoding.UTF8, "application/json"));
if ((int)enc2.StatusCode != 412) throw new Exception($"Expected 412, got {(int)enc2.StatusCode}");
Console.WriteLine("New encrypt blocked (412) — correct");
// Decrypt OLD jweToken — still works
var decResp = await appHttp.PostAsync(
$"{BASE_URL}/api/v3/crypto/decrypt",
new StringContent($"{{ \"jweToken\": {oldJwe} }}",
Encoding.UTF8, "application/json"));
var dec = JsonDocument.Parse(await decResp.Content.ReadAsStringAsync()).RootElement;
var plaintext = Encoding.UTF8.GetString(
Convert.FromBase64String(dec.GetProperty("decryptedData").GetString()));
if (plaintext != "Before revoke") throw new Exception("Mismatch");
Console.WriteLine($"Old ciphertext still decryptable");
}
}Lifecycle states summary
| State | Encrypt / sign | Decrypt / verify | Material retained |
|---|---|---|---|
| ACTIVE | ✅ | ✅ | Yes |
| SUSPENDED | ❌ 412 | ❌ 412 | Yes |
| REVOKED | ❌ 412 | ✅ with warning | Yes |
| ARCHIVED | ❌ 412 | ✅ with warning | Yes (read-only) |
| DESTROYED | ❌ 410 Gone | ❌ 410 Gone | No |
Only DESTROYED breaks historical decryption. REVOKED preserves the material for the retention window — configurable per tenant policy.
Where to go next
- Key Lifecycle Management — walk through every state transition.
- RSA-3072 Sign, then Rapid Revocation — same shape but for signatures.
POST /keys/{kid}/revoke— endpoint reference.
Updated 10 days ago
Did this page help you?