diff --git a/clients/js/src/cipher.ts b/clients/js/src/cipher.ts index bffd072d..00ed75b4 100644 --- a/clients/js/src/cipher.ts +++ b/clients/js/src/cipher.ts @@ -82,7 +82,10 @@ export abstract class Cipher { public abstract publicKey: Uint8Array; public abstract epoch?: number; - public abstract encrypt(plaintext: Uint8Array): { + public abstract encrypt( + plaintext: Uint8Array, + nonce?: Uint8Array, + ): { ciphertext: Uint8Array; nonce: Uint8Array; }; @@ -93,7 +96,10 @@ export abstract class Cipher { ): Uint8Array; /** Encrypts the plaintext and encodes it for sending. */ - public encryptCall(calldata?: BytesLike | null): BytesLike { + public encryptCall( + calldata?: BytesLike | null, + nonce?: Uint8Array, + ): BytesLike { // Txs without data are just balance transfers, and all data in those is public. if (calldata === undefined || calldata === null || calldata.length === 0) return ''; @@ -104,7 +110,8 @@ export abstract class Cipher { const innerEnvelope = cborEncode({ body: getBytes(calldata) }); - const { ciphertext, nonce } = this.encrypt(innerEnvelope); + let ciphertext: Uint8Array; + ({ ciphertext, nonce } = this.encrypt(innerEnvelope, nonce)); const envelope: Envelope = { format: this.kind, @@ -251,11 +258,14 @@ export class X25519DeoxysII extends Cipher { this.cipher = new deoxysii.AEAD(new Uint8Array(this.key)); // deoxysii owns the input } - public encrypt(plaintext: Uint8Array): { + public encrypt( + plaintext: Uint8Array, + nonce?: Uint8Array, + ): { ciphertext: Uint8Array; nonce: Uint8Array; } { - const nonce = randomBytes(deoxysii.NonceSize); + nonce = nonce ?? randomBytes(deoxysii.NonceSize); const ciphertext = this.cipher.encrypt(nonce, plaintext); return { nonce, ciphertext }; } diff --git a/clients/js/test/cipher.spec.ts b/clients/js/test/cipher.spec.ts index 0e00287b..6fc56bb1 100644 --- a/clients/js/test/cipher.spec.ts +++ b/clients/js/test/cipher.spec.ts @@ -2,7 +2,7 @@ import nacl from 'tweetnacl'; import { hexlify, getBytes } from 'ethers'; -import { X25519DeoxysII } from '@oasisprotocol/sapphire-paratime'; +import { isCalldataEnveloped, X25519DeoxysII } from '@oasisprotocol/sapphire-paratime'; describe('X25519DeoxysII', () => { it('key derivation', () => { @@ -30,7 +30,9 @@ describe('X25519DeoxysII', () => { const cipher = X25519DeoxysII.ephemeral(nacl.box.keyPair().publicKey); for (let i = 1; i < 512; i += 30) { const expected = nacl.randomBytes(i); - const decoded = cipher.decryptCall(cipher.encryptCall(expected)); + const encrypted = cipher.encryptCall(expected); + expect(isCalldataEnveloped(encrypted)).toStrictEqual(true); + const decoded = cipher.decryptCall(encrypted); expect(hexlify(decoded)).toEqual(hexlify(expected)); } }); diff --git a/contracts/contracts/CalldataEncryption.sol b/contracts/contracts/CalldataEncryption.sol new file mode 100644 index 00000000..b1e7358c --- /dev/null +++ b/contracts/contracts/CalldataEncryption.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import {Subcall} from "./Subcall.sol"; +import {Sapphire} from "./Sapphire.sol"; +import "./CBOR.sol" as CBOR; + +function _deriveKey( + bytes32 in_peerPublicKey, + Sapphire.Curve25519SecretKey in_x25519_secret +) view returns (bytes32) { + return + Sapphire.deriveSymmetricKey( + Sapphire.Curve25519PublicKey.wrap(in_peerPublicKey), + in_x25519_secret + ); +} + +function _encryptInner( + bytes memory in_data, + Sapphire.Curve25519SecretKey in_x25519_secret, + bytes15 nonce, + bytes32 peerPublicKey +) view returns (bytes memory out_encrypted) { + bytes memory plaintextEnvelope = abi.encodePacked( + hex"a1", // map(1) + hex"64", // text(4) "body" + "body", + CBOR.encodeBytes(in_data) + ); + + out_encrypted = Sapphire.encrypt( + _deriveKey(peerPublicKey, in_x25519_secret), + nonce, + plaintextEnvelope, + "" + ); +} + +function encryptCallData(bytes memory in_data) + view + returns (bytes memory out_encrypted) +{ + if (in_data.length == 0) { + return ""; + } + + Sapphire.Curve25519PublicKey myPublic; + Sapphire.Curve25519SecretKey mySecret; + + (myPublic, mySecret) = Sapphire.generateCurve25519KeyPair(""); + + bytes15 nonce = bytes15(Sapphire.randomBytes(15, "")); + + Subcall.CallDataPublicKey memory cdpk; + uint256 epoch; + + (epoch, cdpk) = Subcall.coreCallDataPublicKey(); + + return encryptCallData(in_data, myPublic, mySecret, nonce, epoch, cdpk.key); +} + +function encryptCallData( + bytes memory in_data, + Sapphire.Curve25519PublicKey myPublic, + Sapphire.Curve25519SecretKey mySecret, + bytes15 nonce, + uint256 epoch, + bytes32 peerPublicKey +) view returns (bytes memory out_encrypted) { + if (in_data.length == 0) { + return ""; + } + + bytes memory inner = _encryptInner(in_data, mySecret, nonce, peerPublicKey); + + return + abi.encodePacked( + hex"a2", // map(2) + hex"64", // text(4) "body" + "body", + hex"a4", // map(4) + hex"62", // text(2) "pk" + "pk", + hex"5820", // bytes(32) + myPublic, + hex"64", // text(4) "data" + "data", + CBOR.encodeBytes(inner), // bytes(n) inner + hex"65", // text(5) "epoch" + "epoch", + CBOR.encodeUint(epoch), // unsigned(epoch) + hex"65", // text(5) "nonce" + "nonce", + hex"4f", // bytes(15) nonce + nonce, + hex"66", // text(6) "format" + "format", + hex"01" // unsigned(1) + ); +} diff --git a/contracts/contracts/tests/TestCalldataEncryption.sol b/contracts/contracts/tests/TestCalldataEncryption.sol new file mode 100644 index 00000000..3f40bccb --- /dev/null +++ b/contracts/contracts/tests/TestCalldataEncryption.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import { Sapphire } from "../Sapphire.sol"; +import { encryptCallData } from "../CalldataEncryption.sol"; +import { EIP155Signer } from "../EIP155Signer.sol"; + +contract TestCalldataEncryption { + function testEncryptCallData( + bytes memory in_data, + Sapphire.Curve25519PublicKey myPublic, + Sapphire.Curve25519SecretKey mySecret, + bytes15 nonce, + uint256 epoch, + bytes32 peerPublicKey + ) external view returns (bytes memory) { + return encryptCallData(in_data, myPublic, mySecret, nonce, epoch, peerPublicKey); + } + + function makeExampleCall( + bytes calldata in_data, + uint64 nonce, + uint256 gasPrice, + uint64 gasLimit, + address myAddr, + bytes32 myKey + ) + external view + returns (bytes memory) + { + EIP155Signer.EthTx memory theTx = EIP155Signer.EthTx({ + nonce: nonce, + gasPrice: gasPrice, + gasLimit: gasLimit, + value: 0, + to: address(this), + chainId: block.chainid, + data: encryptCallData(abi.encodeCall(this.example, in_data)) + }); + + return EIP155Signer.sign(myAddr, myKey, theTx); + } + + event ExampleEvent(bytes); + + function example(bytes calldata in_calldata) + external + { + emit ExampleEvent(in_calldata); + } +} diff --git a/contracts/test/calldata.spec.ts b/contracts/test/calldata.spec.ts new file mode 100644 index 00000000..88a09344 --- /dev/null +++ b/contracts/test/calldata.spec.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { TestCalldataEncryption } from '../typechain-types/contracts/tests'; +import { + boxKeyPairFromSecretKey, + crypto_box_SECRETKEYBYTES, + isCalldataEnveloped, + X25519DeoxysII, +} from '@oasisprotocol/sapphire-paratime'; +import { hexlify, parseUnits, randomBytes } from 'ethers'; +import { randomInt } from 'crypto'; + +describe('CalldataEncryption', () => { + let contract: TestCalldataEncryption; + + before(async () => { + const factory = await ethers.getContractFactory('TestCalldataEncryption'); + contract = await factory.deploy(); + await contract.waitForDeployment(); + }); + + // Ensures that the JS library provides the same results as Solidity + it('testEncryptCallData', async () => { + for (let i = 1; i < 1024; i += 1 + i / 5) { + const peerKeypair = boxKeyPairFromSecretKey( + randomBytes(crypto_box_SECRETKEYBYTES), + ); + const myKeypair = boxKeyPairFromSecretKey( + randomBytes(crypto_box_SECRETKEYBYTES), + ); + const calldata = randomBytes(i); + const epoch = randomInt(1 << 32); + const nonce = randomBytes(15); + const cipher = X25519DeoxysII.fromSecretKey( + myKeypair.secretKey, + peerKeypair.publicKey, + epoch, + ); + const encryptedCall = cipher.encryptCall(calldata, nonce); + const result = await contract.testEncryptCallData( + calldata, + myKeypair.publicKey, + myKeypair.secretKey, + nonce, + epoch, + peerKeypair.publicKey, + ); + expect(result).eq(hexlify(encryptedCall)); + } + }); + + it('roundtrip encryption', async () => { + // Tests must be submitted from an account which has a balance + // But can't get access to the signer private key from here + // So, assume the 0xf39F address is being used to run tests + const myAddr = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const myKey = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + const [signerAddr] = await ethers.getSigners(); + expect(signerAddr).eq(myAddr); + + for (let i = 1; i < 1024; i += 250) { + // Have the contract sign an encrypted transaction for us + const bytes = randomBytes(i); + const nonce = await ethers.provider.getTransactionCount(myAddr); + const gasPrice = parseUnits('100', 'gwei'); + const gasLimit = 200000; + const tx = await contract.makeExampleCall( + bytes, + nonce, + gasPrice, + gasLimit, + myAddr, + myKey, + ); + + // Then broadcast transaction and make sure the result is given back to us + // Making sure the tx was encrypted, and data is passed correctly + const response = await ethers.provider.broadcastTransaction(tx); + expect(isCalldataEnveloped(response.data)).eq(true); + const receipt = await response.wait(); + expect(receipt?.status).eq(1); + const parsed = contract.interface.parseLog({ + topics: receipt!.logs[0].topics as string[], + data: receipt!.logs[0].data, + }); + expect(parsed!.args[0]).eq(hexlify(bytes)); + } + }); +});