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:

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:

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:

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

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

Opening View in Explorer lands on BaseScan, which shows the on-chain verify call that checked the ML-DSA-44 signature:

ML-DSA variants
The SDK supports all three FIPS 204 parameter sets:
| Level | Security category | Signature size |
|---|---|---|
MlDsa44 | NIST level 2 | 2420 bytes |
MlDsa65 | NIST level 3 | 3309 bytes |
MlDsa87 | NIST level 5 | 4627 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.