diff --git a/contracts/Account.sol b/contracts/Account.sol new file mode 100644 index 0000000..5172fb5 --- /dev/null +++ b/contracts/Account.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {SignatureRSV, EthereumUtils} from "@oasisprotocol/sapphire-contracts/contracts/EthereumUtils.sol"; +import {Sapphire} from "@oasisprotocol/sapphire-contracts/contracts/Sapphire.sol"; +import {EIP155Signer} from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol"; +import {AccountBase, AccountFactoryBase} from "./AccountBase.sol"; + + +// A contract to create per-identity account. +contract AccountFactory is AccountFactoryBase { + event AccountCreated(address contractAddress); + + function clone(address target) + public virtual override { + bytes20 targetBytes = bytes20(target); + address contractAddr; + assembly { + let contractClone := mload(0x40) + mstore(contractClone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000) + mstore(add(contractClone, 0x14), targetBytes) + mstore(add(contractClone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000) + contractAddr := create(0, contractClone, 0x37) + } + require(contractAddr != address(0), "Create failed"); + emit AccountCreated(contractAddr); + } +} + +// A base class for a per-identity account. +// It can be extended to include additional data, for example a symmetric +// key for encrypting and decryption off-chain data. +contract Account is AccountBase { + uint64 nonce; + bool initialized; + + function initialize(address starterOwner) + public virtual + { + require(!initialized, "Contract is already initialized"); + controller = starterOwner; + // Generate the private / public keypair + bytes memory pubKey; + bytes memory privKey; + (pubKey, privKey) = Sapphire.generateSigningKeyPair(Sapphire.SigningAlg.Secp256k1PrehashedKeccak256, Sapphire.randomBytes(32, "")); + publicKey = EthereumUtils.k256PubkeyToEthereumAddress(pubKey); + privateKey = bytes32(privKey); + nonce = 0; + initialized = true; + } + + modifier validateController { + require(msg.sender == controller, "Only the controller may access this function"); + _; + } + + // Update the controller of this account. Useful when a different + // type of credential is to be used and a validator contract is needed. + function updateController(address _controller) external override validateController { + // I'm not sure if this is the correct way to do access control + controller = _controller; + } + + // Functions to check and update permissions. + function hasPermission(address grantee) public view override + returns (bool) { + // Check that there's an entry for the grantee and that the expiration is greater than the current timestamp + return (grantee == controller || (permission[grantee] != 0 && permission[grantee] >= block.timestamp)); + } + + modifier authorized { + require(hasPermission(msg.sender) || msg.sender == address(this), + "Message sender doesn't have permission to access this account"); + _; + } + + function grantPermission(address grantee, uint256 expiry) public virtual override validateController { + permission[grantee] = expiry; + } + + function revokePermission(address grantee) public virtual override validateController { + permission[grantee] = 0; + } + + // The remaining functions use the key pair for normal contract operations. + function signEIP155(EIP155Signer.EthTx calldata txToSign) + public view override authorized + returns (bytes memory) { + return EIP155Signer.sign(publicKey, privateKey, txToSign); + } + + // Sign a digest. + function sign(bytes32 digest) + public view override authorized + returns (SignatureRSV memory) { + return EthereumUtils.sign(publicKey, privateKey, digest); + } + + // Taken from https://github.com/oasisprotocol/sapphire-paratime/blob/main/examples/onchain-signer/contracts/Gasless.sol#L23 + function makeProxyTx( + address in_contract, + bytes memory in_data + ) external view authorized + returns (bytes memory output) { + bytes memory data = abi.encode(in_contract, in_data); + + return + EIP155Signer.sign( + publicKey, + privateKey, + EIP155Signer.EthTx({ + nonce: nonce, + gasPrice: 100_000_000_000, + gasLimit: 250000, + to: address(this), + value: 0, + data: abi.encodeCall(this.proxy, data), + chainId: block.chainid + }) + ); + } + + function proxy(bytes memory data) external authorized payable { + (address addr, bytes memory subcallData) = abi.decode( + data, + (address, bytes) + ); + (bool success, bytes memory outData) = addr.call{value: msg.value}( + subcallData + ); + if (!success) { + // Add inner-transaction meaningful data in case of error. + assembly { + revert(add(outData, 32), mload(outData)) + } + } + + nonce += 1; + } + + + // Call another contract. + function call(address in_contract, bytes memory in_data) + public payable override authorized + returns (bool success, bytes memory out_data) { + + (success, out_data) = in_contract.call{value: msg.value, gas: gasleft()}(in_data); + if (!success) { + assembly { + revert(add(out_data, 32), mload(out_data)) + } + } + } + + // Call another contract. + function staticcall(address in_contract, bytes memory in_data) + public override view authorized + returns (bool success, bytes memory out_data) { + + (success, out_data) = in_contract.staticcall{gas: gasleft()}(in_data); + if (!success) { + assembly { + revert(add(out_data, 32), mload(out_data)) + } + } + } +} \ No newline at end of file diff --git a/contracts/AccountBase.sol b/contracts/AccountBase.sol new file mode 100644 index 0000000..f75e257 --- /dev/null +++ b/contracts/AccountBase.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {SignatureRSV, EthereumUtils} from "@oasisprotocol/sapphire-contracts/contracts/EthereumUtils.sol"; +import {EIP155Signer} from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol"; + +// A contract to create per-identity account. +abstract contract AccountFactoryBase { + function clone (address starterOwner) + public virtual; +} + +// A base class for a per-identity account. +// It can be extended to include additional data, for example a symmetric +// key for encrypting and decryption off-chain data. +abstract contract AccountBase { + // Address of the controller of this account. It can either be + // an EOA (externally owned account), or a validator contract + // (defined below). + address internal controller; + + // Update the controller of this account. Useful when a different + // type of credential is to be used and a validator contract is needed. + function updateController(address _controller) external virtual; + + // A key pair for the account. + address public publicKey; + bytes32 internal privateKey; + + // Grant permission for another address to act as owner for this account + // until expiry. + mapping (address => uint256) permission; + + // Functions to check and update permissions. + function hasPermission(address grantee) public virtual view + returns (bool); + + function grantPermission(address grantee, uint256 expiry) + public virtual; + function revokePermission(address grantee) public virtual; + + // The remaining functions use the key pair for normal contract operations. + + // Sign a transaction. + function signEIP155(EIP155Signer.EthTx calldata txToSign) + public view virtual + returns (bytes memory); + + // Sign a digest. + function sign(bytes32 digest) + public virtual view + returns (SignatureRSV memory); + + // Call another contract. + function call(address in_contract, bytes memory in_data) + public payable virtual + returns (bool success, bytes memory out_data); + + // Call another contract. + function staticcall(address in_contract, bytes memory in_data) + public virtual view + returns (bool success, bytes memory out_data); +} diff --git a/contracts/AccountWithSymKey.sol b/contracts/AccountWithSymKey.sol new file mode 100644 index 0000000..57a85af --- /dev/null +++ b/contracts/AccountWithSymKey.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "./Account.sol"; +import "./AccountBase.sol"; +import {Sapphire} from "@oasisprotocol/sapphire-contracts/contracts/Sapphire.sol"; + +// A contract to create per-identity account. +contract AccountWithSymKeyFactory is AccountFactory { + function clone (address target) + public override { + bytes20 targetBytes = bytes20(target); + address contractAddr; + assembly { + let contractClone := mload(0x40) + mstore(contractClone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000) + mstore(add(contractClone, 0x14), targetBytes) + mstore(add(contractClone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000) + contractAddr := create(0, contractClone, 0x37) + } + emit AccountCreated(contractAddr); + } +} + +contract AccountWithSymKey is Account { + type Key is bytes32; + + function initialize(address starterOwner) + public override { + Account.initialize(starterOwner); + } + + // Named symmetric keys. name -> key + mapping (string => Key) keys; + + // Generate a named symmetric key. + function generateSymKey(string calldata name, bool overwrite) + public authorized { + require(overwrite || Key.unwrap(keys[name]) == bytes32(0), "Key already exists and overwrite is false"); + + keys[name] = Key.wrap(bytes32(Sapphire.randomBytes(32, bytes(name)))); + } + + // Retrieve a named symmetric key. + function getSymKey(string calldata name) + public view authorized + returns (Key key) { + key = keys[name]; + } + + // Delete a named symmetric key. + function deleteSymKey(string calldata name) + external virtual authorized { + keys[name] = Key.wrap(bytes32(0)); + } + + // Encrypt in_data with the named symmetric key. + function encryptSymKey(string calldata name, bytes memory in_data) + public virtual view authorized + returns (bytes memory out_data) { + require(Key.unwrap(keys[name]) != bytes32(0), "Requested key doesn't exist"); + bytes32 nonce = bytes32(Sapphire.randomBytes(32, "")); + bytes memory ciphertext = Sapphire.encrypt(Key.unwrap(keys[name]), nonce, in_data, ""); + out_data = abi.encode(nonce, ciphertext); + } + + function decryptSymKey(string calldata name, bytes memory in_data) + public view authorized + returns (bytes memory out_data) { + (bytes32 nonce, bytes memory ciphertext) = abi.decode(in_data, (bytes32, bytes)); + out_data = Sapphire.decrypt(Key.unwrap(keys[name]), nonce, ciphertext, ""); + } + + // Some key rotation stuff here? +} diff --git a/contracts/Test.sol b/contracts/Test.sol new file mode 100644 index 0000000..9125972 --- /dev/null +++ b/contracts/Test.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +contract Test { + uint256 public counter; + + constructor() { + counter = 0; + } + + function incrementCounter() public { + counter++; + } +} \ No newline at end of file diff --git a/contracts/Validator.sol b/contracts/Validator.sol new file mode 100644 index 0000000..cdb9a6d --- /dev/null +++ b/contracts/Validator.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + + +// A validator contract can forward calls to an account contract when +// its credential is validated. +contract Validator { + type CredentialType is uint32; + + // Some examples defined. Can be extended to other types. + CredentialType public constant CUSTODIAN = CredentialType.wrap(1); + CredentialType public constant PASSWORD = CredentialType.wrap(2); + + struct Credential { + CredentialType credType; + bytes credData; + } + + mapping (address => Credential) private credentials; + + constructor(address account, Credential memory credential) { + credentials[account] = credential; + } + + // Validate the provided credential data. + function validate(address account, bytes calldata credData) + public virtual view + returns (bool) { + if (CredentialType.unwrap(credentials[account].credType) == 0 || credentials[account].credData.length == 0) { + return false; + } else { + return keccak256(credentials[account].credData) == keccak256(credData); + } + } + + modifier authorized(address account, bytes calldata credData) { + require(validate(account, credData), "Specified credentials invalid"); + _; + } + + // Update the credential for this account to the new values. + function updateCredential( + address account, + bytes calldata credData, + CredentialType newCredType, + bytes calldata newCredData) + virtual external authorized(account, credData) { + credentials[account] = Credential(newCredType, newCredData); + } + + // Call a view function in the account, after credential is validated. + function callAccount( + address account, + bytes calldata credential, + bytes calldata in_data) + virtual external view authorized(account, credential) + returns (bool success, bytes memory out_data) { + (success, out_data) = account.staticcall(in_data); + if (!success) { + assembly { + revert(add(out_data, 32), mload(out_data)) + } + } + } + + // Call a state-changing function in the account after credential is validated + function transactAccount( + address account, + bytes calldata credential, + bytes calldata in_data) + virtual external payable authorized(account, credential) + returns (bool success, bytes memory out_data) { + (success, out_data) = account.call{value: msg.value}(in_data); + if (!success) { + assembly { + revert(add(out_data, 32), mload(out_data)) + } + } + } +} \ No newline at end of file diff --git a/hardhat.config.ts b/hardhat.config.ts new file mode 100644 index 0000000..f7110d8 --- /dev/null +++ b/hardhat.config.ts @@ -0,0 +1,51 @@ +import '@oasisprotocol/sapphire-hardhat'; +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; + +const TEST_HDWALLET = { + mnemonic: "measure pigeon simple trend lyrics grab floor airport wreck lend chronic romance", + path: "m/44'/60'/0'/0", + initialIndex: 0, + count: 20, + passphrase: "", +}; + +require('dotenv').config(); + +const accounts = [process.env.PRIVATE_KEY_1, process.env.PRIVATE_KEY_2] + +const config: HardhatUserConfig = { + solidity: { + version: '0.8.19', + settings: { + optimizer: { + enabled: true + } + } + }, + networks: { + 'hardhat': { + chainId: 1337, + hardhat_local: { + url: 'http://127.0.0.1:8545/', + } + }, +// 'sapphire': { +// url: 'https://sapphire.oasis.io', +// chainId: 0x5afe, +// accounts, +// }, + 'sapphire-testnet': { + url: 'https://testnet.sapphire.oasis.dev', + chainId: 0x5aff, + accounts, + }, + 'sapphire-localnet': { + url: 'http://localhost:8545', + chainId: 0x5afd, + accounts, + } + } +}; + +export default config; \ No newline at end of file diff --git a/test/Basic.ts b/test/Basic.ts new file mode 100644 index 0000000..b24e212 --- /dev/null +++ b/test/Basic.ts @@ -0,0 +1,110 @@ +import { ethers } from "hardhat"; +import { ErrorDecoder } from 'ethers-decode-error' + +const { expect } = require("chai"); + +const errorDecoder = ErrorDecoder.create() + +describe('Basic test', () => { + var kmaasInstance; + var account1; + var account2; + + // Call the contract factory to get the per-user instance of the actual KMaaS contract + before(async function () { + // This before function also tests out the lightweight-clone pattern + this.timeout(100000); + [account1, account2] = await ethers.getSigners(); + const kmaasContractFactory = await ethers.getContractFactory('Account', account1); + const kmaasMasterContract = await kmaasContractFactory.deploy() + await kmaasMasterContract.waitForDeployment(); + const kmaasMasterContractAddr = await kmaasMasterContract.getAddress(); + const factory = await ethers.getContractFactory('AccountFactory', account1); + const contract = await factory.deploy(); + await contract.waitForDeployment(); + const kmaasTx = await contract.clone(kmaasMasterContractAddr); + const kmaasReceipt = await kmaasTx.wait(); + // Grab the KMaaS Account address from the events of the transaction + const kmaasAddress = kmaasReceipt.logs[0].args[0]; + kmaasInstance = await ethers.getContractAt('Account', kmaasAddress); + const initializeTx = await kmaasInstance.initialize(account1.address); + await initializeTx.wait(); + }); + + + // Here should define a contract and before function to deploy the KMaaS contract + it('Correctly detects controller', async () => { + const hasPermission = await kmaasInstance.hasPermission(account1.address); + expect(hasPermission).to.equal(true); + }); + + it('Can add and remove account to KMaaS', async () => { + var kmaasInstanceAcc2 = kmaasInstance.connect(account2); + // Grant permission to account 2 for an hour + var expiry = Date.now() + 60*60*1000; + var filler = ethers.encodeBytes32String("filler"); + var tx = await kmaasInstance.grantPermission(account2.address, expiry); + await tx.wait(); + var permission = await kmaasInstance.hasPermission(account2.address); + expect(permission).to.equal(true); + // Revoke permission + tx = await kmaasInstance.revokePermission(account2.address); + await tx.wait(); + // Dummy operation just to make sure account2 doesn't have permission + var permission = await kmaasInstance.hasPermission(account2.address); + expect(permission).to.equal(false); + }); + + it('Signs digest successfully', async () => { + var message = "signature needed"; + var hash = ethers.hashMessage(message); + var kmaasSignature = await kmaasInstance.sign(hash); + var recoveredAddress = ethers.verifyMessage(message, kmaasSignature) + var publicKey = await kmaasInstance.publicKey(); + expect(recoveredAddress).to.equal(publicKey); + }); + + it("Signs and executes transaction successfully", async () => { + // Send some eth from the KMaaS instance's stored public key and check that the balance of that address drops + var kmaasAddress = await kmaasInstance.publicKey(); + // Send some eth to the address stored by KMaaS + var fundTx = await account1.sendTransaction({to: kmaasAddress, value: ethers.parseEther("0.1")}) + await fundTx.wait() + + var initBalance = await account1.provider.getBalance(kmaasAddress); + // Sign a transaction for that address that basically just burns some of the token that it was sent + var burnTx = { + 'nonce': 0, + 'gasPrice': 100000000000, + 'gasLimit': 250000, + 'to': ethers.ZeroAddress, + 'value': ethers.parseEther("0.05"), + 'data': '0x00', + 'chainId': '0x5afd' + } + + var burnTxSigned = await kmaasInstance.signEIP155(burnTx); + var burnTxResponse = await account1.provider.broadcastTransaction(burnTxSigned); + await burnTxResponse.wait() + // Verify that stored public key has less balance in it + var finalBalance = await account1.provider.getBalance(kmaasAddress); + expect(finalBalance).to.be.lessThan(initBalance); + }); + + + it('Successfully calls other contracts', async () => { + // Define contract in-line that requires the sender to be the address stored in the kmaas contract + var publicKey = await kmaasInstance.publicKey(); + var contractFactory = await ethers.getContractFactory("Test"); + var testContract = await contractFactory.deploy(); + await testContract.waitForDeployment(); + + var testContractAddress = await testContract.getAddress(); + var calldata = testContract.interface.encodeFunctionData("incrementCounter", []); + var tx = await kmaasInstance.call(testContractAddress, calldata); + await tx.wait(); + + var counter = await testContract.counter(); + expect(counter).to.equal(1); + }); +}); \ No newline at end of file diff --git a/test/SymKey.ts b/test/SymKey.ts new file mode 100644 index 0000000..04ae12d --- /dev/null +++ b/test/SymKey.ts @@ -0,0 +1,56 @@ +import { ethers } from "hardhat"; +import { ErrorDecoder } from 'ethers-decode-error' + +const { expect } = require("chai"); +const errorDecoder = ErrorDecoder.create() + + + +describe("Symmetric Keys test", () => { + var account; + var kmaasInstance; + + before(async function () { + this.timeout(100000); + [account] = await ethers.getSigners(); + const masterContractFactory = await ethers.getContractFactory('AccountWithSymKey', account); + const masterContract = await masterContractFactory.deploy(); + await masterContract.waitForDeployment(); + const masterContractAddr = await masterContract.getAddress(); + + const factoryContractFactory = await ethers.getContractFactory('AccountWithSymKeyFactory', account); + const factoryContract = await factoryContractFactory.deploy(); + await factoryContract.waitForDeployment(); + const kmaasTx = await factoryContract.clone(masterContractAddr); + const kmaasReceipt = await kmaasTx.wait(); + + // Grab the KMaaS Account address from the events of the transaction + const kmaasAddress = kmaasReceipt.logs[0].args[0]; + kmaasInstance = await ethers.getContractAt('AccountWithSymKey', kmaasAddress); + const initializeTx = await kmaasInstance.initialize(account.address); + await initializeTx.wait(); + }); + + it("Should generate/retrieve/delete symmetric keys", async () => { + const keyName = "key1"; + const genTx = await kmaasInstance.generateSymKey(keyName, false); + await genTx.wait(); + const retrievedKey = await kmaasInstance.getSymKey(keyName); + expect(retrievedKey).to.not.equal(ethers.ZeroHash) + const deleteTx = await kmaasInstance.deleteSymKey(keyName); + await deleteTx.wait() + const deletedKey = await kmaasInstance.getSymKey(keyName); + expect(deletedKey).to.equal(ethers.ZeroHash) + }); + + it("Should encrypt and decrypt data", async () => { + const keyName = "key2"; + const genTx = await kmaasInstance.generateSymKey(keyName, true); + await genTx.wait(); + const plaintext = "this is a test for encryption"; + const ciphertext = await kmaasInstance.encryptSymKey(keyName, ethers.toUtf8Bytes(plaintext)); + const retrievedPlaintext = ethers.toUtf8String(await kmaasInstance.decryptSymKey(keyName, ciphertext)); + expect(retrievedPlaintext).to.equal(plaintext); + }); + +}) \ No newline at end of file diff --git a/test/Validator.ts b/test/Validator.ts new file mode 100644 index 0000000..50118ae --- /dev/null +++ b/test/Validator.ts @@ -0,0 +1,80 @@ +import { ethers } from "hardhat"; + +const { expect } = require("chai"); + + +describe("Validator test", function () { + var kmaasInstance; + var kmaasAddress; + var account1; + var validator; + var cred; + + // Call the contract factory to get the per-user instance of the actual KMaaS contract + before(async function () { + this.timeout(120000); + [account1] = await ethers.getSigners(); + const kmaasFactory = await ethers.getContractFactory('Account', account1); + kmaasInstance = await kmaasFactory.deploy(); + await kmaasInstance.waitForDeployment(); + kmaasAddress = await kmaasInstance.getAddress(); + + const initializeTx = await kmaasInstance.initialize(account1.address); + await initializeTx.wait(); + + cred = { + 'credType': 2, + 'credData': ethers.encodeBytes32String("password!") + }; + + const validatorFactory = await ethers.getContractFactory('Validator', account1); + validator = await validatorFactory.deploy(await kmaasInstance.getAddress(), cred); + await validator.waitForDeployment(); + var validatorAddress = await validator.getAddress(); + + // Change the controller to the validator contract + var expiry = Date.now() + 60*60*1000; + var grantTx = await kmaasInstance.grantPermission(account1.address, expiry); + await grantTx.wait(); + var controllerTx = await kmaasInstance.updateController(await validator.getAddress()); + await controllerTx.wait(); + }); + + it('should authenticate correctly', async () => { + // First attempt is formatting both the address and the credential as the + var hasPermission = await validator.validate(kmaasAddress, cred.credData); + expect(hasPermission).to.equal(true); + }); + + // Should update credential correctly + it('should update the credentials correctly', async () => { + var newPassword = ethers.encodeBytes32String("test123"); + var updateCredTx = await validator.updateCredential(kmaasAddress, cred.credData, 2, newPassword); + await updateCredTx.wait(); + + // Reset back to old password + var resetCredTx = await validator.updateCredential(kmaasAddress, newPassword, 2, cred.credData) + await resetCredTx.wait() + }); + + it('should delegate KMaaS function calls correctly', async () => { + var text = "data to encrypt"; + var digest = ethers.hashMessage(text); + var functionData = kmaasInstance.interface.encodeFunctionData("sign", [digest]); + var success, digestSignature; + [success, digestSignature] = await validator.callAccount(kmaasAddress, cred.credData, functionData); + var digestSignatureR = "0x" + digestSignature.slice(2, 66); + var digestSignatureS = "0x" + digestSignature.slice(66, 130) + var digestSignatureV = "0x" + digestSignature.slice(130, 194); + var digestSignatureObj = ethers.Signature.from({ + "r": digestSignatureR, + "s": digestSignatureS, + "v": digestSignatureV + }) + + // Verify the signature + var publicKey = await kmaasInstance.publicKey(); + var recoveredAddress = ethers.verifyMessage(text, digestSignatureObj) + expect(recoveredAddress).to.equal(publicKey); + }); +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..254ab43 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "NodeNext", + "esModuleInterop": false + } +} \ No newline at end of file