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: implement calldata encryption in Solidity #436

Closed
wants to merge 7 commits into from
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/contracts-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ jobs:
run: make -C clients/js test lint
- name: Build & Test integrations
run: make -C integrations
- name: Build & Test Examples
run: make -C examples
- name: Build & Test sapphire-contracts package
run: make -C contracts
- name: Build & Test Examples
run: make -C examples
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Install forge doc deps
Expand Down
20 changes: 15 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));
CedarMist marked this conversation as resolved.
Show resolved Hide resolved

const envelope: Envelope = {
format: this.kind,
Expand Down Expand Up @@ -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 };
}
Expand Down
1 change: 1 addition & 0 deletions contracts/.prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
typechain-types/
contracts/tests/
4 changes: 3 additions & 1 deletion contracts/.solhint.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"compiler-version": ["error", "^0.8.0"],
"func-visibility": ["warn", { "ignoreConstructors": true }],
"no-inline-assembly": "off",
"not-rely-on-time": "off"
"not-rely-on-time": "off",
"var-name-mixedcase": "off",
"func-name-mixedcase": "off"
}
}
13 changes: 13 additions & 0 deletions contracts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ The format is inspired by [Keep a Changelog].

[Keep a Changelog]: https://keepachangelog.com/en/1.0.0/

## 0.3.0 (2024-10)

https://github.com/oasisprotocol/sapphire-paratime/milestone/9

### Added

* CalldataEncryption, for native transaction encryptions
* HMAC SHA512_256

### Fixed

* Various lint warnings

## 0.2.11 (2024-09)

### Added
Expand Down
169 changes: 169 additions & 0 deletions contracts/contracts/CBOR.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: Apache-2.0

pragma solidity ^0.8.0;

/// While parsing CBOR map, unexpected key
error CBOR_Error_InvalidKey();

/// While parsing CBOR map, length is invalid, or other parse error
error CBOR_Error_InvalidMap();

/// While parsing CBOR structure, data length was unexpected
error CBOR_Error_InvalidLength(uint256);

/// Value cannot be parsed as a uint
error CBOR_Error_InvalidUintPrefix(uint8);

/// Unsigned integer of unknown size
error CBOR_Error_InvalidUintSize(uint8);

/// CBOR parsed valid is out of expected range
error CBOR_Error_ValueOutOfRange();

error CBOR_Error_UintTooLong();

error CBOR_Error_BytesTooLong();

function encodeUint(uint256 value) pure returns (bytes memory) {
if (value < 24) {
return abi.encodePacked(uint8(value));
} else if (value <= type(uint8).max) {
return abi.encodePacked(uint8(24), uint8(value));
} else if (value <= type(uint16).max) {
return abi.encodePacked(uint8(25), uint16(value));
} else if (value <= type(uint32).max) {
return abi.encodePacked(uint8(26), uint32(value));
} else if (value <= type(uint64).max) {
return abi.encodePacked(uint8(27), uint64(value));
}
// XXX: encoding beyond 64bit uints isn't 100% supported
revert CBOR_Error_UintTooLong();
}

function encodeBytes(bytes memory in_bytes)
pure
returns (bytes memory out_cbor)
{
/*
0x40..0x57 byte string (0x00..0x17 bytes follow)
0x58 byte string (one-byte uint8_t for n, and then n bytes follow)
0x59 byte string (two-byte uint16_t for n, and then n bytes follow)
0x5a byte string (four-byte uint32_t for n, and then n bytes follow)
0x5b byte string (eight-byte uint64_t for n, and then n bytes follow)
*/
if (in_bytes.length <= 0x17) {
return abi.encodePacked(uint8(0x40 + in_bytes.length), in_bytes);
}
if (in_bytes.length <= 0xFF) {
return abi.encodePacked(uint8(0x58), uint8(in_bytes.length), in_bytes);
}
if (in_bytes.length <= 0xFFFF) {
return abi.encodePacked(uint8(0x59), uint16(in_bytes.length), in_bytes);
}
// We assume Solidity won't be encoding anything larger than 64kb
revert CBOR_Error_BytesTooLong();
}

function parseMapStart(bytes memory in_data, uint256 in_offset)
pure
returns (uint256 n_entries, uint256 out_offset)
{
uint256 b = uint256(uint8(in_data[in_offset]));
if (b < 0xa0 || b > 0xb7) {
revert CBOR_Error_InvalidMap();
}

n_entries = b - 0xa0;
out_offset = in_offset + 1;
}

function parseUint(bytes memory result, uint256 offset)
pure
returns (uint256 newOffset, uint256 value)
{
uint8 prefix = uint8(result[offset]);
uint256 len;

if (prefix <= 0x17) {
return (offset + 1, prefix);
}
// Byte array(uint256), parsed as a big-endian integer.
else if (prefix == 0x58) {
len = uint8(result[++offset]);
offset++;
}
// Byte array, parsed as a big-endian integer.
else if (prefix & 0x40 == 0x40) {
len = uint8(result[offset++]) ^ 0x40;
}
// Unsigned integer, CBOR encoded.
else if (prefix & 0x10 == 0x10) {
if (prefix == 0x18) {
len = 1;
} else if (prefix == 0x19) {
len = 2;
} else if (prefix == 0x1a) {
len = 4;
} else if (prefix == 0x1b) {
len = 8;
} else {
revert CBOR_Error_InvalidUintSize(prefix);
}
offset += 1;
}
// Unknown...
else {
revert CBOR_Error_InvalidUintPrefix(prefix);
}

if (len > 0x20) revert CBOR_Error_InvalidLength(len);

assembly {
value := mload(add(add(0x20, result), offset))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loads a 256bit word from an offset of a byte array, the first 256bits of the byte array is the length prefix, which is skipped.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. Maybe add a comment?

Suggested change
value := mload(add(add(0x20, result), offset))
// Load value from the result array at position 32+offset.
value := mload(add(add(0x20, result), offset))

}

value = value >> (256 - (len * 8));

newOffset = offset + len;
}

function parseUint64(bytes memory result, uint256 offset)
pure
returns (uint256 newOffset, uint64 value)
{
uint256 tmp;

(newOffset, tmp) = parseUint(result, offset);

if (tmp > type(uint64).max) revert CBOR_Error_ValueOutOfRange();

value = uint64(tmp);
}

function parseUint128(bytes memory result, uint256 offset)
pure
returns (uint256 newOffset, uint128 value)
{
uint256 tmp;

(newOffset, tmp) = parseUint(result, offset);

if (tmp > type(uint128).max) revert CBOR_Error_ValueOutOfRange();

value = uint128(tmp);
}

function parseKey(bytes memory result, uint256 offset)
pure
returns (uint256 newOffset, bytes32 keyDigest)
{
if (result[offset] & 0x60 != 0x60) revert CBOR_Error_InvalidKey();

uint8 len = uint8(result[offset++]) ^ 0x60;

assembly {
keyDigest := keccak256(add(add(0x20, result), offset), len)
}

newOffset = offset + len;
}
118 changes: 118 additions & 0 deletions contracts/contracts/CalldataEncryption.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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;

library CalldataEncryption {
function _deriveKey(
bytes32 in_peerPublicKey,
Sapphire.Curve25519SecretKey in_x25519_secret
) internal 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
) internal view returns (bytes memory out_encrypted) {
bytes memory plaintextEnvelope = abi.encodePacked(
hex"a1", // map(1)
hex"64", // text(4) "body"
CedarMist marked this conversation as resolved.
Show resolved Hide resolved
"body",
CBOR.encodeBytes(in_data)
);

out_encrypted = Sapphire.encrypt(
_deriveKey(peerPublicKey, in_x25519_secret),
nonce,
plaintextEnvelope,
""
);
}

function encryptCallData(bytes memory in_data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please document this and add a short example similar to other functions in Sapphire.sol.

public
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
) public view returns (bytes memory out_encrypted) {
if (in_data.length == 0) {
return "";
}

bytes memory inner = _encryptInner(
in_data,
mySecret,
nonce,
peerPublicKey
);

return
abi.encodePacked(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect the output bytecode for this is going to be huge

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)
);
}
}
Loading
Loading