Skip to content

@pqc-sdk/core / pqc

Variable: pqc

const pqc: object

SDK entry point: post-quantum hybrid encryption and digital signatures with safe defaults, zero configuration.

keys.generate() with no arguments still returns ml-kem-768 (the pqcenc.v1 envelope); pass { algorithm: 'x-wing' } for the classical+PQC hybrid KEM (X25519 + ML-KEM-768, pqcenc.v2 envelope) recommended for long-term data — see the "Choosing an algorithm" guide. The no-arg default changes to x-wing at v1.0.

Type Declaration

decrypt

decrypt: (ciphertext, secretKey) => Promise<Uint8Array<ArrayBufferLike>>

Decrypts a ciphertext produced by encrypt, discriminating the envelope on its leading version byte (0x01 = ml-kem-768 v1, 0x02 = x-wing v2; unknown versions fail with INVALID_CIPHERTEXT). If the ciphertext was tampered with or the key does not match, it throws PqcError with code DECRYPTION_FAILED — it never returns corrupted data.

Parameters

ciphertext

Uint8Array

secretKey

SecretKey<KemAlgorithm>

Returns

Promise<Uint8Array<ArrayBufferLike>>

Example

ts
import { pqc } from '@pqc-sdk/core';

const plaintext = await pqc.decrypt(ciphertext, pair.secretKey);
new TextDecoder().decode(plaintext);

decryptStream

decryptStream: (secretKey, ciphertext) => AsyncGenerator<Uint8Array<ArrayBufferLike>>

Decrypts a stream produced by encryptStream, discriminating the envelope on its leading version byte the same way decrypt does (0x03 = ml-kem-768 streaming, 0x04 = x-wing streaming; unrelated to and rejected the same way as the one-shot 0x01/0x02 bytes).

IMPORTANT — incremental release, read before writing decrypted output anywhere observable. This function yields each plaintext chunk as soon as that specific chunk authenticates. Every yielded chunk is genuinely authentic on its own — but that is not the same guarantee one-shot decrypt gives, where the call returning already means the whole plaintext is authentic. Here, a truncated or tampered stream can — and, for a stream truncated after the point of tampering, will — yield one or more genuine prefix chunks before the iterable throws PqcError (DECRYPTION_FAILED). The only signal that the full plaintext is authentic and complete is the iterable finishing without throwing — never any individual yielded chunk, and never "N chunks came out without error yet." A consumer writing this output to a file, a socket, or anywhere else observable must treat that output as provisional until the loop below completes cleanly, and must discard or roll back whatever was written if it throws instead. This is structural to streaming/online AEAD (age has the same property), not a shortcut this implementation took — see docs/serialization-format.md §9.3 for the full decode algorithm this follows.

Parameters

secretKey

SecretKey<KemAlgorithm>

ciphertext

AsyncIterable<Uint8Array<ArrayBufferLike>>

Returns

AsyncGenerator<Uint8Array<ArrayBufferLike>>

Example

ts
import { pqc } from '@pqc-sdk/core';

const pair = await pqc.keys.generate();
async function* source() {
  yield new TextEncoder().encode('streamed data');
}
const ciphertextChunks: Uint8Array[] = [];
for await (const chunk of pqc.encryptStream(pair.publicKey, source())) {
  ciphertextChunks.push(chunk);
}
async function* replay() {
  yield* ciphertextChunks;
}

// Correct handling of the incremental-release property: buffer chunks
// as they arrive, but only treat them as real output once the loop
// finishes without throwing. On error, the provisional buffer is
// discarded — never returned, never written to a permanent sink.
const provisional: Uint8Array[] = [];
let plaintext: Uint8Array;
try {
  for await (const chunk of pqc.decryptStream(pair.secretKey, replay())) {
    provisional.push(chunk); // authentic on its own, but NOT proof of completeness
  }
  // Reached only on clean completion — now, and only now, the full
  // plaintext is confirmed authentic.
  plaintext = new Uint8Array(provisional.reduce((n, c) => n + c.length, 0));
  let offset = 0;
  for (const chunk of provisional) {
    plaintext.set(chunk, offset);
    offset += chunk.length;
  }
} catch (error) {
  // provisional is discarded here, not used — the stream never
  // completed, so nothing in it is trustworthy as "the" plaintext.
  throw error;
}

decryptWebStream

decryptWebStream: (secretKey) => TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>

decryptStream as a Web Streams TransformStream. Same incremental-release property as decryptStream applies here too — see its documentation. Piping into a TransformStream does not change that property: bytes written to a downstream sink before the pipe completes are provisional, and the sink must be treated as incomplete until pipeTo's returned promise resolves without rejecting.

Parameters

secretKey

SecretKey<KemAlgorithm>

Returns

TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>

Example

ts
import { createReadStream, createWriteStream } from 'node:fs';
import { Readable, Writable } from 'node:stream';
import { pqc } from '@pqc-sdk/core';

// pipeTo's promise rejecting means the sink file holds an incomplete,
// unverified prefix — callers must delete/ignore it on rejection, never
// treat a partially-written output file as done.
await Readable.toWeb(createReadStream('large-file.bin.enc'))
  .pipeThrough(pqc.decryptWebStream(pair.secretKey))
  .pipeTo(Writable.toWeb(createWriteStream('large-file.bin')));

encrypt

encrypt: (data, publicKey) => Promise<Uint8Array<ArrayBufferLike>>

Hybrid encryption: encapsulates a secret with the public key's KEM and encrypts the data with AES-256-GCM using that secret. The result is a single self-contained Uint8Array that only decrypt can open.

The key chooses the envelope (docs/serialization-format.md §2): an ml-kem-768 key (FIPS 203) produces a v1 envelope, an x-wing key (X25519 + ML-KEM-768 hybrid, draft-connolly-cfrg-xwing-kem-10) a v2 one.

Parameters

data

string | Uint8Array<ArrayBufferLike>

publicKey

PublicKey<KemAlgorithm>

Returns

Promise<Uint8Array<ArrayBufferLike>>

Example

ts
import { pqc } from '@pqc-sdk/core';

const pair = await pqc.keys.generate();
const ciphertext = await pqc.encrypt('sensitive data', pair.publicKey);

const hybrid = await pqc.keys.generate({ algorithm: 'x-wing' });
const v2Ciphertext = await pqc.encrypt('sensitive data', hybrid.publicKey);

encryptStream

encryptStream: (publicKey, plaintext, options?) => AsyncGenerator<Uint8Array<ArrayBufferLike>>

Hybrid streaming encryption: like encrypt, but for payloads too large to hold in memory at once. Encapsulates one KEM secret per stream (fresh, single-use, same as encrypt) and seals fixed-size chunks of plaintext under it, using age's STREAM chunk framing (docs/serialization-format.md §9). Bounded memory: roughly one chunk at a time, independent of total payload size.

Yields the 3-byte header and KEM ciphertext first, then one sealed chunk per plaintext chunk. Concatenating everything yielded reproduces the full wire format §9 describes.

chunkSize (advanced, rarely needed) defaults to 64 KiB — see StreamOptions.

Parameters

publicKey

PublicKey<KemAlgorithm>

plaintext

AsyncIterable<Uint8Array<ArrayBufferLike>>

options?

StreamOptions

Returns

AsyncGenerator<Uint8Array<ArrayBufferLike>>

Example

ts
import { pqc } from '@pqc-sdk/core';

const pair = await pqc.keys.generate();
async function* source() {
  yield new TextEncoder().encode('streamed data');
}
const parts: Uint8Array[] = [];
for await (const chunk of pqc.encryptStream(pair.publicKey, source())) {
  parts.push(chunk);
}

encryptWebStream

encryptWebStream: (publicKey, options?) => TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>

encryptStream as a Web Streams TransformStream, for pipeThrough/pipeTo pipelines on runtimes with the WHATWG Streams API (Node 18+, Deno, Cloudflare Workers — see docs/compatibility.md for which are actually verified). A thin wrapper: all the cryptographic work happens in encryptStream, this only bridges the two shapes.

Parameters

publicKey

PublicKey<KemAlgorithm>

options?

StreamOptions

Returns

TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>

Example

ts
import { createReadStream, createWriteStream } from 'node:fs';
import { Readable, Writable } from 'node:stream';
import { pqc } from '@pqc-sdk/core';

const pair = await pqc.keys.generate();
await Readable.toWeb(createReadStream('large-file.bin'))
  .pipeThrough(pqc.encryptWebStream(pair.publicKey))
  .pipeTo(Writable.toWeb(createWriteStream('large-file.bin.enc')));

keys

readonly keys: object

keys.deserialize

deserialize: {(serialized): PqcKey; <A, U>(serialized, expected): PqcKey<A, U>; }

Call Signature

(serialized): PqcKey

Rebuilds a key from the serialize format. Validates version, algorithm, use and length; on any problem it throws PqcError with code INVALID_SERIALIZED_KEY or INVALID_KEY.

Pass expected to assert the algorithm and use, getting back a narrow key type (e.g. PublicKey<'ml-kem-768'>) that drops straight into encrypt / sign without an as never cast. A mismatch throws WRONG_ALGORITHM or WRONG_KEY_USE.

Parameters
serialized

string

Returns

PqcKey

Example
ts
import { pqc } from '@pqc-sdk/core';

const token = pqc.keys.serialize((await pqc.keys.generate()).publicKey);
// Narrow to a typed key by asserting the expected algorithm and use:
const publicKey = pqc.keys.deserialize(token, { algorithm: 'ml-kem-768', use: 'public' });
const ciphertext = await pqc.encrypt('payload', publicKey);
Call Signature

<A, U>(serialized, expected): PqcKey<A, U>

Rebuilds a key from the serialize format. Validates version, algorithm, use and length; on any problem it throws PqcError with code INVALID_SERIALIZED_KEY or INVALID_KEY.

Pass expected to assert the algorithm and use, getting back a narrow key type (e.g. PublicKey<'ml-kem-768'>) that drops straight into encrypt / sign without an as never cast. A mismatch throws WRONG_ALGORITHM or WRONG_KEY_USE.

Type Parameters
A

A extends Algorithm

U

U extends KeyUse

Parameters
serialized

string

expected

ExpectedKey<A, U>

Returns

PqcKey<A, U>

Example
ts
import { pqc } from '@pqc-sdk/core';

const token = pqc.keys.serialize((await pqc.keys.generate()).publicKey);
// Narrow to a typed key by asserting the expected algorithm and use:
const publicKey = pqc.keys.deserialize(token, { algorithm: 'ml-kem-768', use: 'public' });
const ciphertext = await pqc.encrypt('payload', publicKey);

keys.generate

generate: {(): Promise<KeyPair<"x-wing">>; <A>(options): Promise<KeyPair<A>>; (options?): Promise<KeyPair<Algorithm>>; }

Call Signature

(): Promise<KeyPair<"x-wing">>

Generates a post-quantum key pair. With no options it generates an X-Wing hybrid pair (X25519 + ML-KEM-768), ready for pqc.encrypt.

The no-argument default is hybrid because a break in either component still leaves the other standing — the same reasoning behind TLS 1.3's X25519MLKEM768, Signal's PQXDH, and the BSI and ANSSI recommendations. ML-KEM-768 is young, and a cryptanalytic result against it would leave a pure-PQ ciphertext with nothing to fall back on.

'ml-kem-768' remains fully supported and is the right choice in two cases: when FIPS certification scope matters (X-Wing is not covered by FIPS 203, so a compliance regime requiring a certified KEM needs the pure one), and when size or speed dominate (32 bytes less per envelope, and roughly 2–4× faster per operation — see docs/MIGRATION-0.8.md).

Returns

Promise<KeyPair<"x-wing">>

Example
ts
import { pqc } from '@pqc-sdk/core';

const encryption = await pqc.keys.generate(); // x-wing hybrid
const pureMlKem = await pqc.keys.generate({ algorithm: 'ml-kem-768' });
const signing = await pqc.keys.generate({ algorithm: 'ml-dsa-65' });
Call Signature

<A>(options): Promise<KeyPair<A>>

Generates a post-quantum key pair. With no options it generates an X-Wing hybrid pair (X25519 + ML-KEM-768), ready for pqc.encrypt.

The no-argument default is hybrid because a break in either component still leaves the other standing — the same reasoning behind TLS 1.3's X25519MLKEM768, Signal's PQXDH, and the BSI and ANSSI recommendations. ML-KEM-768 is young, and a cryptanalytic result against it would leave a pure-PQ ciphertext with nothing to fall back on.

'ml-kem-768' remains fully supported and is the right choice in two cases: when FIPS certification scope matters (X-Wing is not covered by FIPS 203, so a compliance regime requiring a certified KEM needs the pure one), and when size or speed dominate (32 bytes less per envelope, and roughly 2–4× faster per operation — see docs/MIGRATION-0.8.md).

Type Parameters
A

A extends Algorithm

Parameters
options

GenerateOptions<A> & object

Returns

Promise<KeyPair<A>>

Example
ts
import { pqc } from '@pqc-sdk/core';

const encryption = await pqc.keys.generate(); // x-wing hybrid
const pureMlKem = await pqc.keys.generate({ algorithm: 'ml-kem-768' });
const signing = await pqc.keys.generate({ algorithm: 'ml-dsa-65' });
Call Signature

(options?): Promise<KeyPair<Algorithm>>

Generates a post-quantum key pair. With no options it generates an X-Wing hybrid pair (X25519 + ML-KEM-768), ready for pqc.encrypt.

The no-argument default is hybrid because a break in either component still leaves the other standing — the same reasoning behind TLS 1.3's X25519MLKEM768, Signal's PQXDH, and the BSI and ANSSI recommendations. ML-KEM-768 is young, and a cryptanalytic result against it would leave a pure-PQ ciphertext with nothing to fall back on.

'ml-kem-768' remains fully supported and is the right choice in two cases: when FIPS certification scope matters (X-Wing is not covered by FIPS 203, so a compliance regime requiring a certified KEM needs the pure one), and when size or speed dominate (32 bytes less per envelope, and roughly 2–4× faster per operation — see docs/MIGRATION-0.8.md).

Parameters
options?

GenerateOptions<Algorithm>

Returns

Promise<KeyPair<Algorithm>>

Example
ts
import { pqc } from '@pqc-sdk/core';

const encryption = await pqc.keys.generate(); // x-wing hybrid
const pureMlKem = await pqc.keys.generate({ algorithm: 'ml-kem-768' });
const signing = await pqc.keys.generate({ algorithm: 'ml-dsa-65' });

keys.serialize

serialize: (key) => string

Serializes a key to a portable string: pqcv1.<algorithm>.<use>.<base64url>.

Parameters
key

PqcKey

Returns

string

Example
ts
import { pqc } from '@pqc-sdk/core';

const pair = await pqc.keys.generate();
const token = pqc.keys.serialize(pair.publicKey);
// "pqcv1.ml-kem-768.public.h1q3…"

sign

sign: (data, secretKey, options?) => Promise<Uint8Array<ArrayBufferLike>>

Signs data with ML-DSA-65 (FIPS 204) in hedged mode (randomized signing, the standard's default). Returns the 3309-byte signature.

Parameters

data

string | Uint8Array<ArrayBufferLike>

secretKey

SecretKey<"ml-dsa-65">

options?

SignatureOptions

Returns

Promise<Uint8Array<ArrayBufferLike>>

Example

ts
import { pqc } from '@pqc-sdk/core';

const pair = await pqc.keys.generate({ algorithm: 'ml-dsa-65' });
const signature = await pqc.sign(document, pair.secretKey);

verify

verify: (data, signature, publicKey, options?) => Promise<boolean>

Verifies an ML-DSA-65 signature. Returns false for invalid or malformed signatures (it never throws because of a corrupted signature); it only throws if the key is not ML-DSA.

Parameters

data

string | Uint8Array<ArrayBufferLike>

signature

Uint8Array

publicKey

PublicKey<"ml-dsa-65">

options?

SignatureOptions

Returns

Promise<boolean>

Example

ts
import { pqc } from '@pqc-sdk/core';

const valid = await pqc.verify(document, signature, pair.publicKey);
if (!valid) throw new Error('invalid signature');

Example

ts
import { pqc } from '@pqc-sdk/core';

// Encryption (ML-KEM-768 + AES-256-GCM)
const pair = await pqc.keys.generate();
const ciphertext = await pqc.encrypt('hello', pair.publicKey);
const plaintext = await pqc.decrypt(ciphertext, pair.secretKey);

// Hybrid encryption (X-Wing: X25519 + ML-KEM-768 + AES-256-GCM)
const hybrid = await pqc.keys.generate({ algorithm: 'x-wing' });
const hybridCiphertext = await pqc.encrypt('hello', hybrid.publicKey);
const hybridPlaintext = await pqc.decrypt(hybridCiphertext, hybrid.secretKey);

// Signatures (ML-DSA-65)
const signer = await pqc.keys.generate({ algorithm: 'ml-dsa-65' });
const signature = await pqc.sign('document', signer.secretKey);
await pqc.verify('document', signature, signer.publicKey); // true

For payloads too large to hold in memory at once, see encryptStream/decryptStream (or the Web Streams adapters, encryptWebStream/decryptWebStream) — read decryptStream's documentation first, it has an incremental-release property one-shot decrypt does not.