Skip to main content

Post-Quantum Signing

The boilerplate ships with a working post-quantum (PQ) signing flow built on ML-DSA-44, a NIST-standardized signature scheme designed to remain secure against quantum attacks. The user types a message in the app, it is hashed with keccak256, signed cooperatively by the device and duo-server using a ML-DSA keyshare, and the signature is verified on-chain by the ZKNOX verifier contract on Ethereum Sepolia.

The Silent Shard SDK exposes all three NIST security levels — MlDsa44, MlDsa65, and MlDsa87 — through MldsaSession. The boilerplate demo uses MlDsa44 because that is the variant the current ZKNOX Solidity verifier accepts.

Session and keyshare

The MLDSA session is initialized alongside the ECDSA and EdDSA sessions during app startup — see MPC Sessions for the session lifecycle.

Once the session exists, the useMldsa hook gives the rest of the app a small surface for loading the keyshare and signing a message hash:

// hooks/mpc/useMldsa.ts
export function useMldsa() {
const { mldsa } = useWalletStore();
const keyId = React.useMemo(() => mldsa.mpcShareId, [mldsa.mpcShareId]);
const publicKey = React.useMemo(() => mldsa.publicKey, [mldsa.publicKey]);

const getKeyshare = React.useCallback(async (): Promise<MldsaKeyshare | null> => {
if (!keyId) return null;
const keyshareB64 = await storageProvider.getKeyshare(keyId);
if (!keyshareB64) return null;
return MldsaKeyshare.fromBase64(keyshareB64);
}, [keyId]);

const sign = React.useCallback(
async (messageHash: string, level: 'MlDsa44' | 'MlDsa65' | 'MlDsa87' = 'MlDsa44'): Promise<string> => {
const mldsaSession = silentShardSDK.mldsaSession;
if (!mldsaSession) throw new Error('MLDSA session not initialized');
const keyshare = await getKeyshare();
if (!keyshare) throw new Error('MLDSA keyshare not found');
return mldsaSession.sign({
keyshare,
messageHash: messageHash.replace(/^0x/, ''),
level,
});
},
[getKeyshare],
);

return { keyId, publicKey, getKeyshare, sign };
}

sign() defaults to MlDsa44 and delegates to MldsaSession.sign(), which runs the Duo-MPC distributed signing protocol — the device and duo-server each hold a share of the ML-DSA secret and contribute to the signature without either side reconstructing the full key.

The ZKNOX flow end-to-end

queries/chains/useZknoxSendTransaction.tsx ties everything together. The user enters a message; the hook produces a ML-DSA-44 signature, formats it for the on-chain verifier, sanity-checks it off-chain with readContract, then submits the verification call on-chain (gas paid by the user's regular ECDSA wallet on Sepolia).

// queries/chains/useZknoxSendTransaction.tsx (excerpted)
const messageHash = keccak256(toBytes(message));

// 1. MPC-sign the message hash with the ML-DSA-44 keyshare
const mldsaSigHex = await mldsa.sign(messageHash);
const mldsaSigBytes = hexToBytes(`0x${mldsaSigHex}`);

// 2. Pack public key + signature into the ZKNOX verifier's calldata shape
const pkBytes = hexToBytes(`0x${mldsa.publicKey}`);
const verifierPk = toZkNoxNistPublicKey(pkBytes);
const verifierSig = splitMldsa44Signature(mldsaSigBytes);
const mPrime = `0x0000${messageHash.replace(/^0x/, '')}` as Hex;

const calldata = encodeFunctionData({
abi: zknoxAbi,
functionName: 'expose_verify_internal',
args: [verifierPk, mPrime, verifierSig],
});

// 3. Off-chain sanity check
const verified = await client.readContract({
address: chain.zknoxAddress,
abi: zknoxAbi,
functionName: 'expose_verify_internal',
args: [verifierPk, mPrime, verifierSig] as const,
});
if (!verified) throw new Error('ZKNOX off-chain verification failed');

// 4. Submit the verification on-chain via the user's ECDSA wallet
const txHash = await walletClient.sendTransaction({
account: walletClient.account,
to: chain.zknoxAddress,
data: calldata,
value: 0n,
chainId: chain.chainId,
gas: (gasEstimate * 120n) / 100n,
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
});

toZkNoxNistPublicKey and splitMldsa44Signature live in libs/zknox/index.ts — an encoding layer that converts the ML-DSA-44 public key and signature into the form the ZKNOX Solidity verifier expects (NTT-domain matrices for the public key, and a (cTilde, z, h) split for the signature).

What the user sees

The user picks the Ethereum Sepolia (ZKNOX) chain from the wallet home and taps Send:

Wallet home with Ethereum Sepolia (ZKNOX) selected
Home page

The Send screen swaps the usual amount/recipient form for a free-form message input, with the ZKNOX verifier address surfaced at the top:

ZKNOX Send screen with message input and verifier address
Send form

After signing, the new entry shows up in Activities as a contract call labelled "ZKNOX message signed":

Activities list with ZKNOX message signed entry
Activities list

Tapping the entry opens the transaction detail with the action, contract, network, fee, and transaction hash:

Transaction detail for the ZKNOX message signing call
Transaction detail

Opening View in Explorer lands on Etherscan Sepolia, which shows the on-chain expose_verify_internal call that verified the ML-DSA-44 signature:

Etherscan transaction details showing the expose_verify_internal call
Etherscan verification

ML-DSA variants

The SDK supports all three FIPS 204 parameter sets:

LevelSecurity categorySignature size
MlDsa44NIST level 22420 bytes
MlDsa65NIST level 33309 bytes
MlDsa87NIST level 54627 bytes

The boilerplate hardcodes MlDsa44 because that is what the current ZKNOX Sepolia verifier accepts. To use MlDsa65 or MlDsa87, pass the level as the second argument to mldsa.sign() and pair it with a verifier that accepts that variant:

const sigHex = await mldsa.sign(messageHash, 'MlDsa65');

See the MldsaSession API reference for the full session and keyshare surface.

Security note

At no point is the full ML-DSA secret key assembled on the device or on the duo-server. The mldsaSession.sign() call runs the Duo-MPC distributed signing protocol — each side contributes its share and the signature is produced cooperatively. Post-quantum resistance is provided by ML-DSA itself (lattice-based, NIST-standardized); the MPC layer protects the key material from compromise of either single party.