diff --git a/e2eproxy/.dockerignore b/e2eproxy/.dockerignore new file mode 100644 index 00000000..b73550af --- /dev/null +++ b/e2eproxy/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.local +.cache \ No newline at end of file diff --git a/e2eproxy/.gitignore b/e2eproxy/.gitignore new file mode 100644 index 00000000..d41882d4 --- /dev/null +++ b/e2eproxy/.gitignore @@ -0,0 +1,8 @@ +node_modules +.cache +.local +pnpm-lock.yaml +typechain-types +cache +artifacts +.bash_history diff --git a/e2eproxy/Dockerfile.dev b/e2eproxy/Dockerfile.dev new file mode 100644 index 00000000..142e2e35 --- /dev/null +++ b/e2eproxy/Dockerfile.dev @@ -0,0 +1,3 @@ +FROM node:lts +RUN npm install -g pnpm +WORKDIR /src diff --git a/e2eproxy/Makefile b/e2eproxy/Makefile new file mode 100644 index 00000000..822bc9db --- /dev/null +++ b/e2eproxy/Makefile @@ -0,0 +1,42 @@ +REPO=e2eproxy-sapphire +LABEL=cedarmist +DOCKER_RUN=docker run -v `pwd`:/src:rw --rm -ti -u `id -u`:`id -g` +DOCKER_RUN_DEV=$(DOCKER_RUN) --network host -w /src -h $(REPO)-dev -e HOME=/src -e HISTFILESIZE=0 -e HISTCONTROL=ignoreboth:erasedups $(REPO)/dev + +all: + @echo ... + +tsc: + $(DOCKER_RUN_DEV) pnpm tsc + +hardhat-compile: + $(DOCKER_RUN_DEV) pnpm hardhat compile + +hardhat-test: + $(DOCKER_RUN_DEV) pnpm hardhat test --network sapphire_local + +pnpm-install: + $(DOCKER_RUN_DEV) pnpm install + +#SAPPHIRE_DEV_DOCKER=ghcr.io/oasisprotocol/sapphire-dev:local +SAPPHIRE_DEV_DOCKER=ghcr.io/oasisprotocol/sapphire-dev:latest + +sapphire-dev: + docker run --rm -it -p8545:8545 -p8546:8546 $(SAPPHIRE_DEV_DOCKER) -to 'test test test test test test test test test test test junk' -n 20 + +cache: + mkdir cache + +cache/%.docker: Dockerfile.% cache + if [ ! -f "$@" ]; then \ + docker build -f $< -t "${REPO}/$*" . ; \ + docker image inspect "${REPO}/$*" > $@ ; \ + fi + +clean: + rm -rf artifacts cache coverage lib node_modules typechain-types + rm -rf .cache .config .local .npm + rm -rf .bash_history .node_repl_history .ts_node_repl_history coverage.json pnpm-lock.yaml + +shell: cache/dev.docker + $(DOCKER_RUN_DEV) /bin/bash diff --git a/e2eproxy/README.md b/e2eproxy/README.md new file mode 100644 index 00000000..b87be90d --- /dev/null +++ b/e2eproxy/README.md @@ -0,0 +1,16 @@ +# End-to-End encrypted proxy contract example + +This repo provides an example of how a contract can relay encrypted transactions on Oasis Sapphire, so the relayer cannot see which contract is being invoked or what the parameters are. + +Using the [@oasis-protocol/sapphire-contracts](https://www.npmjs.com/package/@oasisprotocol/sapphire-contracts) library The E2EProxy contract generates a long-term X25519 keypair which allows users to submit a Deoxys-II encrypted payload (with forward secrecy) containing the contract address to invoke and the calldata to pass. + +While [@oasis-protocol/sapphire-hardhat](https://www.npmjs.com/package/@oasisprotocol/sapphire-hardhat) package makes testing easy with Hardhat, you also need to run a local [sapphire-dev](https://github.com/oasisprotocol/oasis-web3-gateway/pkgs/container/sapphire-dev) instance which supports the necessary EVM precompiles. + +For your convenience there is a `Makefile` which uses Docker to keep everything neatly contained: + +``` +make sapphire-dev & # This will take a few minutes +make pnpm-install +make hardhat-compile +make hardhat-test +``` \ No newline at end of file diff --git a/e2eproxy/contracts/E2EProxy.sol b/e2eproxy/contracts/E2EProxy.sol new file mode 100644 index 00000000..2e3c24dd --- /dev/null +++ b/e2eproxy/contracts/E2EProxy.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: CC-PDDC + +pragma solidity ^0.8.0; + +import "@oasisprotocol/sapphire-contracts/contracts/Sapphire.sol"; + +contract E2EProxy { + Sapphire.Curve25519PublicKey internal immutable publicKey; + + Sapphire.Curve25519SecretKey internal immutable privateKey; + + constructor (bytes memory extra_entropy) { + (publicKey, privateKey) = Sapphire.generateCurve25519KeyPair(extra_entropy); + } + + function getPublicKey() + external view + returns (bytes32) + { + return Sapphire.Curve25519PublicKey.unwrap(publicKey); + } + + function proxy(bytes32 peerPublicKey, bytes32 nonce, bytes memory data) + external payable + { + bytes32 symmetricKey = Sapphire.deriveSymmetricKey(Sapphire.Curve25519PublicKey.wrap(peerPublicKey), privateKey); + + (address addr, bytes memory subcall_data) = abi.decode(Sapphire.decrypt(symmetricKey, nonce, data, ""), (address, bytes)); + + (bool success, bytes memory subcall_ret) = addr.call{value: msg.value}(subcall_data); + } +} diff --git a/e2eproxy/contracts/Example.sol b/e2eproxy/contracts/Example.sol new file mode 100644 index 00000000..b5054a69 --- /dev/null +++ b/e2eproxy/contracts/Example.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: CC-PDDC + +pragma solidity ^0.8.0; + +contract Example { + event Called(address from, uint value, bytes data); + + function transferFrom(address from, address to, uint amount) + external + payable + { + emit Called(msg.sender, msg.value, abi.encodeWithSelector(msg.sig, from, to, amount)); + } +} \ No newline at end of file diff --git a/e2eproxy/hardhat.config.ts b/e2eproxy/hardhat.config.ts new file mode 100644 index 00000000..ecb577af --- /dev/null +++ b/e2eproxy/hardhat.config.ts @@ -0,0 +1,63 @@ +import { HardhatUserConfig } from "hardhat/config"; +import '@oasisprotocol/sapphire-hardhat'; +import "@nomicfoundation/hardhat-toolbox"; + +const env_private_key = process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : []; + +const TEST_HDWALLET = { + mnemonic: "test test test test test test test test test test test junk", + path: "m/44'/60'/0'/0", + initialIndex: 0, + count: 20, + passphrase: "", +}; + +const config: HardhatUserConfig = { + mocha: { + timeout: 400000 + }, + paths: { + tests: "./tests" + }, + solidity: { + version: "0.8.18", + settings: { + viaIR: false, + /* + debug: { + revertStrings: "debug" + }, + */ + optimizer: { + enabled: true, + runs: 200, + } + }, + }, + typechain: { + target: "ethers-v5" // TODO: upgrade to ethers-v6 when hardhat-toolbox supports it + }, + networks: { + hardhat: { + chainId: 1337 // We set 1337 to make interacting with MetaMask simpler + }, + sapphire_local: { + url: "http://localhost:8545", + accounts: TEST_HDWALLET, + chainId: 0x5afd, + }, + // https://docs.oasis.io/dapp/sapphire/ + sapphire_mainnet: { + url: "https://sapphire.oasis.io/", + accounts: env_private_key, + chainId: 0x5afe, + }, + sapphire_testnet: { + url: "https://testnet.sapphire.oasis.dev", + accounts: TEST_HDWALLET, + chainId: 0x5aff, + } + } +}; + +export default config; diff --git a/e2eproxy/package.json b/e2eproxy/package.json new file mode 100644 index 00000000..f4aec12d --- /dev/null +++ b/e2eproxy/package.json @@ -0,0 +1,37 @@ +{ + "name": "e2eproxy-sapphire", + "version": "0.1.0", + "description": "End-to-End encryption between Client and Sapphire EVM contract using X25519 and Deoxys-II-256-128", + "main": "lib/index.js", + "keywords": [ + "oasis", + "sapphire", + "e2e", + "encryption", + "evm", + "x25519", + "deoxys-ii" + ], + "directories": { + "test": "tests" + }, + "author": "CedarMist", + "license": "CC-PDDC", + "devDependencies": { + "@ethersproject/abi": "^5.7.0", + "@nomicfoundation/hardhat-network-helpers": "^1.0.8", + "@nomicfoundation/hardhat-toolbox": "^2.0.2", + "@nomiclabs/hardhat-ethers": "^2.2.3", + "@oasisprotocol/sapphire-hardhat": "^1.0.3", + "@types/node": "^18.16.0", + "hardhat": "^2.14.0", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" + }, + "dependencies": { + "@ethersproject/providers": "^5.7.2", + "@oasisprotocol/sapphire-contracts": "^0.2.1", + "@oasisprotocol/sapphire-paratime": "^1.0.15", + "ethers": "^5.7.2" + } +} \ No newline at end of file diff --git a/e2eproxy/tests/e2eproxy.ts b/e2eproxy/tests/e2eproxy.ts new file mode 100644 index 00000000..fc624848 --- /dev/null +++ b/e2eproxy/tests/e2eproxy.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: CC-PDDC + +import { randomBytes } from "crypto"; +import { expect } from 'chai'; +import { ethers } from "hardhat"; +import { Interface } from '@ethersproject/abi'; + +import * as sapphire from '@oasisprotocol/sapphire-paratime' + +describe('E2EProxy', function () { + async function deploy() { + const Example_Contract = await ethers.getContractFactory("Example"); + const example = await Example_Contract.deploy(); + + const extra_entropy = randomBytes(128); + + const E2EProxy_Contract = await ethers.getContractFactory("E2EProxy"); + const e2e = await E2EProxy_Contract.deploy(ethers.utils.arrayify(extra_entropy)); + + return { example, e2e }; + } + + it("End-to-end encrypted proxied call", async function () + { + const {example, e2e} = await deploy(); + + // Create the calldata for an example function call + const iface = new Interface([ + "function transferFrom(address from, address to, uint amount)" + ]); + const example_calldata = iface.encodeFunctionData("transferFrom", [ + "0x8ba1f109551bD432803012645Ac136ddd64DBA72", + "0xaB7C8803962c0f2F5BBBe3FA8bf41cd82AA1923C", + ethers.utils.parseEther("789.10111213") + ]) + + // Encode the proxied call, specifying the address of the contract to invoke and its calldata + const plaintext = ethers.utils.defaultAbiCoder.encode([ "address", "bytes" ], [ example.address, example_calldata ]); + + // Retrieve E2EProxy long-term public key & encrypt the proxied contract call with an ephemeral keypair + const e2e_pubkey = await e2e.getPublicKey(); + const box = sapphire.cipher.X25519DeoxysII.ephemeral(e2e_pubkey); + let {nonce, ciphertext} = await box.encrypt(ethers.utils.arrayify(plaintext)); + const nonce_bytes32_hex = ethers.utils.hexlify(nonce) + "0000000000000000000000000000000000"; + + // Invoke the proxy contract + const result = await e2e.proxy(box.publicKey, nonce_bytes32_hex, ciphertext, {value: ethers.utils.parseUnits("123456", "wei")}); + const receipt = await result.wait(); + + // Verify the parameters received from the Example contract via proxy + let found = false; + if( receipt.events ) { + for( const r of receipt.events?.values() ) { + if( r.address == example.address ) { + const ev = example.interface.events['Called(address,uint256,bytes)']; + const decoded = example.interface.decodeEventLog(ev, r.data, r.topics); + expect(decoded.from).equals(e2e.address); + expect(decoded.value).equals(123456); + expect(decoded.data).equals(example_calldata); + found = true; + } + } + } + expect(found).equals(true); + }); +}); diff --git a/e2eproxy/tsconfig.json b/e2eproxy/tsconfig.json new file mode 100644 index 00000000..6c337792 --- /dev/null +++ b/e2eproxy/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } + } + \ No newline at end of file