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 a client_credentials-grant token used for runtime encrypt / decrypt. Its code never sees the successor kid.

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 — sensitive
import 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 is ankasecure-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 keyRequested vs algorithmUsed fields 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


Did this page help you?