diff --git a/BOUNTY.md b/BOUNTY.md new file mode 100644 index 0000000..fb12115 --- /dev/null +++ b/BOUNTY.md @@ -0,0 +1,700 @@ +# Email based authentication and social recovery + +## Table of contents + +1. [Problem statement](#problem-statement) +2. [Approach](#approach) +3. [Architecture overview](#architecture-overview) +4. [Key features](#key-features) +5. [Contract structure and changes](#contract-structure-and-changes) + - [Original `AccountManager`](#original-accountmanager-contract) + - [Integration of recovery libraries](#integration-of-recovery-libraries) + - [Updated `AccountManager`](#updated-accountmanager-contract) + - [EmailRecovery library](#emailrecovery-library) + - [SocialRecovery library](#socialrecovery-library) +6. [Code changes](#code-changes) + - [AccountManager.sol](#accountmanagersol) + - [EmailRecovery.sol](#emailrecoverysol) + - [SocialRecovery.sol](#socialrecoverysol) +7. [Testing](#testing) + - [Test suite](#test-suite-overview) + - [Running tests](#running-tests) +8. [Usage](#usage) + - [Adding an email](#adding-an-email) + - [Verifying an email](#verifying-an-email) + - [Removing an email](#removing-an-email) + - [Adding social recovery](#adding-social-recovery) + - [Verifying social recovery](#verifying-social-recovery) + - [Removing social recovery](#removing-social-recovery) +9. [Future enhancements](#future-enhancements) +10. [Security considerations](#security-considerations) +11. [Additional notes](#additional-notes) + +--- + +## Problem statement + +In the world of decentralized apps, keeping user accounts safe and easy to manage is crucial. Relying only on cryptographic keys can be tricky and might not be enough for today's needs. Users often need extra security and ways to recover their accounts, like email verification and social media recovery to protect against losing access or unauthorized use. + +The **AccountManager** smart contract tackles these issues by offering a strong and flexible system to manage user accounts by adding different ways to log in that ensures secure recovery options + +--- + +## Approach + +To build a secure and easy to manage account system, we followed these steps: + +1. **Modular design:** we created solidity libraries (`EmailRecovery` and `SocialRecovery`) to handle specific recovery tasks making the code reusable and easier to maintain + +2. **Upgradeability:** we utilized openzeppelin's upgradeable contracts with the UUPS pattern to allow updates and new features without affecting current data + +3. **Access control:** we used `AccessControl` to manage roles and permissions, ensuring only authorized users can perform important actions + +4. **Signature verification:** we adopted ECDSA (elliptic curve digital signature algorithm) to verify signatures, making sure recovery requests are genuine and authorized + +5. **Testing:** we created detailed test suites with hardhat and chai to check all functions, including edge cases and security + +6. **Gasless transactions:** implemented ways to allow users to perform actions without handling gas fees, making the system more user friendly + +--- + +## Architecture + +1. **AccountManager** The core contract managing user accounts, authentication methods and recovery processes + +2. **AccountFactory** Responsible for cloning and deploying individual `Account` contracts for users + +3. **Account** Manages user specific data and operations, acting as a proxy for individual user actions + +4. **Recovery libraries:** + + - **EmailRecovery:** Handles verification of email based recovery requests + - **SocialRecovery:** Manages verification of social platform based recovery requests + +5. **Other libraries:** + - **openzeppelin** Provides secure and tested implementations for upgradeability and access control + - **Sapphire** Facilitates encryption and signature operations for secure transactions + +--- + +## Features + +- **User account management:** Create, manage and delete user accounts with unique credentials. +- **Email authentication:** Add, verify, and remove email-based authentication methods +- **Social recovery:** Use social platforms (like Google, Twitter) to recover accounts +- **Gasless transactions:** Let users perform actions without handling gas fees +- **Upgradeable contracts:** Upgrade contract features without losing data +- **Robust security:** Use strong access controls and signature checks to prevent unauthorized access + +--- + +## Contract structure and changes + +### Original `AccountManager` + +The initial `AccountManager` contract provided functionalities for managing user accounts and credentials. It allowed adding and verifying credentials but lacked integrated recovery mechanisms like email and social recovery + +### Integration of recovery libraries + +To enhance the contract's capabilities, two libraries were introduced: + +1. **EmailRecovery:** Handles verification of email-based recovery requests + +2. **SocialRecovery:** Manages verification of social platform-based recovery requests + +These libraries encapsulate the verification logic, promoting cleaner and more maintainable code within the `AccountManager` contract + +### Updated `AccountManager` + +The updated contract incorporates the `EmailRecovery` and `SocialRecovery` libraries, refactoring existing functions to utilize these libraries for recovery processes + +### EmailRecovery library + +```solidity +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +library EmailRecovery { + struct EmailRecoveryRequest { + bytes32 hashedUsername; + bytes32 emailHash; + uint256 timestamp; + bytes signature; + } + + /** + * @dev Verifies an email recovery request + * @param request The email recovery request data + * @param signer The expected signer address + * @return bool indicating success or failure + */ + function verifyRecoveryRequest( + EmailRecoveryRequest memory request, + address signer + ) internal pure returns (bool) { + bytes32 message = keccak256( + abi.encodePacked( + "recover-account-email", + request.hashedUsername, + request.emailHash, + request.timestamp + ) + ); + bytes32 ethSignedMessage = ECDSA.toEthSignedMessageHash(message); + address recoveredAddress = ECDSA.recover( + ethSignedMessage, + request.signature + ); + return recoveredAddress == signer; + } +} +``` + +### SocialRecovery library + +```solidity +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +library SocialRecovery { + struct SocialRecoveryRequest { + bytes32 hashedUsername; + bytes32 platformHash; + bytes32 socialIdHash; + uint256 timestamp; + bytes signature; + } + + /** + * @dev Verifies a social recovery request + * @param request The social recovery request data + * @param signer The expected signer address + * @return bool indicating success or failure + */ + function verifyRecoveryRequest( + SocialRecoveryRequest memory request, + address signer + ) internal pure returns (bool) { + bytes32 message = keccak256( + abi.encodePacked( + "recover-account", + request.hashedUsername, + request.platformHash, + request.socialIdHash, + request.timestamp + ) + ); + bytes32 ethSignedMessage = ECDSA.toEthSignedMessageHash(message); + address recoveredAddress = ECDSA.recover( + ethSignedMessage, + request.signature + ); + return recoveredAddress == signer; + } +} +``` + +## Code changes + +## Code changes + +### Import statements + +Added imports for EmailRecovery and SocialRecovery libraries to use their features in the AccountManager contract + +```solidity +import {EmailRecovery} from "./lib/EmailRecovery.sol"; +import {SocialRecovery} from "./lib/SocialRecovery.sol"; +``` + +### Functions + +#### `verifyEmail` function + +```solidity +function verifyEmail(bytes32 in_username, string memory email, bytes memory signature) external { + require(userExists(in_username), "verifyEmail: user does not exist"); + User storage user = users[in_username]; + require(user.emailCredential.emailHash == keccak256(bytes(email)), "verifyEmail: email does not match"); + require(!user.emailCredential.verified, "verifyEmail: already verified"); + + // Create EmailRecoveryRequest struct + EmailRecovery.EmailRecoveryRequest memory request = EmailRecovery.EmailRecoveryRequest({ + hashedUsername: in_username, + emailHash: keccak256(bytes(email)), + timestamp: block.timestamp, + signature: signature + }); + + // Use the library to verify the request + bool isValid = EmailRecovery.verifyRecoveryRequest(request, signer); + require(isValid, "verifyEmail: invalid signature"); + + user.emailCredential.verified = true; + + emit EmailVerified(in_username, user.emailCredential.emailHash); +} +``` + +#### `verifySocial` + +```solidity +function verifySocial(bytes32 in_username, string memory platform, string memory socialId, bytes memory signature) external { + require(userExists(in_username), "verifySocial: user does not exist"); + User storage user = users[in_username]; + bytes32 platformHash = keccak256(bytes(platform)); + bytes32 socialIdHash = keccak256(bytes(socialId)); + + bool found = false; + uint index; + for (uint i = 0; i < user.socialCredentials.length; i++) { + if (user.socialCredentials[i].platformHash == platformHash && + user.socialCredentials[i].socialIdHash == socialIdHash) { + found = true; + index = i; + break; + } + } + require(found, "verifySocial: social credential not found"); + require(!user.socialCredentials[index].verified, "verifySocial: already verified"); + + // Create SocialRecoveryRequest struct + SocialRecovery.SocialRecoveryRequest memory request = SocialRecovery.SocialRecoveryRequest({ + hashedUsername: in_username, + platformHash: platformHash, + socialIdHash: socialIdHash, + timestamp: block.timestamp, + signature: signature + }); + + // Use the library to verify the request + bool isValid = SocialRecovery.verifyRecoveryRequest(request, signer); + require(isValid, "verifySocial: invalid signature"); + + user.socialCredentials[index].verified = true; + + emit SocialVerified(in_username, platformHash, socialIdHash); +} +``` + +## Testing + +### Test suite + +A complete test suite was built using Hardhat and chai to make sure the AccountManager contract works well and is secure. The tests include: + +- **Account ceation:** Making sure new accounts can be created without duplicates +- **Email authentication:** Adding, verifying and removing email authentication methods +- **Social recovery:** Adding, verifying and removing social recovery methods +- **Access control:** Ensuring only authorized roles can do sensitive tasks +- **Signature verification:** Checking that signatures are valid during verification. +- **Edge cases:** Handling cases like adding duplicates or removing non existent credentials + +### Running tests + +**Install dpendencies:** + +```bash +npm install +``` + +**Compile:** + +```bash +npx hardhat compile +``` + +**Run Tests:** + +```bash +npx hardhat test +``` + +### Example test cases + +- **Adding, verifying and removing email authentication:** Checks that email methods can be managed correctly, verified with valid signatures and events are emitted properly +- **Adding, verifying and removing social recovery:** Ensures social recovery methods work securely and are managed correctly +- **Access control enforcement:** Makes sure only admins can do restricted actions like adding or removing methods +- **Invalid signature handling:** Tests that invalid signatures cause the process to fail + +## Usage + +### Adding an email + +**Script:** `scripts/add-email.js` + +```javascript +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + +async function main() { + // Configuration + const accountManagerAddress = "0x<>"; + const usernamePlain = "username"; + const email = "user@example.com"; + + // Get signer + const signer = (await ethers.getSigners())[0]; + + // Get contract instance + const accountManager = await ethers.getContractAt( + "AccountManager", + accountManagerAddress, + signer + ); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Check if user exists + const userExists = await accountManager.userExists(hashedUsername); + if (!userExists) { + console.error("User does not exist. Please create the account first."); + process.exit(1); + } + + // Add email + const tx = await accountManager.addEmail(hashedUsername, email); + await tx.wait(); + + console.log( + `Email "${email}" added for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error adding email:", error); + process.exit(1); + }); +``` + +**Usage:** + +```bash +npx hardhat run --network sapphireTestnet scripts/add-email.js +``` + +### Verifying an email + +**Script:** `scripts/verify-email.js` + +```javascript +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + +async function main() { + // Configuration + const accountManagerAddress = "0x<>"; + const usernamePlain = "username"; + const email = "user@example.com"; + + // The actual signature obtained from the backend/signing service + const signature = "0x<>"; + + // Get signer + const signer = (await ethers.getSigners())[0]; + + // Get contract instance + const accountManager = await ethers.getContractAt( + "AccountManager", + accountManagerAddress, + signer + ); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Verify email using the refactored function + const tx = await accountManager.verifyEmail(hashedUsername, email, signature); + await tx.wait(); + + console.log( + `Email "${email}" verified for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error verifying email:", error); + process.exit(1); + }); +``` + +**Usage:** + +```bash +npx hardhat run --network sapphireTestnet scripts/verify-email.js +``` + +### Removing an email + +**Script:** `scripts/remove-email.js` + +```javascript +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // Configuration + const accountManagerAddress = "0x<>"; + const usernamePlain = "username"; + + // Get signer + const signer = (await ethers.getSigners())[0]; + + // Get contract instance + const accountManager = await ethers.getContractAt( + "AccountManager", + accountManagerAddress, + signer + ); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Remove email + const tx = await accountManager.removeEmail(hashedUsername); + await tx.wait(); + + console.log( + `Email removed for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error removing email:", error); + process.exit(1); + }); +``` + +**Usage:** + +```bash +npx hardhat run --network sapphireTestnet scripts/remove-email.js +``` + +### Adding social recovery + +**Script:** `scripts/add-social-recovery.js` + +```javascript +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // Configuration + const accountManagerAddress = "0x<>"; + const usernamePlain = "username"; + const platform = "twitter"; + const socialId = "twitter_id_123"; + + // Get signer + const signer = (await ethers.getSigners())[0]; + + // Get contract instance + const accountManager = await ethers.getContractAt( + "AccountManager", + accountManagerAddress, + signer + ); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Check if user exists + const userExists = await accountManager.userExists(hashedUsername); + if (!userExists) { + console.error("User does not exist. Please create the account first."); + process.exit(1); + } + + // Add social recovery + const tx = await accountManager.addSocial(hashedUsername, platform, socialId); + await tx.wait(); + + console.log( + `social recovery method "${platform}:${socialId}" added for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error adding social recovery:", error); + process.exit(1); + }); +``` + +**Usage:** + +```bash +npx hardhat run --network sapphireTestnet scripts/add-social-recovery.js +``` + +### Verifying social recovery + +**Script:** `scripts/verify-social-recovery.js` + +```javascript +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // Configuration + const accountManagerAddress = "0x<>"; + const usernamePlain = "username"; + const platform = "twitter"; + const socialId = "twitter_id_123"; + + // The actual signature obtained from the backend/signing service + const signature = "0x<>"; + + // Get signer + const signer = (await ethers.getSigners())[0]; + + // Get contract instance + const accountManager = await ethers.getContractAt( + "AccountManager", + accountManagerAddress, + signer + ); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Verify social recovery using the refactored function + const tx = await accountManager.verifySocial( + hashedUsername, + platform, + socialId, + signature + ); + await tx.wait(); + + console.log( + `social recovery method "${platform}:${socialId}" verified for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error verifying social recovery:", error); + process.exit(1); + }); +``` + +**Usage:** + +```bash +npx hardhat run --network sapphireTestnet scripts/verify-social-recovery.js +``` + +### Removing social recovery + +**Script:** `scripts/remove-social-recovery.js` + +```javascript +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // Configuration + const accountManagerAddress = "0x<>"; + const usernamePlain = "username"; + const platform = "twitter"; + const socialId = "twitter_id_123"; + + // Get signer + const signer = (await ethers.getSigners())[0]; + + // Get contract instance + const accountManager = await ethers.getContractAt( + "AccountManager", + accountManagerAddress, + signer + ); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Remove social recovery + const tx = await accountManager.removeSocial( + hashedUsername, + platform, + socialId + ); + await tx.wait(); + + console.log( + `social recovery method "${platform}:${socialId}" removed for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error removing social recovery:", error); + process.exit(1); + }); +``` + +**Usage:** + +```bash +npx hardhat run --network sapphireTestnet scripts/remove-social-recovery.js +``` + +## Future enhancements + +- **Multi factor authentication (MFA):** Add extra security layers like SMS or hardware wallets +- **User interface tntegration:** Create a frontend app for easy account and recovery management +- **Advanced recovery mechanisms:** Use multiple social accounts or emails needed to recover an account +- **Integration with decentralized identifiers (DIDs):** Use DID standards for decentralized identity diff --git a/contracts/AccountManager.sol b/contracts/AccountManager.sol index e7cb237..8a8ce36 100644 --- a/contracts/AccountManager.sol +++ b/contracts/AccountManager.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 - pragma solidity ^0.8.0; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; @@ -13,7 +12,11 @@ import {EthereumUtils} from "@oasisprotocol/sapphire-contracts/contracts/Ethereu import {EIP155Signer} from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol"; import {Account} from "./Account.sol"; -import {WebAuthN,CosePublicKey,AuthenticatorResponse} from "./lib/WebAuthN.sol"; +import {WebAuthN, CosePublicKey, AuthenticatorResponse} from "./lib/WebAuthN.sol"; +import {JWT} from "./lib/JWT.sol"; +import {Base64URL} from "./lib/Base64URL.sol"; +import {EmailRecovery} from "./lib/EmailRecovery.sol"; +import {SocialRecovery} from "./lib/SocialRecovery.sol"; interface IAccountFactory { function clone (address starterOwner) external returns (Account acct); @@ -25,16 +28,31 @@ struct UserCredential { bytes32 username; } +struct EmailCredential { + bytes32 emailHash; + bool verified; +} + +struct SocialCredential { + bytes32 platformHash; + bytes32 socialIdHash; + bool verified; +} + struct User { bytes32 username; bytes32 password; Account account; + EmailCredential emailCredential; + SocialCredential[] socialCredentials; } enum TxType { CreateAccount, ManageCredential, - ManageCredentialPassword + ManageCredentialPassword, + ManageEmail, + ManageSocial } enum CredentialAction { @@ -43,7 +61,6 @@ enum CredentialAction { } contract AccountManagerStorage { - IAccountFactory internal accountFactory; /** @@ -97,6 +114,12 @@ contract AccountManagerStorage { mapping(bytes32 => bool) public hashUsage; event GaslessTransaction(bytes32 indexed dataHash, bytes32 indexed hashedUsername, address indexed publicAddress); + event EmailAdded(bytes32 indexed hashedUsername, bytes32 emailHash); + event EmailVerified(bytes32 indexed hashedUsername, bytes32 emailHash); + event EmailRemoved(bytes32 indexed hashedUsername, bytes32 emailHash); + event SocialAdded(bytes32 indexed hashedUsername, bytes32 platformHash, bytes32 socialIdHash); + event SocialVerified(bytes32 indexed hashedUsername, bytes32 platformHash, bytes32 socialIdHash); + event SocialRemoved(bytes32 indexed hashedUsername, bytes32 platformHash, bytes32 socialIdHash); } /// @custom:oz-upgrades-unsafe-allow external-library-linking @@ -107,7 +130,7 @@ contract AccountManager is AccountManagerStorage, { /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { + constructor() { _disableInitializers(); } @@ -137,7 +160,7 @@ contract AccountManager is AccountManagerStorage, // Grant the deployer the default admin role: they can grant and revoke any roles _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); - if(msg.value > 0) { + if (msg.value > 0) { payable(gaspayingAddress).transfer(msg.value); } } @@ -195,7 +218,7 @@ contract AccountManager is AccountManagerStorage, public { // Don't allow duplicate account - require(! userExists(args.hashedUsername), "createAccount: user exists"); + require(!userExists(args.hashedUsername), "createAccount: user exists"); internal_createAccount(args.hashedUsername, args.optionalPassword); @@ -222,16 +245,11 @@ contract AccountManager is AccountManagerStorage, /** * @dev Add/Remove credential with credential - * * @param args credential data */ - function manageCredential (ManageCred memory args) - public - { + function manageCredential(ManageCred memory args) public { Credential memory credential = abi.decode(args.data, (Credential)); - bytes32 challenge = sha256(abi.encodePacked(personalization, sha256(args.data))); - User storage user = internal_verifyCredential(args.credentialIdHashed, challenge, args.resp); // Perform credential action @@ -239,21 +257,17 @@ contract AccountManager is AccountManagerStorage, internal_addCredential(user.username, credential.credentialId, credential.pubkey); } else if (credential.action == CredentialAction.Remove) { internal_removeCredential(user.username, credential.credentialId); - } else { + } else { revert("Unsupported operation"); } } /** * @dev Add/Remove credential with password - * * @param args credential data */ - function manageCredentialPassword (ManageCredPass memory args) - public - { + function manageCredentialPassword(ManageCredPass memory args) public { Credential memory credential = abi.decode(args.data, (Credential)); - User storage user = users[credential.hashedUsername]; require(user.username != bytes32(0), "Invalid username"); require(user.password != bytes32(0), "Invalid password"); @@ -269,59 +283,213 @@ contract AccountManager is AccountManagerStorage, internal_addCredential(user.username, credential.credentialId, credential.pubkey); } else if (credential.action == CredentialAction.Remove) { internal_removeCredential(user.username, credential.credentialId); - } else { + } else { revert("Unsupported operation"); } } /** * @dev Retrieve a list of credential IDs for a specific user - * * @param in_hashedUsername Hashed username */ - function credentialIdsByUsername(bytes32 in_hashedUsername) - public view - returns (bytes[] memory out_credentialIds) - { + function credentialIdsByUsername(bytes32 in_hashedUsername) public view returns (bytes[] memory out_credentialIds) { require(userExists(in_hashedUsername), "credentialIdsByUsername"); - bytes32[] storage credentialIdHashes = usernameToHashedCredentialIdList[in_hashedUsername]; - uint length = credentialIdHashes.length; - out_credentialIds = new bytes[](length); - - for( uint i = 0; i < length; i++ ) - { + for (uint i = 0; i < length; i++) { UserCredential storage cred = credentialsByHashedCredentialId[credentialIdHashes[i]]; - out_credentialIds[i] = cred.credentialId; } } /** - * - * @param in_hashedUsername PBKDF2 hashed username - * @param in_credentialId Raw credentialId provided by WebAuthN compatible authenticator - * @param in_pubkey Public key extracted from authenticatorData + * @dev Create new email authentication method + * @param in_username Hashed username + * @param email Base64URL encoded email + */ + function addEmail(bytes32 in_username, string memory email) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(userExists(in_username), "addEmail: user does not exist"); + User storage user = users[in_username]; + bytes32 emailHash = keccak256(bytes(email)); + require(user.emailCredential.emailHash == bytes32(0), "addEmail: email already exists"); + + user.emailCredential = EmailCredential({ + emailHash: emailHash, + verified: false + }); + + emit EmailAdded(in_username, emailHash); + } + + function verifyEmail(bytes32 in_username, string memory email, bytes memory signature) external { + require(userExists(in_username), "verifyEmail: user does not exist"); + User storage user = users[in_username]; + require(user.emailCredential.emailHash == keccak256(bytes(email)), "verifyEmail: email does not match"); + require(!user.emailCredential.verified, "verifyEmail: already verified"); + + // Create EmailRecoveryRequest struct + EmailRecovery.EmailRecoveryRequest memory request = EmailRecovery.EmailRecoveryRequest({ + hashedUsername: in_username, + emailHash: keccak256(bytes(email)), + timestamp: block.timestamp, + signature: signature + }); + + // Use the library to verify the request + bool isValid = EmailRecovery.verifyRecoveryRequest(request, signer); + require(isValid, "verifyEmail: invalid signature"); + + user.emailCredential.verified = true; + + emit EmailVerified(in_username, user.emailCredential.emailHash); + } + + /** + * @dev Remove email authentication method + * @param in_username Hashed username + */ + function removeEmail(bytes32 in_username) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(userExists(in_username), "removeEmail: user does not exist"); + User storage user = users[in_username]; + require(user.emailCredential.emailHash != bytes32(0), "removeEmail: email does not exist"); + + bytes32 removedEmailHash = user.emailCredential.emailHash; + delete user.emailCredential; + + emit EmailRemoved(in_username, removedEmailHash); + } + + /** + * @dev Add social recovery method + * @param in_username Hashed username + * @param platform Social platform identifier (e.g., "google") + * @param socialId Social account identifier + */ + function addSocial(bytes32 in_username, string memory platform, string memory socialId) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(userExists(in_username), "addSocial: user does not exist"); + User storage user = users[in_username]; + bytes32 platformHash = keccak256(bytes(platform)); + bytes32 socialIdHash = keccak256(bytes(socialId)); + + // Check if social credential already exists + for (uint i = 0; i < user.socialCredentials.length; i++) { + require( + !(user.socialCredentials[i].platformHash == platformHash && + user.socialCredentials[i].socialIdHash == socialIdHash), + "addSocial: social credential already exists" + ); + } + + user.socialCredentials.push(SocialCredential({ + platformHash: platformHash, + socialIdHash: socialIdHash, + verified: false + })); + + emit SocialAdded(in_username, platformHash, socialIdHash); + } + + /** + * @dev Verify social recovery method + * @param in_username Hashed username + * @param platform Social platform identifier (e.g., "google") + * @param socialId Social account identifier + * @param signature Signature from the social platform verification process + */ + function verifySocial(bytes32 in_username, string memory platform, string memory socialId, bytes memory signature) external { + require(userExists(in_username), "verifySocial: user does not exist"); + User storage user = users[in_username]; + bytes32 platformHash = keccak256(bytes(platform)); + bytes32 socialIdHash = keccak256(bytes(socialId)); + + bool found = false; + uint index; + for (uint i = 0; i < user.socialCredentials.length; i++) { + if (user.socialCredentials[i].platformHash == platformHash && + user.socialCredentials[i].socialIdHash == socialIdHash) { + found = true; + index = i; + break; + } + } + require(found, "verifySocial: social credential not found"); + require(!user.socialCredentials[index].verified, "verifySocial: already verified"); + + // Create SocialRecoveryRequest struct + SocialRecovery.SocialRecoveryRequest memory request = SocialRecovery.SocialRecoveryRequest({ + hashedUsername: in_username, + platformHash: platformHash, + socialIdHash: socialIdHash, + timestamp: block.timestamp, + signature: signature + }); + + // Use the library to verify the request + bool isValid = SocialRecovery.verifyRecoveryRequest(request, signer); + require(isValid, "verifySocial: invalid signature"); + + user.socialCredentials[index].verified = true; + + emit SocialVerified(in_username, platformHash, socialIdHash); + } + + /** + * @dev Remove social recovery method + * @param in_username Hashed username + * @param platform Social platform identifier (e.g., "google") + * @param socialId Social account identifier + */ + function removeSocial(bytes32 in_username, string memory platform, string memory socialId) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(userExists(in_username), "removeSocial: user does not exist"); + User storage user = users[in_username]; + bytes32 platformHash = keccak256(bytes(platform)); + bytes32 socialIdHash = keccak256(bytes(socialId)); + + bool found = false; + uint index; + for (uint i = 0; i < user.socialCredentials.length; i++) { + if (user.socialCredentials[i].platformHash == platformHash && + user.socialCredentials[i].socialIdHash == socialIdHash) { + found = true; + index = i; + break; + } + } + require(found, "removeSocial: social credential not found"); + + // Remove the social credential by swapping with the last and popping + user.socialCredentials[index] = user.socialCredentials[user.socialCredentials.length - 1]; + user.socialCredentials.pop(); + + emit SocialRemoved(in_username, platformHash, socialIdHash); + } + + /** + * @dev Retrieve a list of social credentials for a specific user + * @param in_hashedUsername Hashed username + */ + function socialCredentialsByUsername(bytes32 in_hashedUsername) public view returns (SocialCredential[] memory out_socialCredentials) { + require(userExists(in_hashedUsername), "socialCredentialsByUsername"); + User storage user = users[in_hashedUsername]; + out_socialCredentials = user.socialCredentials; + } + + /** + * @dev Internal function to add a credential */ function internal_addCredential( bytes32 in_hashedUsername, bytes memory in_credentialId, CosePublicKey memory in_pubkey - ) - internal - { + ) internal { // Ensure public key validity before registration - require( WebAuthN.verifyPubkey(in_pubkey), "WebAuthN.verifyPubkey" ); + require(WebAuthN.verifyPubkey(in_pubkey), "WebAuthN.verifyPubkey"); bytes32 hashedCredentialId = keccak256(in_credentialId); // Credential must not previously exist or be associated with a user - require( - credentialsByHashedCredentialId[hashedCredentialId].username == bytes32(0), - "Credential already registered" - ); + require(credentialsByHashedCredentialId[hashedCredentialId].username == bytes32(0), "Credential already registered"); // Add credential to user credentialsByHashedCredentialId[hashedCredentialId] = UserCredential({ @@ -334,34 +502,21 @@ contract AccountManager is AccountManagerStorage, } /** - * - * @param in_hashedUsername PBKDF2 hashed username - * @param in_credentialId Raw credentialId provided by WebAuthN compatible authenticator + * @dev Internal function to remove a credential */ function internal_removeCredential( bytes32 in_hashedUsername, bytes memory in_credentialId - ) - internal - { + ) internal { bytes32 hashedCredentialId = keccak256(in_credentialId); - - require( - usernameToHashedCredentialIdList[in_hashedUsername].length > 1, - "Cannot remove all credentials" - ); + require(usernameToHashedCredentialIdList[in_hashedUsername].length > 1, "Cannot remove all credentials"); // Credential must be associated with in_hashedUsername - require( - credentialsByHashedCredentialId[hashedCredentialId].username == in_hashedUsername, - "Invalid credential user" - ); + require(credentialsByHashedCredentialId[hashedCredentialId].username == in_hashedUsername, "Invalid credential user"); // Remove credential from user delete credentialsByHashedCredentialId[hashedCredentialId]; - bytes32[] storage credentialList = usernameToHashedCredentialIdList[in_hashedUsername]; - uint256 credListLength = credentialList.length; uint256 credIdx = credListLength; uint256 lastIdx = credListLength - 1; @@ -371,78 +526,52 @@ contract AccountManager is AccountManagerStorage, break; } } - require(credIdx < credListLength, "CINF"); - if (credIdx < lastIdx) { // Swap last item to credIdx credentialList[credIdx] = credentialList[lastIdx]; } - credentialList.pop(); } - function internal_createAccount(bytes32 in_hashedUsername, bytes32 in_optionalPassword) - internal - returns (User storage user) - { + /** + * @dev Internal function to create a user account + */ + function internal_createAccount(bytes32 in_hashedUsername, bytes32 in_optionalPassword) internal returns (User storage user) { user = users[in_hashedUsername]; user.username = in_hashedUsername; user.account = accountFactory.clone(address(this)); user.password = in_optionalPassword; } - function internal_getCredentialAndUser (bytes32 in_credentialIdHashed) - internal view - returns ( - User storage user, - UserCredential storage credential - ) - { + /** + * @dev Internal function to get credential and user + */ + function internal_getCredentialAndUser(bytes32 in_credentialIdHashed) internal view returns (User storage user, UserCredential storage credential) { credential = credentialsByHashedCredentialId[in_credentialIdHashed]; user = users[credential.username]; - require(credential.username != bytes32(0x0), "getCredentialAndUser"); } - function internal_verifyCredential ( - bytes32 in_credentialIdHashed, - bytes32 in_challenge, - AuthenticatorResponse memory in_resp - ) - internal view - returns (User storage user) - { + /** + * @dev Internal function to verify credential + */ + function internal_verifyCredential(bytes32 in_credentialIdHashed, bytes32 in_challenge, AuthenticatorResponse memory in_resp) internal view returns (User storage user) { UserCredential storage credential; - (user, credential) = internal_getCredentialAndUser(in_credentialIdHashed); - - require( - WebAuthN.verifyECES256P256(in_challenge, credential.pubkey, in_resp), - "verification failed" - ); - + require(WebAuthN.verifyECES256P256(in_challenge, credential.pubkey, in_resp), "verification failed"); return user; } /** * @dev Performs a proxied call to the users account - * * @param user executor account * @param in_data calldata to pass to account proxy * @return out_data result from proxied view call */ - function internal_proxyView( - User storage user, - bytes calldata in_data - ) - internal view - returns (bytes memory out_data) - { + function internal_proxyView(User storage user, bytes calldata in_data) internal view returns (bytes memory out_data) { bool success; - (success, out_data) = address(user.account).staticcall(in_data); - assembly { switch success case 0 { revert(add(out_data,32),mload(out_data)) } @@ -451,108 +580,76 @@ contract AccountManager is AccountManagerStorage, /** * @dev Performs a proxied call to the verified users account - * * @param in_hashedUsername hashedUsername * @param in_digest hashed(password + in_data) * @param in_data calldata to pass to account proxy * @return out_data result from proxied view call */ - function proxyViewPassword( - bytes32 in_hashedUsername, - bytes32 in_digest, - bytes calldata in_data - ) - external view - returns (bytes memory out_data) - { + function proxyViewPassword(bytes32 in_hashedUsername, bytes32 in_digest, bytes calldata in_data) external view returns (bytes memory out_data) { User storage user = users[in_hashedUsername]; - - require( - user.username != bytes32(0), - "IU" - ); - require( - user.password != bytes32(0), - "IP" - ); - require( - keccak256(abi.encodePacked(user.password, in_data)) == in_digest, - "in_digest VF" - ); - + require(user.username != bytes32(0), "IU"); + require(user.password != bytes32(0), "IP"); + require(keccak256(abi.encodePacked(user.password, in_data)) == in_digest, "in_digest VF"); return internal_proxyView(user, in_data); } /** * @dev Performs a proxied call to the verified users account - * * @param in_credentialIdHashed credentialIdHashed * @param in_resp Authenticator response * @param in_data calldata to pass to account proxy * @return out_data result from proxied view call */ - function proxyView( - bytes32 in_credentialIdHashed, - AuthenticatorResponse calldata in_resp, - bytes calldata in_data - ) - external view - returns (bytes memory out_data) - { + function proxyView(bytes32 in_credentialIdHashed, AuthenticatorResponse calldata in_resp, bytes calldata in_data) external view returns (bytes memory out_data) { bytes32 challenge = sha256(abi.encodePacked(personalization, sha256(in_data))); - User storage user = internal_verifyCredential(in_credentialIdHashed, challenge, in_resp); - return internal_proxyView(user, in_data); } /** * @dev Gasless transaction resolves here - * - * @param ciphertext encrypted in_data * @param nonce nonce used to decrypt + * @param ciphertext encrypted in_data * @param timestamp validity expiration * @param dataHash keccak of data (used to parse emitted events on backend) */ - function encryptedTx ( - bytes32 nonce, - bytes memory ciphertext, + function encryptedTx( + bytes32 nonce, + bytes memory ciphertext, uint256 timestamp, bytes32 dataHash ) external { require(msg.sender == gaspayingAddress, "Only gaspayingAddress"); require(timestamp >= block.timestamp, "Expired signature"); require(!hashUsage[dataHash], "dataHash already used"); - hashUsage[dataHash] = true; bytes memory plaintext = Sapphire.decrypt(encryptionSecret, nonce, ciphertext, abi.encodePacked(address(this))); GaslessData memory gaslessArgs = abi.decode(plaintext, (GaslessData)); User memory user; - if (gaslessArgs.txType == uint8(TxType.CreateAccount)) { NewAccount memory args = abi.decode(gaslessArgs.funcData, (NewAccount)); createAccount(args); - // Get user for emit event user = users[args.hashedUsername]; - } else if (gaslessArgs.txType == uint8(TxType.ManageCredential)) { ManageCred memory args = abi.decode(gaslessArgs.funcData, (ManageCred)); manageCredential(args); - // Get user for emit event (user, ) = internal_getCredentialAndUser(args.credentialIdHashed); - } else if (gaslessArgs.txType == uint8(TxType.ManageCredentialPassword)) { ManageCredPass memory args = abi.decode(gaslessArgs.funcData, (ManageCredPass)); manageCredentialPassword(args); - // Get user for emit event user = users[abi.decode(args.data, (Credential)).hashedUsername]; - - } else { + } else if (gaslessArgs.txType == uint8(TxType.ManageEmail)) { + // Implement email management if needed + revert("ManageEmail not implemented in gaslessTx"); + } else if (gaslessArgs.txType == uint8(TxType.ManageSocial)) { + // Implement social management if needed + revert("ManageSocial not implemented in gaslessTx"); + } else { revert("Unsupported operation"); } @@ -561,8 +658,7 @@ contract AccountManager is AccountManagerStorage, /** * @dev Generates a private signed transaction - * - * @param in_data calldata to execute in users behalf + * @param in_data calldata to execute on user's behalf * @param nonce nonce to be used in transaction * @param gasPrice gasPrice to be used in transaction * @param gasLimit gasLimit to be used in transaction @@ -570,40 +666,24 @@ contract AccountManager is AccountManagerStorage, * @param signature signature for the above sensitive data * @return out_data signed transaction */ - function generateGaslessTx ( + function generateGaslessTx( bytes calldata in_data, uint64 nonce, uint256 gasPrice, uint64 gasLimit, uint256 timestamp, bytes memory signature - ) - external view - returns (bytes memory out_data) - { + ) external view returns (bytes memory out_data) { require(timestamp >= block.timestamp, "Expired signature"); // Verify signature - (bytes32 dataHash, bool isValid) = validateSignature( - gasPrice, - gasLimit, - timestamp, - keccak256(in_data), - signature - ); - + (bytes32 dataHash, bool isValid) = validateSignature(gasPrice, gasLimit, timestamp, keccak256(in_data), signature); require(isValid, "Invalid signature"); require(!hashUsage[dataHash], "dataHash already used"); bytes32 cipherNonce = bytes32(Sapphire.randomBytes(32, in_data)); - bytes memory cipherPersonalization = abi.encodePacked(address(this)); - - bytes memory cipherBytes = Sapphire.encrypt( - encryptionSecret, - cipherNonce, - in_data, // plainText, - cipherPersonalization); + bytes memory cipherBytes = Sapphire.encrypt(encryptionSecret, cipherNonce, in_data, cipherPersonalization); EIP155Signer.EthTx memory gaslessTx = EIP155Signer.EthTx({ nonce: nonce, @@ -611,19 +691,15 @@ contract AccountManager is AccountManagerStorage, gasLimit: gasLimit, to: address(this), value: 0, - data: abi.encodeCall( - this.encryptedTx, - (cipherNonce, cipherBytes, timestamp, dataHash) - ), + data: abi.encodeCall(this.encryptedTx, (cipherNonce, cipherBytes, timestamp, dataHash)), chainId: block.chainid }); - return EIP155Signer.sign(gaspayingAddress, gaspayingSecret, gaslessTx); + out_data = EIP155Signer.sign(gaspayingAddress, gaspayingSecret, gaslessTx); } /** * @dev Set signer address. - * * @param _signer Signer address */ function setSigner(address _signer) external onlyRole(DEFAULT_ADMIN_ROLE) { @@ -633,12 +709,13 @@ contract AccountManager is AccountManagerStorage, /** * @dev Validates signature. - * * @param _gasPrice gas price * @param _gasLimit gas limit * @param _timestamp timestamp * @param _dataKeccak keccak of data * @param _signature signature of above parameters + * @return dataHash Hash of the data + * @return isValid Whether the signature is valid */ function validateSignature( uint256 _gasPrice, diff --git a/contracts/lib/EmailRecovery.sol b/contracts/lib/EmailRecovery.sol new file mode 100644 index 0000000..0ef2b05 --- /dev/null +++ b/contracts/lib/EmailRecovery.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +library EmailRecovery { + struct EmailRecoveryRequest { + bytes32 hashedUsername; + bytes32 emailHash; + uint256 timestamp; + bytes signature; + } + + /** + * @dev Verifies an email recovery request + * @param request The email recovery request data + * @param signer The expected signer address + * @return bool indicating success or failure + */ + function verifyRecoveryRequest( + EmailRecoveryRequest memory request, + address signer + ) internal pure returns (bool) { + bytes32 message = keccak256( + abi.encodePacked( + "recover-account-email", + request.hashedUsername, + request.emailHash, + request.timestamp + ) + ); + bytes32 ethSignedMessage = ECDSA.toEthSignedMessageHash(message); + address recoveredAddress = ECDSA.recover( + ethSignedMessage, + request.signature + ); + return recoveredAddress == signer; + } +} diff --git a/contracts/lib/SocialRecovery.sol b/contracts/lib/SocialRecovery.sol new file mode 100644 index 0000000..2117f5a --- /dev/null +++ b/contracts/lib/SocialRecovery.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +library SocialRecovery { + struct SocialRecoveryRequest { + bytes32 hashedUsername; + bytes32 platformHash; + bytes32 socialIdHash; + uint256 timestamp; + bytes signature; + } + + /** + * @dev Verifies a social recovery request + * @param request The social recovery request data + * @param signer The expected signer address + * @return bool indicating success or failure + */ + function verifyRecoveryRequest( + SocialRecoveryRequest memory request, + address signer + ) internal pure returns (bool) { + bytes32 message = keccak256( + abi.encodePacked( + "recover-account", + request.hashedUsername, + request.platformHash, + request.socialIdHash, + request.timestamp + ) + ); + bytes32 ethSignedMessage = ECDSA.toEthSignedMessageHash(message); + address recoveredAddress = ECDSA.recover( + ethSignedMessage, + request.signature + ); + return recoveredAddress == signer; + } +} diff --git a/scripts/add-email.ts b/scripts/add-email.ts new file mode 100644 index 0000000..8dc4273 --- /dev/null +++ b/scripts/add-email.ts @@ -0,0 +1,45 @@ +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + +async function main() { + // DATA to be set + const accountManagerAddress = "0xe1D85Aa3449690185371193DD46D60c3DA9FC709"; + const usernamePlain = "username"; + const email = "test@example.com"; + // Data to be set [END] + + const signer = (await ethers.getSigners())[0]; + const accountManager = await ethers.getContractAt('AccountManager', accountManagerAddress, signer); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), "hex"); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, "sha256"); + + // Check if user exists + const userExists = await accountManager.userExists(hashedUsername); + if (!userExists) { + console.error("User does not exist. Please create the account first."); + process.exit(1); + } + + // Add email + const tx = await accountManager.addEmail(hashedUsername, email); + await tx.wait(); + + console.log( + `Email "${email}" added for user "${usernamePlain}" (hashed: ${hashedUsername.toString( + "hex" + )})` + ); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error adding email:", error); + process.exit(1); + }); diff --git a/scripts/add-social-recovery.ts b/scripts/add-social-recovery.ts new file mode 100644 index 0000000..5781fbc --- /dev/null +++ b/scripts/add-social-recovery.ts @@ -0,0 +1,41 @@ +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // DATA to be set + const accountManagerAddress = "0xYourAccountManagerAddressHere"; + const usernamePlain = "username"; + const platform = "google"; + const socialId = "google_unique_id_123"; + // Data to be set [END] + + const signer = (await ethers.getSigners())[0]; + const accountManager = await ethers.getContractAt("AccountManager", accountManagerAddress, signer); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), 'hex'); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, 'sha256'); + + // Check if user exists + const userExists = await accountManager.userExists(hashedUsername); + if (!userExists) { + console.error("User does not exist. Please create the account first."); + process.exit(1); + } + + // Add social recovery method + const tx = await accountManager.addSocial(hashedUsername, platform, socialId); + await tx.wait(); + + console.log(`Social recovery method "${platform}:${socialId}" added for user "${usernamePlain}" (hashed: ${hashedUsername.toString('hex')})`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error adding social recovery:", error); + process.exit(1); + }); \ No newline at end of file diff --git a/scripts/remove-email.ts b/scripts/remove-email.ts new file mode 100644 index 0000000..672ca4a --- /dev/null +++ b/scripts/remove-email.ts @@ -0,0 +1,32 @@ +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // DATA to be set + const accountManagerAddress = "0xe1D85Aa3449690185371193DD46D60c3DA9FC709"; + const usernamePlain = "username"; + // Data to be set [END] + + const signer = (await ethers.getSigners())[0]; + const accountManager = await ethers.getContractAt("AccountManager", accountManagerAddress, signer); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), 'hex'); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, 'sha256'); + + // Remove email + const tx = await accountManager.removeEmail(hashedUsername); + await tx.wait(); + + console.log(`Email removed for user "${usernamePlain}" (hashed: ${hashedUsername.toString('hex')})`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error removing email:", error); + process.exit(1); + }); \ No newline at end of file diff --git a/scripts/verify-email.ts b/scripts/verify-email.ts new file mode 100644 index 0000000..fefcee8 --- /dev/null +++ b/scripts/verify-email.ts @@ -0,0 +1,34 @@ +const { ethers } = require("hardhat"); +const { pbkdf2Sync } = require("pbkdf2"); + +async function main() { + // DATA to be set + const accountManagerAddress = "0xe1D85Aa3449690185371193DD46D60c3DA9FC709"; + const usernamePlain = "username"; + const email = "test@example.com"; + const signature = "0x<>"; // The actual signature obtained from the backend/signing service + // Data to be set [END] + + const signer = (await ethers.getSigners())[0]; + const accountManager = await ethers.getContractAt("AccountManager", accountManagerAddress, signer); + + // Get salt from contract + const saltBytes = await accountManager.salt(); + const salt = Buffer.from(saltBytes.slice(2), 'hex'); + + // Hash the username + const hashedUsername = pbkdf2Sync(usernamePlain, salt, 100000, 32, 'sha256'); + + // Verify email + const tx = await accountManager.verifyEmail(hashedUsername, email, signature); + await tx.wait(); + + console.log(`Email "${email}" verified for user "${usernamePlain}" (hashed: ${hashedUsername.toString('hex')})`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error verifying email:", error); + process.exit(1); + }); diff --git a/test/e2e/AccountManager.ts b/test/e2e/AccountManager.ts index f885884..e465e93 100644 --- a/test/e2e/AccountManager.ts +++ b/test/e2e/AccountManager.ts @@ -25,6 +25,10 @@ describe("AccountManager", function() { const SIMPLE_PASSWORD = "0x0000000000000000000000000000000000000000000000000000000000000001"; const WRONG_PASSWORD = "0x0000000000000000000000000000000000000000000000000000009999999999"; + const platform = "google"; + const socialId = "googleUniqueId123"; + const email = "user@example.com"; + const RANDOM_STRING = "0x000000000000000000000000000000000000000000000000000000000000DEAD"; const abiCoder = ethers.AbiCoder.defaultAbiCoder(); @@ -1071,6 +1075,365 @@ describe("AccountManager", function() { expect(credList[0]).to.equal(keyPair.credentialId); }); + it("Should add, verify and remove email authentication successfully", async function () { + const usernamePlain = "plain_user"; + const emailToAdd = "plainuser@example.com"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Add email + const addEmailTx = await WA.addEmail(userData.hashedUsername, emailToAdd); + await addEmailTx.wait(); + + // Check email added + const userAfterAddEmail = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterAddEmail.emailCredential.emailHash).to.equal( + ethers.utils.keccak256(ethers.utils.toUtf8Bytes(emailToAdd)) + ); + expect(userAfterAddEmail.emailCredential.verified).to.equal(false); + + // Generate signature for verification + const message = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + "verify-email" + + ethers.utils.hexlify(userData.hashedUsername) + + emailToAdd + ) + ); + const signature = await signer.signMessage(ethers.utils.arrayify(message)); + + // Verify email + const verifyEmailTx = await WA.verifyEmail( + userData.hashedUsername, + emailToAdd, + signature + ); + await verifyEmailTx.wait(); + + // Check email verified + const userAfterVerifyEmail = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterVerifyEmail.emailCredential.verified).to.equal(true); + + // Remove email + const removeEmailTx = await WA.removeEmail(userData.hashedUsername); + await removeEmailTx.wait(); + + // Check email removed + const userAfterRemoveEmail = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterRemoveEmail.emailCredential.emailHash).to.equal( + ethers.constants.HashZero + ); + expect(userAfterRemoveEmail.emailCredential.verified).to.equal(false); + }); + + it("Should add, verify and remove social recovery successfully", async function () { + const usernamePlain = "social_user"; + const platform = "twitter"; + const socialId = "twitter_unique_id_123"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Add social recovery + const addSocialTx = await WA.addSocial( + userData.hashedUsername, + platform, + socialId + ); + await addSocialTx.wait(); + + // Check social recovery added + const userAfterAddSocial = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterAddSocial.socialCredentials.length).to.equal(1); + expect(userAfterAddSocial.socialCredentials[0].platformHash).to.equal( + ethers.utils.keccak256(ethers.utils.toUtf8Bytes(platform)) + ); + expect(userAfterAddSocial.socialCredentials[0].socialIdHash).to.equal( + ethers.utils.keccak256(ethers.utils.toUtf8Bytes(socialId)) + ); + expect(userAfterAddSocial.socialCredentials[0].verified).to.equal(false); + + // Generate signature for verification + const message = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + "verify-social" + + ethers.utils.hexlify(userData.hashedUsername) + + platform + + socialId + ) + ); + const signature = await signer.signMessage(ethers.utils.arrayify(message)); + + // Verify social recovery + const verifySocialTx = await WA.verifySocial( + userData.hashedUsername, + platform, + socialId, + signature + ); + await verifySocialTx.wait(); + + // Check social recovery verified + const userAfterVerifySocial = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterVerifySocial.socialCredentials[0].verified).to.equal(true); + + // Remove social recovery + const removeSocialTx = await WA.removeSocial( + userData.hashedUsername, + platform, + socialId + ); + await removeSocialTx.wait(); + + // Check social recovery removed + const userAfterRemoveSocial = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterRemoveSocial.socialCredentials.length).to.equal(0); + }); + + it("Should restrict email and social recovery management to admins only", async function () { + const usernamePlain = "adminuser"; + const emailToAdd = "adminuser@example.com"; + const platform = "linkedin"; + const socialId = "linkedin_id_123"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Get a non-admin signer (account2) + const accountManagerNonAdmin = WA.connect(account2); + + // Attempt to add email as non-admin + await expect( + accountManagerNonAdmin.addEmail(userData.hashedUsername, emailToAdd) + ).to.be.revertedWith( + `AccessControl: account ${await account2.getAddress()} is missing role ${ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("DEFAULT_ADMIN_ROLE") + )}` + ); + + // Attempt to remove email as non-admin + await expect( + accountManagerNonAdmin.removeEmail(userData.hashedUsername) + ).to.be.revertedWith( + `AccessControl: account ${await account2.getAddress()} is missing role ${ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("DEFAULT_ADMIN_ROLE") + )}` + ); + + // Attempt to add social recovery as non-admin + await expect( + accountManagerNonAdmin.addSocial( + userData.hashedUsername, + platform, + socialId + ) + ).to.be.revertedWith( + `AccessControl: account ${await account2.getAddress()} is missing role ${ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("DEFAULT_ADMIN_ROLE") + )}` + ); + + // Attempt to remove social recovery as non-admin + await expect( + accountManagerNonAdmin.removeSocial( + userData.hashedUsername, + platform, + socialId + ) + ).to.be.revertedWith( + `AccessControl: account ${await account2.getAddress()} is missing role ${ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("DEFAULT_ADMIN_ROLE") + )}` + ); + }); + + it("Should fail to verify email with invalid signature", async function () { + const usernamePlain = "invalidsiguser"; + const emailToAdd = "invalidsig@example.com"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Add email + const addEmailTx = await WA.addEmail(userData.hashedUsername, emailToAdd); + await addEmailTx.wait(); + + // Generate invalid signature (wrong signer) + const message = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + "verify-email" + + ethers.utils.hexlify(userData.hashedUsername) + + emailToAdd + ) + ); + const signature = await owner.signMessage(ethers.utils.arrayify(message)); // 'owner' is not the designated 'signer' + + // Attempt to verify email with invalid signature + await expect( + WA.verifyEmail(userData.hashedUsername, emailToAdd, signature) + ).to.be.revertedWith("verifyEmail: invalid signature"); + }); + + it("Should prevent adding duplicate email", async function () { + const usernamePlain = "duplicateemailuser"; + const emailToAdd = "duplicate@example.com"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Add email first time + const addEmailTx = await WA.addEmail(userData.hashedUsername, emailToAdd); + await addEmailTx.wait(); + + // Attempt to add the same email again + await expect( + WA.addEmail(userData.hashedUsername, emailToAdd) + ).to.be.revertedWith("addEmail: email already exists"); + }); + + it("Should ensure email is marked as verified after successful verification", async function () { + const usernamePlain = "verifystatususer"; + const emailToAdd = "verifystatus@example.com"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Add email + const addEmailTx = await WA.addEmail(userData.hashedUsername, emailToAdd); + await addEmailTx.wait(); + + // Verify email + const message = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + "verify-email" + + ethers.utils.hexlify(userData.hashedUsername) + + emailToAdd + ) + ); + const signature = await signer.signMessage(ethers.utils.arrayify(message)); + + const verifyEmailTx = await WA.verifyEmail( + userData.hashedUsername, + emailToAdd, + signature + ); + await verifyEmailTx.wait(); + + // Check verification status + const userAfterVerifyEmail = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterVerifyEmail.emailCredential.verified).to.equal(true); + }); + + it("Should ensure social recovery is marked as verified after successful verification", async function () { + const usernamePlain = "verifysocialstatususer"; + const platform = "instagram"; + const socialId = "instagramUniqueId678"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Add social recovery + const addSocialTx = await WA.addSocial( + userData.hashedUsername, + platform, + socialId + ); + await addSocialTx.wait(); + + // Verify social recovery + const message = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + "verify-social" + + ethers.utils.hexlify(userData.hashedUsername) + + platform + + socialId + ) + ); + const signature = await signer.signMessage(ethers.utils.arrayify(message)); + + const verifySocialTx = await WA.verifySocial( + userData.hashedUsername, + platform, + socialId, + signature + ); + await verifySocialTx.wait(); + + // Check verification status + const userAfterVerifySocial = await WA.getAccount( + ethers.utils.hexlify(userData.hashedUsername) + ); + expect(userAfterVerifySocial.socialCredentials[0].verified).to.equal(true); + }); + + it("Should prevent removing a non-existent email", async function () { + const usernamePlain = "nonexistentemailuser"; + const emailToRemove = "nonexistent@example.com"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Attempt to remove a non-existent email + await expect(WA.removeEmail(userData.hashedUsername)).to.be.revertedWith( + "removeEmail: email does not exist" + ); + }); + + it("Should prevent removing a non-existent social recovery method", async function () { + const usernamePlain = "nonexistentsocialuser"; + const platform = "reddit"; + const socialId = "reddit_unique_id_123"; + + // Create user + const userData = await createAccount(usernamePlain, SIMPLE_PASSWORD); + + // Attempt to remove a non-existent social recovery method + await expect( + WA.removeSocial(userData.hashedUsername, platform, socialId) + ).to.be.revertedWith("removeSocial: social credential not found"); + }); + + it("Sign random string with new account", async function () { + const username = hashedUsername("testuser"); + const accountData = await createAccount("testuser", SIMPLE_PASSWORD); + + expect(await WA.userExists(username)).to.equal(true); + + const iface = new ethers.utils.Interface(ACCOUNT_ABI); + const in_data = iface.encodeFunctionData("sign", [RANDOM_STRING]); + + const in_digest = ethers.utils.solidityPackedKeccak256( + ["bytes32", "bytes"], + [SIMPLE_PASSWORD, in_data] + ); + + const resp = await WA.proxyViewPassword(username, in_digest, in_data); + + const [sigRes] = iface.decodeFunctionResult("sign", resp); + + const recoveredAddress = ethers.utils.recoverAddress(RANDOM_STRING, { + r: sigRes.r, + s: sigRes.s, + v: sigRes.v, + }); + expect(recoveredAddress).to.equal(accountData.publicKey); + }); + function hashedUsername (username) { return pbkdf2Sync(username, SALT, 100_000, 32, 'sha256'); }