Re-Sign RSA-2048 → ML-DSA-65 (PQC signature migration)

Migrate an existing JWS signed with RSA-2048 to a fresh ML-DSA-65 signature — payload bytes preserved. The parallel of key rotation but for signatures, not envelopes.

✍️ Re-Sign RSA-2048 → ML-DSA-65 (PQC signature migration)

Scenario: you have an existing JWS token signed with RSA-2048 — perhaps stored in a database, embedded in a document, distributed to counterparties. Your PQC deadline arrives. Re-sign the payload with ML-DSA-65 (NIST FIPS 204, Level 3), producing a new JWS with the same payload bytes but a post-quantum signature. No re-transmission of the payload required.

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 OLD_KID="rsa2048-legacy-sig"
export NEW_KID="mldsa65-successor-sig"
# ADMIN_TOKEN and APP_TOKEN acquired as in the AES-256 recipe

# Step 2: admin provisions both keys
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\":\"$OLD_KID\", \"kty\":\"RSA\", \"algorithm\":\"RS256\",
        \"purpose\":\"SIGN_VERIFY\", \"keyOps\":[\"sign\",\"verify\"] }" | jq

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\":\"$NEW_KID\", \"kty\":\"ML-DSA\", \"algorithm\":\"ML-DSA-65\",
        \"purpose\":\"SIGN_VERIFY\", \"keyOps\":[\"sign\",\"verify\"] }" | jq

# Step 3: app signs with the OLD (RSA) key — this represents the legacy JWS
export DATA_B64=$(printf "%s" "Long-lived contract — v1" | base64 -w0)
export OLD_JWS=$(curl -s -X POST "$BASE_URL/api/v3/crypto/sign" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"kid\":\"$OLD_KID\", \"data\":\"$DATA_B64\" }" | jq -r .jwsToken)

# Step 4: re-sign the JWS with the NEW (ML-DSA-65) key
#         The endpoint verifies the incoming JWS under OLD_KID, then produces
#         a fresh JWS with the same payload under NEW_KID.
curl -s -X POST "$BASE_URL/api/v3/crypto/resign" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"jwsToken\":\"$OLD_JWS\", \"newKid\":\"$NEW_KID\" }" \
  | tee /tmp/resigned.json | jq '{algorithmUsed, keyRequested}'
# → algorithmUsed: "ML-DSA-65"    keyRequested: "mldsa65-successor-sig"

# Step 5: verify the NEW JWS
export NEW_JWS=$(jq -r .jwsToken /tmp/resigned.json)
curl -s -X POST "$BASE_URL/api/v3/crypto/verify" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"jwsToken\":\"$NEW_JWS\" }" | jq
# → { "isValid": true, "algorithmUsed": "ML-DSA-65", ... }
import base64, requests

BASE = "https://staging.ankatech.co"
TENANT_ID = "00000000-0000-0000-0000-000000000003"
OLD_KID, NEW_KID = "rsa2048-legacy-sig", "mldsa65-successor-sig"
# admin / app tokens as in the AES-256 recipe

# Admin provisions both keys
for spec in [
    {"kid": OLD_KID, "kty": "RSA",    "algorithm": "RS256"},
    {"kid": NEW_KID, "kty": "ML-DSA", "algorithm": "ML-DSA-65"},
]:
    requests.post(f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys", headers=admin,
                  json={**spec, "purpose": "SIGN_VERIFY", "keyOps": ["sign","verify"]}
                 ).raise_for_status()

# App signs with legacy RSA
data_b64 = base64.b64encode(b"Long-lived contract - v1").decode()
old_jws = requests.post(f"{BASE}/api/v3/crypto/sign", headers=app,
                        json={"kid": OLD_KID, "data": data_b64}).json()["jwsToken"]

# Re-sign into ML-DSA-65
resigned = requests.post(f"{BASE}/api/v3/crypto/resign", headers=app,
                         json={"jwsToken": old_jws, "newKid": NEW_KID}).json()
new_jws = resigned["jwsToken"]
print(f"Re-signed as {resigned['algorithmUsed']}")

# Verify the NEW signature
ver = requests.post(f"{BASE}/api/v3/crypto/verify", headers=app,
                    json={"jwsToken": new_jws}).json()
assert ver["isValid"] and ver["algorithmUsed"] == "ML-DSA-65"
print("PQC re-sign PASSED")
import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import com.fasterxml.jackson.databind.*;

public class ReSignRsaToMlDsa {
    static final String BASE_URL  = "https://staging.ankatech.co";
    static final String TENANT_ID = "00000000-0000-0000-0000-000000000003";
    static final String OLD_KID   = "rsa2048-legacy-sig";
    static final String NEW_KID   = "mldsa65-successor-sig";

    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 provisions both keys
        for (String[] spec : new String[][] {
            { OLD_KID, "RSA",    "RS256" },
            { NEW_KID, "ML-DSA", "ML-DSA-65" },
        }) {
            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(String.format(
                    "{ \"kid\": \"%s\", \"kty\": \"%s\", \"algorithm\": \"%s\","
                    + " \"purpose\": \"SIGN_VERIFY\", \"keyOps\": [\"sign\",\"verify\"] }",
                    spec[0], spec[1], spec[2]))).build(),
                HttpResponse.BodyHandlers.ofString());
        }

        // Sign with legacy RSA
        var dataB64 = Base64.getEncoder().encodeToString("Long-lived contract - v1".getBytes());
        var sigResp = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/sign"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(
                "{ \"kid\": \"" + OLD_KID + "\", \"data\": \"" + dataB64 + "\" }")).build(),
            HttpResponse.BodyHandlers.ofString());
        String oldJws = json.readTree(sigResp.body()).get("jwsToken").asText();

        // Re-sign into ML-DSA-65
        var reResp = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/resign"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(String.format(
                "{ \"jwsToken\": \"%s\", \"newKid\": \"%s\" }", oldJws, NEW_KID))).build(),
            HttpResponse.BodyHandlers.ofString());
        JsonNode re = json.readTree(reResp.body());
        System.out.println("Re-signed as " + re.get("algorithmUsed").asText());

        // Verify the NEW signature
        String newJws = re.get("jwsToken").asText();
        var verResp = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/verify"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(
                "{ \"jwsToken\": \"" + newJws + "\" }")).build(),
            HttpResponse.BodyHandlers.ofString());
        JsonNode ver = json.readTree(verResp.body());
        if (!ver.get("isValid").asBoolean() || !ver.get("algorithmUsed").asText().equals("ML-DSA-65"))
            throw new RuntimeException("PQC re-sign failed");
        System.out.println("PQC re-sign PASSED");
    }
}
const BASE_URL = "https://staging.ankatech.co";
const TENANT_ID = "00000000-0000-0000-0000-000000000003";
const OLD_KID = "rsa2048-legacy-sig", NEW_KID = "mldsa65-successor-sig";
// admin / app / jsonH as in AES-256 recipe

// Admin provisions both keys
for (const spec of [
  { kid: OLD_KID, kty: "RSA",    algorithm: "RS256" },
  { kid: NEW_KID, kty: "ML-DSA", algorithm: "ML-DSA-65" },
]) {
  await fetch(`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys`, {
    method: "POST", headers: jsonH(admin),
    body: JSON.stringify({ ...spec, purpose: "SIGN_VERIFY", keyOps: ["sign","verify"] }),
  });
}

// App signs with legacy RSA
const dataB64 = Buffer.from("Long-lived contract - v1").toString("base64");
const oldJws = (await (await fetch(`${BASE_URL}/api/v3/crypto/sign`, {
  method: "POST", headers: jsonH(app),
  body: JSON.stringify({ kid: OLD_KID, data: dataB64 }),
})).json()).jwsToken;

// Re-sign into ML-DSA-65
const resigned = await (await fetch(`${BASE_URL}/api/v3/crypto/resign`, {
  method: "POST", headers: jsonH(app),
  body: JSON.stringify({ jwsToken: oldJws, newKid: NEW_KID }),
})).json();
console.log(`Re-signed as ${resigned.algorithmUsed}`);

// Verify NEW signature
const ver = await (await fetch(`${BASE_URL}/api/v3/crypto/verify`, {
  method: "POST", headers: jsonH(app),
  body: JSON.stringify({ jwsToken: resigned.jwsToken }),
})).json();
if (!ver.isValid || ver.algorithmUsed !== "ML-DSA-65") throw new Error("Fail");
console.log("PQC re-sign PASSED");
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

class ReSignRsaToMlDsa
{
    const string BASE_URL  = "https://staging.ankatech.co";
    const string TENANT_ID = "00000000-0000-0000-0000-000000000003";
    const string OLD_KID   = "rsa2048-legacy-sig";
    const string NEW_KID   = "mldsa65-successor-sig";

    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 provisions both keys
        foreach (var spec in new[] {
            new { kid = OLD_KID, kty = "RSA",    algorithm = "RS256"     },
            new { kid = NEW_KID, kty = "ML-DSA", algorithm = "ML-DSA-65" },
        }) {
            await adminHttp.PostAsync(
                $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys",
                new StringContent(JsonSerializer.Serialize(new {
                    spec.kid, spec.kty, spec.algorithm,
                    purpose = "SIGN_VERIFY", keyOps = new[] { "sign", "verify" },
                }), Encoding.UTF8, "application/json"));
        }

        // Sign with legacy RSA
        var dataB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("Long-lived contract - v1"));
        var sigResp = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/sign",
            new StringContent(JsonSerializer.Serialize(new { kid = OLD_KID, data = dataB64 }),
                              Encoding.UTF8, "application/json"));
        var oldJws = JsonDocument.Parse(await sigResp.Content.ReadAsStringAsync())
                                 .RootElement.GetProperty("jwsToken").GetString();

        // Re-sign into ML-DSA-65
        var reResp = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/resign",
            new StringContent(JsonSerializer.Serialize(new { jwsToken = oldJws, newKid = NEW_KID }),
                              Encoding.UTF8, "application/json"));
        var re = JsonDocument.Parse(await reResp.Content.ReadAsStringAsync()).RootElement;
        Console.WriteLine($"Re-signed as {re.GetProperty("algorithmUsed").GetString()}");

        // Verify NEW signature
        var newJws = re.GetProperty("jwsToken").GetString();
        var verResp = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/verify",
            new StringContent(JsonSerializer.Serialize(new { jwsToken = newJws }),
                              Encoding.UTF8, "application/json"));
        var ver = JsonDocument.Parse(await verResp.Content.ReadAsStringAsync()).RootElement;
        if (!ver.GetProperty("isValid").GetBoolean()
            || ver.GetProperty("algorithmUsed").GetString() != "ML-DSA-65")
            throw new Exception("PQC re-sign failed");
        Console.WriteLine("PQC re-sign PASSED");
    }
}

What just happened

  • The re-sign endpoint is a two-step operation the platform runs atomically: verify the incoming JWS under OLD_KID, then produce a fresh JWS with the same payload bytes under NEW_KID.
  • Payload never leaves the platform — you send only the JWS token, not the raw content.
  • The old JWS remains valid — this recipe doesn't invalidate history, it just produces a PQC-signed alternative.

Where to go next


Did this page help you?