Skip to content

Commit c6ad9ab

Browse files
[FEAT] Add on-chain signature verification for decrypt results (#42)
* implement contract changes to verify decryption sigs * remove decryption id from sig * improvements * PR comments fixes, tests, ci * fix ci * fix test * fix existing tests * use tryRecover for non-reverting signature verification * add onlyIfEnabled to publish functions * replace require string with LengthMismatch custom error * add safety comment and advance free memory pointer in assembly * use bytes32 for ctHash in Impl and FHE overloads * add verifyDecryptResultSafe typed overloads * add tests for tryRecover, onlyIfEnabled, and edge cases * update changelog * Add `FHE.allowPublic` as alias for `FHE.allowGlobal` (#48) * Add `allowPublic` alias for `allowGlobal` * Add isPubliclyAllowed and isGloballyAllowed to TaskManager * Add tests for isPubliclyAllowed and isGloballyAllowed * update changelog * add FHE.isPubliclyAllowed, remove from DeterministicTM * remove isGloballyAllowed, keep only isPubliclyAllowed * update changelog --------- Co-authored-by: Roee Zolantz <zolantz.roee@gmail.com> --------- Co-authored-by: Architect <83823837+architect-dev@users.noreply.github.com>
1 parent a4458ac commit c6ad9ab

17 files changed

Lines changed: 1702 additions & 20 deletions

File tree

.github/workflows/test.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Tests
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- master
7+
push:
8+
branches:
9+
- master
10+
11+
jobs:
12+
test-host-chain:
13+
name: Host Chain Contract Tests
14+
runs-on: ubuntu-latest
15+
defaults:
16+
run:
17+
working-directory: contracts/internal/host-chain
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
28+
- name: Install pnpm
29+
uses: pnpm/action-setup@v2
30+
with:
31+
version: 9
32+
33+
- name: Get pnpm store directory
34+
shell: bash
35+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
36+
37+
- name: Setup pnpm cache
38+
uses: actions/cache@v4
39+
with:
40+
path: ${{ env.STORE_PATH }}
41+
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('contracts/internal/host-chain/pnpm-lock.yaml') }}
42+
restore-keys: |
43+
${{ runner.os }}-pnpm-store-
44+
45+
- name: Install dependencies
46+
run: pnpm install
47+
48+
- name: Compile contracts
49+
run: pnpm compile
50+
env:
51+
# Dummy keys for hardhat config (not used - tests run on Hardhat network)
52+
KEY: "0x0000000000000000000000000000000000000000000000000000000000000001"
53+
KEY2: "0x0000000000000000000000000000000000000000000000000000000000000002"
54+
AGGREGATOR_KEY: "0x0000000000000000000000000000000000000000000000000000000000000003"
55+
56+
- name: Run tests
57+
run: pnpm test
58+
env:
59+
KEY: "0x0000000000000000000000000000000000000000000000000000000000000001"
60+
KEY2: "0x0000000000000000000000000000000000000000000000000000000000000002"
61+
AGGREGATOR_KEY: "0x0000000000000000000000000000000000000000000000000000000000000003"

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Added
6+
- `isPubliclyAllowed(uint256 ctHash)` view function on `TaskManager` to query whether a ciphertext handle has been publicly allowed (via `allowGlobal` / `allowPublic`). Delegates to `acl.globalAllowed()`.
7+
- `FHE.isPubliclyAllowed()` typed overloads for all encrypted types (`ebool`, `euint8`, ..., `eaddress`) so contracts can query public-allow status directly via the FHE library.
8+
- `publishDecryptResult()` and `publishDecryptResultBatch()` on TaskManager for publishing signed decrypt results on-chain
9+
- `verifyDecryptResult()` (reverts on invalid) and `verifyDecryptResultSafe()` (returns false) for signature verification without publishing
10+
- `decryptResultSigner` state variable and `setDecryptResultSigner()` admin function
11+
- Typed overloads in `FHE.sol` for all encrypted types (`ebool`, `euint8`, ..., `eaddress`)
12+
- `onlyIfEnabled` modifier on publish functions
13+
- `LengthMismatch` custom error replacing require string in batch publish
14+
315
## v0.1.0
416

517
### Breaking Changes

contracts/FHE.sol

Lines changed: 308 additions & 0 deletions
Large diffs are not rendered by default.

contracts/ICofhe.sol

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,20 @@ interface ITaskManager {
103103

104104
function allow(uint256 ctHash, address account) external;
105105
function isAllowed(uint256 ctHash, address account) external returns (bool);
106+
function isPubliclyAllowed(uint256 ctHash) external view returns (bool);
106107
function allowGlobal(uint256 ctHash) external;
107108
function allowTransient(uint256 ctHash, address account) external;
108109
function getDecryptResultSafe(uint256 ctHash) external view returns (uint256, bool);
109110
function getDecryptResult(uint256 ctHash) external view returns (uint256);
111+
112+
function publishDecryptResult(uint256 ctHash, uint256 result, bytes calldata signature) external;
113+
function publishDecryptResultBatch(uint256[] calldata ctHashes, uint256[] calldata results, bytes[] calldata signatures) external;
114+
function verifyDecryptResult(uint256 ctHash, uint256 result, bytes calldata signature) external view returns (bool);
115+
function verifyDecryptResultSafe(
116+
uint256 ctHash,
117+
uint256 result,
118+
bytes calldata signature
119+
) external view returns (bool);
110120
}
111121

112122
library Utils {

contracts/internal/host-chain/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ KEY="0xb03608c1c1f1461ed55c1c2315ab27fe82e2fe4c289ff753dea99078848d27dd"
1111
KEY2="0xcb5790da63720727af975f42c79f69918580209889225fa7128c92402a6d3a65"
1212
AGGREGATOR_KEY="dbfae500d71337029492a6f7f6c82e014467d1a847b684a9bca8403fbc0d6e45"
1313
# verifier signer address to be set only on deployment
14-
VERIFIER_ADDRESS="0x0000000000000000000000000000000000000000"
14+
VERIFIER_ADDRESS="0x0000000000000000000000000000000000000000"
15+
# decrypt result signer address (dispatcher's signing key address)
16+
DECRYPT_RESULT_SIGNER="0x0000000000000000000000000000000000000000"

contracts/internal/host-chain/contracts/TaskManager.sol

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ error InvalidSecurityZone(int32 zone, int32 min, int32 max);
2525
error InvalidSignature();
2626
error InvalidSigner(address signer, address expectedSigner);
2727
error UnsupportedType(uint256 t);
28+
error LengthMismatch();
2829

2930
// Access control errors
3031
error InvalidAddress();
@@ -48,6 +49,13 @@ library TMCommon {
4849
The format: keccak256(operands_list, op)[0:29] || is_trivial (1 bit) & ct_type (7 bit) || securityZone
4950
*/
5051

52+
// Constants for decrypt result hash computation (message format: result || enc_type || chain_id || ct_hash)
53+
uint256 internal constant SHIFT_ENC_TYPE = 224; // Shift for 4-byte enc_type (256 - 32 = 224)
54+
uint256 internal constant SHIFT_CHAIN_ID = 192; // Shift for 8-byte chain_id (256 - 64 = 192)
55+
uint256 internal constant OFFSET_ENC_TYPE = 0x20; // Byte offset for enc_type in message
56+
uint256 internal constant OFFSET_CHAIN_ID = 0x24; // Byte offset for chain_id in message
57+
uint256 internal constant OFFSET_CT_HASH = 0x2c; // Byte offset for ctHash in message
58+
uint256 internal constant MESSAGE_LENGTH = 0x4c; // Total message length: 76 bytes
5159

5260
function uint256ToBytes32(uint256 value) internal pure returns (bytes memory) {
5361
bytes memory result = new bytes(32);
@@ -161,6 +169,7 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
161169
__UUPSUpgradeable_init();
162170
initialized = true;
163171
verifierSigner = address(1);
172+
decryptResultSigner = address(1);
164173
isEnabled = true;
165174
}
166175

@@ -193,6 +202,8 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
193202
event TaskCreated(uint256 ctHash, string operation, uint256 input1, uint256 input2, uint256 input3);
194203
event ProtocolNotification(uint256 ctHash, string operation, string errorMessage);
195204
event DecryptionResult(uint256 ctHash, uint256 result, address indexed requestor);
205+
event DecryptResultSignerChanged(address indexed oldSigner, address indexed newSigner);
206+
event VerifierSignerChanged(address indexed oldSigner, address indexed newSigner);
196207

197208
struct Task {
198209
address creator;
@@ -225,6 +236,10 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
225236
// If disabled, all operations will revert
226237
bool public isEnabled;
227238

239+
// Signer address for decrypt result verification (threshold network's signing key)
240+
// When set to address(0), signature verification is skipped (debug mode)
241+
address public decryptResultSigner;
242+
228243

229244
modifier onlyAggregator() {
230245
if (!aggregators[msg.sender]) {
@@ -549,6 +564,119 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
549564
}
550565
}
551566

567+
/// @notice Publish a signed decrypt result to the chain
568+
/// @dev Anyone with a valid signature from the decrypt network can call this
569+
/// @param ctHash The ciphertext hash
570+
/// @param result The decrypted plaintext value
571+
/// @param signature The ECDSA signature from the decrypt network
572+
function publishDecryptResult(
573+
uint256 ctHash,
574+
uint256 result,
575+
bytes calldata signature
576+
) external onlyIfEnabled {
577+
_verifyDecryptResult(ctHash, result, signature, true);
578+
plaintextsStorage.storeResult(ctHash, result);
579+
emit DecryptionResult(ctHash, result, msg.sender);
580+
}
581+
582+
/// @notice Publish multiple decrypt results in one transaction
583+
/// @dev Amortizes base tx cost across multiple operations
584+
function publishDecryptResultBatch(
585+
uint256[] calldata ctHashes,
586+
uint256[] calldata results,
587+
bytes[] calldata signatures
588+
) external onlyIfEnabled {
589+
uint256 length = ctHashes.length;
590+
if (results.length != length || signatures.length != length) revert LengthMismatch();
591+
592+
for (uint256 i = 0; i < length; i++) {
593+
_verifyDecryptResult(ctHashes[i], results[i], signatures[i], true);
594+
plaintextsStorage.storeResult(ctHashes[i], results[i]);
595+
emit DecryptionResult(ctHashes[i], results[i], msg.sender);
596+
}
597+
}
598+
599+
/// @notice Verify a decrypt result signature without publishing
600+
/// @dev Returns true if signature is valid, reverts otherwise
601+
/// @return True if signature is valid
602+
function verifyDecryptResult(
603+
uint256 ctHash,
604+
uint256 result,
605+
bytes calldata signature
606+
) external view returns (bool) {
607+
return _verifyDecryptResult(ctHash, result, signature, true);
608+
}
609+
610+
/// @notice Verify a decrypt result signature without publishing (non-reverting)
611+
/// @dev Returns false if signature is invalid instead of reverting
612+
/// @return True if signature is valid, false otherwise
613+
function verifyDecryptResultSafe(
614+
uint256 ctHash,
615+
uint256 result,
616+
bytes calldata signature
617+
) external view returns (bool) {
618+
return _verifyDecryptResult(ctHash, result, signature, false);
619+
}
620+
621+
/// @dev Verify decrypt result signature
622+
/// @dev Skips verification if decryptResultSigner is address(0) (debug mode)
623+
/// @param shouldRevert If true, reverts on invalid signature; if false, returns false
624+
function _verifyDecryptResult(
625+
uint256 ctHash,
626+
uint256 result,
627+
bytes calldata signature,
628+
bool shouldRevert
629+
) private view returns (bool) {
630+
if (decryptResultSigner == address(0)) {
631+
return true;
632+
}
633+
634+
bytes32 messageHash = _computeDecryptResultHash(ctHash, result);
635+
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(messageHash, signature);
636+
637+
if (err != ECDSA.RecoverError.NoError || recovered == address(0)) {
638+
if (shouldRevert) revert InvalidSignature();
639+
return false;
640+
}
641+
if (recovered != decryptResultSigner) {
642+
if (shouldRevert) revert InvalidSigner(recovered, decryptResultSigner);
643+
return false;
644+
}
645+
return true;
646+
}
647+
648+
/// @dev Compute message hash using assembly for gas efficiency
649+
/// @notice Format: result (32) || enc_type (4) || chain_id (8) || ct_hash (32) = 76 bytes
650+
function _computeDecryptResultHash(
651+
uint256 ctHash,
652+
uint256 result
653+
) private view returns (bytes32 messageHash) {
654+
uint8 encryptionType = TMCommon.getUintTypeFromHash(ctHash);
655+
uint64 chainId = uint64(block.chainid);
656+
657+
// Load constants for assembly
658+
uint256 shiftEncType = TMCommon.SHIFT_ENC_TYPE;
659+
uint256 shiftChainId = TMCommon.SHIFT_CHAIN_ID;
660+
uint256 offsetEncType = TMCommon.OFFSET_ENC_TYPE;
661+
uint256 offsetChainId = TMCommon.OFFSET_CHAIN_ID;
662+
uint256 offsetCtHash = TMCommon.OFFSET_CT_HASH;
663+
uint256 msgLength = TMCommon.MESSAGE_LENGTH;
664+
665+
// Assembly for gas-efficient message construction
666+
// Overlapping 32-byte mstores are safe here: each subsequent mstore overwrites
667+
// only the tail bytes of the previous one, and the final mstore (ctHash) lands
668+
// exactly at the end of the 76-byte message, so all fields end up correctly placed.
669+
assembly {
670+
let ptr := mload(0x40)
671+
mstore(ptr, result) // bytes 0-31: result
672+
mstore(add(ptr, offsetEncType), shl(shiftEncType, encryptionType)) // bytes 32-35: enc_type
673+
mstore(add(ptr, offsetChainId), shl(shiftChainId, chainId)) // bytes 36-43: chain_id
674+
mstore(add(ptr, offsetCtHash), ctHash) // bytes 44-75: ctHash
675+
messageHash := keccak256(ptr, msgLength) // hash 76 bytes
676+
mstore(0x40, add(ptr, msgLength)) // advance free memory pointer
677+
}
678+
}
679+
552680
function handleError(uint256 ctHash, string memory operation, string memory errorMessage) external onlyAggregator {
553681
emit ProtocolNotification(ctHash, operation, errorMessage);
554682
}
@@ -609,6 +737,10 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
609737
return acl.isAllowed(ctHash, account);
610738
}
611739

740+
function isPubliclyAllowed(uint256 ctHash) external view returns (bool) {
741+
return acl.globalAllowed(ctHash);
742+
}
743+
612744
function extractSigner(EncryptedInput memory input, address sender) private view returns (address) {
613745
bytes memory combined = abi.encodePacked(
614746
input.ctHash,
@@ -629,7 +761,17 @@ contract TaskManager is ITaskManager, Initializable, UUPSUpgradeable, Ownable2St
629761
}
630762

631763
function setVerifierSigner(address signer) external onlyOwner {
764+
address oldSigner = verifierSigner;
632765
verifierSigner = signer;
766+
emit VerifierSignerChanged(oldSigner, signer);
767+
}
768+
769+
/// @notice Set the authorized signer for decrypt results
770+
/// @param signer The new signer address (address(0) disables verification)
771+
function setDecryptResultSigner(address signer) external onlyOwner {
772+
address oldSigner = decryptResultSigner;
773+
decryptResultSigner = signer;
774+
emit DecryptResultSignerChanged(oldSigner, signer);
633775
}
634776

635777
function setSecurityZoneMax(int32 securityZone) external onlyOwner {

contracts/internal/host-chain/contracts/tests/OnChain.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ contract OnChain {
153153
}
154154

155155
function cantEncryptWithFakeSecurityZone() public returns (euint32) {
156-
return FHE.asEuint32(16, 100);
156+
return FHE.asEuint32(16, 200); // 200 is outside valid range (-128 to 127)
157157
}
158158

159159
function cantCastWithFakeType() public returns (uint256) {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity >=0.8.13 <0.9.0;
4+
5+
import {FHE, euint8} from "@fhenixprotocol/cofhe-contracts/FHE.sol";
6+
7+
contract PubliclyAllowedTest {
8+
euint8 public lastHandle;
9+
10+
function createAndAllowGlobal(uint8 value) public returns (euint8) {
11+
euint8 encrypted = FHE.asEuint8(value);
12+
FHE.allowGlobal(encrypted);
13+
lastHandle = encrypted;
14+
return encrypted;
15+
}
16+
17+
function createWithoutGlobal(uint8 value) public returns (euint8) {
18+
euint8 encrypted = FHE.asEuint8(value);
19+
lastHandle = encrypted;
20+
return encrypted;
21+
}
22+
}

contracts/internal/host-chain/deploy/deploy.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,39 @@ async function TaskManagerSetup(TMProxyContract: any, aggregatorSigners: any[])
122122
process.env.VERIFIER_ADDRESS,
123123
);
124124
await tx.wait();
125-
console.log(chalk.green("Successfully set verifier signer address"));
125+
console.log(chalk.green(`Successfully set verifier signer address: ${process.env.VERIFIER_ADDRESS}`));
126126
} catch (e) {
127127
console.error(chalk.red(`Failed setVerifierSigner transaction: ${e}`));
128128
return e;
129129
}
130+
131+
// Set the decrypt result signer (dispatcher's signing key)
132+
try {
133+
const connectedImplementation = TMProxyContract.connect(aggregatorSigners[0]);
134+
if (process.env.DECRYPT_RESULT_SIGNER === "0x0000000000000000000000000000000000000000") {
135+
const networkName = hre?.network?.name;
136+
const networkConfig = hre?.network?.config as any;
137+
const networkUrl = networkConfig?.url;
138+
if (
139+
networkUrl &&
140+
!networkUrl.includes("localhost") &&
141+
!networkUrl.includes("127.0.0.1") &&
142+
!networkName?.startsWith("localfhenix")
143+
) {
144+
console.error(chalk.red("refusing to set DECRYPT_RESULT_SIGNER to 0 on a non-local network!"));
145+
return;
146+
}
147+
}
148+
149+
const tx = await connectedImplementation.setDecryptResultSigner(
150+
process.env.DECRYPT_RESULT_SIGNER,
151+
);
152+
await tx.wait();
153+
console.log(chalk.green(`Successfully set decrypt result signer address: ${process.env.DECRYPT_RESULT_SIGNER}`));
154+
} catch (e) {
155+
console.error(chalk.red(`Failed setDecryptResultSigner transaction: ${e}`));
156+
return e;
157+
}
130158
console.log("\n");
131159
}
132160

@@ -301,7 +329,7 @@ function getAggregatorWallets(ethers: any) {
301329
const func: DeployFunction = async function () {
302330
console.log(chalk.bold.blue("-----------------------Network-----------------------------"));
303331
console.log(chalk.green("Network name:", hre.network.name));
304-
console.log(chalk.green("Network:", hre.network.config));
332+
console.log(chalk.green("Network:", JSON.stringify(hre.network.config, (_, v) => typeof v === 'bigint' ? v.toString() : v)));
305333
console.log("\n");
306334

307335
// Note: we need to use an unused account for deployment via ignition, or it will complain

0 commit comments

Comments
 (0)