Message signing

OP_NET provides the MessageSigner class for all message signing operations. This class supports ML-DSA signing and verification for quantum-resistant cryptography, Schnorr signing and verification for classical operations, tweaked signatures for Taproot compatibility, and message hashing utilities.

ML-DSA Signing

Basic ML-DSA Signing

Use the signMLDSAMessage() method to generate quantum-resistant signatures using the ML-DSA algorithm.

The following example shows how to use signMLDSAMessage():
typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel } from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

// Generate wallet
const mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.derive(0);

// Sign a message
const message = 'Hello, Quantum World!';
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message);

console.log('Message:', signed.message);
console.log('Signature:', Buffer.from(signed.signature).toString('hex'));
console.log('Public Key:', Buffer.from(signed.publicKey).toString('hex'));
console.log('Security Level:', signed.securityLevel);

ML-DSA Signature Sizes

ML-DSA signature sizes vary by security level:

  • LEVEL2: Smallest signature size (2420 bytes), suitable for most applications.
  • LEVEL3: Medium signature size (3309 bytes) with increased security.
  • LEVEL5: Largest signature size (4627 bytes) with maximum quantum resistance.
The following example shows the different signature sizes:
typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel } from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

// LEVEL2 (ML-DSA-44)
const level2Mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL2);
const level2Wallet = level2Mnemonic.derive(0);
const level2Sig = MessageSigner.signMLDSAMessage(level2Wallet.mldsaKeypair, 'test');
console.log('LEVEL2 Signature Size:', level2Sig.signature.length); // 2420 bytes

// LEVEL3 (ML-DSA-65)
const level3Mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL3);
const level3Wallet = level3Mnemonic.derive(0);
const level3Sig = MessageSigner.signMLDSAMessage(level3Wallet.mldsaKeypair, 'test');
console.log('LEVEL3 Signature Size:', level3Sig.signature.length); // 3309 bytes

// LEVEL5 (ML-DSA-87)
const level5Mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL5);
const level5Wallet = level5Mnemonic.derive(0);
const level5Sig = MessageSigner.signMLDSAMessage(level5Wallet.mldsaKeypair, 'test');
console.log('LEVEL5 Signature Size:', level5Sig.signature.length); // 4627 bytes

Verifying ML-DSA Signatures

If required, use the QuantumBIP32Factory.fromPublicKey() method to create a public-key-only keypair, then call the MessageSigner.verifyMLDSASignature() method to verify the signature.

The following example shows how to verify a ML-DSA signature:
typescript
import { Mnemonic, MessageSigner, QuantumBIP32Factory } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.derive(0);

// Sign message
const message = 'Verify this quantum signature';
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message);

// Create public-key-only keypair for verification
const publicKeyPair = QuantumBIP32Factory.fromPublicKey(
    signed.publicKey,       // ML-DSA public key from signature
    wallet.chainCode,       // Chain code from wallet
    network,                // Network (mainnet/testnet/regtest)
    securityLevel          // ML-DSA security level (LEVEL2/LEVEL3/LEVEL5)
);

// Verify signature
const isValid = MessageSigner.verifyMLDSASignature(
    publicKeyPair,         // Use the public-key-only keypair
    signed.message,
    signed.signature
);

console.log('Signature valid:', isValid);  // true

// Verify with wrong message fails
const isInvalid = MessageSigner.verifyMLDSASignature(
    publicKeyPair,
    'Wrong message',
    signed.signature
);
console.log('Invalid signature:', isInvalid);  // false
Important

The verifyMLDSASignature() method requires a keypair object, not just a raw public key.

  • If you have the original keypair: Use it directly (e.g., wallet.mldsaKeypair).
  • If you only have the public key: Use QuantumBIP32Factory.fromPublicKey() to reconstruct the keypair.

When to Use QuantumBIP32Factory.fromPublicKey()

Use this method when the original keypair is not available:

  • Verifying signatures received from external sources over the network.
  • Validating signatures using public keys stored in a database.
  • Working with public keys in distributed systems.
  • Processing signatures from third-party applications.

This method is not required when the keypair is already available:

  • Verifying self-generated signatures within the same session.
  • Testing signatures immediately after signing.
  • When wallet.mldsaKeypair is available.

Creating a Public-Key-Only Keypair

Use the QuantumBIP32Factory.fromPublicKey() method to create a public-key-only keypair.

Required parameters:

  • publicKey: The ML-DSA public key (1312 bytes for LEVEL2, 1952 for LEVEL3, 2592 for LEVEL5).
  • chainCode: BIP32 chain code (32 bytes) - available from wallet.chainCode.
  • network: Bitcoin network configuration object.
  • securityLevel: Must match the security level used to generate the original key.
The following code shows QuantumBIP32Factory.fromPublicKey() required parameters:
typescript
const keypair = QuantumBIP32Factory.fromPublicKey(
    publicKey,      // Uint8Array - ML-DSA public key (1312-2592 bytes)
    chainCode,      // Buffer - Chain code (32 bytes)
    network,        // Network - networks.bitcoin, networks.testnet, or networks.regtest
    securityLevel   // MLDSASecurityLevel - LEVEL2, LEVEL3, or LEVEL5
);

Why Is This Required?

The verifyMLDSASignature() method requires a keypair object rather than a raw public key for the following reasons:

  • The keypair contains the MLDSASecurityLevel information necessary for verification.
  • The ML-DSA algorithm requires a specific key structure for signature verification.
  • The keypair format ensures compatibility with BIP32 hierarchical deterministic key derivation.

Common Verification Scenarios

Scenario 1: Verifying self-generated signatures within the same session.
The following example shows how to use existing keypair to verify signature:
typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel } from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

const mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.derive(0);

const message = 'My message';
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message);

// You already have the keypair - use it directly
const valid = MessageSigner.verifyMLDSASignature(
    wallet.mldsaKeypair,  // Use existing keypair
    signed.message,
    signed.signature
);

console.log('Valid:', valid);  // true
Scenario 2: Verifying a signature from an external source.
The following example shows how to use QuantumBIP32Factory.fromPublicKey() to verify a signature:
typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel, QuantumBIP32Factory } from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

// You receive these from the network/API:
const receivedPublicKey = Buffer.from(/* hex string from network */);
const receivedMessage = 'Message from sender';
const receivedSignature = Buffer.from(/* hex string from network */);
const receivedChainCode = Buffer.from(/* hex string from network */);
const receivedSecurityLevel = MLDSASecurityLevel.LEVEL2;

// Reconstruct keypair from public key
const keypair = QuantumBIP32Factory.fromPublicKey(
    receivedPublicKey,
    receivedChainCode,
    networks.bitcoin,
    receivedSecurityLevel
);

// Verify the signature
const valid = MessageSigner.verifyMLDSASignature(
    keypair,
    receivedMessage,
    receivedSignature
);

console.log('Signature from other party valid:', valid);
Scenario 3: Verifying stored signatures.
The following example shows how to use QuantumBIP32Factory.fromPublicKey() to verify a signature:
typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel,QuantumBIP32Factory } from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

// Load public key and signature from database
const storedPublicKey = await db.getPublicKey(userId);
const storedChainCode = await db.getChainCode(userId);
const storedSecurityLevel = await db.getSecurityLevel(userId);
const signature = await db.getSignature(messageId);
const message = await db.getMessage(messageId);

// Reconstruct keypair
const keypair = QuantumBIP32Factory.fromPublicKey(
    Buffer.from(storedPublicKey, 'hex'),
    Buffer.from(storedChainCode, 'hex'),
    networks.bitcoin,
    storedSecurityLevel
);

// Verify
const valid = MessageSigner.verifyMLDSASignature(
    keypair,
    message,
    Buffer.from(signature, 'hex')
);

console.log('Stored signature valid:', valid);

Security Considerations

Chain Code:

  • The chain code is public information in BIP32.
  • Store the chain Code alongside the public key for future verification.
  • It's not sensitive but required for keypair reconstruction.

Security Level Matching:

  • Always use the same security level for verification as was used for signing.
  • Mismatched security levels cause verification to fail.
  • Store the security alongside the public key for future verification.

Network Matching:

  • Ensure the network parameter matches the original signing network.
  • Mainnet keys won't verify correctly if checked against another network.

Message Integrity:

  • The message must be identical during signing and verification.
  • Any modification, even a single byte, causes verification to fail.

Schnorr Signing

Basic Schnorr Signing

Use the signMessage() method to perform a classical Schnorr signature.

The following example shows how to use the signMessage():
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Sign with Schnorr
const message = 'Hello, Bitcoin!';
const signed = MessageSigner.signMessage(wallet.keypair, message);

console.log('Message:', signed.message);
console.log('Signature:', Buffer.from(signed.signature).toString('hex'));
console.log('Public Key:', Buffer.from(signed.publicKey).toString('hex'));
console.log('Signature Size:', signed.signature.length);  // 64 bytes (Schnorr)

Verifying Schnorr Signatures

Use the verifySignature() method to verify a classical Schnorr signature.

The following example shows how to use the verifySignature():
typescript
import { Mnemonic, MLDSASecurityLevel, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate(undefined, '', networks.bitcoin, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.derive(0);

// Sign message
const message = 'Verify this Schnorr signature';
const signed = MessageSigner.signMessage(wallet.keypair, message);

// Verify signature
const isValid = MessageSigner.verifySignature(
    signed.publicKey,
    signed.message,
    signed.signature
);

console.log('Signature valid:', isValid);  // true

Input Formats

Both ML-DSA and Schnorr signing method support multiple input formats.

String Messages

The following examples shows how to sign a string message:
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// UTF-8 string
const signed1 = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, 'Hello, World!');

// Any string content
const signed2 = MessageSigner.signMLDSAMessage(
    wallet.mldsaKeypair,
    'Emoji test: 🚀 Quantum 🔐'
);
        

Buffer Messages

The following examples shows how to sign a buffer message:
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// From UTF-8 string
const message1 = Buffer.from('Hello, Buffer!', 'utf-8');
const signed1 = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message1);

// Binary data
const message2 = Buffer.from([0x01, 0x02, 0x03, 0x04]);
const signed2 = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message2);

// From hex
const message3 = Buffer.from('abcdef1234567890', 'hex');
const signed3 = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message3);

Uint8Array Messages

The following examples shows how to sign a Uint8Array message:
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Uint8Array
const message = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message);

Hex String Messages

The following examples shows how to sign a Hex string message:
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Hex string (with 0x prefix)
const signed1 = MessageSigner.signMLDSAMessage(
    wallet.mldsaKeypair,
    '0xdeadbeef'
);

// Hex string (without 0x prefix)
const signed2 = MessageSigner.signMLDSAMessage(
    wallet.mldsaKeypair,
    'abcdef1234567890'
);

Cross-Format Verification

Verification works across all input formats.

The following examples shows how the verification works across all format:
typescript
import { Mnemonic, MessageSigner, QuantumBIP32Factory } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

const message = 'Test message';

// Sign with string
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, message);

// Create public-key-only keypair for verification
const publicKeyPair = QuantumBIP32Factory.fromPublicKey(
    signed.publicKey,
    wallet.chainCode,
    network,
    securityLevel
);

// Verify with Buffer
const messageBuffer = Buffer.from(message, 'utf-8');
const valid1 = MessageSigner.verifyMLDSASignature(
    publicKeyPair,
    messageBuffer,
    signed.signature
);

// Verify with Uint8Array
const messageArray = new Uint8Array(Buffer.from(message, 'utf-8'));
const valid2 = MessageSigner.verifyMLDSASignature(
    publicKeyPair,
    messageArray,
    signed.signature
);

console.log(valid1 && valid2);  // true - all formats work!

Tweaked Signatures

Tweaked Schnorr Signing

Use the tweakAndSignMessage() method to sign with tweaked keys for Taproot compatibility.

The following example shows how to use tweakAndSignMessage():
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Sign with tweaked key
const message = 'Taproot message';
const signed = MessageSigner.tweakAndSignMessage(wallet.keypair, message);

console.log('Tweaked Signature:', Buffer.from(signed.signature).toString('hex'));
console.log('Tweaked Public Key:', Buffer.from(signed.publicKey).toString('hex'));

Verifying Tweaked Signatures

Use the tweakAndVerifySignature() method to verify a message signed with tweakAndSignMessage().

The following example shows how to use tweakAndVerifySignature():
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Sign with tweak
const message = 'Verify tweaked signature';
const signed = MessageSigner.tweakAndSignMessage(wallet.keypair, message);

// Verify with tweak
const isValid = MessageSigner.tweakAndVerifySignature(
    signed.publicKey,
    signed.message,
    signed.signature
);

console.log('Tweaked signature valid:', isValid);  // true

Message Hashing

SHA-256 Hashing

The MessageSigner automatically hashes messages before signing.

Pre-hashed Messages

The MessageSigner class also supports signing pre-hashed messages.

The following example shows how to sign a pre-hashed message:
typescript
import { Mnemonic, MessageSigner } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// You can also sign pre-hashed data
const message = 'Original message';
const hash = MessageSigner.sha256(message);

// Sign the hash directly
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, hash);

Security Best Practices

Good Practices

typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel } from '@btc-vision/transaction';

// Use appropriate security level for your use case
const standardWallet = Mnemonic.generate(
    undefined,                            // Default strength (12 words)
    '',                                   // No passphrase
    networks.bitcoin,                     // Mainnet
    MLDSASecurityLevel.LEVEL2             // Good for most applications
);

// Include context in your messages
const message = JSON.stringify({
    action: 'transfer',
    amount: 1000,
    timestamp: Date.now(),
    nonce: crypto.randomBytes(16).toString('hex')
});

// Verify signatures before trusting
const isValid = MessageSigner.verifyMLDSASignature(
    publicKey,
    message,
    signature
);
if (!isValid) {
    throw new Error('Invalid signature');
}

// Store signatures with metadata
const signatureData = {
    message: signed.message,
    signature: Buffer.from(signed.signature).toString('hex'),
    publicKey: Buffer.from(signed.publicKey).toString('hex'),
    securityLevel: signed.securityLevel,
    timestamp: Date.now()
};

Bad Practices

typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Don't sign without verification
MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, userInput);  // Dangerous!

// Don't use signatures without checking validity
// Always verify!

// Don't expose private keys
console.log(wallet.privateKey);  // Never do this!

// Don't sign arbitrary untrusted data
const untrustedData = externalAPI.getData();
// Validate and sanitize first!

// Don't reuse signatures for different messages
// Generate new signature for each unique message

Message Structure

typescript
import { Mnemonic, MessageSigner, MLDSASecurityLevel } from '@btc-vision/transaction';

const mnemonic = Mnemonic.generate();

const wallet = mnemonic.derive(0);

// Good: Structured, verifiable message
interface SignedMessage {
    version: number;
    action: string;
    payload: any;
    timestamp: number;
    nonce: string;
}

const message: SignedMessage = {
    version: 1,
    action: 'authenticate',
    payload: { userId: '123' },
    timestamp: Date.now(),
    nonce: crypto.randomBytes(16).toString('hex')
};

const messageString = JSON.stringify(message);
const signed = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, messageString);

Complete Example

The following example shows how to get all supported signing methods:
typescript
import {
    MessageSigner,
    MLDSASecurityLevel,
    Mnemonic,
    QuantumBIP32Factory,
} from '@btc-vision/transaction';
import { networks } from '@btc-vision/bitcoin';

const network = networks.regtest;
const securityLevel = MLDSASecurityLevel.LEVEL2;

// Setup
const mnemonic = Mnemonic.generate(undefined, undefined, network, securityLevel);

const wallet = mnemonic.derive(0);

// 1. Sign with ML-DSA (Quantum-resistant)
console.log('=== ML-DSA Signing ===');
const quantumMessage = 'Quantum-resistant message';
const quantumSigned = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, quantumMessage);

console.log('Message:', quantumSigned.message);
console.log('Signature Size:', quantumSigned.signature.length, 'bytes');
console.log('Public Key Size:', quantumSigned.publicKey.length, 'bytes');
console.log('Security Level:', quantumSigned.securityLevel);

const keypair = QuantumBIP32Factory.fromPublicKey(
    quantumSigned.publicKey,
    wallet.chainCode,
    network,
    securityLevel,
);

// Verify ML-DSA
const quantumValid = MessageSigner.verifyMLDSASignature(
    keypair,
    quantumMessage,
    quantumSigned.signature,
);

console.log('ML-DSA Valid:', quantumValid);

// 2. Sign with Schnorr (Classical)
console.log('\n=== Schnorr Signing ===');
const classicalMessage = 'Classical signature';
const classicalSigned = MessageSigner.signMessage(wallet.keypair, classicalMessage);

console.log('Message:', classicalSigned.message);
console.log('Signature Size:', classicalSigned.signature.length, 'bytes');

// Verify Schnorr
const classicalValid = MessageSigner.verifySignature(
    wallet.keypair.publicKey,
    classicalMessage,
    classicalSigned.signature,
);
console.log('Schnorr Valid:', classicalValid);

// 3. Multiple Input Formats
console.log('\n=== Input Format Tests ===');

const testMessage = 'Format test';

// String
const sig1 = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, testMessage);

// Buffer
const sig2 = MessageSigner.signMLDSAMessage(wallet.mldsaKeypair, Buffer.from(testMessage, 'utf-8'));

// Uint8Array
const sig3 = MessageSigner.signMLDSAMessage(
    wallet.mldsaKeypair,
    new Uint8Array(Buffer.from(testMessage, 'utf-8')),
);

// All verify successfully
console.log(
    'String format valid:',
    MessageSigner.verifyMLDSASignature(wallet.mldsaKeypair, testMessage, sig1.signature),
);
console.log(
    'Buffer format valid:',
    MessageSigner.verifyMLDSASignature(
        wallet.mldsaKeypair,
        Buffer.from(testMessage),
        sig2.signature,
    ),
);
console.log(
    'Uint8Array format valid:',
    MessageSigner.verifyMLDSASignature(
        wallet.mldsaKeypair,
        new Uint8Array(Buffer.from(testMessage)),
        sig3.signature,
    ),
);