RSA-2048 → ML-KEM-768 Immediate Rotation
Rotate a classical RSA-2048 key into a post-quantum ML-KEM-768 successor. Application code keeps referencing the original kid — the platform silently redirects to the PQC successor.
🔀 RSA-2048 → ML-KEM-768 Immediate Rotation
Scenario: you have a classical RSA-2048 encryption key in production. Your quantum-readiness deadline arrives. An admin rotates it into a post-quantum ML-KEM-768 successor — the application code never changes. Every subsequent encrypt call using the original kid transparently redirects to the PQC successor.
This is the killer recipe for the platform — the whole reason the abstraction of "the app references a kid, the platform resolves the algorithm" exists. And it also shows the auth separation cleanly: the admin does the rotation, the app just keeps encrypting.
Prerequisites
Two identities in the same tenant (same as the AES-256 recipe):
- Admin (email + password + tenant UUID) — issues a
password-grant token used for key creation and rotation. - Application (
client_id+client_secret) — issues aclient_credentials-grant token used for runtime encrypt / decrypt. Its code never sees the successorkid.
Step-by-step
# ── Env
export BASE_URL="https://staging.ankatech.co"
export ADMIN_EMAIL="[email protected]"
export ADMIN_PASSWORD="••••••••••••"
export TENANT_ID="00000000-0000-0000-0000-000000000003"
export CLIENT_ID="00000000-0000-0000-0000-000000000000"
export CLIENT_SECRET="••••••••••••••••••••••••••••••••"
export SRC_KID="rsa2048-payments-src"
export SUCC_KID="mlkem768-payments-succ"
# ── Step 1a: Admin token (password grant) — for create+rotate
export ADMIN_TOKEN=$(curl -s -X POST "$BASE_URL/api/v3/auth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "username=$ADMIN_EMAIL" \
--data-urlencode "password=$ADMIN_PASSWORD" \
--data-urlencode "tenant_id=$TENANT_ID" \
| jq -r .access_token)
# ── Step 1b: App token (client_credentials grant) — for encrypt/decrypt
export APP_TOKEN=$(curl -s -X POST "$BASE_URL/api/v3/auth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
| jq -r .access_token)
# ── Step 2: Admin creates the classical source key — RSA-2048
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\": \"$SRC_KID\",
\"kty\": \"RSA\",
\"algorithm\": \"RSA-OAEP-256\",
\"purpose\": \"ENCRYPT_DECRYPT\",
\"keyOps\": [\"encrypt\", \"decrypt\"],
\"exportable\": true
}" | jq
# ── Step 3: Admin rotates into ML-KEM-768 successor
# acknowledgeCapabilityReduction=true is required when the new
# algorithm supports fewer operations (RSA sign/verify; ML-KEM does not).
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$SRC_KID/rotations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"newKey\": {
\"kid\": \"$SUCC_KID\",
\"kty\": \"ML-KEM\",
\"algorithm\": \"ML-KEM-768\",
\"purpose\": \"ENCRYPT_DECRYPT\",
\"keyOps\": [\"encrypt\", \"decrypt\"]
},
\"acknowledgeCapabilityReduction\": true
}" | jq
# ── Step 4: App encrypts using the ORIGINAL kid — no code change
export DATA_B64=$(printf "%s" "Payment #12345 — sensitive" | base64 -w0)
curl -s -X POST "$BASE_URL/api/v3/crypto/encrypt" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"kid\": \"$SRC_KID\", \"data\": \"$DATA_B64\" }" \
| tee /tmp/enc.json \
| jq '{keyRequested, algorithmUsed, materialVersion}'
# ── keyRequested = "rsa2048-payments-src" (what your app asked for)
# ── algorithmUsed = "ML-KEM-768+A256GCM" (what actually ran)
# The redirect happened transparently.
# ── Step 5: App decrypts (same transparent redirect via the token header)
export JWE=$(jq -c .jweToken /tmp/enc.json)
curl -s -X POST "$BASE_URL/api/v3/crypto/decrypt" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"jweToken\": $JWE }" | jq
# ── Step 6: Verify
DECRYPTED_B64=$(curl -s -X POST "$BASE_URL/api/v3/crypto/decrypt" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"jweToken\": $JWE }" | jq -r .decryptedData)
echo "$DECRYPTED_B64" | base64 -d
# → Payment #12345 — sensitiveimport base64
import requests
BASE_URL = "https://staging.ankatech.co"
ADMIN_EMAIL = "[email protected]"
ADMIN_PASSWORD = "••••••••••••"
TENANT_ID = "00000000-0000-0000-0000-000000000003"
CLIENT_ID = "00000000-0000-0000-0000-000000000000"
CLIENT_SECRET = "••••••••••••••••••••••••••••••••"
SRC_KID = "rsa2048-payments-src"
SUCC_KID = "mlkem768-payments-succ"
def token(fields):
return requests.post(f"{BASE_URL}/api/v3/auth/token",
data=fields).json()["access_token"]
# ── Step 1a: Admin token — password grant
admin_auth = {"Authorization": f"Bearer {token({
'grant_type': 'password',
'username': ADMIN_EMAIL,
'password': ADMIN_PASSWORD,
'tenant_id': TENANT_ID,
})}"}
# ── Step 1b: App token — client_credentials grant
app_auth = {"Authorization": f"Bearer {token({
'grant_type': 'client_credentials',
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
})}"}
# ── Step 2: Admin creates the RSA-2048 source key
requests.post(
f"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys",
headers=admin_auth,
json={
"kid": SRC_KID,
"kty": "RSA",
"algorithm": "RSA-OAEP-256",
"purpose": "ENCRYPT_DECRYPT",
"keyOps": ["encrypt", "decrypt"],
"exportable": True,
},
).raise_for_status()
print(f"Source key: {SRC_KID}")
# ── Step 3: Admin rotates to ML-KEM-768 successor
requests.post(
f"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys/{SRC_KID}/rotations",
headers=admin_auth,
json={
"newKey": {
"kid": SUCC_KID,
"kty": "ML-KEM",
"algorithm": "ML-KEM-768",
"purpose": "ENCRYPT_DECRYPT",
"keyOps": ["encrypt", "decrypt"],
},
"acknowledgeCapabilityReduction": True,
},
).raise_for_status()
print(f"Rotated: {SRC_KID} → {SUCC_KID}")
# ── Step 4: App encrypts using the ORIGINAL kid — no code change
plaintext = "Payment #12345 — sensitive"
data_b64 = base64.b64encode(plaintext.encode()).decode()
enc = requests.post(
f"{BASE_URL}/api/v3/crypto/encrypt",
headers=app_auth,
json={"kid": SRC_KID, "data": data_b64},
).json()
print(f"keyRequested: {enc['keyRequested']} ← what the app asked for")
print(f"algorithmUsed: {enc['algorithmUsed']} ← what actually ran (PQC)")
# ── Step 5-6: App decrypts + verify
dec = requests.post(
f"{BASE_URL}/api/v3/crypto/decrypt",
headers=app_auth,
json={"jweToken": enc["jweToken"]},
).json()
decrypted = base64.b64decode(dec["decryptedData"]).decode()
assert decrypted == plaintext
assert "ML-KEM" in enc["algorithmUsed"], "Redirect did NOT happen"
print(f"Transparent rotation OK: '{decrypted}'")import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import com.fasterxml.jackson.databind.*;
public class RsaToMlKemRotation {
static final String BASE_URL = "https://staging.ankatech.co";
static final String ADMIN_EMAIL = "[email protected]";
static final String ADMIN_PASSWORD = "••••••••••••";
static final String TENANT_ID = "00000000-0000-0000-0000-000000000003";
static final String CLIENT_ID = "00000000-0000-0000-0000-000000000000";
static final String CLIENT_SECRET = "••••••••••••••••••••••••••••••••";
static final String SRC_KID = "rsa2048-payments-src";
static final String SUCC_KID = "mlkem768-payments-succ";
static final HttpClient http = HttpClient.newHttpClient();
static final ObjectMapper json = new ObjectMapper();
static String token(String body) throws Exception {
var resp = http.send(
HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/auth/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build(),
HttpResponse.BodyHandlers.ofString());
return json.readTree(resp.body()).get("access_token").asText();
}
public static void main(String[] args) throws Exception {
// ── Step 1a: Admin token — password grant
var adminAuth = "Bearer " + token(
"grant_type=password&username=" + ADMIN_EMAIL
+ "&password=" + ADMIN_PASSWORD
+ "&tenant_id=" + TENANT_ID);
// ── Step 1b: App token — client_credentials grant
var appAuth = "Bearer " + token(
"grant_type=client_credentials"
+ "&client_id=" + CLIENT_ID
+ "&client_secret=" + CLIENT_SECRET);
// ── Step 2: Admin creates the RSA-2048 source 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": "RSA",
"algorithm": "RSA-OAEP-256",
"purpose": "ENCRYPT_DECRYPT",
"keyOps": ["encrypt", "decrypt"],
"exportable": true
}""".formatted(SRC_KID)))
.build(),
HttpResponse.BodyHandlers.ofString());
// ── Step 3: Admin rotates to ML-KEM-768 successor
http.send(
HttpRequest.newBuilder(URI.create(
BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID
+ "/keys/" + SRC_KID + "/rotations"))
.header("Authorization", adminAuth)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{
"newKey": {
"kid": "%s",
"kty": "ML-KEM",
"algorithm": "ML-KEM-768",
"purpose": "ENCRYPT_DECRYPT",
"keyOps": ["encrypt", "decrypt"]
},
"acknowledgeCapabilityReduction": true
}""".formatted(SUCC_KID)))
.build(),
HttpResponse.BodyHandlers.ofString());
// ── Step 4: App encrypts using the ORIGINAL kid — no code change
var plaintext = "Payment #12345 — sensitive";
var dataB64 = Base64.getEncoder().encodeToString(plaintext.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\": \"" + SRC_KID + "\", \"data\": \"" + dataB64 + "\" }"))
.build(),
HttpResponse.BodyHandlers.ofString());
JsonNode enc = json.readTree(encResp.body());
System.out.println("keyRequested: " + enc.get("keyRequested").asText());
System.out.println("algorithmUsed: " + enc.get("algorithmUsed").asText());
// ── Step 5-6: App decrypts + verify
var jweToken = enc.get("jweToken").toString();
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\": " + jweToken + " }"))
.build(),
HttpResponse.BodyHandlers.ofString());
var decrypted = new String(Base64.getDecoder().decode(
json.readTree(decResp.body()).get("decryptedData").asText()));
assert decrypted.equals(plaintext);
assert enc.get("algorithmUsed").asText().contains("ML-KEM");
System.out.println("Transparent rotation OK: " + decrypted);
}
}const BASE_URL = "https://staging.ankatech.co";
const ADMIN_EMAIL = "[email protected]";
const ADMIN_PASSWORD = "••••••••••••";
const TENANT_ID = "00000000-0000-0000-0000-000000000003";
const CLIENT_ID = "00000000-0000-0000-0000-000000000000";
const CLIENT_SECRET = "••••••••••••••••••••••••••••••••";
const SRC_KID = "rsa2048-payments-src";
const SUCC_KID = "mlkem768-payments-succ";
async function getToken(fields) {
const r = await fetch(`${BASE_URL}/api/v3/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fields),
});
return (await r.json()).access_token;
}
// ── Step 1a: Admin token
const adminAuth = { "Authorization": `Bearer ${await getToken({
grant_type: "password",
username: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
tenant_id: TENANT_ID,
})}` };
// ── Step 1b: App token
const appAuth = { "Authorization": `Bearer ${await getToken({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
})}` };
const jsonH = (auth) => ({ ...auth, "Content-Type": "application/json" });
// ── Step 2: Admin creates the RSA-2048 source key
await fetch(`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys`, {
method: "POST",
headers: jsonH(adminAuth),
body: JSON.stringify({
kid: SRC_KID,
kty: "RSA",
algorithm: "RSA-OAEP-256",
purpose: "ENCRYPT_DECRYPT",
keyOps: ["encrypt", "decrypt"],
exportable: true,
}),
});
console.log(`Source key: ${SRC_KID}`);
// ── Step 3: Admin rotates to ML-KEM-768 successor
await fetch(
`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys/${SRC_KID}/rotations`,
{
method: "POST",
headers: jsonH(adminAuth),
body: JSON.stringify({
newKey: {
kid: SUCC_KID,
kty: "ML-KEM",
algorithm: "ML-KEM-768",
purpose: "ENCRYPT_DECRYPT",
keyOps: ["encrypt", "decrypt"],
},
acknowledgeCapabilityReduction: true,
}),
},
);
console.log(`Rotated: ${SRC_KID} → ${SUCC_KID}`);
// ── Step 4: App encrypts using the ORIGINAL kid — no code change
const plaintext = "Payment #12345 — sensitive";
const dataB64 = Buffer.from(plaintext).toString("base64");
const enc = await (await fetch(`${BASE_URL}/api/v3/crypto/encrypt`, {
method: "POST",
headers: jsonH(appAuth),
body: JSON.stringify({ kid: SRC_KID, data: dataB64 }),
})).json();
console.log(`keyRequested: ${enc.keyRequested} ← what the app asked for`);
console.log(`algorithmUsed: ${enc.algorithmUsed} ← what actually ran`);
// ── Step 5-6: App decrypts + verify
const dec = await (await fetch(`${BASE_URL}/api/v3/crypto/decrypt`, {
method: "POST",
headers: jsonH(appAuth),
body: JSON.stringify({ jweToken: enc.jweToken }),
})).json();
const decrypted = Buffer.from(dec.decryptedData, "base64").toString();
if (decrypted !== plaintext) throw new Error("Mismatch!");
if (!enc.algorithmUsed.includes("ML-KEM")) throw new Error("No PQC redirect!");
console.log(`Transparent rotation OK: '${decrypted}'`);using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class RsaToMlKemRotation
{
const string BASE_URL = "https://staging.ankatech.co";
const string ADMIN_EMAIL = "[email protected]";
const string ADMIN_PASSWORD = "••••••••••••";
const string TENANT_ID = "00000000-0000-0000-0000-000000000003";
const string CLIENT_ID = "00000000-0000-0000-0000-000000000000";
const string CLIENT_SECRET = "••••••••••••••••••••••••••••••••";
const string SRC_KID = "rsa2048-payments-src";
const string SUCC_KID = "mlkem768-payments-succ";
static async Task<string> GetToken(
HttpClient http, IEnumerable<KeyValuePair<string,string>> fields)
{
var resp = await http.PostAsync($"{BASE_URL}/api/v3/auth/token",
new FormUrlEncodedContent(fields));
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync())
.RootElement.GetProperty("access_token").GetString();
}
static async Task Main()
{
var http = new HttpClient();
// ── Step 1a: Admin token — password grant
var adminToken = await GetToken(http, new[]
{
new KeyValuePair<string,string>("grant_type", "password"),
new KeyValuePair<string,string>("username", ADMIN_EMAIL),
new KeyValuePair<string,string>("password", ADMIN_PASSWORD),
new KeyValuePair<string,string>("tenant_id", TENANT_ID),
});
// ── Step 1b: App token — client_credentials grant
var appToken = await GetToken(http, new[]
{
new KeyValuePair<string,string>("grant_type", "client_credentials"),
new KeyValuePair<string,string>("client_id", CLIENT_ID),
new KeyValuePair<string,string>("client_secret", CLIENT_SECRET),
});
var adminHttp = new HttpClient();
adminHttp.DefaultRequestHeaders.Add("Authorization", $"Bearer {adminToken}");
var appHttp = new HttpClient();
appHttp.DefaultRequestHeaders.Add("Authorization", $"Bearer {appToken}");
// ── Step 2: Admin creates the RSA-2048 source key
await adminHttp.PostAsync(
$"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys",
new StringContent(JsonSerializer.Serialize(new
{
kid = SRC_KID,
kty = "RSA",
algorithm = "RSA-OAEP-256",
purpose = "ENCRYPT_DECRYPT",
keyOps = new[] { "encrypt", "decrypt" },
exportable = true,
}), Encoding.UTF8, "application/json"));
Console.WriteLine($"Source key: {SRC_KID}");
// ── Step 3: Admin rotates to ML-KEM-768 successor
await adminHttp.PostAsync(
$"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys/{SRC_KID}/rotations",
new StringContent(JsonSerializer.Serialize(new
{
newKey = new
{
kid = SUCC_KID,
kty = "ML-KEM",
algorithm = "ML-KEM-768",
purpose = "ENCRYPT_DECRYPT",
keyOps = new[] { "encrypt", "decrypt" },
},
acknowledgeCapabilityReduction = true,
}), Encoding.UTF8, "application/json"));
Console.WriteLine($"Rotated: {SRC_KID} → {SUCC_KID}");
// ── Step 4: App encrypts using the ORIGINAL kid — no code change
var plaintext = "Payment #12345 — sensitive";
var dataB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(plaintext));
var encResp = await appHttp.PostAsync(
$"{BASE_URL}/api/v3/crypto/encrypt",
new StringContent(
JsonSerializer.Serialize(new { kid = SRC_KID, data = dataB64 }),
Encoding.UTF8, "application/json"));
var enc = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine($"keyRequested: {enc.GetProperty("keyRequested").GetString()}"
+ " ← what the app asked for");
Console.WriteLine($"algorithmUsed: {enc.GetProperty("algorithmUsed").GetString()}"
+ " ← what actually ran (PQC)");
// ── Step 5-6: App decrypts + verify
var jweToken = enc.GetProperty("jweToken").GetRawText();
var decResp = await appHttp.PostAsync(
$"{BASE_URL}/api/v3/crypto/decrypt",
new StringContent($"{{ \"jweToken\": {jweToken} }}",
Encoding.UTF8, "application/json"));
var dec = JsonDocument.Parse(await decResp.Content.ReadAsStringAsync()).RootElement;
var decrypted = Encoding.UTF8.GetString(
Convert.FromBase64String(dec.GetProperty("decryptedData").GetString()));
if (decrypted != plaintext) throw new Exception("Mismatch!");
if (!enc.GetProperty("algorithmUsed").GetString().Contains("ML-KEM"))
throw new Exception("No PQC redirect!");
Console.WriteLine($"Transparent rotation OK: '{decrypted}'");
}
}Expected output
Source key: rsa2048-payments-src
Rotated: rsa2048-payments-src → mlkem768-payments-succ
keyRequested: rsa2048-payments-src ← what the app asked for
algorithmUsed: ML-KEM-768+A256GCM ← what actually ran (PQC)
Transparent rotation OK: 'Payment #12345 — sensitive'
What just happened
Two separated concerns, two identities:
- The admin (password grant) provisioned the RSA-2048 key, then rotated it into an ML-KEM-768 successor. All admin operations run on
/api/v3/admin/*with an admin-audience token. - The application (client_credentials grant) kept using the original
kid(rsa2048-payments-src) for encrypt. Its token audience isankasecure-core, which authorises/api/v3/crypto/*but nothing on/api/v3/admin/*.
Transparent redirect at runtime:
- The platform silently routed the encrypt to the successor and ran ML-KEM-768 — a NIST-standardised post-quantum KEM.
- On decrypt, the JWE token's protected header carries the successor
kid— the platform routes the decryption without you specifying it. - The
keyRequestedvsalgorithmUsedfields on every response make the redirect observable in your logs, without the application's business logic having to care.
That is the crypto agility promise: cryptographic change becomes a policy decision, not a code deployment.
Real-world use
- Quantum readiness deadline — an admin rotates all encrypt keys into PQC ahead of Q-Day, without touching one line of application code.
- Regulatory jurisdiction change — a policy update swaps the algorithm to one approved by the new jurisdiction.
- Key material compromise — the admin rotates immediately; new ciphertexts use the successor; old ciphertexts remain decryptable because the previous material version is retained under the same stable kid until archived.
Where to go next
- AES-256 Encrypt / Decrypt — start here if you're new to the platform.
- ML-KEM-1024 Encrypt / Decrypt — start fresh with PQC.
POST /api/v3/admin/tenants/{tenantId}/keys/{kid}/rotations— full endpoint reference with all rotation options.GET /api/v3/algorithms— every valid PQC and classical target for rotation.
Updated 10 days ago