Post-quantum keys

Sign NEAR transactions with post-quantum ML-DSA-65 keys.

A sufficiently large quantum computer would break the elliptic-curve signatures (Ed25519, secp256k1) that protect blockchain accounts today. NEAR's answer is ML-DSA-65 — the NIST FIPS 204 lattice signature scheme also known as Dilithium — added as an account access-key and transaction-signing type starting at protocol version 85. FastNear ships it opt-in in the standalone @fastnear/ml-dsa-65 package (Node.js 20.19+ or a modern browser), so apps that only use classical keys never download the post-quantum implementation.

Install it with npm i @fastnear/ml-dsa-65, or load it in a browser with <script src="https://js.fastnear.com/ml-dsa-65.js"></script> next to near.js — it defines the NearMlDsa65 global (generateSigner, publicKeyToHandle, verifyHash) with the same API as the ESM build, and no build step.

How enrollment works
  • Generate an in-memory signer with generateSigner() — keys never touch disk unless you export them, and signer.destroy() zeroizes package-owned buffers when the lifecycle ends.
  • Enroll the signer's full public key with a classical full-access AddKey — enrollment always starts from an existing classical signer.
  • Sign and submit through sendTx({ signerId, signer, … }) in @fastnear/api. The signer already satisfies TransactionSigner structurally — it has publicKey and signHash() — so you pass it straight in with no adapter, and queryProtocolVersion() gates on v85.
  • Signers are in-memory by default. To restore one across restarts, rebuild it from retained secret material with signerFromSeed() or signerFromSecretKey(); verifyHash() checks a signature offline.
Two key forms
  • Full form ml-dsa-65:<base58> — use it for AddKey, direct access-key lookup, signing, and DeleteKey.
  • Compact handle ml-dsa-65-hash:<base58> — what NEAR stores on-chain and what access-key list responses expose: the SHA3-256 digest of a domain tag (near:ml-dsa-65-pubkey-hash:v1) followed by the raw public key.
  • Reconcile the two with publicKeyToHandle() before comparing against list responses.
Wire facts
  • Seed: 32 bytes. Public key: 1,952 bytes. Expanded secret key: 4,032 bytes. Signature: 3,309 bytes.
  • NEAR charges 100 Ggas for each outer or delegated ML-DSA-65 signature verification, so transactions are substantially larger and costlier than classical equivalents.
  • Scope: account access keys and transaction signatures only — validator and staking keys remain Ed25519.
Quickstarts

Generate a signer, enroll it, then send with it.

The three steps of the happy path, in order, straight from the catalog (ESM). The full set — generate, enroll, explicit-send, enroll-delete, reconcile — lives in the mlDsa65 key of recipes.json.

Generate an in-memory signer

Retain only public recovery metadata, and always destroy the signer when its lifecycle ends.

import { generateSigner } from "@fastnear/ml-dsa-65";

const signer = generateSigner();

try {
  // Public values are safe to retain for enrollment and cleanup.
  const recovery = {
    network: "testnet",
    accountId: "device.testnet",
    publicKey: signer.publicKey,
    publicKeyHandle: signer.publicKeyHandle,
  };

  console.log(recovery);
  // Never log or persist signer.exportSeed() or signer.exportSecretKey().
} finally {
  signer.destroy();
}
Enroll the public key with a classical AddKey

An ML-DSA-65 key cannot add itself — the AddKey is signed by an existing classical full-access key. Pass the full ml-dsa-65: key, never the ml-dsa-65-hash: handle.

import {
  actions,
  queryAccessKey,
  queryProtocolVersion,
  sendTx,
} from "@fastnear/api";
import { signerFromPrivateKey } from "@fastnear/utils";

export async function enrollMlDsa65Key({ accountId, classicalPrivateKey, signer }) {
  const protocolVersion = await queryProtocolVersion({ network: "testnet" });
  if (protocolVersion < 85) {
    throw new Error(`testnet protocol ${protocolVersion} does not support ML-DSA-65`);
  }

  // The AddKey itself is signed by an existing classical full-access key.
  const classicalSigner = signerFromPrivateKey(classicalPrivateKey);

  await sendTx({
    signerId: accountId,
    signer: classicalSigner,
    receiverId: accountId,
    // Pass the full ml-dsa-65:<base58> key. Never the ml-dsa-65-hash: handle.
    actions: [actions.addFullAccessKey({ publicKey: signer.publicKey })],
    waitUntil: "FINAL",
    network: "testnet",
  });

  // Read back with the full key: direct access-key lookup accepts it, while
  // access-key list responses expose only signer.publicKeyHandle.
  return queryAccessKey({
    accountId,
    publicKey: signer.publicKey,
    blockId: "final",
    network: "testnet",
  });
}
Send with an enrolled signer

The explicit-signer branch of sendTx, after the full public key has been enrolled on the account with a classical full-access AddKey.

import {
  actions,
  queryProtocolVersion,
  sendTx,
} from "@fastnear/api";

export async function sendOneYoctoWithMlDsa65({ accountId, signer }) {
  const protocolVersion = await queryProtocolVersion({ network: "testnet" });
  if (protocolVersion < 85) {
    throw new Error(`testnet protocol ${protocolVersion} does not support ML-DSA-65`);
  }

  return sendTx({
    signerId: accountId,
    signer,
    receiverId: accountId,
    actions: [actions.transfer("1")],
    waitUntil: "FINAL",
    network: "testnet",
  });
}
Safety rules

Treat key material and activation signals strictly.

These rules come from the machine-readable catalog — agents and humans should apply the same ones.

Activation and lifecycle
  • Check the selected RPC's active protocol_version and require 85 or later before adding or using an ML-DSA-65 key; do not use node software versions or latest_protocol_version as activation signals.
  • Never print or persist generated seeds or expanded secret keys. Keep a temporary recovery record public-only: network, account ID, full public key, and hash handle.
  • Public keys, secret keys, and signatures all share the same ml-dsa-65: prefix, so exportSecretKey() is indistinguishable by shape from signer.publicKey. Tell them apart by decoded length (1,952 vs 4,032 bytes), never by prefix.
  • After an AddKey attempt, do not trust a single absence read: submit a finalized classical DeleteKey nonce barrier, confirm absence at finality, and only then remove the record.
Backend and runtime
  • The reference backend (@noble/post-quantum) describes itself as self-audited and does not claim constant-time side-channel protection — prefer a native, WASM, HSM, or hardware TransactionSigner when the threat model requires one.
  • destroy() provides best-effort zeroization of package-owned JavaScript buffers, not a hard memory-erasure guarantee.
  • Constrained QuickJS and MCU runtimes are not a v1 compatibility target.
Go deeper

AI agents: read the mlDsa65 key in recipes.json — it carries all four quickstarts plus the sizes, key forms, and safety rules on this page. The scheme is defined in NIST FIPS 204, and the package lives on npm.