Skip to content

Commit ef8c480

Browse files
authored
Merge pull request #449 from oasisprotocol/CedarMist/calldataencryption-v2
contracts: calldata encryption function (Solidity)
2 parents 88143e1 + d4c083f commit ef8c480

File tree

5 files changed

+270
-7
lines changed

5 files changed

+270
-7
lines changed

clients/js/src/cipher.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ export abstract class Cipher {
8282
public abstract publicKey: Uint8Array;
8383
public abstract epoch?: number;
8484

85-
public abstract encrypt(plaintext: Uint8Array): {
85+
public abstract encrypt(
86+
plaintext: Uint8Array,
87+
nonce?: Uint8Array,
88+
): {
8689
ciphertext: Uint8Array;
8790
nonce: Uint8Array;
8891
};
@@ -93,7 +96,10 @@ export abstract class Cipher {
9396
): Uint8Array;
9497

9598
/** Encrypts the plaintext and encodes it for sending. */
96-
public encryptCall(calldata?: BytesLike | null): BytesLike {
99+
public encryptCall(
100+
calldata?: BytesLike | null,
101+
nonce?: Uint8Array,
102+
): BytesLike {
97103
// Txs without data are just balance transfers, and all data in those is public.
98104
if (calldata === undefined || calldata === null || calldata.length === 0)
99105
return '';
@@ -104,7 +110,8 @@ export abstract class Cipher {
104110

105111
const innerEnvelope = cborEncode({ body: getBytes(calldata) });
106112

107-
const { ciphertext, nonce } = this.encrypt(innerEnvelope);
113+
let ciphertext: Uint8Array;
114+
({ ciphertext, nonce } = this.encrypt(innerEnvelope, nonce));
108115

109116
const envelope: Envelope = {
110117
format: this.kind,
@@ -251,11 +258,13 @@ export class X25519DeoxysII extends Cipher {
251258
this.cipher = new deoxysii.AEAD(new Uint8Array(this.key)); // deoxysii owns the input
252259
}
253260

254-
public encrypt(plaintext: Uint8Array): {
261+
public encrypt(
262+
plaintext: Uint8Array,
263+
nonce: Uint8Array = randomBytes(deoxysii.NonceSize),
264+
): {
255265
ciphertext: Uint8Array;
256266
nonce: Uint8Array;
257267
} {
258-
const nonce = randomBytes(deoxysii.NonceSize);
259268
const ciphertext = this.cipher.encrypt(nonce, plaintext);
260269
return { nonce, ciphertext };
261270
}

clients/js/test/cipher.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import nacl from 'tweetnacl';
44
import { hexlify, getBytes } from 'ethers';
5-
import { X25519DeoxysII } from '@oasisprotocol/sapphire-paratime';
5+
import {
6+
isCalldataEnveloped,
7+
X25519DeoxysII,
8+
} from '@oasisprotocol/sapphire-paratime';
69

710
describe('X25519DeoxysII', () => {
811
it('key derivation', () => {
@@ -30,7 +33,9 @@ describe('X25519DeoxysII', () => {
3033
const cipher = X25519DeoxysII.ephemeral(nacl.box.keyPair().publicKey);
3134
for (let i = 1; i < 512; i += 30) {
3235
const expected = nacl.randomBytes(i);
33-
const decoded = cipher.decryptCall(cipher.encryptCall(expected));
36+
const encrypted = cipher.encryptCall(expected);
37+
expect(isCalldataEnveloped(encrypted)).toStrictEqual(true);
38+
const decoded = cipher.decryptCall(encrypted);
3439
expect(hexlify(decoded)).toEqual(hexlify(expected));
3540
}
3641
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
pragma solidity ^0.8.0;
4+
5+
import {Subcall} from "./Subcall.sol";
6+
import {Sapphire} from "./Sapphire.sol";
7+
import "./CBOR.sol" as CBOR;
8+
9+
function _deriveKey(
10+
bytes32 in_peerPublicKey,
11+
Sapphire.Curve25519SecretKey in_x25519_secret
12+
) view returns (bytes32) {
13+
return
14+
Sapphire.deriveSymmetricKey(
15+
Sapphire.Curve25519PublicKey.wrap(in_peerPublicKey),
16+
in_x25519_secret
17+
);
18+
}
19+
20+
function _encryptInner(
21+
bytes memory in_data,
22+
Sapphire.Curve25519SecretKey in_x25519_secret,
23+
bytes15 nonce,
24+
bytes32 peerPublicKey
25+
) view returns (bytes memory out_encrypted) {
26+
bytes memory plaintextEnvelope = abi.encodePacked(
27+
hex"a1", // map(1)
28+
hex"64", // text(4) "body"
29+
"body",
30+
CBOR.encodeBytes(in_data)
31+
);
32+
33+
out_encrypted = Sapphire.encrypt(
34+
_deriveKey(peerPublicKey, in_x25519_secret),
35+
nonce,
36+
plaintextEnvelope,
37+
""
38+
);
39+
}
40+
41+
function encryptCallData(bytes memory in_data)
42+
view
43+
returns (bytes memory out_encrypted)
44+
{
45+
if (in_data.length == 0) {
46+
return "";
47+
}
48+
49+
Sapphire.Curve25519PublicKey myPublic;
50+
Sapphire.Curve25519SecretKey mySecret;
51+
52+
(myPublic, mySecret) = Sapphire.generateCurve25519KeyPair("");
53+
54+
bytes15 nonce = bytes15(Sapphire.randomBytes(15, ""));
55+
56+
Subcall.CallDataPublicKey memory cdpk;
57+
uint256 epoch;
58+
59+
(epoch, cdpk) = Subcall.coreCallDataPublicKey();
60+
61+
return encryptCallData(in_data, myPublic, mySecret, nonce, epoch, cdpk.key);
62+
}
63+
64+
function encryptCallData(
65+
bytes memory in_data,
66+
Sapphire.Curve25519PublicKey myPublic,
67+
Sapphire.Curve25519SecretKey mySecret,
68+
bytes15 nonce,
69+
uint256 epoch,
70+
bytes32 peerPublicKey
71+
) view returns (bytes memory out_encrypted) {
72+
if (in_data.length == 0) {
73+
return "";
74+
}
75+
76+
bytes memory inner = _encryptInner(in_data, mySecret, nonce, peerPublicKey);
77+
78+
return
79+
abi.encodePacked(
80+
hex"a2", // map(2)
81+
hex"64", // text(4) "body"
82+
"body",
83+
hex"a4", // map(4)
84+
hex"62", // text(2) "pk"
85+
"pk",
86+
hex"5820", // bytes(32)
87+
myPublic,
88+
hex"64", // text(4) "data"
89+
"data",
90+
CBOR.encodeBytes(inner), // bytes(n) inner
91+
hex"65", // text(5) "epoch"
92+
"epoch",
93+
CBOR.encodeUint(epoch), // unsigned(epoch)
94+
hex"65", // text(5) "nonce"
95+
"nonce",
96+
hex"4f", // bytes(15) nonce
97+
nonce,
98+
hex"66", // text(6) "format"
99+
"format",
100+
hex"01" // unsigned(1)
101+
);
102+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
pragma solidity ^0.8.0;
4+
5+
import { Sapphire } from "../Sapphire.sol";
6+
import { encryptCallData } from "../CalldataEncryption.sol";
7+
import { EIP155Signer } from "../EIP155Signer.sol";
8+
9+
contract TestCalldataEncryption {
10+
function testEncryptCallData(
11+
bytes memory in_data,
12+
Sapphire.Curve25519PublicKey myPublic,
13+
Sapphire.Curve25519SecretKey mySecret,
14+
bytes15 nonce,
15+
uint256 epoch,
16+
bytes32 peerPublicKey
17+
) external view returns (bytes memory) {
18+
return encryptCallData(in_data, myPublic, mySecret, nonce, epoch, peerPublicKey);
19+
}
20+
21+
function makeExampleCall(
22+
bytes calldata in_data,
23+
uint64 nonce,
24+
uint256 gasPrice,
25+
uint64 gasLimit,
26+
address myAddr,
27+
bytes32 myKey
28+
)
29+
external view
30+
returns (bytes memory)
31+
{
32+
EIP155Signer.EthTx memory theTx = EIP155Signer.EthTx({
33+
nonce: nonce,
34+
gasPrice: gasPrice,
35+
gasLimit: gasLimit,
36+
value: 0,
37+
to: address(this),
38+
chainId: block.chainid,
39+
data: encryptCallData(abi.encodeCall(this.example, in_data))
40+
});
41+
42+
return EIP155Signer.sign(myAddr, myKey, theTx);
43+
}
44+
45+
event ExampleEvent(bytes);
46+
47+
function example(bytes calldata in_calldata)
48+
external
49+
{
50+
emit ExampleEvent(in_calldata);
51+
}
52+
}

contracts/test/calldata.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { expect } from 'chai';
4+
import { ethers } from 'hardhat';
5+
import { TestCalldataEncryption } from '../typechain-types/contracts/tests';
6+
import {
7+
boxKeyPairFromSecretKey,
8+
crypto_box_SECRETKEYBYTES,
9+
isCalldataEnveloped,
10+
X25519DeoxysII,
11+
} from '@oasisprotocol/sapphire-paratime';
12+
import { hexlify, parseUnits, randomBytes } from 'ethers';
13+
import { randomInt } from 'crypto';
14+
import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers';
15+
16+
describe('CalldataEncryption', () => {
17+
let contract: TestCalldataEncryption;
18+
let signers: HardhatEthersSigner[];
19+
20+
before(async () => {
21+
const factory = await ethers.getContractFactory('TestCalldataEncryption');
22+
contract = await factory.deploy();
23+
await contract.waitForDeployment();
24+
25+
signers = await ethers.getSigners();
26+
});
27+
28+
// Ensures that the JS library provides the same results as Solidity
29+
it('testEncryptCallData', async () => {
30+
for (let i = 1; i < 1024; i += 1 + i / 5) {
31+
const peerKeypair = boxKeyPairFromSecretKey(
32+
randomBytes(crypto_box_SECRETKEYBYTES),
33+
);
34+
const myKeypair = boxKeyPairFromSecretKey(
35+
randomBytes(crypto_box_SECRETKEYBYTES),
36+
);
37+
const calldata = randomBytes(i);
38+
const epoch = randomInt(1 << 32);
39+
const nonce = randomBytes(15);
40+
const cipher = X25519DeoxysII.fromSecretKey(
41+
myKeypair.secretKey,
42+
peerKeypair.publicKey,
43+
epoch,
44+
);
45+
const encryptedCall = cipher.encryptCall(calldata, nonce);
46+
const result = await contract.testEncryptCallData(
47+
calldata,
48+
myKeypair.publicKey,
49+
myKeypair.secretKey,
50+
nonce,
51+
epoch,
52+
peerKeypair.publicKey,
53+
);
54+
expect(result).eq(hexlify(encryptedCall));
55+
}
56+
});
57+
58+
it('roundtrip encryption', async () => {
59+
// Tests must be submitted from an account which has a balance
60+
// But can't get access to the signer private key from here
61+
// So, assume the 0xf39F address is being used to run tests
62+
const myAddr = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
63+
const myKey =
64+
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
65+
expect(signers[0].address).eq(myAddr);
66+
67+
for (let i = 1; i < 1024; i += 250) {
68+
// Have the contract sign an encrypted transaction for us
69+
const bytes = randomBytes(i);
70+
const nonce = await ethers.provider.getTransactionCount(myAddr);
71+
const gasPrice = parseUnits('100', 'gwei');
72+
const gasLimit = 200000;
73+
const tx = await contract.makeExampleCall(
74+
bytes,
75+
nonce,
76+
gasPrice,
77+
gasLimit,
78+
myAddr,
79+
myKey,
80+
);
81+
82+
// Then broadcast transaction and make sure the result is given back to us
83+
// Making sure the tx was encrypted, and data is passed correctly
84+
const response = await ethers.provider.broadcastTransaction(tx);
85+
expect(isCalldataEnveloped(response.data)).eq(true);
86+
const receipt = await response.wait();
87+
expect(receipt?.status).eq(1);
88+
const parsed = contract.interface.parseLog({
89+
topics: receipt!.logs[0].topics as string[],
90+
data: receipt!.logs[0].data,
91+
});
92+
expect(parsed!.args[0]).eq(hexlify(bytes));
93+
}
94+
});
95+
});

0 commit comments

Comments
 (0)