Skip to main content

Post-Quantum Signing

The boilerplate codebase includes a 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 Base 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 ML-DSA session is initialized alongside the ECDSA and EdDSA sessions during app startup for Duo backends — see MPC Sessions for the session lifecycle. ML-DSA keyshares are supported on Duo only.

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:

// src/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,
customTags: { 0: policyTransactionTypes.mldsa },
});
},
[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.

One-time key registration

Before the first signature can be verified, the ML-DSA public key is registered on-chain. src/queries/chains/useZknoxRegisterKey.tsx encodes the public key for the verifier, sends a setKey transaction, and stores the resulting pointer — the address of a small contract that now holds the key:

// src/queries/chains/useZknoxRegisterKey.tsx (excerpted)

// 1. Encode the ML-DSA public key into the verifier's format
const verifierPk = toZkNoxNistPublicKey(hexToBytes(`0x${mldsa.publicKey}`));
const blob = encodePubKeyBlob(verifierPk);
const setKeyData = encodeFunctionData({ abi: zkNoxAbi, functionName: 'setKey', args: [blob] });

// 2. Derive the pointer address up front via eth_call (no state change)
const callResult = await client.call({ to: chain.zknoxAddress, data: setKeyData /* ... */ });
const [pointerBytes] = decodeAbiParameters([{ type: 'bytes' }], callResult.data);
const pointer = getAddress(pointerBytes);

// 3. Register on-chain, wait for the pointer contract to propagate, then persist it
const txHash = await walletClient.sendTransaction({ to: chain.zknoxAddress, data: setKeyData /* ... */ });
await client.waitForTransactionReceipt({ hash: txHash });
setZknoxPointer(pointer);

The pointer is kept in walletStore, so registration runs once per key.

The signing flow end-to-end

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

// src/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 signature = `0x${mldsaSigHex}` as const;

// 2. Verify against the registered key pointer — off-chain first, before spending gas
const verifyArgs = [zknoxPointer, messageHash, signature, '0x'] as const;
const verified = await client.readContract({
address: chain.zknoxAddress,
abi: zkNoxAbi,
functionName: 'verify',
args: verifyArgs,
});
if (!verified) throw new Error('ZKNOX off-chain verification failed');

// 3. Submit the same verification on-chain via the user's ECDSA wallet
const calldata = encodeFunctionData({ abi: zkNoxAbi, functionName: 'verify', args: verifyArgs });
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,
});

verify takes the pointer from registration, the 32-byte message hash, the raw ML-DSA-44 signature, and an empty context value. toZkNoxNistPublicKey and encodePubKeyBlob live in src/libs/zknox/index.ts — an encoding layer that converts the ML-DSA-44 public key into the form the ZKNOX Solidity verifier expects.

What the user sees

The user picks the ZKNOX asset from the wallet home and taps Send:

Wallet home with the ZKNOX asset selected
Home page

On first use, the app asks to register the ML-DSA-44 public key with the ZKNOX verifier — the one-time setKey transaction from above:

Register key screen showing the ML-DSA-44 key and the ZKNOX verifier contract
Register key

Once registered, 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 BaseScan, which shows the on-chain verify call that checked the ML-DSA-44 signature:

BaseScan transaction details showing the verify call
BaseScan 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 uses MlDsa44 because that is what the current ZKNOX verifier accepts. The level is fixed at key generation — keygen('MlDsa44') — and signing must use the same level as the keyshare, so switching variants means generating a new keyshare and pairing it with a verifier that accepts that variant.

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.