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 ​

collectDecryptStream ​

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

Safe, buffering wrapper around decryptStream that resolves to the full plaintext only after the entire stream authenticates cleanly.

decryptStream is an async generator that yields each plaintext chunk as soon as it individually authenticates. That incremental-release property means a truncated or tampered stream can yield genuine prefix chunks before throwing — the caller is responsible for not treating those provisional chunks as final output until the loop completes. Getting that contract right everywhere is error-prone.

collectDecryptStream handles it correctly by design: it buffers all chunks internally and only concatenates and returns them once the generator finishes without error. If the stream is tampered, truncated, or uses the wrong key, it throws PqcError with code DECRYPTION_FAILED or INVALID_CIPHERTEXT — exactly the same codes as decryptStream — and no partial plaintext is ever returned.

Trade-off: the whole plaintext is held in memory. Use decryptStream directly when the payload may be too large to buffer (e.g. large files), reading its documentation carefully to honour the incremental-release contract yourself.

Parameters ​

secretKey ​

SecretKey<KemAlgorithm>

ciphertext ​

AsyncIterable<Uint8Array<ArrayBufferLike>>

Returns ​

Promise<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; }

// Safe: throws on any tampering, never returns partial plaintext.
const plaintext = await pqc.collectDecryptStream(pair.secretKey, replay());

See ​

collectDecryptStream for the safe buffering variant.

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: <A>(data, secretKey, options?) => Promise<Uint8Array<ArrayBufferLike>>

Signs data with ML-DSA (FIPS 204) in hedged mode (randomized signing, the standard's default). Returns the signature, whose length depends on the key's parameter set (2420 bytes for ML-DSA-44, 3309 for ML-DSA-65, 4627 for ML-DSA-87).

Type Parameters ​

A ​

A extends SignatureAlgorithm

Parameters ​

data ​

string | Uint8Array<ArrayBufferLike>

secretKey ​

SecretKey<A>

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: <A>(data, signature, publicKey, options?) => Promise<boolean>

Verifies an ML-DSA signature. Returns false for invalid or malformed signatures, and — per FIPS 204 §3.6.2 — for a public key of the wrong length. It never throws because of a corrupted signature or a mis-sized key; it throws only when the key is not an ML-DSA public key at all (wrong algorithm, wrong use, unknown algorithm) or when the context string exceeds 255 bytes.

Type Parameters ​

A ​

A extends SignatureAlgorithm

Parameters ​

data ​

string | Uint8Array<ArrayBufferLike>

signature ​

Uint8Array

publicKey ​

PublicKey<A>

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.