diff --git a/packages/pq-eth-signer/ts/README.md b/packages/pq-eth-signer/ts/README.md index 831f019..1e6b283 100644 --- a/packages/pq-eth-signer/ts/README.md +++ b/packages/pq-eth-signer/ts/README.md @@ -1,6 +1,8 @@ # pq-eth-signer -Ethereum transaction signing with PQ +Ethereum transaction signing with post-quantum ML-DSA keys. Part of the [post-quantum-packages](https://github.com/aspect-build/post-quantum-packages) monorepo. + +Bridges ML-DSA (FIPS 204) to Ethereum — key generation, EIP-1559 transaction signing, EIP-712 typed data, and key import/export. Zero ethers.js dependency. ## Installation @@ -8,14 +10,161 @@ Ethereum transaction signing with PQ npm install pq-eth-signer ``` -## Usage +## Quick Start + +```typescript +import { PQSigner } from 'pq-eth-signer'; + +// Generate a new ML-DSA-65 keypair +const signer = PQSigner.generate(); +console.log(signer.address); // 0x... (checksummed) +console.log(signer.algorithm); // 'ML-DSA-65' + +// Sign a message +const message = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); +const signature = signer.sign(message); +const valid = signer.verify(message, signature); // true + +// Sign an EIP-1559 transaction +const tx = signer.signTransaction({ + to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + value: 1000000000000000000n, // 1 ETH + nonce: 0, + chainId: 1n, + gasLimit: 21000n, + maxFeePerGas: 30000000000n, + maxPriorityFeePerGas: 2000000000n, +}); +console.log(tx.hash); // 0x... transaction hash +console.log(tx.rawTransaction); // RLP-encoded signed tx +console.log(tx.signature); // Raw ML-DSA signature + +// Sign EIP-712 typed data +const typedSig = signer.signTypedData( + { name: 'MyApp', version: '1', chainId: 1n }, + { Transfer: [{ name: 'to', type: 'address' }, { name: 'amount', type: 'uint256' }] }, + 'Transfer', + { to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', amount: 1000n }, +); +``` + +## API + +### `PQSigner` + +The main class for post-quantum Ethereum signing. + +#### Static Methods + +| Method | Description | +|--------|-------------| +| `PQSigner.generate(options?)` | Generate a new keypair. Options: `algorithm` (`'ML-DSA-44'` \| `'ML-DSA-65'` \| `'ML-DSA-87'`, default `'ML-DSA-65'`), `seed` (32-byte `Uint8Array` for deterministic keygen) | +| `PQSigner.fromSecretKey(sk, algorithm)` | Reconstruct from raw secret key bytes | +| `PQSigner.fromPem(pem)` | Import from PEM-encoded private key (via `pq-key-encoder`) | + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `algorithm` | `SupportedAlgorithm` | `'ML-DSA-44'` \| `'ML-DSA-65'` \| `'ML-DSA-87'` | +| `publicKey` | `Uint8Array` | Raw public key bytes | +| `address` | `string` | Checksummed Ethereum-style address | + +#### Instance Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `sign(message)` | `Uint8Array` | Sign arbitrary bytes | +| `signTransaction(tx)` | `SignedTransaction` | Sign an EIP-1559 transaction | +| `signTypedData(domain, types, primaryType, message)` | `Uint8Array` | Sign EIP-712 typed data | +| `verify(message, signature)` | `boolean` | Verify a signature | +| `exportPublicKey(format)` | varies | Export public key (`'raw'` \| `'pem'` \| `'spki'` \| `'jwk'`) | +| `exportSecretKey(format)` | varies | Export secret key (`'raw'` \| `'pem'`) | +| `exportKey()` | `ExportedKey` | Export algorithm + publicKey + address | + +### Types + +```typescript +interface TransactionRequest { + to: string; // 0x-prefixed address + value?: bigint; // wei (default: 0n) + data?: Uint8Array; // calldata + nonce: number; + chainId: bigint; + gasLimit: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +} + +interface SignedTransaction { + hash: string; // 0x-prefixed tx hash + rawTransaction: Uint8Array; // RLP-encoded signed tx + signature: Uint8Array; // Raw ML-DSA signature +} + +interface EIP712Domain { + name?: string; + version?: string; + chainId?: bigint; + verifyingContract?: string; + salt?: Uint8Array; +} +``` + +### Utilities ```typescript -import { } from 'pq-eth-signer'; +import { deriveAddress, bytesToHex, hexToBytes, checksumAddress } from 'pq-eth-signer'; + +// Derive address from raw public key +const address = deriveAddress(publicKey, 'ML-DSA-65'); + +// Hex conversion +const hex = bytesToHex(new Uint8Array([0xab, 0xcd])); // 'abcd' +const bytes = hexToBytes('abcd'); // Uint8Array([0xab, 0xcd]) +``` -// Coming soon +## Key Sizes + +| Algorithm | Public Key | Secret Key | Signature | Security | +|-----------|-----------|------------|-----------|----------| +| ML-DSA-44 | 1,312 B | 2,560 B | 2,420 B | NIST Level 2 | +| ML-DSA-65 | 1,952 B | 4,032 B | 3,309 B | NIST Level 3 | +| ML-DSA-87 | 2,592 B | 4,896 B | 4,627 B | NIST Level 5 | + +## Design Decisions + +- **No ethers.js dependency** — lightweight EIP-1559 serialization (RLP) implemented directly. Users can wrap with ethers if needed. +- **SPKI-based address derivation** — `keccak256(toSPKI(pubkey))` last 20 bytes. SPKI encoding embeds the algorithm OID, preventing cross-algorithm address collisions. +- **Private field for secret key** — `#secretKey` is not directly accessible. Explicit `exportSecretKey()` required. +- **Hedged signing** — noble's `ml_dsa*.sign()` uses internal randomness by default. Each signature is different but all verify correctly. + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `pq-oid` | Algorithm identifiers (this monorepo) | +| `pq-key-encoder` | PEM/SPKI/JWK import/export (this monorepo) | +| `@noble/post-quantum` | ML-DSA keygen/sign/verify | +| `@noble/hashes` | keccak256 for address derivation + tx hashing | + +## Testing + +```bash +bun test # 64 tests ``` +Tests cover: +- Key generation (all 3 ML-DSA levels) +- Deterministic keygen from seed +- Secret key round-trip (export → import) +- PEM import/export round-trip +- Address derivation determinism +- EIP-1559 transaction serialization + signing +- EIP-712 typed data hashing + signing +- Sign/verify round-trip +- Error handling (invalid keys, unsupported algorithms) + ## License MIT diff --git a/packages/pq-eth-signer/ts/package.json b/packages/pq-eth-signer/ts/package.json index 46bd1cc..a0fb744 100644 --- a/packages/pq-eth-signer/ts/package.json +++ b/packages/pq-eth-signer/ts/package.json @@ -1,24 +1,45 @@ { "name": "pq-eth-signer", "version": "0.0.1", - "description": "Ethereum transaction signing with PQ", + "description": "Ethereum transaction signing with post-quantum ML-DSA keys", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "engines": { + "node": ">=18" + }, + "sideEffects": false, "files": [ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc -b", + "test": "bun test", "prepublishOnly": "npm run build" }, "keywords": [ "post-quantum", "cryptography", - "pqc" + "pqc", + "ethereum", + "ml-dsa", + "signing" ], "author": "", "license": "MIT", + "dependencies": { + "pq-oid": "^1.0.2", + "pq-key-encoder": "^1.0.3", + "@noble/post-quantum": "^0.5.2", + "@noble/hashes": "^1.7.0" + }, "devDependencies": { "typescript": "^5.0.0" } diff --git a/packages/pq-eth-signer/ts/src/address.ts b/packages/pq-eth-signer/ts/src/address.ts new file mode 100644 index 0000000..adb8594 --- /dev/null +++ b/packages/pq-eth-signer/ts/src/address.ts @@ -0,0 +1,22 @@ +import { keccak_256 } from '@noble/hashes/sha3'; +import { toSPKI } from 'pq-key-encoder'; +import type { SupportedAlgorithm } from './types'; +import { checksumAddress } from './utils'; + +/** + * Derive an Ethereum-style address from a post-quantum public key. + * + * The address is computed as the last 20 bytes of keccak256(SPKI(publicKey)), + * where the SPKI encoding includes the algorithm OID. This prevents + * cross-algorithm address collisions. + */ +export function deriveAddress(publicKey: Uint8Array, algorithm: SupportedAlgorithm): string { + const spki = toSPKI({ alg: algorithm, type: 'public', bytes: publicKey }); + const hash = keccak_256(spki); + const addressBytes = hash.slice(hash.length - 20); + let hex = '0x'; + for (let i = 0; i < addressBytes.length; i++) { + hex += addressBytes[i].toString(16).padStart(2, '0'); + } + return checksumAddress(hex); +} diff --git a/packages/pq-eth-signer/ts/src/eip712.ts b/packages/pq-eth-signer/ts/src/eip712.ts new file mode 100644 index 0000000..100a362 --- /dev/null +++ b/packages/pq-eth-signer/ts/src/eip712.ts @@ -0,0 +1,195 @@ +import { keccak_256 } from '@noble/hashes/sha3'; +import type { EIP712Domain, TypedDataField } from './types'; +import { bigintToBytes, concatBytes, hexToBytes } from './utils'; + +const encoder = new TextEncoder(); + +/** Compute keccak256 of a UTF-8 string. */ +function keccakString(value: string): Uint8Array { + return keccak_256(encoder.encode(value)); +} + +/** + * Encode a type string for EIP-712. + * e.g. "Mail(address from,address to,string contents)" + */ +function encodeType(primaryType: string, types: Record): string { + const deps = findTypeDependencies(primaryType, types); + deps.delete(primaryType); + const sorted = [primaryType, ...Array.from(deps).sort()]; + + let result = ''; + for (const typeName of sorted) { + const fields = types[typeName]; + if (!fields) { + continue; + } + result += `${typeName}(${fields.map((f) => `${f.type} ${f.name}`).join(',')})`; + } + return result; +} + +/** Recursively find all referenced types. */ +function findTypeDependencies( + typeName: string, + types: Record, + result: Set = new Set(), +): Set { + if (result.has(typeName)) { + return result; + } + const fields = types[typeName]; + if (!fields) { + return result; + } + result.add(typeName); + for (const field of fields) { + // Strip ALL array dimensions before looking up the base type + const baseType = field.type.replace(/(\[\d*\])+$/, ''); + if (types[baseType]) { + findTypeDependencies(baseType, types, result); + } + } + return result; +} + +/** Hash the type string. */ +function hashType(primaryType: string, types: Record): Uint8Array { + return keccakString(encodeType(primaryType, types)); +} + +/** ABI-encode a single value for EIP-712. */ +function encodeValue( + fieldType: string, + value: unknown, + types: Record, +): Uint8Array { + // Struct type — recursively hash + if (types[fieldType]) { + return hashStruct(fieldType, value as Record, types); + } + + // Array type + if (fieldType.endsWith(']')) { + const baseType = fieldType.replace(/\[\d*\]$/, ''); + const items = value as unknown[]; + const encoded = items.map((item) => encodeValue(baseType, item, types)); + return keccak_256(concatBytes(...encoded)); + } + + // Dynamic types + if (fieldType === 'bytes') { + return keccak_256(value as Uint8Array); + } + if (fieldType === 'string') { + return keccakString(value as string); + } + + // Static types — pad to 32 bytes + const result = new Uint8Array(32); + + if (fieldType === 'address') { + const addr = hexToBytes(value as string); + result.set(addr, 32 - addr.length); + return result; + } + + if (fieldType === 'bool') { + result[31] = value ? 1 : 0; + return result; + } + + if (fieldType.startsWith('uint')) { + const bytes = bigintToBytes(value as bigint); + result.set(bytes, 32 - bytes.length); + return result; + } + + if (fieldType.startsWith('int')) { + let val = value as bigint; + if (val < 0n) { + val = (1n << 256n) + val; + } + const bytes = bigintToBytes(val); + result.set(bytes, 32 - bytes.length); + return result; + } + + if (fieldType.startsWith('bytes')) { + const bytes = value as Uint8Array; + const n = Number(fieldType.slice(5)); + if (!Number.isNaN(n) && bytes.length > n) { + throw new Error(`Value too large for ${fieldType}: got ${bytes.length} bytes, expected \u2264${n}`); + } + result.set(bytes, 0); + return result; + } + + throw new Error(`Unsupported EIP-712 type: ${fieldType}`); +} + +/** Hash a struct: keccak256(hashType || encodeData). */ +export function hashStruct( + primaryType: string, + data: Record, + types: Record, +): Uint8Array { + const typeHash = hashType(primaryType, types); + const fields = types[primaryType]; + if (!fields) { + throw new Error(`Unknown type: ${primaryType}`); + } + + const values: Uint8Array[] = [typeHash]; + for (const field of fields) { + values.push(encodeValue(field.type, data[field.name], types)); + } + return keccak_256(concatBytes(...values)); +} + +/** Build the EIP-712 domain separator. */ +export function domainSeparator(domain: EIP712Domain): Uint8Array { + const domainTypes: TypedDataField[] = []; + const domainValues: Record = {}; + + if (domain.name !== undefined) { + domainTypes.push({ name: 'name', type: 'string' }); + domainValues.name = domain.name; + } + if (domain.version !== undefined) { + domainTypes.push({ name: 'version', type: 'string' }); + domainValues.version = domain.version; + } + if (domain.chainId !== undefined) { + domainTypes.push({ name: 'chainId', type: 'uint256' }); + domainValues.chainId = domain.chainId; + } + if (domain.verifyingContract !== undefined) { + domainTypes.push({ name: 'verifyingContract', type: 'address' }); + domainValues.verifyingContract = domain.verifyingContract; + } + if (domain.salt !== undefined) { + domainTypes.push({ name: 'salt', type: 'bytes32' }); + domainValues.salt = domain.salt; + } + + const types: Record = { EIP712Domain: domainTypes }; + return hashStruct('EIP712Domain', domainValues, types); +} + +/** + * Compute the EIP-712 hash to sign. + * + * Returns keccak256("\x19\x01" || domainSeparator || hashStruct(primaryType, message)). + */ +export function hashTypedData( + domain: EIP712Domain, + types: Record, + primaryType: string, + message: Record, +): Uint8Array { + const ds = domainSeparator(domain); + const structHash = hashStruct(primaryType, message, types); + const prefix = new Uint8Array([0x19, 0x01]); + return keccak_256(concatBytes(prefix, ds, structHash)); +} diff --git a/packages/pq-eth-signer/ts/src/errors.ts b/packages/pq-eth-signer/ts/src/errors.ts new file mode 100644 index 0000000..17d08ff --- /dev/null +++ b/packages/pq-eth-signer/ts/src/errors.ts @@ -0,0 +1,40 @@ +/** Base error for pq-eth-signer failures. */ +export class PQSignerError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'PQSignerError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Error for invalid or unsupported key inputs. */ +export class InvalidKeyError extends PQSignerError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'InvalidKeyError'; + } +} + +/** Error for invalid transaction fields. */ +export class InvalidTransactionError extends PQSignerError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'InvalidTransactionError'; + } +} + +/** Error for unsupported algorithm selections. */ +export class UnsupportedAlgorithmError extends PQSignerError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'UnsupportedAlgorithmError'; + } +} + +/** Error for signing operation failures. */ +export class SigningError extends PQSignerError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'SigningError'; + } +} diff --git a/packages/pq-eth-signer/ts/src/index.ts b/packages/pq-eth-signer/ts/src/index.ts index 4ddf86c..ae6689d 100644 --- a/packages/pq-eth-signer/ts/src/index.ts +++ b/packages/pq-eth-signer/ts/src/index.ts @@ -1,4 +1,12 @@ -// pq-eth-signer - Ethereum transaction signing with PQ -// Implementation coming soon - -export {}; +export { deriveAddress } from './address'; +export { domainSeparator, hashStruct, hashTypedData } from './eip712'; +export * from './errors'; +export { PQSigner } from './signer'; +export { + hashSignedTransaction, + hashUnsignedTransaction, + serializeSignedTransaction, + serializeUnsignedTransaction, +} from './transaction'; +export * from './types'; +export { bytesToHex, checksumAddress, hexToBytes } from './utils'; diff --git a/packages/pq-eth-signer/ts/src/signer.ts b/packages/pq-eth-signer/ts/src/signer.ts new file mode 100644 index 0000000..110ddd2 --- /dev/null +++ b/packages/pq-eth-signer/ts/src/signer.ts @@ -0,0 +1,226 @@ +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'; +import type { PQJwk } from 'pq-key-encoder'; +import { fromPEM, toJWK, toPEM, toSPKI } from 'pq-key-encoder'; +import { deriveAddress } from './address'; +import { hashTypedData } from './eip712'; +import { InvalidKeyError, SigningError, UnsupportedAlgorithmError } from './errors'; +import { + hashSignedTransaction, + hashUnsignedTransaction, + serializeSignedTransaction, +} from './transaction'; +import type { + EIP712Domain, + ExportedKey, + PQSignerOptions, + SignedTransaction, + SupportedAlgorithm, + TransactionRequest, + TypedDataField, +} from './types'; + +type MLDSAInstance = typeof ml_dsa44 | typeof ml_dsa65 | typeof ml_dsa87; + +const ALGORITHM_MAP: Record = { + 'ML-DSA-44': ml_dsa44, + 'ML-DSA-65': ml_dsa65, + 'ML-DSA-87': ml_dsa87, +}; + +function getAlgorithmInstance(algorithm: SupportedAlgorithm): MLDSAInstance { + const instance = ALGORITHM_MAP[algorithm]; + if (!instance) { + throw new UnsupportedAlgorithmError(`Unsupported algorithm: ${algorithm}`); + } + return instance; +} + +/** + * Post-quantum Ethereum signer using ML-DSA (FIPS 204). + * + * Supports key generation, EIP-1559 transaction signing, EIP-712 typed data + * signing, and key import/export via pq-key-encoder. + */ +export class PQSigner { + /** The ML-DSA algorithm level. */ + readonly algorithm: SupportedAlgorithm; + /** Raw public key bytes. */ + readonly publicKey: Uint8Array; + /** Derived Ethereum-style address (checksummed). */ + readonly address: string; + + #secretKey: Uint8Array; + #instance: MLDSAInstance; + + private constructor(algorithm: SupportedAlgorithm, publicKey: Uint8Array, secretKey: Uint8Array) { + this.algorithm = algorithm; + this.publicKey = publicKey; + this.#secretKey = secretKey; + this.#instance = getAlgorithmInstance(algorithm); + this.address = deriveAddress(publicKey, algorithm); + } + + /** Generate a new keypair. */ + static generate(options?: PQSignerOptions): PQSigner { + const algorithm = options?.algorithm ?? 'ML-DSA-65'; + const instance = getAlgorithmInstance(algorithm); + + let keypair: { publicKey: Uint8Array; secretKey: Uint8Array }; + if (options?.seed) { + if (options.seed.length !== 32) { + throw new InvalidKeyError('Seed must be exactly 32 bytes.'); + } + keypair = instance.keygen(options.seed); + } else { + keypair = instance.keygen(); + } + + return new PQSigner(algorithm, keypair.publicKey, keypair.secretKey); + } + + /** Reconstruct a signer from a raw secret key. */ + static fromSecretKey(secretKey: Uint8Array, algorithm: SupportedAlgorithm): PQSigner { + if (!(secretKey instanceof Uint8Array) || secretKey.length === 0) { + throw new InvalidKeyError('Secret key must be a non-empty Uint8Array.'); + } + + const instance = getAlgorithmInstance(algorithm); + const info = getKeyInfo(algorithm); + if (secretKey.length !== info.secretKeySize) { + throw new InvalidKeyError( + `Invalid secret key size for ${algorithm}. Expected ${info.secretKeySize} bytes, got ${secretKey.length}.`, + ); + } + + // Derive public key from secret key using noble's getPublicKey + let publicKey: Uint8Array; + try { + publicKey = instance.getPublicKey(secretKey); + } catch (error) { + throw new InvalidKeyError('Failed to derive public key from secret key.', { cause: error }); + } + + return new PQSigner(algorithm, publicKey, secretKey); + } + + /** Import a signer from a PEM-encoded private key. */ + static fromPem(pem: string): PQSigner { + try { + const keyData = fromPEM(pem); + if (keyData.type !== 'private') { + throw new InvalidKeyError('PEM must contain a private key.'); + } + const algorithm = keyData.alg as SupportedAlgorithm; + if (!ALGORITHM_MAP[algorithm]) { + throw new UnsupportedAlgorithmError(`Unsupported algorithm in PEM: ${keyData.alg}`); + } + return PQSigner.fromSecretKey(keyData.bytes, algorithm); + } catch (error) { + if (error instanceof InvalidKeyError || error instanceof UnsupportedAlgorithmError) { + throw error; + } + throw new InvalidKeyError('Failed to parse PEM key.', { cause: error }); + } + } + + /** Sign an arbitrary message (raw bytes). */ + sign(message: Uint8Array): Uint8Array { + try { + return this.#instance.sign(message, this.#secretKey); + } catch (error) { + throw new SigningError('Signing failed.', { cause: error }); + } + } + + /** Sign an EIP-1559 transaction. */ + signTransaction(tx: TransactionRequest): SignedTransaction { + const txHash = hashUnsignedTransaction(tx); + const signature = this.sign(txHash); + const rawTransaction = serializeSignedTransaction(tx, signature); + const hash = hashSignedTransaction(tx, signature); + + return { hash, rawTransaction, signature }; + } + + /** Sign EIP-712 typed data. */ + signTypedData( + domain: EIP712Domain, + types: Record, + primaryType: string, + message: Record, + ): Uint8Array { + const digest = hashTypedData(domain, types, primaryType, message); + return this.sign(digest); + } + + /** Verify a signature against a message using this signer's public key. */ + verify(message: Uint8Array, signature: Uint8Array): boolean { + try { + return this.#instance.verify(signature, message, this.publicKey); + } catch { + return false; + } + } + + /** Export the public key in various formats. */ + exportPublicKey(format: 'raw'): Uint8Array; + exportPublicKey(format: 'pem'): string; + exportPublicKey(format: 'spki'): Uint8Array; + exportPublicKey(format: 'jwk'): PQJwk; + exportPublicKey(format: 'raw' | 'pem' | 'spki' | 'jwk'): Uint8Array | string | PQJwk { + const keyData = { alg: this.algorithm, type: 'public' as const, bytes: this.publicKey }; + switch (format) { + case 'raw': + return new Uint8Array(this.publicKey); + case 'pem': + return toPEM(keyData); + case 'spki': + return toSPKI(keyData); + case 'jwk': + return toJWK(keyData); + default: + throw new InvalidKeyError(`Unsupported export format: ${format}`); + } + } + + /** Export the secret key. Use with caution. */ + exportSecretKey(format: 'raw'): Uint8Array; + exportSecretKey(format: 'pem'): string; + exportSecretKey(format: 'raw' | 'pem'): Uint8Array | string { + const keyData = { alg: this.algorithm, type: 'private' as const, bytes: this.#secretKey }; + switch (format) { + case 'raw': + return new Uint8Array(this.#secretKey); + case 'pem': + return toPEM(keyData); + default: + throw new InvalidKeyError(`Unsupported export format: ${format}`); + } + } + + /** Export public key info (algorithm, publicKey, address). */ + exportKey(): ExportedKey { + return { + algorithm: this.algorithm, + publicKey: new Uint8Array(this.publicKey), + address: this.address, + }; + } +} + +/** Key size info for ML-DSA algorithms. */ +function getKeyInfo(algorithm: SupportedAlgorithm): { + publicKeySize: number; + secretKeySize: number; +} { + switch (algorithm) { + case 'ML-DSA-44': + return { publicKeySize: 1312, secretKeySize: 2560 }; + case 'ML-DSA-65': + return { publicKeySize: 1952, secretKeySize: 4032 }; + case 'ML-DSA-87': + return { publicKeySize: 2592, secretKeySize: 4896 }; + default: + throw new UnsupportedAlgorithmError(`Unknown algorithm: ${algorithm}`); + } +} diff --git a/packages/pq-eth-signer/ts/src/transaction.ts b/packages/pq-eth-signer/ts/src/transaction.ts new file mode 100644 index 0000000..e7e3169 --- /dev/null +++ b/packages/pq-eth-signer/ts/src/transaction.ts @@ -0,0 +1,154 @@ +import { keccak_256 } from '@noble/hashes/sha3'; +import { InvalidTransactionError } from './errors'; +import type { TransactionRequest } from './types'; +import { bigintToBytes, bytesToHex, concatBytes, hexToBytes } from './utils'; + +// --- RLP Encoding --- + +/** RLP-encode a single byte array. */ +function rlpEncodeBytes(data: Uint8Array): Uint8Array { + if (data.length === 1 && data[0] < 0x80) { + return data; + } + if (data.length <= 55) { + const result = new Uint8Array(1 + data.length); + result[0] = 0x80 + data.length; + result.set(data, 1); + return result; + } + const lenBytes = bigintToBytes(BigInt(data.length)); + const result = new Uint8Array(1 + lenBytes.length + data.length); + result[0] = 0xb7 + lenBytes.length; + result.set(lenBytes, 1); + result.set(data, 1 + lenBytes.length); + return result; +} + +/** RLP-encode a list of already-encoded items. */ +function rlpEncodeList(items: Uint8Array[]): Uint8Array { + const payload = concatBytes(...items); + if (payload.length <= 55) { + const result = new Uint8Array(1 + payload.length); + result[0] = 0xc0 + payload.length; + result.set(payload, 1); + return result; + } + const lenBytes = bigintToBytes(BigInt(payload.length)); + const result = new Uint8Array(1 + lenBytes.length + payload.length); + result[0] = 0xf7 + lenBytes.length; + result.set(lenBytes, 1); + result.set(payload, 1 + lenBytes.length); + return result; +} + +/** RLP-encode a bigint as minimal big-endian bytes. */ +function rlpEncodeBigint(value: bigint): Uint8Array { + if (value === 0n) { + return rlpEncodeBytes(new Uint8Array(0)); + } + return rlpEncodeBytes(bigintToBytes(value)); +} + +/** RLP-encode a non-negative integer. */ +function rlpEncodeNumber(value: number): Uint8Array { + return rlpEncodeBigint(BigInt(value)); +} + +// --- Transaction Serialization --- + +function validateAddress(address: string): void { + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new InvalidTransactionError(`Invalid address: ${address}`); + } +} + +function validateTransaction(tx: TransactionRequest): void { + validateAddress(tx.to); + if (tx.nonce < 0 || !Number.isInteger(tx.nonce)) { + throw new InvalidTransactionError('Nonce must be a non-negative integer.'); + } + if (tx.chainId <= 0n) { + throw new InvalidTransactionError('Chain ID must be positive.'); + } + if (tx.gasLimit <= 0n) { + throw new InvalidTransactionError('Gas limit must be positive.'); + } + if (tx.maxFeePerGas < 0n) { + throw new InvalidTransactionError('Max fee per gas must be non-negative.'); + } + if (tx.maxPriorityFeePerGas < 0n) { + throw new InvalidTransactionError('Max priority fee per gas must be non-negative.'); + } + if (tx.maxPriorityFeePerGas > tx.maxFeePerGas) { + throw new InvalidTransactionError('Max priority fee per gas must not exceed max fee per gas.'); + } +} + +/** + * Serialize an EIP-1559 (type 2) unsigned transaction for signing. + * + * Format: 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, + * gasLimit, to, value, data, accessList]) + */ +export function serializeUnsignedTransaction(tx: TransactionRequest): Uint8Array { + validateTransaction(tx); + + const fields: Uint8Array[] = [ + rlpEncodeBigint(tx.chainId), + rlpEncodeNumber(tx.nonce), + rlpEncodeBigint(tx.maxPriorityFeePerGas), + rlpEncodeBigint(tx.maxFeePerGas), + rlpEncodeBigint(tx.gasLimit), + rlpEncodeBytes(hexToBytes(tx.to)), + rlpEncodeBigint(tx.value ?? 0n), + rlpEncodeBytes(tx.data ?? new Uint8Array(0)), + rlpEncodeList([]), // accessList — empty + ]; + + const rlpPayload = rlpEncodeList(fields); + return concatBytes(new Uint8Array([0x02]), rlpPayload); +} + +/** Hash an unsigned transaction for signing: keccak256(serialized). */ +export function hashUnsignedTransaction(tx: TransactionRequest): Uint8Array { + return keccak_256(serializeUnsignedTransaction(tx)); +} + +/** + * Serialize a signed EIP-1559 transaction. + * + * Format: 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, + * gasLimit, to, value, data, accessList, signatureBytes]) + * + * The PQ signature is appended as an opaque bytes field. This is designed for + * smart contract wallets (ERC-4337) where the signature is validated on-chain + * by custom verification logic. + */ +export function serializeSignedTransaction( + tx: TransactionRequest, + signature: Uint8Array, +): Uint8Array { + validateTransaction(tx); + + const fields: Uint8Array[] = [ + rlpEncodeBigint(tx.chainId), + rlpEncodeNumber(tx.nonce), + rlpEncodeBigint(tx.maxPriorityFeePerGas), + rlpEncodeBigint(tx.maxFeePerGas), + rlpEncodeBigint(tx.gasLimit), + rlpEncodeBytes(hexToBytes(tx.to)), + rlpEncodeBigint(tx.value ?? 0n), + rlpEncodeBytes(tx.data ?? new Uint8Array(0)), + rlpEncodeList([]), // accessList — empty + rlpEncodeBytes(signature), + ]; + + const rlpPayload = rlpEncodeList(fields); + return concatBytes(new Uint8Array([0x02]), rlpPayload); +} + +/** Compute the transaction hash of a signed transaction. */ +export function hashSignedTransaction(tx: TransactionRequest, signature: Uint8Array): string { + const raw = serializeSignedTransaction(tx, signature); + return bytesToHex(keccak_256(raw)); +} diff --git a/packages/pq-eth-signer/ts/src/types.ts b/packages/pq-eth-signer/ts/src/types.ts new file mode 100644 index 0000000..257b4e6 --- /dev/null +++ b/packages/pq-eth-signer/ts/src/types.ts @@ -0,0 +1,67 @@ +import type { MLDSAAlgorithm } from 'pq-oid'; + +/** Supported post-quantum signing algorithms. */ +export type SupportedAlgorithm = MLDSAAlgorithm; + +/** Options for creating a new PQSigner. */ +export type PQSignerOptions = { + /** ML-DSA algorithm level. Defaults to 'ML-DSA-65'. */ + algorithm?: SupportedAlgorithm; + /** Optional 32-byte seed for deterministic key generation. */ + seed?: Uint8Array; +}; + +/** EIP-1559 (type 2) transaction request fields. */ +export type TransactionRequest = { + /** Recipient address (0x-prefixed, 20-byte hex). */ + to: string; + /** Transfer value in wei. Defaults to 0n. */ + value?: bigint; + /** Contract calldata. */ + data?: Uint8Array; + /** Sender nonce. */ + nonce: number; + /** Chain identifier. */ + chainId: bigint; + /** Gas limit. */ + gasLimit: bigint; + /** EIP-1559 max fee per gas. */ + maxFeePerGas: bigint; + /** EIP-1559 max priority fee per gas. */ + maxPriorityFeePerGas: bigint; +}; + +/** Result of signing a transaction. */ +export type SignedTransaction = { + /** Transaction hash (0x-prefixed hex). */ + hash: string; + /** RLP-encoded signed transaction bytes. */ + rawTransaction: Uint8Array; + /** Raw ML-DSA signature bytes. */ + signature: Uint8Array; +}; + +/** Exported key information. */ +export type ExportedKey = { + /** Algorithm used. */ + algorithm: SupportedAlgorithm; + /** Raw public key bytes. */ + publicKey: Uint8Array; + /** Derived Ethereum-style address (0x-prefixed, checksummed). */ + address: string; +}; + +/** EIP-712 domain separator fields. */ +export type EIP712Domain = { + name?: string; + version?: string; + chainId?: bigint; + verifyingContract?: string; + salt?: Uint8Array; +}; + +/** EIP-712 typed data field descriptor. */ +export type TypedDataField = { + name: string; + type: string; +}; diff --git a/packages/pq-eth-signer/ts/src/utils.ts b/packages/pq-eth-signer/ts/src/utils.ts new file mode 100644 index 0000000..19e31d7 --- /dev/null +++ b/packages/pq-eth-signer/ts/src/utils.ts @@ -0,0 +1,89 @@ +import { keccak_256 } from '@noble/hashes/sha3'; + +const HEX_CHARS = '0123456789abcdef'; + +/** Convert a Uint8Array to a 0x-prefixed hex string. */ +export function bytesToHex(bytes: Uint8Array): string { + let hex = '0x'; + for (let i = 0; i < bytes.length; i++) { + hex += HEX_CHARS[bytes[i] >> 4] + HEX_CHARS[bytes[i] & 0x0f]; + } + return hex; +} + +/** Convert a hex string (with or without 0x prefix) to Uint8Array. */ +export function hexToBytes(hex: string): Uint8Array { + const stripped = hex.startsWith('0x') ? hex.slice(2) : hex; + if (stripped.length % 2 !== 0) { + throw new Error('Hex string must have even length.'); + } + const bytes = new Uint8Array(stripped.length / 2); + for (let i = 0; i < bytes.length; i++) { + const hi = Number.parseInt(stripped[i * 2], 16); + const lo = Number.parseInt(stripped[i * 2 + 1], 16); + if (Number.isNaN(hi) || Number.isNaN(lo)) { + throw new Error(`Invalid hex character at position ${i * 2}.`); + } + bytes[i] = (hi << 4) | lo; + } + return bytes; +} + +/** Encode a non-negative bigint as a minimal big-endian byte array. */ +export function bigintToBytes(value: bigint): Uint8Array { + if (value === 0n) { + return new Uint8Array(0); + } + let hex = value.toString(16); + if (hex.length % 2 !== 0) { + hex = `0${hex}`; + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** Decode big-endian bytes to a bigint. */ +export function bytesToBigint(bytes: Uint8Array): bigint { + if (bytes.length === 0) { + return 0n; + } + let hex = '0x'; + for (let i = 0; i < bytes.length; i++) { + hex += HEX_CHARS[bytes[i] >> 4] + HEX_CHARS[bytes[i] & 0x0f]; + } + return BigInt(hex); +} + +/** Apply EIP-55 mixed-case checksum to a 20-byte hex address. */ +export function checksumAddress(address: string): string { + const stripped = address.toLowerCase().replace('0x', ''); + const hash = keccak_256(new TextEncoder().encode(stripped)); + const hashHex = Array.from(hash) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + let checksummed = '0x'; + for (let i = 0; i < 40; i++) { + const charCode = Number.parseInt(hashHex[i], 16); + checksummed += charCode >= 8 ? stripped[i].toUpperCase() : stripped[i]; + } + return checksummed; +} + +/** Concatenate multiple Uint8Arrays into one. */ +export function concatBytes(...arrays: Uint8Array[]): Uint8Array { + let totalLength = 0; + for (const arr of arrays) { + totalLength += arr.length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} diff --git a/packages/pq-eth-signer/ts/tests/address.test.ts b/packages/pq-eth-signer/ts/tests/address.test.ts new file mode 100644 index 0000000..f525d8c --- /dev/null +++ b/packages/pq-eth-signer/ts/tests/address.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test'; +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'; +import { deriveAddress } from '../src/address'; + +describe('deriveAddress', () => { + it('derives a valid 0x-prefixed checksummed address', () => { + const { publicKey } = ml_dsa65.keygen(); + const address = deriveAddress(publicKey, 'ML-DSA-65'); + + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(address.startsWith('0x')).toBe(true); + expect(address.length).toBe(42); + }); + + it('produces deterministic addresses from the same key', () => { + const seed = new Uint8Array(32); + seed[0] = 42; + const { publicKey } = ml_dsa65.keygen(seed); + + const address1 = deriveAddress(publicKey, 'ML-DSA-65'); + const address2 = deriveAddress(publicKey, 'ML-DSA-65'); + expect(address1).toBe(address2); + }); + + it('produces different addresses for different keys', () => { + const seed1 = new Uint8Array(32); + seed1[0] = 1; + const seed2 = new Uint8Array(32); + seed2[0] = 2; + + const kp1 = ml_dsa65.keygen(seed1); + const kp2 = ml_dsa65.keygen(seed2); + + const addr1 = deriveAddress(kp1.publicKey, 'ML-DSA-65'); + const addr2 = deriveAddress(kp2.publicKey, 'ML-DSA-65'); + expect(addr1).not.toBe(addr2); + }); + + it('produces different addresses for same key bytes but different algorithms', () => { + // ML-DSA-44 has 1312-byte public keys, ML-DSA-65 has 1952-byte + // We can only test that the function works with each algorithm + const { publicKey: pk44 } = ml_dsa44.keygen(); + const { publicKey: pk65 } = ml_dsa65.keygen(); + const { publicKey: pk87 } = ml_dsa87.keygen(); + + const addr44 = deriveAddress(pk44, 'ML-DSA-44'); + const addr65 = deriveAddress(pk65, 'ML-DSA-65'); + const addr87 = deriveAddress(pk87, 'ML-DSA-87'); + + expect(addr44).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(addr65).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(addr87).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('applies EIP-55 checksum correctly', () => { + const { publicKey } = ml_dsa65.keygen(); + const address = deriveAddress(publicKey, 'ML-DSA-65'); + + // Address should have mixed case (unless all chars happen to be one case) + // At minimum it should be a valid checksum address + const lower = address.toLowerCase(); + const rechecked = deriveAddress(publicKey, 'ML-DSA-65'); + expect(rechecked).toBe(address); + + // Verify it's not all lowercase (checksum should mix cases) + // Note: statistically extremely unlikely for all 40 hex chars to be same case + expect(address).not.toBe(lower); + }); + + it('deterministic across seed-based keygen', () => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + seed[i] = i; + } + + const kp1 = ml_dsa65.keygen(seed); + const kp2 = ml_dsa65.keygen(seed); + + expect(Array.from(kp1.publicKey)).toEqual(Array.from(kp2.publicKey)); + expect(deriveAddress(kp1.publicKey, 'ML-DSA-65')).toBe( + deriveAddress(kp2.publicKey, 'ML-DSA-65'), + ); + }); +}); diff --git a/packages/pq-eth-signer/ts/tests/eip712.test.ts b/packages/pq-eth-signer/ts/tests/eip712.test.ts new file mode 100644 index 0000000..7f634af --- /dev/null +++ b/packages/pq-eth-signer/ts/tests/eip712.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'bun:test'; +import { domainSeparator, hashStruct, hashTypedData } from '../src/eip712'; +import type { EIP712Domain, TypedDataField } from '../src/types'; + +const MAIL_TYPES: Record = { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], +}; + +const MAIL_DOMAIN: EIP712Domain = { + name: 'Ether Mail', + version: '1', + chainId: 1n, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', +}; + +const MAIL_MESSAGE = { + from: { name: 'Cow', wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' }, + to: { name: 'Bob', wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' }, + contents: 'Hello, Bob!', +}; + +describe('domainSeparator', () => { + it('produces a 32-byte hash', () => { + const ds = domainSeparator(MAIL_DOMAIN); + expect(ds.length).toBe(32); + }); + + it('is deterministic', () => { + const ds1 = domainSeparator(MAIL_DOMAIN); + const ds2 = domainSeparator(MAIL_DOMAIN); + expect(Array.from(ds1)).toEqual(Array.from(ds2)); + }); + + it('changes with different domain fields', () => { + const ds1 = domainSeparator(MAIL_DOMAIN); + const ds2 = domainSeparator({ ...MAIL_DOMAIN, chainId: 5n }); + expect(Array.from(ds1)).not.toEqual(Array.from(ds2)); + }); + + it('handles partial domain (name only)', () => { + const ds = domainSeparator({ name: 'Test' }); + expect(ds.length).toBe(32); + }); + + it('handles domain with salt', () => { + const salt = new Uint8Array(32); + salt[0] = 0xff; + const ds = domainSeparator({ name: 'Test', salt }); + expect(ds.length).toBe(32); + }); +}); + +describe('hashStruct', () => { + it('produces a 32-byte hash', () => { + const hash = hashStruct('Mail', MAIL_MESSAGE, MAIL_TYPES); + expect(hash.length).toBe(32); + }); + + it('is deterministic', () => { + const h1 = hashStruct('Mail', MAIL_MESSAGE, MAIL_TYPES); + const h2 = hashStruct('Mail', MAIL_MESSAGE, MAIL_TYPES); + expect(Array.from(h1)).toEqual(Array.from(h2)); + }); + + it('changes when message changes', () => { + const h1 = hashStruct('Mail', MAIL_MESSAGE, MAIL_TYPES); + const h2 = hashStruct('Mail', { ...MAIL_MESSAGE, contents: 'Different content' }, MAIL_TYPES); + expect(Array.from(h1)).not.toEqual(Array.from(h2)); + }); + + it('throws on unknown type', () => { + expect(() => hashStruct('Unknown', {}, MAIL_TYPES)).toThrow('Unknown type'); + }); +}); + +describe('hashTypedData', () => { + it('produces a 32-byte hash', () => { + const hash = hashTypedData(MAIL_DOMAIN, MAIL_TYPES, 'Mail', MAIL_MESSAGE); + expect(hash.length).toBe(32); + }); + + it('is deterministic', () => { + const h1 = hashTypedData(MAIL_DOMAIN, MAIL_TYPES, 'Mail', MAIL_MESSAGE); + const h2 = hashTypedData(MAIL_DOMAIN, MAIL_TYPES, 'Mail', MAIL_MESSAGE); + expect(Array.from(h1)).toEqual(Array.from(h2)); + }); + + it('starts with 0x1901 prefix internally (hash changes with domain)', () => { + const h1 = hashTypedData(MAIL_DOMAIN, MAIL_TYPES, 'Mail', MAIL_MESSAGE); + const h2 = hashTypedData( + { ...MAIL_DOMAIN, name: 'Different App' }, + MAIL_TYPES, + 'Mail', + MAIL_MESSAGE, + ); + expect(Array.from(h1)).not.toEqual(Array.from(h2)); + }); + + it('handles types with uint256 fields', () => { + const types: Record = { + Transfer: [ + { name: 'amount', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + }; + const message = { + amount: 1000000000000000000n, + to: '0x0000000000000000000000000000000000000001', + }; + const hash = hashTypedData({ name: 'Test' }, types, 'Transfer', message); + expect(hash.length).toBe(32); + }); + + it('handles types with bool fields', () => { + const types: Record = { + Approval: [ + { name: 'approved', type: 'bool' }, + { name: 'spender', type: 'address' }, + ], + }; + const message = { + approved: true, + spender: '0x0000000000000000000000000000000000000001', + }; + const hash = hashTypedData({ name: 'Test' }, types, 'Approval', message); + expect(hash.length).toBe(32); + }); + + it('handles types with bytes field', () => { + const types: Record = { + Data: [{ name: 'payload', type: 'bytes' }], + }; + const message = { + payload: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + }; + const hash = hashTypedData({ name: 'Test' }, types, 'Data', message); + expect(hash.length).toBe(32); + }); +}); diff --git a/packages/pq-eth-signer/ts/tests/signer.test.ts b/packages/pq-eth-signer/ts/tests/signer.test.ts new file mode 100644 index 0000000..e0e4723 --- /dev/null +++ b/packages/pq-eth-signer/ts/tests/signer.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'bun:test'; +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'; +import { PQSigner } from '../src/signer'; +import type { TransactionRequest } from '../src/types'; + +const SAMPLE_TX: TransactionRequest = { + to: '0x0000000000000000000000000000000000000001', + nonce: 0, + chainId: 1n, + gasLimit: 21000n, + maxFeePerGas: 20000000000n, + maxPriorityFeePerGas: 1000000000n, + value: 1000000000000000000n, +}; + +describe('PQSigner.generate', () => { + it('generates a signer with default ML-DSA-65', () => { + const signer = PQSigner.generate(); + expect(signer.algorithm).toBe('ML-DSA-65'); + expect(signer.publicKey.length).toBe(1952); + expect(signer.address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('generates ML-DSA-44 signer', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-44' }); + expect(signer.algorithm).toBe('ML-DSA-44'); + expect(signer.publicKey.length).toBe(1312); + }); + + it('generates ML-DSA-87 signer', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-87' }); + expect(signer.algorithm).toBe('ML-DSA-87'); + expect(signer.publicKey.length).toBe(2592); + }); + + it('produces deterministic keypair from seed', () => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i++) seed[i] = i; + + const s1 = PQSigner.generate({ seed }); + const s2 = PQSigner.generate({ seed }); + + expect(s1.address).toBe(s2.address); + expect(Array.from(s1.publicKey)).toEqual(Array.from(s2.publicKey)); + }); + + it('rejects invalid seed length', () => { + expect(() => PQSigner.generate({ seed: new Uint8Array(16) })).toThrow('32 bytes'); + }); +}); + +describe('PQSigner.fromSecretKey', () => { + it('reconstructs signer from exported secret key', () => { + const original = PQSigner.generate(); + const sk = original.exportSecretKey('raw'); + const restored = PQSigner.fromSecretKey(sk, original.algorithm); + + expect(restored.address).toBe(original.address); + expect(Array.from(restored.publicKey)).toEqual(Array.from(original.publicKey)); + }); + + it('rejects empty secret key', () => { + expect(() => PQSigner.fromSecretKey(new Uint8Array(0), 'ML-DSA-65')).toThrow(); + }); + + it('rejects wrong-sized secret key', () => { + expect(() => PQSigner.fromSecretKey(new Uint8Array(100), 'ML-DSA-65')).toThrow( + 'Invalid secret key size', + ); + }); +}); + +describe('PQSigner.fromPem', () => { + it('round-trips through PEM export/import', () => { + const original = PQSigner.generate({ algorithm: 'ML-DSA-44' }); + const pem = original.exportSecretKey('pem'); + const restored = PQSigner.fromPem(pem as string); + + expect(restored.address).toBe(original.address); + expect(restored.algorithm).toBe('ML-DSA-44'); + }); + + it('rejects invalid PEM', () => { + expect(() => PQSigner.fromPem('not a pem')).toThrow(); + }); +}); + +describe('PQSigner.sign / verify', () => { + it('signs and verifies a message', () => { + const signer = PQSigner.generate(); + const message = new Uint8Array([1, 2, 3, 4, 5]); + const signature = signer.sign(message); + + expect(signature.length).toBeGreaterThan(0); + expect(signer.verify(message, signature)).toBe(true); + }); + + it('verification fails for wrong message', () => { + const signer = PQSigner.generate(); + const message = new Uint8Array([1, 2, 3]); + const signature = signer.sign(message); + + const wrongMessage = new Uint8Array([4, 5, 6]); + expect(signer.verify(wrongMessage, signature)).toBe(false); + }); + + it('verification fails for wrong key', () => { + const signer1 = PQSigner.generate(); + const signer2 = PQSigner.generate(); + const message = new Uint8Array([1, 2, 3]); + const signature = signer1.sign(message); + + expect(signer2.verify(message, signature)).toBe(false); + }); + + it('cross-validates with noble ml_dsa65.verify', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-65' }); + const message = new Uint8Array([0xca, 0xfe]); + const signature = signer.sign(message); + + const valid = ml_dsa65.verify(signature, message, signer.publicKey); + expect(valid).toBe(true); + }); + + it('cross-validates with noble ml_dsa44.verify', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-44' }); + const message = new Uint8Array([0xbe, 0xef]); + const signature = signer.sign(message); + + expect(ml_dsa44.verify(signature, message, signer.publicKey)).toBe(true); + }); + + it('cross-validates with noble ml_dsa87.verify', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-87' }); + const message = new Uint8Array([0xde, 0xad]); + const signature = signer.sign(message); + + expect(ml_dsa87.verify(signature, message, signer.publicKey)).toBe(true); + }); +}); + +describe('PQSigner.signTransaction', () => { + it('returns hash, rawTransaction, and signature', () => { + const signer = PQSigner.generate(); + const result = signer.signTransaction(SAMPLE_TX); + + expect(result.hash).toMatch(/^0x[0-9a-f]{64}$/); + expect(result.rawTransaction.length).toBeGreaterThan(0); + expect(result.rawTransaction[0]).toBe(0x02); // EIP-1559 type + expect(result.signature.length).toBeGreaterThan(0); + }); + + it('signature verifies against unsigned tx hash', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-65' }); + const result = signer.signTransaction(SAMPLE_TX); + + // Manually compute the hash that was signed + const { hashUnsignedTransaction } = require('../src/transaction'); + const txHash = hashUnsignedTransaction(SAMPLE_TX); + + expect(signer.verify(txHash, result.signature)).toBe(true); + }); + + it('produces different signatures for different transactions', () => { + const signer = PQSigner.generate(); + const tx2 = { ...SAMPLE_TX, nonce: 1 }; + + const r1 = signer.signTransaction(SAMPLE_TX); + const r2 = signer.signTransaction(tx2); + + expect(r1.hash).not.toBe(r2.hash); + }); +}); + +describe('PQSigner.signTypedData', () => { + const domain = { name: 'Test', version: '1', chainId: 1n }; + const types = { + Message: [ + { name: 'content', type: 'string' }, + { name: 'value', type: 'uint256' }, + ], + }; + const message = { content: 'hello', value: 42n }; + + it('signs and verifies typed data', () => { + const signer = PQSigner.generate(); + const signature = signer.signTypedData(domain, types, 'Message', message); + + expect(signature.length).toBeGreaterThan(0); + + // Verify against the typed data hash + const { hashTypedData } = require('../src/eip712'); + const digest = hashTypedData(domain, types, 'Message', message); + expect(signer.verify(digest, signature)).toBe(true); + }); +}); + +describe('PQSigner key export', () => { + it('exports public key in raw format', () => { + const signer = PQSigner.generate(); + const raw = signer.exportPublicKey('raw'); + expect(raw).toBeInstanceOf(Uint8Array); + expect(raw.length).toBe(signer.publicKey.length); + }); + + it('exports public key in PEM format', () => { + const signer = PQSigner.generate(); + const pem = signer.exportPublicKey('pem'); + expect(typeof pem).toBe('string'); + expect((pem as string).startsWith('-----BEGIN PUBLIC KEY-----')).toBe(true); + }); + + it('exports public key in SPKI format', () => { + const signer = PQSigner.generate(); + const spki = signer.exportPublicKey('spki'); + expect(spki).toBeInstanceOf(Uint8Array); + expect((spki as Uint8Array).length).toBeGreaterThan(signer.publicKey.length); + }); + + it('exports public key in JWK format', () => { + const signer = PQSigner.generate(); + const jwk = signer.exportPublicKey('jwk') as Record; + expect(jwk.kty).toBe('PQC'); + expect(jwk.alg).toBe(signer.algorithm); + expect(typeof jwk.x).toBe('string'); + }); + + it('exports secret key in raw format', () => { + const signer = PQSigner.generate(); + const sk = signer.exportSecretKey('raw'); + expect(sk).toBeInstanceOf(Uint8Array); + expect(sk.length).toBeGreaterThan(0); + }); + + it('exports secret key in PEM format', () => { + const signer = PQSigner.generate(); + const pem = signer.exportSecretKey('pem'); + expect(typeof pem).toBe('string'); + expect((pem as string).startsWith('-----BEGIN PRIVATE KEY-----')).toBe(true); + }); + + it('exports key info', () => { + const signer = PQSigner.generate({ algorithm: 'ML-DSA-44' }); + const info = signer.exportKey(); + expect(info.algorithm).toBe('ML-DSA-44'); + expect(info.address).toBe(signer.address); + expect(info.publicKey.length).toBe(1312); + }); +}); diff --git a/packages/pq-eth-signer/ts/tests/transaction.test.ts b/packages/pq-eth-signer/ts/tests/transaction.test.ts new file mode 100644 index 0000000..282c384 --- /dev/null +++ b/packages/pq-eth-signer/ts/tests/transaction.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'bun:test'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { + hashSignedTransaction, + hashUnsignedTransaction, + serializeSignedTransaction, + serializeUnsignedTransaction, +} from '../src/transaction'; +import type { TransactionRequest } from '../src/types'; + +const SAMPLE_TX: TransactionRequest = { + to: '0x0000000000000000000000000000000000000001', + nonce: 0, + chainId: 1n, + gasLimit: 21000n, + maxFeePerGas: 20000000000n, + maxPriorityFeePerGas: 1000000000n, + value: 1000000000000000000n, // 1 ETH +}; + +describe('serializeUnsignedTransaction', () => { + it('produces a type 2 envelope (starts with 0x02)', () => { + const serialized = serializeUnsignedTransaction(SAMPLE_TX); + expect(serialized[0]).toBe(0x02); + }); + + it('produces deterministic output', () => { + const a = serializeUnsignedTransaction(SAMPLE_TX); + const b = serializeUnsignedTransaction(SAMPLE_TX); + expect(Array.from(a)).toEqual(Array.from(b)); + }); + + it('handles zero-value transactions', () => { + const tx: TransactionRequest = { + ...SAMPLE_TX, + value: 0n, + }; + const serialized = serializeUnsignedTransaction(tx); + expect(serialized[0]).toBe(0x02); + expect(serialized.length).toBeGreaterThan(1); + }); + + it('handles transactions with data', () => { + const tx: TransactionRequest = { + ...SAMPLE_TX, + data: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + }; + const serialized = serializeUnsignedTransaction(tx); + expect(serialized[0]).toBe(0x02); + // Should be longer than without data + const withoutData = serializeUnsignedTransaction(SAMPLE_TX); + expect(serialized.length).toBeGreaterThan(withoutData.length); + }); + + it('rejects invalid address', () => { + const tx: TransactionRequest = { + ...SAMPLE_TX, + to: '0xinvalid', + }; + expect(() => serializeUnsignedTransaction(tx)).toThrow('Invalid address'); + }); + + it('rejects negative nonce', () => { + const tx: TransactionRequest = { + ...SAMPLE_TX, + nonce: -1, + }; + expect(() => serializeUnsignedTransaction(tx)).toThrow('Nonce must be'); + }); + + it('rejects zero chain ID', () => { + const tx: TransactionRequest = { + ...SAMPLE_TX, + chainId: 0n, + }; + expect(() => serializeUnsignedTransaction(tx)).toThrow('Chain ID must be positive'); + }); + + it('rejects priority fee exceeding max fee', () => { + const tx: TransactionRequest = { + ...SAMPLE_TX, + maxPriorityFeePerGas: 100n, + maxFeePerGas: 50n, + }; + expect(() => serializeUnsignedTransaction(tx)).toThrow( + 'Max priority fee per gas must not exceed max fee per gas', + ); + }); +}); + +describe('hashUnsignedTransaction', () => { + it('produces a 32-byte keccak256 hash', () => { + const hash = hashUnsignedTransaction(SAMPLE_TX); + expect(hash.length).toBe(32); + }); + + it('matches manual keccak256 of serialized tx', () => { + const serialized = serializeUnsignedTransaction(SAMPLE_TX); + const expected = keccak_256(serialized); + const actual = hashUnsignedTransaction(SAMPLE_TX); + expect(Array.from(actual)).toEqual(Array.from(expected)); + }); + + it('produces different hashes for different transactions', () => { + const tx2: TransactionRequest = { ...SAMPLE_TX, nonce: 1 }; + const hash1 = hashUnsignedTransaction(SAMPLE_TX); + const hash2 = hashUnsignedTransaction(tx2); + expect(Array.from(hash1)).not.toEqual(Array.from(hash2)); + }); +}); + +describe('serializeSignedTransaction', () => { + const fakeSig = new Uint8Array(3309).fill(0xab); // ML-DSA-65 sig size + + it('produces a type 2 envelope', () => { + const raw = serializeSignedTransaction(SAMPLE_TX, fakeSig); + expect(raw[0]).toBe(0x02); + }); + + it('is longer than unsigned transaction (includes signature)', () => { + const unsigned = serializeUnsignedTransaction(SAMPLE_TX); + const signed = serializeSignedTransaction(SAMPLE_TX, fakeSig); + expect(signed.length).toBeGreaterThan(unsigned.length); + }); + + it('changes when signature changes', () => { + const sig1 = new Uint8Array(3309).fill(0xab); + const sig2 = new Uint8Array(3309).fill(0xcd); + const raw1 = serializeSignedTransaction(SAMPLE_TX, sig1); + const raw2 = serializeSignedTransaction(SAMPLE_TX, sig2); + expect(Array.from(raw1)).not.toEqual(Array.from(raw2)); + }); +}); + +describe('hashSignedTransaction', () => { + const fakeSig = new Uint8Array(3309).fill(0xab); + + it('returns a 0x-prefixed hex string', () => { + const hash = hashSignedTransaction(SAMPLE_TX, fakeSig); + expect(hash).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('is deterministic', () => { + const hash1 = hashSignedTransaction(SAMPLE_TX, fakeSig); + const hash2 = hashSignedTransaction(SAMPLE_TX, fakeSig); + expect(hash1).toBe(hash2); + }); +}); diff --git a/packages/pq-eth-signer/ts/tsconfig.json b/packages/pq-eth-signer/ts/tsconfig.json index 0e1d7ae..ff3edbe 100644 --- a/packages/pq-eth-signer/ts/tsconfig.json +++ b/packages/pq-eth-signer/ts/tsconfig.json @@ -8,7 +8,9 @@ "rootDir": "./src", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "composite": true }, + "references": [{ "path": "../../pq-oid/ts" }, { "path": "../../pq-key-encoder/ts" }], "include": ["src"] }