Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

contracts: calldata encryption function (Solidity) #449

Merged
merged 2 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions clients/js/src/cipher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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 '';
Expand All @@ -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,
Expand Down Expand Up @@ -251,11 +258,13 @@ 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 = randomBytes(deoxysii.NonceSize),
): {
ciphertext: Uint8Array;
nonce: Uint8Array;
} {
const nonce = randomBytes(deoxysii.NonceSize);
const ciphertext = this.cipher.encrypt(nonce, plaintext);
return { nonce, ciphertext };
}
Expand Down
9 changes: 7 additions & 2 deletions clients/js/test/cipher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

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', () => {
Expand Down Expand Up @@ -30,7 +33,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));
}
});
Expand Down
102 changes: 102 additions & 0 deletions contracts/contracts/CalldataEncryption.sol
Original file line number Diff line number Diff line change
@@ -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)
);
}
52 changes: 52 additions & 0 deletions contracts/contracts/tests/TestCalldataEncryption.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
95 changes: 95 additions & 0 deletions contracts/test/calldata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// 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';
import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers';

describe('CalldataEncryption', () => {
let contract: TestCalldataEncryption;
let signers: HardhatEthersSigner[];

before(async () => {
const factory = await ethers.getContractFactory('TestCalldataEncryption');
contract = await factory.deploy();
await contract.waitForDeployment();

signers = await ethers.getSigners();
});

// 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';
expect(signers[0].address).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));
}
});
});
Loading