Skip to content

Keys and passwords ​

Keys are the one thing you must get right operationally. This page covers the key objects, how to store and move them, how memory is handled, and how to derive keys from passwords.

The key objects ​

ClassHoldsNotes
PublicKeyAlgorithm name and raw public bytesSafe to share, log and store. fingerprint() gives a stable id.
SecretKeyThe secret halfStays in WebAssembly memory; .free() wipes the owned buffers. Never printed.
KeyPairBothFrom generateKeyPair(), KeyPair.fromCborBundle or KeyPair.fromKeys(pub, sec)
SecretBytes (alias SharedSecret)A secret byte stringKEM shared secrets and Argon2id master keys; deriveKey, exportBytes, .free()

Every key records its algorithm and a migration state, and every API checks them: a key of one algorithm cannot be used with another suite (AlgorithmMismatchError).

Serialization ​

ts
import { PublicKey, SecretKey, KeyPair, HybridKEM } from 'quantum-safe-ts';

using pair = new HybridKEM().generateKeyPair();

// Public keys: PEM, CBOR, JWK. All safe to publish.
const pem = pair.publicKey.toPem();       // QUANTUM SAFE PUBLIC KEY, identical to quantum-safe-py's to_pem()
const jwk = pair.publicKey.toJwk();       // kty "AKP"; public keys only
const cbor = pair.publicKey.toCbor();
for (const back of [PublicKey.fromPem(pem), PublicKey.fromJwk(jwk), PublicKey.fromCbor(cbor)]) {
  if (back.fingerprint() !== pair.publicKey.fingerprint()) throw new Error('fingerprint changed');
  back.free();
}

// Secret keys: PEM or CBOR. These contain the secret: protect them.
const secretPem = pair.secretKey.toPem();     // QUANTUM SAFE SECRET KEY
using restored = SecretKey.fromPem(secretPem);
if (restored.algorithm !== 'X25519+ML-KEM-768') throw new Error('unexpected algorithm');

// A pair travels as one CBOR bundle.
const bundle = pair.toCborBundle();
using again = KeyPair.fromCborBundle(bundle);
if (again.publicKey.fingerprint() !== pair.publicKey.fingerprint()) throw new Error('bundle changed the key');
FormatMethodsNotes
CBORtoCbor / fromCbor{v, algo, ms, ktype, key}, identical to quantum-safe-py
PEMtoPem / fromPemQUANTUM SAFE PUBLIC KEY / QUANTUM SAFE SECRET KEY
JWKtoJwk / fromJwkPublic keys only. Secret keys are never exported as JWK. (StandardJwt has its own private JWK for ML-DSA, see JWT.)
RawPublicKey.fromBytes(algorithm, bytes), toBytes(); SecretKey.fromBytes, exportBytes()For wire formats you define. Check the algorithm and length.
Fingerprintfingerprint(), fingerprintColon()sha256(algorithm ‖ 0x00 ‖ key) as hex, or in aa:bb:... form
BundleKeyPair.toCborBundle / fromCborBundlePublic and secret key together
AssembleKeyPair.fromKeys(pub, sec)From separately held keys

What the parsers refuse ​

Parsing keys that come from outside is a security boundary, so the parsers are strict. They refuse (with a typed error, never a crash):

  • a secret key loaded as a public key or the reverse (ktype must be present and must match);
  • a public key of the wrong length for its algorithm, which is how a secret key pasted into a public-key field is caught;
  • a JWK that is not kty: "AKP";
  • a newer format version than this library knows (IncompatibleKeyVersionError) and a version below 1;
  • payloads over 10 MB, nesting that is too deep, duplicate map keys, trailing bytes and non-minimal integer encodings.
ts
import { PublicKey, SecretKey, HybridKEM, KeyParseError } from 'quantum-safe-ts';

using pair = new HybridKEM().generateKeyPair();

let refused = 0;
const attempts: (() => unknown)[] = [
  () => PublicKey.fromCbor(pair.secretKey.toCbor()),             // secret key presented as a public key
  () => SecretKey.fromCbor(pair.publicKey.toCbor()),             // and the reverse
  () => PublicKey.fromPem(pair.secretKey.toPem()),
  () => PublicKey.fromBytes('X25519+ML-KEM-768', new Uint8Array(32)), // wrong length
  () => PublicKey.fromJwk('{"kty":"RSA","alg":"X25519+ML-KEM-768","pub":"AAAA"}'),
];
for (const attempt of attempts) {
  try { attempt(); } catch (e) { if (e instanceof KeyParseError) refused++; else throw e; }
}
if (refused !== attempts.length) throw new Error(`only ${refused} of ${attempts.length} were refused`);

Memory ​

SecretKey, SecretBytes and KeyPair hold their secrets in WebAssembly memory and wipe the owned buffers on .free() or at the end of a using scope. After free(), any use throws InvalidArgumentError. Anything you copy out (exportBytes(), toPem() of a secret, plaintext from Envelope.open) lives in the JavaScript heap, where this library cannot wipe it: call wipe() on those copies when done. toString(), toJSON() and util.inspect never reveal key material.

ts
import { HybridKEM, InvalidArgumentError, wipe } from 'quantum-safe-ts';

const pair = new HybridKEM().generateKeyPair();
const copy = pair.secretKey.exportBytes();       // a JS-heap copy: your responsibility now
wipe(copy);
if (copy.some((b) => b !== 0)) throw new Error('wipe must zero the copy');
pair.free();
try {
  pair.secretKey.exportBytes();                  // use after free
  throw new Error('should have failed');
} catch (e) {
  if (!(e instanceof InvalidArgumentError)) throw e;
}
using other = new HybridKEM().generateKeyPair();
if (JSON.stringify(other.secretKey) !== '{"type":"SecretKey","redacted":true}') throw new Error('secrets must not serialise');

Honest limits: copies made while arguments are passed to WebAssembly and while parsing may remain in WebAssembly linear memory after free(); the JavaScript engine may keep copies of strings (the easy layer's PEM strings) you cannot wipe. See the security model.

Where to keep secret keys ​

OptionWhen
A secrets manager or KMS, loaded at start-upServers. Load the PEM/CBOR into a SecretKey, free it when done.
Encrypted at rest with a key derived from a passwordDesktop or CLI tools (see below)
localStorage / IndexedDB in a browserOnly if you accept that any script on the page can read it. Prefer not persisting secret keys.
In source control or an environment fileNever. quantum-safe-audit flags embedded private keys (QSJ060).

Passwords ​

ts
import { deriveMasterKey, utf8, toHex } from 'quantum-safe-ts';

using master = await deriveMasterKey('correct horse battery staple', utf8('0123456789abcdef')); // Argon2id
const authKey = master.deriveKey(32, utf8('myapp-auth-v1'));
const vaultKey = master.deriveKey(32, utf8('myapp-vault-v1'));
if (toHex(authKey) === toHex(vaultKey)) throw new Error('subkeys must differ');

deriveMasterKey(password, salt) is Argon2id with 19 MiB of memory, 2 passes and 1 lane, returning a 32-byte SecretBytes. The salt must be at least 8 bytes: use 16 or more random bytes, store it next to the ciphertext, and never reuse one across users. Derive purpose-specific subkeys with deriveKey(length, info) rather than using the master key directly. No Unicode normalisation is applied: normalise passwords yourself (for example NFKC) so the same password always yields the same key. Argon2id slows guessing; it cannot rescue a guessable password. In a browser, run it in a Web Worker: it takes tens of milliseconds on a desktop and blocks while it runs.

Common mistakes ​

MistakeFix
Logging a SecretKey or its PEMtoString()/toJSON() are safe, but toPem() of a secret is the secret itself
Forgetting .free()Use using, or try { ... } finally { key.free() }
Using the same salt for every userRandom 16+ bytes per user
Using the master key directly as an encryption keyderiveKey with a distinct info
Storing the secret next to the data it protectsKeep keys in a separate trust domain

Apache-2.0.