Skip to content

Migrating to post-quantum ​

Migration is gradual: you cannot swap every key and every client at once. This toolkit covers the parts a library can: finding what needs to change, upgrading keys without discarding the classical identity, and tracking progress with an audit trail.

StepTool
1. Find classical cryptographyquantum-safe-audit and the GitHub Action
2. Upgrade keys to hybridUpgrader
3. Track and gate each key's progressMigrationStateManager with a store you provide
4. Retire the classical halfUpgrader.stripClassicalComponent, once all clients are ready

It does not re-encrypt your stored data, and it does not decide when your clients are ready.

A plan, end to end ​

  1. Inventory. Run npx quantum-safe-audit scan . and cbom .; read the audit page. Decide which findings are exposed to harvest-now-decrypt-later (anything whose confidentiality must outlive about a decade: encrypted data at rest, recorded traffic, long-lived secrets) and fix those first. Signatures matter once a quantum computer exists, so they can follow.
  2. Pick suites (Choosing what to use): hybrid X25519+ML-KEM-768 and Ed25519+ML-DSA-65 (or -v2) in most cases.
  3. Upgrade keys with Upgrader, one population at a time, recording each key as hybrid_transition in a MigrationStateManager.
  4. Dual-run. Servers accept both the old and the new; clients move when ready. Watch migrationProgress() and needsMigration().
  5. Prefer post-quantum (pqc_preferred) once nearly every client can use the new key, then retire the classical half (pqc_only) with Upgrader.stripClassicalComponent. Going back is possible but explicit and recorded.
  6. Gate CI with the GitHub Action so new classical cryptography cannot slip back in.
StateMeaningTypical action
classical_onlyOnly the classical key existsUpgrade it
hybrid_transitionA hybrid key exists; clients are movingRun both; track progress
pqc_preferredNearly everyone uses the new key; classical is a fallbackPrepare to retire the classical half
pqc_onlyClassical half removed (terminal)Done

Upgrade a key ​

Upgrader adds a fresh post-quantum key to an existing classical key and keeps the classical bytes unchanged, so the same X25519 or Ed25519 identity continues inside the new hybrid key.

ts
import { HybridKEM, Upgrader, utf8 } from 'quantum-safe-ts';

// Pretend these are your existing X25519 key bytes (32 + 32 bytes). Here we lift them out of a throwaway key.
using old = new HybridKEM().generateKeyPair();
const pubRaw = old.publicKey.toBytes();
const secRaw = old.secretKey.exportBytes();
const len = (raw: Uint8Array) => (raw[0]! << 8) | raw[1]!;
const classicalPublic = pubRaw.slice(2, 2 + len(pubRaw));
const classicalSecret = secRaw.slice(2, 2 + len(secRaw));

const result = Upgrader.upgradeKemKey({ classicalSecret, classicalPublic, classicalAlgorithm: 'X25519', targetPqc: 'ML-KEM-768' });
using upgraded = result.keyPair;
if (result.newAlgorithm !== 'X25519+ML-KEM-768' || result.migrationState !== 'hybrid_transition') throw new Error('unexpected');

// The upgraded key is a working hybrid key.
const kem = new HybridKEM('X25519+ML-KEM-768');
const { ciphertext, sharedSecret } = kem.encapsulate(upgraded.publicKey);
using back = kem.decapsulate(upgraded.secretKey, ciphertext);
sharedSecret.free();
if (back.exportBytes().length !== 32) throw new Error('unexpected');
secRaw.fill(0);

The upgrader round-trips the new key (encapsulate and decapsulate, or sign and verify) before returning it, so a classical public key that does not belong to the secret, or a swapped pair, is refused at upgrade time instead of producing a hybrid key that can never decrypt.

Supported: X25519 and P-256 (KEM, with ML-KEM-512/768/1024 where approved), Ed25519 and P-256 (signing, with ML-DSA-44/65/87 where approved). Raw key layouts follow quantum-safe-py: X25519 and Ed25519 are 32 raw bytes; a P-256 secret is its PKCS#8 PEM text as bytes; a P-256 KEM public key is the 65-byte uncompressed point; a P-256 signing public key is the 64-byte raw x ‖ y.

The old classical key is not a drop-in for the new one

The hybrid key is packed as u16 length ‖ classical ‖ post-quantum. A sender that only speaks plain X25519 cannot use it. Keep the original classical key (and a classical decryption path) for any such sender during the transition, and plan to retire it. (quantum-safe-py's documentation says old senders can still use the classical component; its code does not support that either.)

Track progress ​

ts
import { MemoryMigrationStore, MigrationStateManager } from 'quantum-safe-ts';

const manager = new MigrationStateManager(new MemoryMigrationStore());
await manager.transition({
  keyId: 'user-1', fromState: 'classical_only', toState: 'hybrid_transition', algorithm: 'X25519+ML-KEM-768', actor: 'rotation-job',
});
await manager.transition({ keyId: 'user-1', fromState: 'hybrid_transition', toState: 'pqc_preferred', algorithm: 'X25519+ML-KEM-768' });

// Going backwards needs an explicit flag and a reason, and is recorded.
await manager.transition({
  keyId: 'user-1', fromState: 'pqc_preferred', toState: 'hybrid_transition', algorithm: 'X25519+ML-KEM-768',
  allowBackward: true, reason: 'client rollback: interop bug',
});

const progress = await manager.migrationProgress();
if (progress.hybrid_transition !== 1 || (await manager.getHistory('user-1')).length !== 3) throw new Error('unexpected');

The states are classical_only → hybrid_transition → pqc_preferred → pqc_only. pqc_only is terminal. A transition names the state it expects the key to be in; if another writer moved the key first, you get an error instead of a silent overwrite.

Re-wrap sealed envelopes ​

Upgrading a key does not touch what was already encrypted to it. To move stored envelopes to the new key, re-wrap them: open under the old secret key, seal the plaintext to the new public key, keep the AAD. Envelope.rewrap does one message; rewrapDirectory from quantum-safe-ts/rewrap (Node.js) does a directory.

ts
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Envelope, HybridKEM, SealedMessage, utf8 } from 'quantum-safe-ts';
import { rewrapDirectory } from 'quantum-safe-ts/rewrap';

using oldPair = new HybridKEM('X25519+ML-KEM-768').generateKeyPair();
using newPair = new HybridKEM('X25519+ML-KEM-1024').generateKeyPair();

// One message: a new SealedMessage for the new key; the original is untouched.
const sealed = Envelope.seal(utf8('record body'), oldPair.publicKey, { aad: utf8('record-42') });
const moved = Envelope.rewrap(sealed, oldPair.secretKey, newPair.publicKey, { expectedAad: utf8('record-42') });
if (moved.algorithm !== 'X25519+ML-KEM-1024') throw new Error('not moved');

// A directory: the source is not touched, every result is opened again and compared.
const source = mkdtempSync(join(tmpdir(), 'rewrap-src-'));
const destination = mkdtempSync(join(tmpdir(), 'rewrap-dst-'));
writeFileSync(join(source, 'record-42.sealed'), sealed.toBytes());
const report = await rewrapDirectory(source, destination, {
  oldSecretKey: oldPair.secretKey,
  newPublicKey: newPair.publicKey,
  newSecretKey: newPair.secretKey,
  expectedAad: (relativePath) => utf8(relativePath === 'record-42.sealed' ? 'record-42' : relativePath),
});
if (!report.ok || report.counts.rewrapped !== 1) throw new Error('re-wrap failed');
const written = SealedMessage.fromBytes(new Uint8Array(readFileSync(join(destination, 'record-42.sealed'))));
console.log(report.counts, written.algorithm); // { rewrapped: 1, skipped: 0, failed: 0 } X25519+ML-KEM-1024

What the driver guarantees:

  • The source is not touched unless you pass inPlace: true, which replaces files one at a time through a temporary file and an atomic rename.
  • Resumable and idempotent. A file whose output already exists and is a valid migrated copy is already_migrated; a second run does nothing and an interrupted run continues.
  • Verified. With newSecretKey every written file is opened again and its plaintext compared (SHA-256) with the original. Without it verified is null: nothing was checked.
  • One bad file does not stop the run. Each item ends rewrapped, skipped (not an envelope), already_migrated, would_rewrap (dryRun: true) or failed, with a reason that contains no secret. The report holds names, algorithms and counts, never keys or plaintext.
  • The AAD is checked. Pass expectedAad (bytes, or a function of the relative path); if you omit it each message's own AAD is accepted and kept, and aadChecked is false.

quantum-safe-py has the same operation (Envelope.rewrap, quantum_safe.migrate.rewrap.rewrap_directory and qs-migrate rewrap), with the same report shape; what one library re-wraps the other opens. Streaming envelopes are a different container.

Concurrency: what is and is not safe ​

Each key's history is stored as one document and written with a single compare-and-set. That gives you:

SetupGuarantee
One process, any storeTransitions on a key are serialised.
Several processes, one machineUse FileMigrationStore (below). Tested with concurrent child processes: exactly one writer wins a race.
Several machinesProvide a store with an atomic compareAndSet (Redis, Postgres, DynamoDB, SQLite recipes in the interface docs). The manager is safe if your store is. We have not tested those backends here.
A store without compareAndSetOnly in-process safety. manager.crossProcessSafe is false. Use an external lock.
ts
// Node.js only; a complete, running example with two racing workers is in the Cookbook (recipe 6).
import { MigrationStateManager } from 'quantum-safe-ts';
import { FileMigrationStore } from 'quantum-safe-ts/file-store';

const manager = new MigrationStateManager(new FileMigrationStore('./migration-state'));

FileMigrationStore uses a per-key lock directory stamped with an owner token and kept fresh by a heartbeat, re-checks ownership before each write, and finishes with an fsynced atomic rename. A process frozen for longer than staleLockMs can lose its lock; it then fails with "lock lost" instead of writing. It is for one host: network filesystems (NFS, SMB) and some container volume drivers do not give atomic mkdir or rename, so do not share one directory across machines.

Moving state to or from quantum-safe-py ​

Records are JSON in this library and CBOR in quantum-safe-py's store, so they are not interchangeable as stored. Converters read and write py's exact layout (<id>_current, <id>_history, CBOR):

ts
import { MemoryMigrationStore, MigrationStateManager, exportToPyStore, importFromPyStore } from 'quantum-safe-ts';

const manager = new MigrationStateManager(new MemoryMigrationStore());
await manager.transition({ keyId: 'user-1', fromState: 'classical_only', toState: 'hybrid_transition', algorithm: 'X25519+ML-KEM-768', actor: 'job' });

const entries = await exportToPyStore(manager);            // Map<string, Uint8Array>: '<id>_current' and '<id>_history' as CBOR, load into a py store dict
console.log([...entries.keys()]);

const other = new MigrationStateManager(new MemoryMigrationStore());
const imported = await importFromPyStore(entries, other);  // validates the history chain; refuses to overwrite existing keys
if (imported.length !== 1 || (await other.getCurrentState('user-1')) !== 'hybrid_transition') throw new Error('round trip failed');

Both directions are verified against the real Python library, and the exported bytes are identical to what py writes.

Apache-2.0.