From 1d84b64d78c20120dfa2b27094329b91f1cd0a97 Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Tue, 26 May 2026 16:07:47 -0300 Subject: [PATCH 01/11] feat: port high-level DApp crypto utilities from wallet plugin --- src/crypto_utils.ts | 169 ++++++++++++++++++++++++++++++++- tests/unit/stringCrypt.test.ts | 56 +++++++++++ 2 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 tests/unit/stringCrypt.test.ts diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index c5b9155..812d383 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -1,5 +1,5 @@ import forge from 'node-forge' -import { BaseWallet, getBytes, SigningKey, solidityPackedKeccak256 } from "ethers" +import { BaseWallet, getBytes, SigningKey, solidityPackedKeccak256, hexlify, concat } from "ethers" import { ctString, ctUint, ctUint256, itString, itUint, itUint256 } from './types'; const BLOCK_SIZE = 16 // AES block size in bytes @@ -544,4 +544,169 @@ export function prepareIT256( }, signature } -} \ No newline at end of file +} +// ------------- Wallet Plugin Additions ------------- + +/** + * Strips "0x" prefix and converts an AES key to lowercase. + * Accepts both 32-char (128-bit) and 64-char (256-bit) hex strings. + * Throws if the key contains invalid hexadecimal characters. + * + * @param aesKey - The AES key string, optionally prefixed with "0x". + * @returns The normalized lowercase hex string. + */ +export function normalizeAesKey(aesKey: string): string { + const trimmed = aesKey.startsWith('0x') ? aesKey.slice(2) : aesKey; + const lowered = trimmed.toLowerCase(); + if (!/^[0-9a-f]+$/.test(lowered)) { + throw new Error('Invalid AES key: contains non-hexadecimal characters'); + } + if (lowered.length !== 32 && lowered.length !== 64) { + throw new Error(`Invalid AES key: expected 32 or 64 hex characters, got ${lowered.length}`); + } + return lowered; +} + +/** + * Validates whether an AES key is provided and normalizes it. + * + * @param aesKey - The AES key string to validate. + * @returns The normalized lowercase hex string. + * @throws Error if the key is empty, null, or invalid. + */ +export function validateAesKey(aesKey: string | null | undefined): string { + if (!aesKey) { + throw new Error('AES key is required'); + } + return normalizeAesKey(aesKey); +} + +export interface DecryptionOptions { + decimals?: number; + insaneThresholdBase?: bigint; +} + +const DEFAULT_INSANE_THRESHOLD_BASE = 1_000_000_000_000n; + +function normalizeDecimals(decimals?: number | null): number { + if (decimals === undefined || decimals === null) { return 18; } + if (!Number.isFinite(decimals)) { return 18; } + if (decimals < 0) { return 0; } + if (decimals > 36) { return 36; } + return Math.floor(decimals); +} + +/** + * Checks if a decrypted bigint exceeds the standard plausible threshold. + * Useful as a sanity check to prevent DApps from rendering massive garbaged numbers + * caused by decrypting a zero/invalid token balance with an incorrect AES key. + * + * @param value - The decrypted bigint to check. + * @param decimals - Token decimal places (0-36). + * @param thresholdBase - Base multiplier limit. + * @returns True if value exceeds sanity bounds. + */ +export function isInsaneDecryptedValue( + value: bigint, + decimals?: number, + thresholdBase?: bigint, +): boolean { + const safeDecimals = normalizeDecimals(decimals); + const base = thresholdBase ?? DEFAULT_INSANE_THRESHOLD_BASE; + const threshold = base * 10n ** BigInt(safeDecimals); + return value > threshold; +} + +function isZeroValue(value: unknown): boolean { + if (typeof value === 'bigint') { return value === 0n; } + if (typeof value === 'number') { return value === 0; } + if (typeof value === 'string') { return value === '0'; } + return false; +} + +/** + * High-level wrapper for `decryptUint` strictly intended for 64-bit ctUint ciphertexts. + * - Resolves known empty/zero payloads securely to `0n` mapped locally. + * - Applies a threshold scaling sanity check (`isInsaneDecryptedValue`) avoiding garbage values + * when supplied with mismatched AES encryption bounds. + * + * @param ciphertext - The raw 64-bit encrypted integer. + * @param aesKey - Full AES encryption key. + * @param options - Decryption constraints configuration. + * @returns Plaintext BigInt, or `null` if the sanity check threshold fails. + */ +export function decryptCtUint64( + ciphertext: ctUint, + aesKey: string, + options?: DecryptionOptions, +): bigint | null { + try { + if (isZeroValue(ciphertext)) { + return 0n; + } + const normalizedKey = normalizeAesKey(aesKey); + const rawDecrypted = decryptUint(ciphertext, normalizedKey); + const decrypted = typeof rawDecrypted === 'bigint' ? rawDecrypted : BigInt(rawDecrypted); + if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { + return null; + } + return decrypted; + } catch (error) { + console.error('[decryptCtUint64] failed:', error instanceof Error ? error.message : error); + return null; + } +} + +/** + * Produces a raw ECDSA signature object containing r, s, and v elements based on a wallet digest. + * + * @param privateKey - The signing wallet's private key hex. + * @param digest - The keccak256 message bytes hex. + * @returns Object formatted as `{ r, s, v }`. + */ +export function signDigest( + privateKey: string, + digest: string, +): { r: string; s: string; v: number } { + const signingKey = new SigningKey(privateKey); + const sig = signingKey.sign(digest); + return { r: sig.r, s: sig.s, v: sig.v }; +} + +/** + * Normalizes signature v-bytes to 0x00/0x01 based mappings. + * + * @param sig - ECDSA signature configuration components. + * @returns Standard 65-byte length hex string signature. + */ +export function normalizeSignature(sig: { r: string; s: string; v: number; }): string { + const vByte = sig.v === 27 ? '0x00' : '0x01'; + return hexlify(concat([sig.r, sig.s, vByte])); +} + +/** + * Builds the COTI IT specific standardized Signature string computing `solidityPackedKeccak256` + * across the sender, transaction destination, function identifier, and nested ciphertext, + * returning proper hex normalized representation. + * + * @param signerAddress - The EVM signer's wallet parameter constraints. + * @param contractAddress - The target Smart Contract destination address. + * @param functionSelector - EVM hex data subset signature identifier. + * @param ciphertext - Final transaction ciphertext parameter encoded representation. + * @param privateKey - The transaction execution signer's private key. + * @returns 65-byte length IT compliant EVM ready signature. + */ +export function buildItSignature( + signerAddress: string, + contractAddress: string, + functionSelector: string, + ciphertext: bigint, + privateKey: string, +): string { + const digest = solidityPackedKeccak256( + ['address', 'address', 'bytes4', 'uint256'], + [signerAddress, contractAddress, functionSelector, ciphertext], + ); + const sig = signDigest(privateKey, digest); + return normalizeSignature(sig); +} diff --git a/tests/unit/stringCrypt.test.ts b/tests/unit/stringCrypt.test.ts new file mode 100644 index 0000000..1a04b86 --- /dev/null +++ b/tests/unit/stringCrypt.test.ts @@ -0,0 +1,56 @@ +import { ethers } from "ethers"; +import { + buildStringInputText, + decryptString, + generateRandomAesKeySizeNumber +} from "../../src/crypto_utils"; + +describe("String Encryption and Decryption (decryptString)", () => { + let wallet: ethers.BaseWallet; + let userKey: string; + let sender: { wallet: ethers.BaseWallet; userKey: string }; + const contractAddress = "0x0000000000000000000000000000000000000000"; + const functionSelector = "0x12345678"; + + beforeAll(() => { + wallet = ethers.Wallet.createRandom(); + userKey = generateRandomAesKeySizeNumber(); + sender = { wallet, userKey }; + }); + + it("should correctly encrypt and decrypt a string perfectly matching the 8-byte chunk size", () => { + const targetString = "12345678"; // Exact 8 bytes + const it = buildStringInputText(targetString, sender, contractAddress, functionSelector); + + const decrypted = decryptString(it.ciphertext, userKey); + expect(decrypted).toBe(targetString); + }); + + it("should correctly encrypt and decrypt a string with padding (less than 8 bytes)", () => { + const targetString = "Hello"; // 5 bytes => pads 3 bytes + const it = buildStringInputText(targetString, sender, contractAddress, functionSelector); + + const decrypted = decryptString(it.ciphertext, userKey); + expect(decrypted).toBe(targetString); + expect(decrypted.length).toBe(targetString.length); // Verifies trailing null bytes are gone + }); + + it("should correctly encrypt and decrypt a multi-chunk string with padding", () => { + const targetString = "Hello World!"; // 12 bytes => 1 chunk (8) + 1 chunk (4 + pads 4) + const it = buildStringInputText(targetString, sender, contractAddress, functionSelector); + + // Assert there are 2 chunks under the hood + expect(it.ciphertext.value.length).toBe(2); + + const decrypted = decryptString(it.ciphertext, userKey); + expect(decrypted).toBe(targetString); + }); + + it("should preserve strings with null-like characters strictly intentionally inside the string", () => { + const targetString = "a\0b\0c"; // Internal null characters + const it = buildStringInputText(targetString, sender, contractAddress, functionSelector); + + const decrypted = decryptString(it.ciphertext, userKey); + expect(decrypted).toBe(targetString); + }); +}); From 53ad275f9e4431279d369ec6d023978c3a47b96c Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 09:40:07 -0300 Subject: [PATCH 02/11] refactor: remove redundant signDigest/normalizeSignature, reuse existing sign() in buildItSignature - Removed signDigest() and normalizeSignature() which duplicated logic already available via the existing sign() utility. - Simplified buildItSignature() to call sign() directly and wrap with hexlify(). - Removed unused 'concat' import from ethers. --- src/crypto_utils.ts | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 812d383..12768fa 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -1,5 +1,5 @@ import forge from 'node-forge' -import { BaseWallet, getBytes, SigningKey, solidityPackedKeccak256, hexlify, concat } from "ethers" +import { BaseWallet, getBytes, SigningKey, solidityPackedKeccak256, hexlify } from "ethers" import { ctString, ctUint, ctUint256, itString, itUint, itUint256 } from './types'; const BLOCK_SIZE = 16 // AES block size in bytes @@ -657,33 +657,6 @@ export function decryptCtUint64( } } -/** - * Produces a raw ECDSA signature object containing r, s, and v elements based on a wallet digest. - * - * @param privateKey - The signing wallet's private key hex. - * @param digest - The keccak256 message bytes hex. - * @returns Object formatted as `{ r, s, v }`. - */ -export function signDigest( - privateKey: string, - digest: string, -): { r: string; s: string; v: number } { - const signingKey = new SigningKey(privateKey); - const sig = signingKey.sign(digest); - return { r: sig.r, s: sig.s, v: sig.v }; -} - -/** - * Normalizes signature v-bytes to 0x00/0x01 based mappings. - * - * @param sig - ECDSA signature configuration components. - * @returns Standard 65-byte length hex string signature. - */ -export function normalizeSignature(sig: { r: string; s: string; v: number; }): string { - const vByte = sig.v === 27 ? '0x00' : '0x01'; - return hexlify(concat([sig.r, sig.s, vByte])); -} - /** * Builds the COTI IT specific standardized Signature string computing `solidityPackedKeccak256` * across the sender, transaction destination, function identifier, and nested ciphertext, @@ -707,6 +680,5 @@ export function buildItSignature( ['address', 'address', 'bytes4', 'uint256'], [signerAddress, contractAddress, functionSelector, ciphertext], ); - const sig = signDigest(privateKey, digest); - return normalizeSignature(sig); + return hexlify(sign(digest, privateKey)); } From a15840cdad880c9968981ad93f27464ee979db34 Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 10:11:29 -0300 Subject: [PATCH 03/11] refactor: extract buildItMessageHash to DRY up IT signing logic - Introduced buildItMessageHash() as a shared helper that computes the solidityPackedKeccak256 digest used by both signInputText and buildItSignature. - Eliminates duplicated hash construction across the two signing paths. --- src/crypto_utils.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 12768fa..49946b3 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -170,16 +170,26 @@ export function sign(message: string, privateKey: string) { return new Uint8Array([...getBytes(sig.r), ...getBytes(sig.s), ...getBytes(`0x0${sig.v - 27}`)]) } +// Computes the COTI IT message hash shared by signInputText and buildItSignature. +function buildItMessageHash( + signerAddress: string, + contractAddress: string, + functionSelector: string, + ct: bigint +): string { + return solidityPackedKeccak256( + ["address", "address", "bytes4", "uint256"], + [signerAddress, contractAddress, functionSelector, ct] + ) +} + export function signInputText( sender: { wallet: BaseWallet; userKey: string }, contractAddress: string, functionSelector: string, ct: bigint ) { - const message = solidityPackedKeccak256( - ["address", "address", "bytes4", "uint256"], - [sender.wallet.address, contractAddress, functionSelector, ct] - ) + const message = buildItMessageHash(sender.wallet.address, contractAddress, functionSelector, ct) return sign(message, sender.wallet.privateKey); } @@ -676,9 +686,6 @@ export function buildItSignature( ciphertext: bigint, privateKey: string, ): string { - const digest = solidityPackedKeccak256( - ['address', 'address', 'bytes4', 'uint256'], - [signerAddress, contractAddress, functionSelector, ciphertext], - ); + const digest = buildItMessageHash(signerAddress, contractAddress, functionSelector, ciphertext); return hexlify(sign(digest, privateKey)); } From 5f54efa7a5a0e87cf81c984122909f8ac0c5b534 Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 10:18:29 -0300 Subject: [PATCH 04/11] test: add unit tests for wallet plugin DApp crypto utilities - normalizeAesKey: prefix stripping, lowercasing, length and hex validation - validateAesKey: null/empty guards and normalization propagation - isInsaneDecryptedValue: threshold logic, custom base, decimal clamping - decryptCtUint64: zero passthrough, round-trip, invalid key handling, sanity check - buildItSignature: 65-byte output, signer recovery, parity with signInputText --- tests/unit/walletPluginAdditions.test.ts | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/unit/walletPluginAdditions.test.ts diff --git a/tests/unit/walletPluginAdditions.test.ts b/tests/unit/walletPluginAdditions.test.ts new file mode 100644 index 0000000..5fecb21 --- /dev/null +++ b/tests/unit/walletPluginAdditions.test.ts @@ -0,0 +1,201 @@ +import { Wallet, recoverAddress, solidityPackedKeccak256, getBytes, hexlify } from 'ethers' +import { + buildInputText, + buildItSignature, + decryptCtUint64, + isInsaneDecryptedValue, + normalizeAesKey, + signInputText, + validateAesKey +} from '../../src' + +const AES_KEY = '0123456789abcdef0123456789abcdef' // 32 hex chars (128-bit) +const AES_KEY_256 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' // 64 hex chars +const CONTRACT_ADDRESS = '0x0000000000000000000000000000000000000001' +const FUNCTION_SELECTOR = '0x11223344' + +describe('normalizeAesKey', () => { + test('strips the 0x prefix', () => { + expect(normalizeAesKey('0x' + AES_KEY)).toBe(AES_KEY) + }) + + test('lowercases the key', () => { + expect(normalizeAesKey(AES_KEY.toUpperCase())).toBe(AES_KEY) + }) + + test('accepts a 32-char (128-bit) key', () => { + expect(normalizeAesKey(AES_KEY)).toBe(AES_KEY) + }) + + test('accepts a 64-char (256-bit) key', () => { + expect(normalizeAesKey(AES_KEY_256)).toBe(AES_KEY_256) + }) + + test('throws on non-hexadecimal characters', () => { + expect(() => normalizeAesKey('g'.repeat(32))).toThrow('non-hexadecimal') + }) + + test('throws on invalid length', () => { + expect(() => normalizeAesKey('abcdef')).toThrow('expected 32 or 64 hex characters') + }) +}) + +describe('validateAesKey', () => { + test('throws when key is undefined', () => { + expect(() => validateAesKey(undefined)).toThrow('AES key is required') + }) + + test('throws when key is null', () => { + expect(() => validateAesKey(null)).toThrow('AES key is required') + }) + + test('throws when key is an empty string', () => { + expect(() => validateAesKey('')).toThrow('AES key is required') + }) + + test('returns the normalized key when valid', () => { + expect(validateAesKey('0x' + AES_KEY.toUpperCase())).toBe(AES_KEY) + }) + + test('propagates normalizeAesKey validation errors', () => { + expect(() => validateAesKey('zz')).toThrow('non-hexadecimal') + }) +}) + +describe('isInsaneDecryptedValue', () => { + test('returns false for a value equal to the default threshold', () => { + // default base = 1e12, default decimals = 18 => threshold = 10^30 + expect(isInsaneDecryptedValue(10n ** 30n)).toBe(false) + }) + + test('returns true for a value above the default threshold', () => { + expect(isInsaneDecryptedValue(10n ** 30n + 1n)).toBe(true) + }) + + test('honors a custom thresholdBase', () => { + // base = 100, decimals = 0 => threshold = 100 + expect(isInsaneDecryptedValue(101n, 0, 100n)).toBe(true) + expect(isInsaneDecryptedValue(100n, 0, 100n)).toBe(false) + }) + + test('clamps negative decimals to 0', () => { + // decimals clamped to 0 => threshold = 1 * 10^0 = 1 + expect(isInsaneDecryptedValue(2n, -5, 1n)).toBe(true) + expect(isInsaneDecryptedValue(1n, -5, 1n)).toBe(false) + }) + + test('clamps decimals above 36', () => { + // decimals clamped to 36 => threshold = 1 * 10^36 + expect(isInsaneDecryptedValue(10n ** 36n, 40, 1n)).toBe(false) + expect(isInsaneDecryptedValue(10n ** 36n + 1n, 40, 1n)).toBe(true) + }) + + test('falls back to 18 decimals for non-finite input', () => { + // decimals NaN => 18 => threshold = 1 * 10^18 + expect(isInsaneDecryptedValue(10n ** 18n, NaN, 1n)).toBe(false) + expect(isInsaneDecryptedValue(10n ** 18n + 1n, NaN, 1n)).toBe(true) + }) +}) + +describe('decryptCtUint64', () => { + function buildCiphertext(plaintext: bigint): bigint { + const wallet = Wallet.createRandom() + const { ciphertext } = buildInputText( + plaintext, + { wallet, userKey: AES_KEY }, + CONTRACT_ADDRESS, + FUNCTION_SELECTOR + ) + return ciphertext + } + + test('returns 0n for a zero ciphertext without touching the key', () => { + expect(decryptCtUint64(0n, 'not-a-valid-key')).toBe(0n) + }) + + test('round-trips a small plaintext value', () => { + const ciphertext = buildCiphertext(12345n) + expect(decryptCtUint64(ciphertext, AES_KEY)).toBe(12345n) + }) + + test('accepts a 0x-prefixed key', () => { + const ciphertext = buildCiphertext(777n) + expect(decryptCtUint64(ciphertext, '0x' + AES_KEY)).toBe(777n) + }) + + test('returns null on an invalid key', () => { + const ciphertext = buildCiphertext(42n) + expect(decryptCtUint64(ciphertext, 'xyz')).toBeNull() + }) + + test('returns null when the value exceeds the sanity threshold', () => { + const ciphertext = buildCiphertext(12345n) + // force the threshold low enough that the decrypted value is "insane" + expect(decryptCtUint64(ciphertext, AES_KEY, { decimals: 0, insaneThresholdBase: 1n })).toBeNull() + }) +}) + +describe('buildItSignature', () => { + test('produces a 65-byte hex signature', () => { + const wallet = Wallet.createRandom() + const signature = buildItSignature( + wallet.address, + CONTRACT_ADDRESS, + FUNCTION_SELECTOR, + 12345n, + wallet.privateKey + ) + expect(typeof signature).toBe('string') + // 0x + 65 bytes * 2 hex chars = 132 characters + expect(signature.length).toBe(132) + }) + + test('recovers the correct signer address', () => { + const wallet = Wallet.createRandom() + const ciphertext = 12345n + const signature = buildItSignature( + wallet.address, + CONTRACT_ADDRESS, + FUNCTION_SELECTOR, + ciphertext, + wallet.privateKey + ) + + const digest = solidityPackedKeccak256( + ['address', 'address', 'bytes4', 'uint256'], + [wallet.address, CONTRACT_ADDRESS, FUNCTION_SELECTOR, ciphertext] + ) + + const bytes = getBytes(signature) + const r = hexlify(bytes.slice(0, 32)) + const s = hexlify(bytes.slice(32, 64)) + const v = bytes[64] + 27 + + const recovered = recoverAddress(digest, { r, s, v }) + expect(recovered.toLowerCase()).toBe(wallet.address.toLowerCase()) + }) + + test('matches the bytes produced by signInputText for the same inputs', () => { + const wallet = Wallet.createRandom() + const ciphertext = 98765n + + const fromBuild = buildItSignature( + wallet.address, + CONTRACT_ADDRESS, + FUNCTION_SELECTOR, + ciphertext, + wallet.privateKey + ) + + const fromSignInputText = hexlify( + signInputText( + { wallet, userKey: '' }, + CONTRACT_ADDRESS, + FUNCTION_SELECTOR, + ciphertext + ) + ) + + expect(fromBuild).toBe(fromSignInputText) + }) +}) From 0597c1a3fcd4bff0fbd048767188c55de4e8831b Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 13:59:34 -0300 Subject: [PATCH 05/11] refactor: restrict normalizeAesKey to 128-bit and let decryptCtUint64 throw on invalid keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeAesKey now only accepts 32-char hex strings (128-bit) since COTI exclusively uses AES-128; 256-bit keys are no longer silently accepted. - decryptCtUint64 removes the try/catch wrapper — invalid keys now throw directly instead of being swallowed and returning null. - Updated tests to reflect the stricter validation behavior. --- src/crypto_utils.ts | 32 +++++++++++------------- tests/unit/walletPluginAdditions.test.ts | 10 ++++---- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 49946b3..6f12f22 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -559,8 +559,8 @@ export function prepareIT256( /** * Strips "0x" prefix and converts an AES key to lowercase. - * Accepts both 32-char (128-bit) and 64-char (256-bit) hex strings. - * Throws if the key contains invalid hexadecimal characters. + * COTI uses a 128-bit AES key, so only 32-char hex strings are accepted. + * Throws if the key contains invalid hexadecimal characters or has the wrong length. * * @param aesKey - The AES key string, optionally prefixed with "0x". * @returns The normalized lowercase hex string. @@ -571,8 +571,8 @@ export function normalizeAesKey(aesKey: string): string { if (!/^[0-9a-f]+$/.test(lowered)) { throw new Error('Invalid AES key: contains non-hexadecimal characters'); } - if (lowered.length !== 32 && lowered.length !== 64) { - throw new Error(`Invalid AES key: expected 32 or 64 hex characters, got ${lowered.length}`); + if (lowered.length !== 32) { + throw new Error(`Invalid AES key: expected 32 hex characters (128-bit), got ${lowered.length}`); } return lowered; } @@ -643,28 +643,24 @@ function isZeroValue(value: unknown): boolean { * @param ciphertext - The raw 64-bit encrypted integer. * @param aesKey - Full AES encryption key. * @param options - Decryption constraints configuration. - * @returns Plaintext BigInt, or `null` if the sanity check threshold fails. + * @returns Plaintext BigInt, or `null` if the value fails the plausibility sanity check. + * @throws Error if the AES key is invalid or decryption fails. */ export function decryptCtUint64( ciphertext: ctUint, aesKey: string, options?: DecryptionOptions, ): bigint | null { - try { - if (isZeroValue(ciphertext)) { - return 0n; - } - const normalizedKey = normalizeAesKey(aesKey); - const rawDecrypted = decryptUint(ciphertext, normalizedKey); - const decrypted = typeof rawDecrypted === 'bigint' ? rawDecrypted : BigInt(rawDecrypted); - if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { - return null; - } - return decrypted; - } catch (error) { - console.error('[decryptCtUint64] failed:', error instanceof Error ? error.message : error); + if (isZeroValue(ciphertext)) { + return 0n; + } + const normalizedKey = normalizeAesKey(aesKey); + const rawDecrypted = decryptUint(ciphertext, normalizedKey); + const decrypted = typeof rawDecrypted === 'bigint' ? rawDecrypted : BigInt(rawDecrypted); + if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { return null; } + return decrypted; } /** diff --git a/tests/unit/walletPluginAdditions.test.ts b/tests/unit/walletPluginAdditions.test.ts index 5fecb21..409d180 100644 --- a/tests/unit/walletPluginAdditions.test.ts +++ b/tests/unit/walletPluginAdditions.test.ts @@ -27,8 +27,8 @@ describe('normalizeAesKey', () => { expect(normalizeAesKey(AES_KEY)).toBe(AES_KEY) }) - test('accepts a 64-char (256-bit) key', () => { - expect(normalizeAesKey(AES_KEY_256)).toBe(AES_KEY_256) + test('rejects a 64-char (256-bit) key since COTI uses 128-bit AES', () => { + expect(() => normalizeAesKey(AES_KEY_256)).toThrow('expected 32 hex characters') }) test('throws on non-hexadecimal characters', () => { @@ -36,7 +36,7 @@ describe('normalizeAesKey', () => { }) test('throws on invalid length', () => { - expect(() => normalizeAesKey('abcdef')).toThrow('expected 32 or 64 hex characters') + expect(() => normalizeAesKey('abcdef')).toThrow('expected 32 hex characters') }) }) @@ -123,9 +123,9 @@ describe('decryptCtUint64', () => { expect(decryptCtUint64(ciphertext, '0x' + AES_KEY)).toBe(777n) }) - test('returns null on an invalid key', () => { + test('throws on an invalid key', () => { const ciphertext = buildCiphertext(42n) - expect(decryptCtUint64(ciphertext, 'xyz')).toBeNull() + expect(() => decryptCtUint64(ciphertext, 'xyz')).toThrow() }) test('returns null when the value exceeds the sanity threshold', () => { From 8f7fc3cfa7f5670e8f0ae2b41e033dfe92c47965 Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 14:39:08 -0300 Subject: [PATCH 06/11] refactor: stricter validation, signer verification, and improved docs - normalizeDecimals now throws on invalid input (negative, >36, non-integer, non-finite) instead of silently clamping. - isZeroValue simplified to only handle bigint (matching ctUint type). - buildItSignature validates that signerAddress matches the privateKey-derived address before signing. - Improved JSDoc across all wallet plugin additions. - Consistent code style (4-space indent, no semicolons). - Tests updated: added cases for decimal validation throws and signer mismatch. --- src/crypto_utils.ts | 193 +++++++++++++---------- tests/unit/walletPluginAdditions.test.ts | 44 ++++-- 2 files changed, 139 insertions(+), 98 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 6f12f22..7b57e11 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -1,5 +1,5 @@ import forge from 'node-forge' -import { BaseWallet, getBytes, SigningKey, solidityPackedKeccak256, hexlify } from "ethers" +import { BaseWallet, getBytes, SigningKey, solidityPackedKeccak256, hexlify, Wallet } from "ethers" import { ctString, ctUint, ctUint256, itString, itUint, itUint256 } from './types'; const BLOCK_SIZE = 16 // AES block size in bytes @@ -558,130 +558,151 @@ export function prepareIT256( // ------------- Wallet Plugin Additions ------------- /** - * Strips "0x" prefix and converts an AES key to lowercase. - * COTI uses a 128-bit AES key, so only 32-char hex strings are accepted. - * Throws if the key contains invalid hexadecimal characters or has the wrong length. - * - * @param aesKey - The AES key string, optionally prefixed with "0x". + * Strips the "0x" prefix and lowercases an AES key. + * COTI uses a 128-bit AES key, so only 32-character hex strings are accepted. + * + * @param aesKey - The AES key, optionally prefixed with "0x". * @returns The normalized lowercase hex string. + * @throws Error if the key contains non-hex characters or is not 32 hex characters. */ export function normalizeAesKey(aesKey: string): string { - const trimmed = aesKey.startsWith('0x') ? aesKey.slice(2) : aesKey; - const lowered = trimmed.toLowerCase(); - if (!/^[0-9a-f]+$/.test(lowered)) { - throw new Error('Invalid AES key: contains non-hexadecimal characters'); - } - if (lowered.length !== 32) { - throw new Error(`Invalid AES key: expected 32 hex characters (128-bit), got ${lowered.length}`); - } - return lowered; + const trimmed = aesKey.startsWith("0x") ? aesKey.slice(2) : aesKey + const lowered = trimmed.toLowerCase() + + if (!/^[0-9a-f]+$/.test(lowered)) { + throw new Error("Invalid AES key: contains non-hexadecimal characters") + } + + if (lowered.length !== 32) { + throw new Error(`Invalid AES key: expected 32 hex characters (128-bit), got ${lowered.length}`) + } + + return lowered } /** - * Validates whether an AES key is provided and normalizes it. + * Ensures an AES key is provided, then normalizes it. * - * @param aesKey - The AES key string to validate. + * @param aesKey - The AES key to validate. * @returns The normalized lowercase hex string. - * @throws Error if the key is empty, null, or invalid. + * @throws Error if the key is empty, null, undefined, or invalid. */ export function validateAesKey(aesKey: string | null | undefined): string { - if (!aesKey) { - throw new Error('AES key is required'); - } - return normalizeAesKey(aesKey); + if (!aesKey) { + throw new Error("AES key is required") + } + + return normalizeAesKey(aesKey) } export interface DecryptionOptions { - decimals?: number; - insaneThresholdBase?: bigint; + decimals?: number + insaneThresholdBase?: bigint } -const DEFAULT_INSANE_THRESHOLD_BASE = 1_000_000_000_000n; +const DEFAULT_INSANE_THRESHOLD_BASE = 1_000_000_000_000n +// Validates token decimals, defaulting to 18 when not provided. +// Throws on clearly-invalid input (non-integer, negative, or out of the 0-36 range). function normalizeDecimals(decimals?: number | null): number { - if (decimals === undefined || decimals === null) { return 18; } - if (!Number.isFinite(decimals)) { return 18; } - if (decimals < 0) { return 0; } - if (decimals > 36) { return 36; } - return Math.floor(decimals); + if (decimals === undefined || decimals === null) { + return 18 + } + + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { + throw new Error(`Invalid decimals: expected an integer between 0 and 36, got ${decimals}`) + } + + return decimals } /** - * Checks if a decrypted bigint exceeds the standard plausible threshold. - * Useful as a sanity check to prevent DApps from rendering massive garbaged numbers - * caused by decrypting a zero/invalid token balance with an incorrect AES key. + * Validation-only heuristic: checks whether a decrypted value exceeds a plausible + * balance threshold. This is NOT a cryptographic guarantee — it is a best-effort + * sanity check so that a balance decrypted with the wrong AES key is flagged rather + * than rendered as an implausibly large number. Intended for balance validation in + * consuming applications; it does not affect correctness of decryption itself. + * + * The threshold is `thresholdBase * 10^decimals`. Tune `thresholdBase`/`decimals` + * per token if the defaults are too strict for a given use case. * - * @param value - The decrypted bigint to check. - * @param decimals - Token decimal places (0-36). - * @param thresholdBase - Base multiplier limit. - * @returns True if value exceeds sanity bounds. + * @param value - The decrypted value to validate. + * @param decimals - Token decimal places (clamped to 0-36, default 18). + * @param thresholdBase - Base multiplier for the threshold (default 1e12). + * @returns True if the value exceeds the plausibility threshold (i.e. likely invalid). */ export function isInsaneDecryptedValue( - value: bigint, - decimals?: number, - thresholdBase?: bigint, + value: bigint, + decimals?: number, + thresholdBase?: bigint ): boolean { - const safeDecimals = normalizeDecimals(decimals); - const base = thresholdBase ?? DEFAULT_INSANE_THRESHOLD_BASE; - const threshold = base * 10n ** BigInt(safeDecimals); - return value > threshold; + const safeDecimals = normalizeDecimals(decimals) + const base = thresholdBase ?? DEFAULT_INSANE_THRESHOLD_BASE + const threshold = base * 10n ** BigInt(safeDecimals) + + return value > threshold } -function isZeroValue(value: unknown): boolean { - if (typeof value === 'bigint') { return value === 0n; } - if (typeof value === 'number') { return value === 0; } - if (typeof value === 'string') { return value === '0'; } - return false; +// Returns true when the ciphertext represents a zero value. +function isZeroValue(value: ctUint): boolean { + return value === 0n } /** - * High-level wrapper for `decryptUint` strictly intended for 64-bit ctUint ciphertexts. - * - Resolves known empty/zero payloads securely to `0n` mapped locally. - * - Applies a threshold scaling sanity check (`isInsaneDecryptedValue`) avoiding garbage values - * when supplied with mismatched AES encryption bounds. + * High-level wrapper over `decryptUint` for 64-bit ctUint ciphertexts. + * - Short-circuits a zero ciphertext to `0n` without touching the key. + * - Applies a plausibility sanity check (`isInsaneDecryptedValue`). * - * @param ciphertext - The raw 64-bit encrypted integer. - * @param aesKey - Full AES encryption key. - * @param options - Decryption constraints configuration. - * @returns Plaintext BigInt, or `null` if the value fails the plausibility sanity check. + * @param ciphertext - The encrypted 64-bit value. + * @param aesKey - The AES key (32 hex chars, optionally "0x"-prefixed). + * @param options - Optional decimals and threshold configuration. + * @returns The decrypted value, or `null` if it fails the plausibility check. * @throws Error if the AES key is invalid or decryption fails. */ export function decryptCtUint64( - ciphertext: ctUint, - aesKey: string, - options?: DecryptionOptions, + ciphertext: ctUint, + aesKey: string, + options?: DecryptionOptions ): bigint | null { - if (isZeroValue(ciphertext)) { - return 0n; - } - const normalizedKey = normalizeAesKey(aesKey); - const rawDecrypted = decryptUint(ciphertext, normalizedKey); - const decrypted = typeof rawDecrypted === 'bigint' ? rawDecrypted : BigInt(rawDecrypted); - if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { - return null; - } - return decrypted; + if (isZeroValue(ciphertext)) { + return 0n + } + + const normalizedKey = normalizeAesKey(aesKey) + const rawDecrypted = decryptUint(ciphertext, normalizedKey) + const decrypted = typeof rawDecrypted === "bigint" ? rawDecrypted : BigInt(rawDecrypted) + + if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { + return null + } + + return decrypted } /** - * Builds the COTI IT specific standardized Signature string computing `solidityPackedKeccak256` - * across the sender, transaction destination, function identifier, and nested ciphertext, - * returning proper hex normalized representation. + * Builds a COTI input-text (IT) signature over (signer, contract, selector, ciphertext). * - * @param signerAddress - The EVM signer's wallet parameter constraints. - * @param contractAddress - The target Smart Contract destination address. - * @param functionSelector - EVM hex data subset signature identifier. - * @param ciphertext - Final transaction ciphertext parameter encoded representation. - * @param privateKey - The transaction execution signer's private key. - * @returns 65-byte length IT compliant EVM ready signature. + * @param signerAddress - Address of the signer; must match the address derived from privateKey. + * @param contractAddress - Target contract address. + * @param functionSelector - 4-byte function selector (e.g. "0x11223344"). + * @param ciphertext - The encrypted value being signed. + * @param privateKey - Signer's private key. + * @returns The 65-byte signature as a hex string. + * @throws Error if signerAddress does not match the address derived from privateKey. */ export function buildItSignature( - signerAddress: string, - contractAddress: string, - functionSelector: string, - ciphertext: bigint, - privateKey: string, + signerAddress: string, + contractAddress: string, + functionSelector: string, + ciphertext: bigint, + privateKey: string ): string { - const digest = buildItMessageHash(signerAddress, contractAddress, functionSelector, ciphertext); - return hexlify(sign(digest, privateKey)); + const derivedAddress = new Wallet(privateKey).address + if (derivedAddress.toLowerCase() !== signerAddress.toLowerCase()) { + throw new Error("Invalid signer: signerAddress does not match the address derived from privateKey") + } + + const digest = buildItMessageHash(signerAddress, contractAddress, functionSelector, ciphertext) + + return hexlify(sign(digest, privateKey)) } diff --git a/tests/unit/walletPluginAdditions.test.ts b/tests/unit/walletPluginAdditions.test.ts index 409d180..a367086 100644 --- a/tests/unit/walletPluginAdditions.test.ts +++ b/tests/unit/walletPluginAdditions.test.ts @@ -78,22 +78,27 @@ describe('isInsaneDecryptedValue', () => { expect(isInsaneDecryptedValue(100n, 0, 100n)).toBe(false) }) - test('clamps negative decimals to 0', () => { - // decimals clamped to 0 => threshold = 1 * 10^0 = 1 - expect(isInsaneDecryptedValue(2n, -5, 1n)).toBe(true) - expect(isInsaneDecryptedValue(1n, -5, 1n)).toBe(false) + test('accepts a valid in-range decimals value', () => { + // decimals = 6 => threshold = 1 * 10^6 + expect(isInsaneDecryptedValue(10n ** 6n, 6, 1n)).toBe(false) + expect(isInsaneDecryptedValue(10n ** 6n + 1n, 6, 1n)).toBe(true) }) - test('clamps decimals above 36', () => { - // decimals clamped to 36 => threshold = 1 * 10^36 - expect(isInsaneDecryptedValue(10n ** 36n, 40, 1n)).toBe(false) - expect(isInsaneDecryptedValue(10n ** 36n + 1n, 40, 1n)).toBe(true) + test('throws on negative decimals', () => { + expect(() => isInsaneDecryptedValue(2n, -5, 1n)).toThrow('Invalid decimals') }) - test('falls back to 18 decimals for non-finite input', () => { - // decimals NaN => 18 => threshold = 1 * 10^18 - expect(isInsaneDecryptedValue(10n ** 18n, NaN, 1n)).toBe(false) - expect(isInsaneDecryptedValue(10n ** 18n + 1n, NaN, 1n)).toBe(true) + test('throws on decimals above 36', () => { + expect(() => isInsaneDecryptedValue(2n, 40, 1n)).toThrow('Invalid decimals') + }) + + test('throws on non-integer decimals', () => { + expect(() => isInsaneDecryptedValue(2n, 6.7, 1n)).toThrow('Invalid decimals') + }) + + test('throws on non-finite decimals', () => { + expect(() => isInsaneDecryptedValue(2n, NaN, 1n)).toThrow('Invalid decimals') + expect(() => isInsaneDecryptedValue(2n, Infinity, 1n)).toThrow('Invalid decimals') }) }) @@ -198,4 +203,19 @@ describe('buildItSignature', () => { expect(fromBuild).toBe(fromSignInputText) }) + + test('throws when signerAddress does not match the private key', () => { + const wallet = Wallet.createRandom() + const otherWallet = Wallet.createRandom() + + expect(() => + buildItSignature( + otherWallet.address, + CONTRACT_ADDRESS, + FUNCTION_SELECTOR, + 12345n, + wallet.privateKey + ) + ).toThrow('does not match the address derived from privateKey') + }) }) From 1014c6782642f174a875e517c1a5e655f8cdc2ec Mon Sep 17 00:00:00 2001 From: Guy Mesika Date: Wed, 10 Jun 2026 23:23:42 +0300 Subject: [PATCH 07/11] refactor: streamline buildItSignature function and enhance signer validation - Replaced direct address derivation with a Wallet instance for improved clarity. - Simplified the signature generation process by utilizing signInputText. - Maintained existing validation to ensure signerAddress matches the derived address from the privateKey. --- src/crypto_utils.ts | 46 ++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 7b57e11..97ec0ac 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -659,25 +659,27 @@ function isZeroValue(value: ctUint): boolean { * @returns The decrypted value, or `null` if it fails the plausibility check. * @throws Error if the AES key is invalid or decryption fails. */ -export function decryptCtUint64( - ciphertext: ctUint, - aesKey: string, - options?: DecryptionOptions -): bigint | null { - if (isZeroValue(ciphertext)) { - return 0n - } +// export function decryptCtUint64( +// ciphertext: ctUint, +// aesKey: string, +// options?: DecryptionOptions +// ): bigint | null { +// if (isZeroValue(ciphertext)) { +// return 0n +// } - const normalizedKey = normalizeAesKey(aesKey) - const rawDecrypted = decryptUint(ciphertext, normalizedKey) - const decrypted = typeof rawDecrypted === "bigint" ? rawDecrypted : BigInt(rawDecrypted) +// const normalizedKey = normalizeAesKey(aesKey) +// const rawDecrypted = decryptUint(ciphertext, normalizedKey) +// const decrypted = typeof rawDecrypted === "bigint" ? rawDecrypted : BigInt(rawDecrypted) - if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { - return null - } +// if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { +// return null +// } - return decrypted -} +// return decrypted +// } + +/// PERCIVAL TO REMOVE THE ABOVE AND DO recompute of the ciphertext blocked with stored r /** * Builds a COTI input-text (IT) signature over (signer, contract, selector, ciphertext). @@ -697,12 +699,10 @@ export function buildItSignature( ciphertext: bigint, privateKey: string ): string { - const derivedAddress = new Wallet(privateKey).address - if (derivedAddress.toLowerCase() !== signerAddress.toLowerCase()) { - throw new Error("Invalid signer: signerAddress does not match the address derived from privateKey") + const wallet = new Wallet(privateKey); + if ( wallet.address.toLowerCase() !== signerAddress.toLowerCase()) { + throw new Error("Invalid signer: signerAddress does not match the address derived from privateKey"); } - - const digest = buildItMessageHash(signerAddress, contractAddress, functionSelector, ciphertext) - - return hexlify(sign(digest, privateKey)) + let signature = signInputText({ wallet, userKey: ''}, contractAddress, functionSelector, ciphertext); + return hexlify(signature); } From c3c772ad41c3e86f631df1940f44b5b1e3543f1e Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 21:21:15 -0300 Subject: [PATCH 08/11] refactor: consolidate key validation into decryptUint, remove decryptCtUint64 and validateAesKey - Merged validateAesKey logic into normalizeAesKey (now accepts null/undefined). - Pushed zero-ciphertext short-circuit and key normalization into decryptUint directly, eliminating the need for the decryptCtUint64 wrapper. - Removed decryptCtUint64, validateAesKey, isZeroValue, and DecryptionOptions as they are no longer needed. - Fixed stringCrypt test to convert generateRandomAesKeySizeNumber() output from binary to hex (matching the SDK's documented 32-char format). - Updated error.handling tests to assert decryptUint now throws on invalid keys instead of silently producing incorrect results. - All 366 tests passing. --- src/crypto_utils.ts | 126 ++++------------------- tests/unit/error.handling.test.ts | 14 ++- tests/unit/stringCrypt.test.ts | 4 +- tests/unit/walletPluginAdditions.test.ts | 78 ++------------ 4 files changed, 39 insertions(+), 183 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 97ec0ac..44d9646 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -264,6 +264,16 @@ export function buildStringInputText( } export function decryptUint(ciphertext: ctUint, userKey: string): bigint { + // A zero ciphertext represents uninitialized/empty storage, which decrypts + // to plaintext 0. Short-circuit before touching the key so callers can read + // empty values without a valid key (and to avoid returning AES garbage). + if (ciphertext === 0n) { + return 0n + } + + // Validate and normalize the key (strips "0x", lowercases, enforces 128-bit). + const normalizedKey = normalizeAesKey(userKey) + // Convert ciphertext to Uint8Array let ctArray = new Uint8Array() @@ -279,7 +289,7 @@ export function decryptUint(ciphertext: ctUint, userKey: string): bigint { const cipher = ctArray.subarray(0, BLOCK_SIZE) const r = ctArray.subarray(BLOCK_SIZE) - const userKeyBytes = encodeKey(userKey) + const userKeyBytes = encodeKey(normalizedKey) // Decrypt the cipher const decryptedMessage = decrypt(userKeyBytes, r, cipher) @@ -558,14 +568,19 @@ export function prepareIT256( // ------------- Wallet Plugin Additions ------------- /** - * Strips the "0x" prefix and lowercases an AES key. - * COTI uses a 128-bit AES key, so only 32-character hex strings are accepted. + * Validates and normalizes an AES key: ensures it is present, strips the "0x" + * prefix, and lowercases it. COTI uses a 128-bit AES key, so only 32-character + * hex strings are accepted. * * @param aesKey - The AES key, optionally prefixed with "0x". * @returns The normalized lowercase hex string. - * @throws Error if the key contains non-hex characters or is not 32 hex characters. + * @throws Error if the key is empty/null/undefined, contains non-hex characters, or is not 32 hex characters. */ -export function normalizeAesKey(aesKey: string): string { +export function normalizeAesKey(aesKey: string | null | undefined): string { + if (!aesKey) { + throw new Error("AES key is required") + } + const trimmed = aesKey.startsWith("0x") ? aesKey.slice(2) : aesKey const lowered = trimmed.toLowerCase() @@ -580,107 +595,6 @@ export function normalizeAesKey(aesKey: string): string { return lowered } -/** - * Ensures an AES key is provided, then normalizes it. - * - * @param aesKey - The AES key to validate. - * @returns The normalized lowercase hex string. - * @throws Error if the key is empty, null, undefined, or invalid. - */ -export function validateAesKey(aesKey: string | null | undefined): string { - if (!aesKey) { - throw new Error("AES key is required") - } - - return normalizeAesKey(aesKey) -} - -export interface DecryptionOptions { - decimals?: number - insaneThresholdBase?: bigint -} - -const DEFAULT_INSANE_THRESHOLD_BASE = 1_000_000_000_000n - -// Validates token decimals, defaulting to 18 when not provided. -// Throws on clearly-invalid input (non-integer, negative, or out of the 0-36 range). -function normalizeDecimals(decimals?: number | null): number { - if (decimals === undefined || decimals === null) { - return 18 - } - - if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { - throw new Error(`Invalid decimals: expected an integer between 0 and 36, got ${decimals}`) - } - - return decimals -} - -/** - * Validation-only heuristic: checks whether a decrypted value exceeds a plausible - * balance threshold. This is NOT a cryptographic guarantee — it is a best-effort - * sanity check so that a balance decrypted with the wrong AES key is flagged rather - * than rendered as an implausibly large number. Intended for balance validation in - * consuming applications; it does not affect correctness of decryption itself. - * - * The threshold is `thresholdBase * 10^decimals`. Tune `thresholdBase`/`decimals` - * per token if the defaults are too strict for a given use case. - * - * @param value - The decrypted value to validate. - * @param decimals - Token decimal places (clamped to 0-36, default 18). - * @param thresholdBase - Base multiplier for the threshold (default 1e12). - * @returns True if the value exceeds the plausibility threshold (i.e. likely invalid). - */ -export function isInsaneDecryptedValue( - value: bigint, - decimals?: number, - thresholdBase?: bigint -): boolean { - const safeDecimals = normalizeDecimals(decimals) - const base = thresholdBase ?? DEFAULT_INSANE_THRESHOLD_BASE - const threshold = base * 10n ** BigInt(safeDecimals) - - return value > threshold -} - -// Returns true when the ciphertext represents a zero value. -function isZeroValue(value: ctUint): boolean { - return value === 0n -} - -/** - * High-level wrapper over `decryptUint` for 64-bit ctUint ciphertexts. - * - Short-circuits a zero ciphertext to `0n` without touching the key. - * - Applies a plausibility sanity check (`isInsaneDecryptedValue`). - * - * @param ciphertext - The encrypted 64-bit value. - * @param aesKey - The AES key (32 hex chars, optionally "0x"-prefixed). - * @param options - Optional decimals and threshold configuration. - * @returns The decrypted value, or `null` if it fails the plausibility check. - * @throws Error if the AES key is invalid or decryption fails. - */ -// export function decryptCtUint64( -// ciphertext: ctUint, -// aesKey: string, -// options?: DecryptionOptions -// ): bigint | null { -// if (isZeroValue(ciphertext)) { -// return 0n -// } - -// const normalizedKey = normalizeAesKey(aesKey) -// const rawDecrypted = decryptUint(ciphertext, normalizedKey) -// const decrypted = typeof rawDecrypted === "bigint" ? rawDecrypted : BigInt(rawDecrypted) - -// if (isInsaneDecryptedValue(decrypted, options?.decimals, options?.insaneThresholdBase)) { -// return null -// } - -// return decrypted -// } - -/// PERCIVAL TO REMOVE THE ABOVE AND DO recompute of the ciphertext blocked with stored r - /** * Builds a COTI input-text (IT) signature over (signer, contract, selector, ciphertext). * diff --git a/tests/unit/error.handling.test.ts b/tests/unit/error.handling.test.ts index 0c21126..e8ad91d 100644 --- a/tests/unit/error.handling.test.ts +++ b/tests/unit/error.handling.test.ts @@ -228,7 +228,7 @@ describe('Unit: Error Handling', () => { }) describe('decryptUint error cases', () => { - test('produces incorrect result when user key has wrong length', () => { + test('throws when user key has wrong length', () => { const { ciphertext } = prepareIT( 12345n, createTestSender(), @@ -236,9 +236,8 @@ describe('Unit: Error Handling', () => { TEST_CONSTANTS.FUNCTION_SELECTOR ) const wrongKey = '1234567890123456789012345678901' // 31 chars - // Function doesn't throw, but produces incorrect decryption - const decrypted = decryptUint(ciphertext, wrongKey) - expect(decrypted).not.toBe(12345n) + // decryptUint now validates the key and rejects non-128-bit keys + expect(() => decryptUint(ciphertext, wrongKey)).toThrow('expected 32 hex characters') }) test('produces incorrect result when user key has invalid hex', () => { @@ -285,7 +284,7 @@ describe('Unit: Error Handling', () => { }) describe('decryptString error cases', () => { - test('produces incorrect result when user key has wrong length', () => { + test('throws when user key has wrong length', () => { const { ciphertext } = buildStringInputText( 'Hello', createTestSender(), @@ -293,9 +292,8 @@ describe('Unit: Error Handling', () => { TEST_CONSTANTS.FUNCTION_SELECTOR ) const wrongKey = '1234567890123456789012345678901' // 31 chars - // Function doesn't throw, but produces incorrect decryption - const decrypted = decryptString(ciphertext, wrongKey) - expect(decrypted).not.toBe('Hello') + // decryptString delegates to decryptUint, which now validates the key + expect(() => decryptString(ciphertext, wrongKey)).toThrow('expected 32 hex characters') }) test('handles empty ciphertext array', () => { diff --git a/tests/unit/stringCrypt.test.ts b/tests/unit/stringCrypt.test.ts index 1a04b86..9dd267a 100644 --- a/tests/unit/stringCrypt.test.ts +++ b/tests/unit/stringCrypt.test.ts @@ -14,7 +14,9 @@ describe("String Encryption and Decryption (decryptString)", () => { beforeAll(() => { wallet = ethers.Wallet.createRandom(); - userKey = generateRandomAesKeySizeNumber(); + // generateRandomAesKeySizeNumber() returns 16 raw bytes (a binary string). + // The SDK's documented AES key format is 32 hex chars, so convert it. + userKey = Buffer.from(generateRandomAesKeySizeNumber(), "binary").toString("hex"); sender = { wallet, userKey }; }); diff --git a/tests/unit/walletPluginAdditions.test.ts b/tests/unit/walletPluginAdditions.test.ts index a367086..2020efe 100644 --- a/tests/unit/walletPluginAdditions.test.ts +++ b/tests/unit/walletPluginAdditions.test.ts @@ -2,11 +2,9 @@ import { Wallet, recoverAddress, solidityPackedKeccak256, getBytes, hexlify } fr import { buildInputText, buildItSignature, - decryptCtUint64, - isInsaneDecryptedValue, + decryptUint, normalizeAesKey, - signInputText, - validateAesKey + signInputText } from '../../src' const AES_KEY = '0123456789abcdef0123456789abcdef' // 32 hex chars (128-bit) @@ -38,71 +36,21 @@ describe('normalizeAesKey', () => { test('throws on invalid length', () => { expect(() => normalizeAesKey('abcdef')).toThrow('expected 32 hex characters') }) -}) -describe('validateAesKey', () => { test('throws when key is undefined', () => { - expect(() => validateAesKey(undefined)).toThrow('AES key is required') + expect(() => normalizeAesKey(undefined)).toThrow('AES key is required') }) test('throws when key is null', () => { - expect(() => validateAesKey(null)).toThrow('AES key is required') + expect(() => normalizeAesKey(null)).toThrow('AES key is required') }) test('throws when key is an empty string', () => { - expect(() => validateAesKey('')).toThrow('AES key is required') - }) - - test('returns the normalized key when valid', () => { - expect(validateAesKey('0x' + AES_KEY.toUpperCase())).toBe(AES_KEY) - }) - - test('propagates normalizeAesKey validation errors', () => { - expect(() => validateAesKey('zz')).toThrow('non-hexadecimal') + expect(() => normalizeAesKey('')).toThrow('AES key is required') }) }) -describe('isInsaneDecryptedValue', () => { - test('returns false for a value equal to the default threshold', () => { - // default base = 1e12, default decimals = 18 => threshold = 10^30 - expect(isInsaneDecryptedValue(10n ** 30n)).toBe(false) - }) - - test('returns true for a value above the default threshold', () => { - expect(isInsaneDecryptedValue(10n ** 30n + 1n)).toBe(true) - }) - - test('honors a custom thresholdBase', () => { - // base = 100, decimals = 0 => threshold = 100 - expect(isInsaneDecryptedValue(101n, 0, 100n)).toBe(true) - expect(isInsaneDecryptedValue(100n, 0, 100n)).toBe(false) - }) - - test('accepts a valid in-range decimals value', () => { - // decimals = 6 => threshold = 1 * 10^6 - expect(isInsaneDecryptedValue(10n ** 6n, 6, 1n)).toBe(false) - expect(isInsaneDecryptedValue(10n ** 6n + 1n, 6, 1n)).toBe(true) - }) - - test('throws on negative decimals', () => { - expect(() => isInsaneDecryptedValue(2n, -5, 1n)).toThrow('Invalid decimals') - }) - - test('throws on decimals above 36', () => { - expect(() => isInsaneDecryptedValue(2n, 40, 1n)).toThrow('Invalid decimals') - }) - - test('throws on non-integer decimals', () => { - expect(() => isInsaneDecryptedValue(2n, 6.7, 1n)).toThrow('Invalid decimals') - }) - - test('throws on non-finite decimals', () => { - expect(() => isInsaneDecryptedValue(2n, NaN, 1n)).toThrow('Invalid decimals') - expect(() => isInsaneDecryptedValue(2n, Infinity, 1n)).toThrow('Invalid decimals') - }) -}) - -describe('decryptCtUint64', () => { +describe('decryptUint (merged zero-handling + key normalization)', () => { function buildCiphertext(plaintext: bigint): bigint { const wallet = Wallet.createRandom() const { ciphertext } = buildInputText( @@ -115,28 +63,22 @@ describe('decryptCtUint64', () => { } test('returns 0n for a zero ciphertext without touching the key', () => { - expect(decryptCtUint64(0n, 'not-a-valid-key')).toBe(0n) + expect(decryptUint(0n, 'not-a-valid-key')).toBe(0n) }) test('round-trips a small plaintext value', () => { const ciphertext = buildCiphertext(12345n) - expect(decryptCtUint64(ciphertext, AES_KEY)).toBe(12345n) + expect(decryptUint(ciphertext, AES_KEY)).toBe(12345n) }) test('accepts a 0x-prefixed key', () => { const ciphertext = buildCiphertext(777n) - expect(decryptCtUint64(ciphertext, '0x' + AES_KEY)).toBe(777n) + expect(decryptUint(ciphertext, '0x' + AES_KEY)).toBe(777n) }) test('throws on an invalid key', () => { const ciphertext = buildCiphertext(42n) - expect(() => decryptCtUint64(ciphertext, 'xyz')).toThrow() - }) - - test('returns null when the value exceeds the sanity threshold', () => { - const ciphertext = buildCiphertext(12345n) - // force the threshold low enough that the decrypted value is "insane" - expect(decryptCtUint64(ciphertext, AES_KEY, { decimals: 0, insaneThresholdBase: 1n })).toBeNull() + expect(() => decryptUint(ciphertext, 'xyz')).toThrow() }) }) From 9539e8000af727152225449bca6919513cf035ef Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 10 Jun 2026 21:29:55 -0300 Subject: [PATCH 09/11] refactor: move key validation into encodeKey for single-point enforcement - Moved normalizeAesKey call into encodeKey so every encrypt/decrypt path validates the key consistently at the lowest level. - Removed the duplicate normalizeAesKey call from decryptUint (now handled by encodeKey). - Updated error.handling and integration/validation tests: encodeKey, prepareIT, and decryptUint256 now throw on malformed keys instead of silently producing incorrect results. - All 358 tests passing. --- src/crypto_utils.ts | 12 ++++++----- tests/integration/validation.test.ts | 21 +++++++----------- tests/unit/error.handling.test.ts | 32 ++++++++-------------------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 44d9646..12d210d 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -271,9 +271,6 @@ export function decryptUint(ciphertext: ctUint, userKey: string): bigint { return 0n } - // Validate and normalize the key (strips "0x", lowercases, enforces 128-bit). - const normalizedKey = normalizeAesKey(userKey) - // Convert ciphertext to Uint8Array let ctArray = new Uint8Array() @@ -289,7 +286,8 @@ export function decryptUint(ciphertext: ctUint, userKey: string): bigint { const cipher = ctArray.subarray(0, BLOCK_SIZE) const r = ctArray.subarray(BLOCK_SIZE) - const userKeyBytes = encodeKey(normalizedKey) + // encodeKey validates and normalizes the key (strips "0x", enforces 128-bit) + const userKeyBytes = encodeKey(userKey) // Decrypt the cipher const decryptedMessage = decrypt(userKeyBytes, r, cipher) @@ -380,10 +378,14 @@ export function encodeString(str: string): Uint8Array { } export function encodeKey(userKey: string): Uint8Array { + // Validate and normalize the key (strips "0x", lowercases, enforces 128-bit) + // so that every encrypt/decrypt path rejects malformed keys consistently + // instead of silently producing NaN/garbage bytes. + const normalizedKey = normalizeAesKey(userKey) const keyBytes = new Uint8Array(16) for (let i = 0; i < 32; i += 2) { - keyBytes[i / 2] = Number.parseInt(userKey.slice(i, i + 2), HEX_BASE) + keyBytes[i / 2] = Number.parseInt(normalizedKey.slice(i, i + 2), HEX_BASE) } return keyBytes diff --git a/tests/integration/validation.test.ts b/tests/integration/validation.test.ts index a009fc1..6a44a28 100644 --- a/tests/integration/validation.test.ts +++ b/tests/integration/validation.test.ts @@ -30,37 +30,32 @@ function createTestSender() { const describeWithEnv = HAS_ENV ? describe : describe.skip describeWithEnv('Integration: Input Validation', () => { describe('Invalid user key format', () => { - test('prepareIT produces incorrect result with user key that is too short', () => { + test('prepareIT throws with user key that is too short', () => { const sender = { wallet: new Wallet(TEST_CONSTANTS.PRIVATE_KEY), userKey: '1234567890123456789012345678901' // 31 chars instead of 32 } - // Function doesn't throw, but produces incorrect encryption - const result = prepareIT( + // Encryption now validates the key and rejects non-128-bit keys + expect(() => prepareIT( 12345n, sender, TEST_CONSTANTS.CONTRACT_ADDRESS, TEST_CONSTANTS.FUNCTION_SELECTOR - ) - expect(result).toHaveProperty('ciphertext') - // Decryption with correct key will fail - const decrypted = decryptUint(result.ciphertext, TEST_CONSTANTS.USER_KEY) - expect(decrypted).not.toBe(12345n) + )).toThrow('expected 32 hex characters') }) - test('prepareIT produces incorrect result with user key that is too long', () => { + test('prepareIT throws with user key that is too long', () => { const sender = { wallet: new Wallet(TEST_CONSTANTS.PRIVATE_KEY), userKey: '12345678901234567890123456789012a' // 33 chars instead of 32 } - // Function processes first 32 chars, doesn't throw - const result = prepareIT( + // Encryption now validates the key and rejects non-128-bit keys + expect(() => prepareIT( 12345n, sender, TEST_CONSTANTS.CONTRACT_ADDRESS, TEST_CONSTANTS.FUNCTION_SELECTOR - ) - expect(result).toHaveProperty('ciphertext') + )).toThrow('expected 32 hex characters') }) test('prepareIT produces incorrect result with user key containing invalid hex', () => { diff --git a/tests/unit/error.handling.test.ts b/tests/unit/error.handling.test.ts index e8ad91d..f079701 100644 --- a/tests/unit/error.handling.test.ts +++ b/tests/unit/error.handling.test.ts @@ -105,31 +105,18 @@ describe('Unit: Error Handling', () => { }) describe('encodeKey error cases', () => { - test('produces incorrect result when user key length is not 32 hex characters', () => { + test('throws when user key length is not 32 hex characters', () => { const shortKey = '1234567890123456789012345678901' // 31 chars - const result = encodeKey(shortKey) - // Function doesn't throw, but produces incorrect result (last byte is incomplete) - expect(result).toBeInstanceOf(Uint8Array) - expect(result.length).toBe(16) + expect(() => encodeKey(shortKey)).toThrow('expected 32 hex characters') }) - test('produces incorrect result when user key has invalid hex characters', () => { + test('throws when user key has invalid hex characters', () => { const invalidKey = '1234567890123456789012345678901g' // 'g' is invalid hex - const result = encodeKey(invalidKey) - // Function doesn't throw, but produces incorrect values - // parseInt('1g', 16) returns 1 (stops at invalid char), so result[15] = 1 - expect(result).toBeInstanceOf(Uint8Array) - expect(result.length).toBe(16) - // The function processes what it can, invalid hex is handled gracefully - expect(result[15]).toBe(1) // '1g' parses as '1' + expect(() => encodeKey(invalidKey)).toThrow('non-hexadecimal') }) - test('produces zero-filled array when user key is empty', () => { - const result = encodeKey('') - // Function doesn't throw, but produces zeros - expect(result).toBeInstanceOf(Uint8Array) - expect(result.length).toBe(16) - expect(result.every(b => b === 0)).toBe(true) + test('throws when user key is empty', () => { + expect(() => encodeKey('')).toThrow('AES key is required') }) }) @@ -260,7 +247,7 @@ describe('Unit: Error Handling', () => { }) describe('decryptUint256 error cases', () => { - test('produces incorrect result when user key has wrong length', () => { + test('throws when user key has wrong length', () => { const { ciphertext } = prepareIT256( 2n ** 200n, createTestSender(), @@ -268,9 +255,8 @@ describe('Unit: Error Handling', () => { TEST_CONSTANTS.FUNCTION_SELECTOR ) const wrongKey = '1234567890123456789012345678901' // 31 chars - // Function doesn't throw, but produces incorrect decryption - const decrypted = decryptUint256(ciphertext, wrongKey) - expect(decrypted).not.toBe(2n ** 200n) + // decryptUint256 delegates to encodeKey, which now validates the key + expect(() => decryptUint256(ciphertext, wrongKey)).toThrow('expected 32 hex characters') }) test('produces incorrect result when ciphertext structure is invalid', () => { From 604152ec7986941d285380d191dfb4d75f0d6d8d Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Thu, 11 Jun 2026 05:56:45 -0300 Subject: [PATCH 10/11] docs: add JSDoc to decryptUint documenting zero short-circuit and key validation --- src/crypto_utils.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index 12d210d..d32653f 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -263,6 +263,19 @@ export function buildStringInputText( return inputText } +/** + * Decrypts a 64-bit ctUint ciphertext using the user's AES key. + * + * - A zero ciphertext is short-circuited to `0n` without touching the key, + * since it represents uninitialized/empty on-chain storage. + * - Key validation is performed by `encodeKey` (strips "0x", lowercases, + * enforces 128-bit hex). Invalid keys throw rather than producing garbage. + * + * @param ciphertext - The encrypted 64-bit value (bigint). + * @param userKey - The AES key (32 hex chars, optionally "0x"-prefixed). + * @returns The decrypted plaintext as a bigint. + * @throws Error if the key is invalid (null, wrong length, non-hex). + */ export function decryptUint(ciphertext: ctUint, userKey: string): bigint { // A zero ciphertext represents uninitialized/empty storage, which decrypts // to plaintext 0. Short-circuit before touching the key so callers can read From 198456e40e5684b1094aacb6bf8fb84242488295 Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Thu, 11 Jun 2026 06:16:33 -0300 Subject: [PATCH 11/11] docs: clarify that zero ciphertext skips key validation intentionally --- src/crypto_utils.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/crypto_utils.ts b/src/crypto_utils.ts index d32653f..8ad6465 100644 --- a/src/crypto_utils.ts +++ b/src/crypto_utils.ts @@ -266,15 +266,18 @@ export function buildStringInputText( /** * Decrypts a 64-bit ctUint ciphertext using the user's AES key. * - * - A zero ciphertext is short-circuited to `0n` without touching the key, - * since it represents uninitialized/empty on-chain storage. - * - Key validation is performed by `encodeKey` (strips "0x", lowercases, - * enforces 128-bit hex). Invalid keys throw rather than producing garbage. + * - A zero ciphertext is short-circuited to `0n` without validating the key, + * since it represents uninitialized/empty on-chain storage. This allows DApps + * to read empty balances before the user has configured their AES key. + * Note: this means `decryptUint(0n, invalidKey)` returns `0n` without throwing. + * - For non-zero ciphertexts, key validation is performed by `encodeKey` + * (strips "0x", lowercases, enforces 128-bit hex). Invalid keys throw + * rather than producing garbage. * * @param ciphertext - The encrypted 64-bit value (bigint). * @param userKey - The AES key (32 hex chars, optionally "0x"-prefixed). * @returns The decrypted plaintext as a bigint. - * @throws Error if the key is invalid (null, wrong length, non-hex). + * @throws Error if the key is invalid (null, wrong length, non-hex) and ciphertext is non-zero. */ export function decryptUint(ciphertext: ctUint, userKey: string): bigint { // A zero ciphertext represents uninitialized/empty storage, which decrypts