diff --git a/packages/native-utils/common/Cargo.toml b/packages/native-utils/common/Cargo.toml index 5e61bc7..0d156ff 100644 --- a/packages/native-utils/common/Cargo.toml +++ b/packages/native-utils/common/Cargo.toml @@ -9,7 +9,7 @@ authors = ["Jannis Pohlmann "] ethereum-types = "0.9" ethabi = { git = "https://github.com/graphprotocol/ethabi", rev = "fe7cab5" } hex = "0.4" -libsecp256k1 = "0.3" +libsecp256k1 = "0.3.5" serde_derive = "1.0" serde = "1.0" tiny-keccak = "2.0" diff --git a/packages/native-utils/common/src/channel.rs b/packages/native-utils/common/src/channel.rs new file mode 100644 index 0000000..ac703ab --- /dev/null +++ b/packages/native-utils/common/src/channel.rs @@ -0,0 +1,47 @@ +use ethabi::{encode, Token}; +use ethereum_types::{Address}; +use serde_derive::*; + +use super::tokenize::*; +use super::types::*; +use super::utils::*; +use super::state::*; + +//#[derive(Deserialize)] +//#[serde(rename_all = "camelCase")] +pub struct SignedStateVarsWithHash { + pub turn_num: Uint48, + pub is_final: bool, + pub outcome: Outcome, + pub app_data: Bytes, + pub hash: Bytes32, + pub signature: RecoverableSignature +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Channel { + pub chain_id: Uint256, + pub channel_nonce: Uint256, + pub participants: Vec
, +} + +impl Channel { + pub fn id(&self) -> Bytes32 { + keccak256( + encode(&[ + self.chain_id.tokenize(), + Token::Array( + self.participants + .iter() + .cloned() + .map(Token::Address) + .collect(), + ), + self.channel_nonce.tokenize(), + ]) + .as_slice(), + ) + .into() + } +} \ No newline at end of file diff --git a/packages/native-utils/common/src/lib.rs b/packages/native-utils/common/src/lib.rs index 83e68ab..7a81dd0 100644 --- a/packages/native-utils/common/src/lib.rs +++ b/packages/native-utils/common/src/lib.rs @@ -1,6 +1,7 @@ mod encode; mod serde; mod state; +mod channel; mod tokenize; mod types; mod utils; @@ -11,4 +12,5 @@ pub mod prelude { pub use super::tokenize::Tokenize; pub use super::types::*; pub use super::utils::*; + pub use super::channel::*; } diff --git a/packages/native-utils/common/src/serde.rs b/packages/native-utils/common/src/serde.rs index 8e9de22..1dc1d58 100644 --- a/packages/native-utils/common/src/serde.rs +++ b/packages/native-utils/common/src/serde.rs @@ -1,25 +1,40 @@ use hex; -use secp256k1::{RecoveryId, Signature}; -use serde::ser::*; +use serde::ser::{Serialize, Serializer}; +use serde::de::{Error, Deserialize, Deserializer}; -pub fn serialize_signature( - (signature, recovery_id): &(Signature, RecoveryId), - serializer: S, -) -> Result -where +use super::state::RecoverableSignature; +use super::types::*; + +impl Serialize for RecoverableSignature { + fn serialize( + &self, + serializer: S, + ) -> Result + where S: Serializer, -{ - // A helper struct to go from `[u8; 64]` to `&[u8]` so that `hex::encode` - // accepts it. - struct RawBytes64([u8; 64]); + { + // A helper struct to go from `[u8; 64]` to `&[u8]` so that `hex::encode` + // accepts it. + struct RawBytes64([u8; 64]); - impl AsRef<[u8]> for RawBytes64 { - fn as_ref(&self) -> &[u8] { - &self.0 + impl AsRef<[u8]> for RawBytes64 { + fn as_ref(&self) -> &[u8] { + &self.0 + } } + + let bytes = self.0.serialize(); + let s = hex::encode(RawBytes64(bytes)); + serializer.serialize_str(format!("0x{}{:x}", s, self.1.serialize() + 27).as_str()) } +} - let bytes = signature.serialize(); - let s = hex::encode(RawBytes64(bytes)); - serializer.serialize_str(format!("0x{}{:x}", s, recovery_id.serialize() + 27).as_str()) +impl<'de> Deserialize<'de> for RecoverableSignature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let bytes: Bytes = Deserialize::deserialize(deserializer)?; + Ok(RecoverableSignature::from_bytes(bytes).map_err(D::Error::custom)?) + } } diff --git a/packages/native-utils/common/src/state.rs b/packages/native-utils/common/src/state.rs index bde560c..3d4f324 100644 --- a/packages/native-utils/common/src/state.rs +++ b/packages/native-utils/common/src/state.rs @@ -6,12 +6,12 @@ use secp256k1::{recover, sign, Message, RecoveryId, SecretKey, Signature}; use serde_derive::*; use super::encode::*; -use super::serde::*; use super::tokenize::*; use super::types::*; use super::utils::*; +use super::channel::*; -#[derive(Deserialize)] +#[derive(Deserialize,PartialEq)] #[serde(rename_all = "camelCase")] pub struct AllocationItem { pub destination: Bytes32, @@ -39,7 +39,7 @@ impl Tokenize for AssetOutcomeType { } } -#[derive(Deserialize)] +#[derive(Deserialize,PartialEq)] #[serde(rename_all = "camelCase")] pub struct AllocationAssetOutcome { pub asset_holder_address: Address, @@ -55,7 +55,7 @@ impl Tokenize for AllocationAssetOutcome { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Guarantee { pub target_channel_id: Bytes32, @@ -71,7 +71,7 @@ impl Tokenize for Guarantee { } } -#[derive(Deserialize)] +#[derive(Deserialize,PartialEq)] #[serde(rename_all = "camelCase")] pub struct GuaranteeAssetOutcome { pub asset_holder_address: Address, @@ -87,7 +87,7 @@ impl Tokenize for GuaranteeAssetOutcome { } } -#[derive(Deserialize)] +#[derive(Deserialize, PartialEq)] #[serde(untagged)] pub enum AssetOutcome { AllocationAssetOutcome(AllocationAssetOutcome), @@ -107,7 +107,7 @@ impl Tokenize for AssetOutcome { } } -#[derive(Deserialize)] +#[derive(Deserialize, PartialEq)] #[serde(transparent)] pub struct Outcome(Vec); @@ -123,34 +123,6 @@ impl Tokenize for Outcome { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Channel { - pub chain_id: Uint256, - pub channel_nonce: Uint256, - pub participants: Vec
, -} - -impl Channel { - pub fn id(&self) -> Bytes32 { - keccak256( - encode(&[ - self.chain_id.tokenize(), - Token::Array( - self.participants - .iter() - .cloned() - .map(Token::Address) - .collect(), - ), - self.channel_nonce.tokenize(), - ]) - .as_slice(), - ) - .into() - } -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct State { @@ -163,6 +135,12 @@ pub struct State { pub app_data: Bytes, } +#[derive(Serialize)] +pub enum Status { + True, + NeedToCheckApp, +} + impl State { pub fn hash_app_part(&self) -> Bytes32 { keccak256( @@ -202,11 +180,11 @@ impl State { Ok(StateSignature { hash, - signature: (signature, recovery_id), + signature: RecoverableSignature(signature, recovery_id), }) } - pub fn recover_address(self, signature: Bytes) -> Result { + pub fn recover_address(&self, signature: Bytes) -> Result { let hash = self.hash(); let hashed_message = hash_message(&hash); let message = Message::parse(&hashed_message); @@ -219,12 +197,94 @@ impl State { Ok(checksum_address(public_key_to_address(public_key))) } + + pub fn validate_peer_update(&self, peer_update: State, peer_signature: Bytes) -> Result { + peer_update.validate_signature(peer_signature)?; + self.require_valid_protocol_transition(peer_update) + } + + fn validate_signature(&self, signature: Bytes) -> Result<(), &'static str> { + let signer_index = ((self.turn_num.0 -1) % self.channel.participants.len() as u64) as usize; + let signer_address = self.channel.participants[signer_index]; + let recovered_address = self.recover_address(signature)?; + + if recovered_address.eq(&checksum_address(signer_address.0.to_vec())) { + Ok(()) + } else { + Err("Signature verification failed") + } + } + + fn _require_extra_implicit_checks(&self, to_state: &State) -> Result<(), &'static str> { + if &self.turn_num.0 + 1 != to_state.turn_num.0 { + Err("turnNum must increment by one") + } else if self.channel.chain_id != to_state.channel.chain_id { + Err("chainId must not change") + } else if self.channel.channel_nonce != to_state.channel.channel_nonce { + Err("channelNonce must not change") + } else if self.app_definition != to_state.app_definition { + Err("appDefinition must not change") + } else if self.challenge_duration != to_state.challenge_duration { + Err("challengeDuration must not change") + } else { + Ok(()) + } + } + + pub fn require_valid_protocol_transition(&self, to_state: State) -> Result { + self._require_extra_implicit_checks(&to_state)?; + + if to_state.is_final { + if self.outcome != to_state.outcome { + Err("Outcome change forbidden") + } else { + Ok(Status::True) + } + } else { + if self.is_final { + Err("transition from a final state to a non-final state") + } else { + if to_state.turn_num < Uint48(2 * to_state.channel.participants.len() as u64) { + if self.outcome != to_state.outcome { + Err("Outcome change forbidden") + } else if self.app_data != to_state.app_data { + Err("appData change forbidden") + } + else { + Ok(Status::True) + } + } + else { + Ok(Status::NeedToCheckApp) + } + } + } + } +} + +pub struct RecoverableSignature(pub Signature, pub RecoveryId); + +impl RecoverableSignature { + pub fn as_bytes(self) -> Bytes { + let mut v = self.0.serialize().to_vec(); + v.push(self.1.serialize()); + Bytes(v) + } + + pub fn from_bytes(bytes: Bytes) -> Result { + assert_eq!(65,bytes.0.len()); + let mut a: [u8; 64] = [0; 64]; + a.copy_from_slice(&bytes.0[0..64]); + Ok(RecoverableSignature( + Signature::parse(&a), + RecoveryId::parse(bytes.0[64] - 27).map_err(|_| "Invalid recovery ID")? + )) + } } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StateSignature { hash: Bytes32, - #[serde(serialize_with = "serialize_signature")] - signature: (Signature, RecoveryId), + signature: RecoverableSignature, } diff --git a/packages/native-utils/common/src/types.rs b/packages/native-utils/common/src/types.rs index 0f4db93..40efbdc 100644 --- a/packages/native-utils/common/src/types.rs +++ b/packages/native-utils/common/src/types.rs @@ -19,6 +19,7 @@ impl ToHexString for Vec { } } +#[derive(PartialEq)] pub struct Bytes(pub Vec); impl Deref for Bytes { @@ -55,6 +56,7 @@ impl Tokenize for Bytes { } } +#[derive(PartialEq)] pub struct Bytes32(Vec); impl From<[u8; 32]> for Bytes32 { @@ -107,6 +109,7 @@ impl Tokenize for Bytes32 { } } +#[derive(PartialEq, PartialOrd)] pub struct Uint48(pub u64); impl<'de> Deserialize<'de> for Uint48 { @@ -124,6 +127,7 @@ impl Tokenize for Uint48 { } } +#[derive(PartialEq)] pub struct Uint256(pub U256); impl From for Uint256 { diff --git a/packages/native-utils/lib/index.d.ts b/packages/native-utils/lib/index.d.ts index 8a8f4f7..9b479cd 100644 --- a/packages/native-utils/lib/index.d.ts +++ b/packages/native-utils/lib/index.d.ts @@ -1,4 +1,4 @@ -import { Channel, State } from '@statechannels/nitro-protocol' +import { Bytes32, Channel, State } from '@statechannels/nitro-protocol' /** * A Nitro state with its state hash and signature from signing the state. @@ -79,3 +79,20 @@ export function signState(state: State, privateKey: string): StateWithHashAndSig * @param signature A signature resulting from a previous call to `signState`. */ export function recoverAddress(state: State, signature: string): string + +/** + * Verifies a signature. + * + * @param state A Nitro state. + * @param signature A signature resulting from a previous call to `signState`. + */ + export function verifySignature(hash: Bytes32, address: string, signature: string): boolean + + /** + * Validate peer update. + * + * @param state A Nitro state. + * @param peer_update Next state suggested by peer + * @param signature Peer's signature for next state. + */ + export function validatePeerUpdate(state, peer_update, signature): string \ No newline at end of file diff --git a/packages/native-utils/lib/index.native.js b/packages/native-utils/lib/index.native.js index 7ccaa96..c8ccf9c 100644 --- a/packages/native-utils/lib/index.native.js +++ b/packages/native-utils/lib/index.native.js @@ -10,6 +10,8 @@ const { signState, recoverAddress, + verifySignature, + validatePeerUpdate, } = require('../native/index.node') function unwrapResult({ Ok, Err }) { @@ -40,4 +42,8 @@ module.exports = { }, recoverAddress: (state, signature) => unwrapResult(recoverAddress(state, signature)), + + verifySignature: (hash, address, signature) => unwrapResult(verifySignature(hash, address, signature)), + + validatePeerUpdate: (state, peer_update, signature) => unwrapResult(validatePeerUpdate(state, peer_update, signature)), } diff --git a/packages/native-utils/lib/index.wasm.js b/packages/native-utils/lib/index.wasm.js index c412f1c..e2e9874 100644 --- a/packages/native-utils/lib/index.wasm.js +++ b/packages/native-utils/lib/index.wasm.js @@ -10,6 +10,8 @@ const { signState, recoverAddress, + verifySignature, + validatePeerUpdate, } = require('@statechannels/wasm-utils') module.exports = { @@ -32,4 +34,6 @@ module.exports = { }, recoverAddress, + verifySignature, + validatePeerUpdate, } diff --git a/packages/native-utils/native/src/lib.rs b/packages/native-utils/native/src/lib.rs index ed63212..e467962 100644 --- a/packages/native-utils/native/src/lib.rs +++ b/packages/native-utils/native/src/lib.rs @@ -37,4 +37,8 @@ export! { fn recoverAddress(state: State, signature: Bytes) -> Result { state.recover_address(signature) } + + fn validatePeerUpdate(state: State, peer_update: State, peer_signature: Bytes) -> Result { + state.validate_peer_update(peer_update, peer_signature) + } } diff --git a/packages/native-utils/tests/sign.test.ts b/packages/native-utils/tests/sign.test.ts index 7122443..d0faede 100644 --- a/packages/native-utils/tests/sign.test.ts +++ b/packages/native-utils/tests/sign.test.ts @@ -11,7 +11,7 @@ const DEFAULT_STATE: State = { channel: { chainId: '1', channelNonce: 1, - participants: ['0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A'], + participants: ['0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377', '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1'], }, challengeDuration: 1, outcome: [], @@ -19,7 +19,8 @@ const DEFAULT_STATE: State = { appData: '0x0000000000000000000000000000000000000000000000000000000000000000', } -const PRIVATE_KEY = '0x1111111111111111111111111111111111111111111111111111111111111111' +const PRIVATE_KEY1 = '0x8da4ef21b864d2cc526dbdb2a120bd2874c36c9d0a1fb7f8c63d7f7a8b41de8f' +const PRIVATE_KEY2 = '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' describe('Hash message', () => { test('Hash a message', async () => { @@ -54,14 +55,14 @@ describe('Sign state', () => { outcome, } const oldSignature = utils.joinSignature( - (await nitro.signState(state, PRIVATE_KEY)).signature, + (await nitro.signState(state, PRIVATE_KEY1)).signature, ) // Native - const nativeSignature = native.signState(state, PRIVATE_KEY).signature + const nativeSignature = native.signState(state, PRIVATE_KEY1).signature // WASM - const wasmSignature = wasm.signState(state, PRIVATE_KEY).signature + const wasmSignature = wasm.signState(state, PRIVATE_KEY1).signature expect(nativeSignature).toStrictEqual(oldSignature) expect(wasmSignature).toStrictEqual(oldSignature) @@ -88,17 +89,17 @@ describe('Sign state', () => { } const oldSignature = utils.joinSignature( - (await nitro.signState(state, PRIVATE_KEY)).signature, + (await nitro.signState(state, PRIVATE_KEY1)).signature, ) // Native - const nativeSignature = native.signState(state, PRIVATE_KEY).signature + const nativeSigned = native.signState(state, PRIVATE_KEY1) // WASM - const wasmSignature = wasm.signState(state, PRIVATE_KEY).signature + const wasmSigned = wasm.signState(state, PRIVATE_KEY1) - expect(nativeSignature).toStrictEqual(oldSignature) - expect(wasmSignature).toStrictEqual(oldSignature) + expect(nativeSigned.signature).toStrictEqual(oldSignature) + expect(wasmSigned.signature).toStrictEqual(oldSignature) }) test('Catches invalid private key', async () => { diff --git a/packages/native-utils/tests/transition.test.ts b/packages/native-utils/tests/transition.test.ts new file mode 100644 index 0000000..c42731d --- /dev/null +++ b/packages/native-utils/tests/transition.test.ts @@ -0,0 +1,172 @@ +import { State } from '@statechannels/nitro-protocol' +import * as wasm from '@statechannels/wasm-utils' +import * as native from '..' + +const OUTCOME = [ + { + assetHolderAddress: '0x0000000000000000000000000000000000000000', + guarantee: { + targetChannelId: + '0x0000000000000000000000000000000000000000000000000000000000000000', + destinations: [ + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x1111111111111111111111111111111111111111111111111111111111111111', + ], + }, + }, +] + +const CURRENT_STATE: State = { + turnNum: 5, + isFinal: false, + channel: { + chainId: '1', + channelNonce: 1, + participants: ['0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377', '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1'], + }, + challengeDuration: 1, + outcome: OUTCOME, + appDefinition: '0x0000000000000000000000000000000000000000', + appData: '0x0000000000000000000000000000000000000000000000000000000000000000', +} + +const NEXT_STATE: State = { + turnNum: 6, + isFinal: false, + channel: { + chainId: '1', + channelNonce: 1, + participants: ['0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377', '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1'], + }, + challengeDuration: 1, + outcome: OUTCOME, + appDefinition: '0x0000000000000000000000000000000000000000', + appData: '0x0000000000000000000000000000000000000000000000000000000000000000', +} + +const PRIVATE_KEY1 = '0x8da4ef21b864d2cc526dbdb2a120bd2874c36c9d0a1fb7f8c63d7f7a8b41de8f' +const PRIVATE_KEY2 = '0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d' + + +describe('Validate state transitions', () => { + test('Pre fund setup passes', async () => { + + let currentState = { + ...CURRENT_STATE, + } + + let peerState = { + ...NEXT_STATE, + } + + // First 2 updates + currentState.turnNum = 1; + peerState.turnNum = 2; + + const nativeSigned = native.signState(peerState, PRIVATE_KEY2); + expect(native.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toEqual("True") + expect(wasm.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toEqual("True") + }) + + test('state transition passes', async () => { + let currentState = { + ...CURRENT_STATE, + } + + let peerState = { + ...NEXT_STATE, + } + + currentState.turnNum = 5; + peerState.turnNum = 6; + + const nativeSigned = native.signState(peerState, PRIVATE_KEY2); + expect(native.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toEqual("NeedToCheckApp") + expect(wasm.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toEqual("NeedToCheckApp") + + }) + + test('signer mismatch fails', async () => { + let currentState = { + ...CURRENT_STATE, + } + + let peerState = { + ...NEXT_STATE, + } + + const nativeSigned = native.signState(peerState, PRIVATE_KEY1); + expect(() => native.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toThrow('Signature verification failed'); + expect(() => wasm.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toThrow('Signature verification failed'); + }); + + test('turn number mismatch fails', async () => { + let currentState = { + ...CURRENT_STATE, + } + + let peerState = { + ...NEXT_STATE, + } + + currentState.turnNum = 4; + peerState.turnNum = 6; + const nativeSigned4 = native.signState(peerState, PRIVATE_KEY2); + expect(() => native.validatePeerUpdate(currentState, peerState, nativeSigned4.signature)).toThrow('turnNum must increment by one'); + expect(() => wasm.validatePeerUpdate(currentState, peerState, nativeSigned4.signature)).toThrow('turnNum must increment by one'); + }); + + test('final transit to non-final fails fails', async () => { + let currentState = { + ...CURRENT_STATE, + } + + let peerState = { + ...NEXT_STATE, + } + + currentState.turnNum = 5; + currentState.isFinal = true; + peerState.turnNum = 6; + peerState.isFinal = false; + const nativeSigned = native.signState(peerState, PRIVATE_KEY2); + expect(() => native.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toThrow('transition from a final state to a non-final state'); + expect(() => wasm.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toThrow('transition from a final state to a non-final state'); + }); + + test('Outcome mismatch mismatch fails', async () => { + const otheroutcome = [ + { + assetHolderAddress: '0x0000000000000000000000000000000000000000', + guarantee: { + targetChannelId: + '0x0000000000000000000000000000000000000000000000000000000000000000', + destinations: [ + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x2222222222222222222222222222222222222222222222220222222222222222', + ], + }, + }, + ] + + let currentState = { + ...CURRENT_STATE, + } + + let peerState = { + ...NEXT_STATE, + } + + currentState.isFinal = true + currentState.turnNum = 5; + + peerState.outcome = otheroutcome + peerState.isFinal = true + peerState.turnNum = 6; + + const nativeSigned = native.signState(peerState, PRIVATE_KEY2); + + expect(() => native.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toThrow('Outcome change forbidden'); + expect(() => wasm.validatePeerUpdate(currentState, peerState, nativeSigned.signature)).toThrow('Outcome change forbidden'); + }) +}) diff --git a/wasm-utils/src/lib.rs b/wasm-utils/src/lib.rs index a28cc4f..82777c8 100644 --- a/wasm-utils/src/lib.rs +++ b/wasm-utils/src/lib.rs @@ -1,6 +1,6 @@ use std::ops::Deref; -use js_sys::JsString; +use js_sys::{JsString}; use wasm_bindgen::prelude::*; use statechannels_native_utils_common::prelude::{hash_message as do_hash_message, *}; @@ -80,3 +80,12 @@ pub fn recover_address(state: &JsState, signature: &JsString) -> Result Result { + let state: State = state.into_serde().unwrap(); + let peer_update: State = peer_update.into_serde().unwrap(); + let signature: Bytes = signature.into_serde().unwrap(); + let result = state.validate_peer_update(peer_update, signature).map_err(JsValue::from)?; + Ok(JsValue::from_serde(&result).unwrap().into()) +} \ No newline at end of file