Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store Credential id with passkey #270

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
5 changes: 3 additions & 2 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const config: HardhatUserConfig = {
},
},
zksolc: {
version: "1.5.9",
version: "1.5.11",
settings: {
// https://era.zksync.io/docs/tools/hardhat/hardhat-zksync-solc.html#configuration
// Native AA calls an internal system contract, so it needs extra permissions
Expand All @@ -56,7 +56,8 @@ const config: HardhatUserConfig = {
version: "0.8.28",
settings: {
evmVersion: "cancun",
}
codegen: "yul",
},
},
};

Expand Down
124 changes: 66 additions & 58 deletions scripts/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "@nomicfoundation/hardhat-toolbox";

import { ethers } from "ethers";
import { writeFileSync } from "fs";
import { task } from "hardhat/config";
import { Wallet } from "zksync-ethers";

Expand Down Expand Up @@ -32,6 +33,59 @@ async function deploy(name: string, deployer: Wallet, proxy: boolean, args?: any
return proxyAddress;
}

async function fundPaymaster(deployer: Wallet, paymaster: string, fund?: string | number) {
if (fund && fund != 0) {
console.log("Funding paymaster with", fund, "ETH...");
await (
await deployer.sendTransaction({
to: paymaster,
value: ethers.parseEther(fund.toString()),
})
).wait();
console.log("Paymaster funded\n");
} else {
console.log("--fund flag not provided, skipping funding paymaster\n");
}
}

function getDeployer(hre, cmd) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { LOCAL_RICH_WALLETS, getProvider } = require("../test/utils");
console.log("Deploying to:", hre.network.name);
const provider = getProvider();

if (hre.network.name == "inMemoryNode" || hre.network.name == "dockerizedNode") {
console.log("Using local rich wallet");
cmd.fund = "1";
return new Wallet(LOCAL_RICH_WALLETS[0].privateKey, provider);
} else {
if (!process.env.WALLET_PRIVATE_KEY) throw "Wallet private key wasn't found in .env file!";
return new Wallet(process.env.WALLET_PRIVATE_KEY, provider);
}
}

function getArgs(cmd) {
if (cmd.only == BEACON_NAME) {
if (!cmd.implementation) {
throw "Account implementation (--implementation <value>) address must be provided to deploy beacon";
}
return [cmd.implementation];
}
if (cmd.only == FACTORY_NAME) {
if (!cmd.implementation) {
throw "Beacon (--beacon <value>) address must be provided to deploy factory";
}
return [cmd.implementation];
}
if (cmd.only == PAYMASTER_NAME) {
if (!cmd.factory || !cmd.sessions) {
throw "Factory (--factory <value>) and SessionModule (--sessions <value>) addresses must be provided to deploy paymaster";
}
return [cmd.factory, cmd.sessions];
}

throw `Unsupported '${cmd.only}' contract name. Use: ${BEACON_NAME}, ${FACTORY_NAME}, ${PAYMASTER_NAME}`;
}

task("deploy", "Deploys ZKsync SSO contracts")
.addOptionalParam("only", "name of a specific contract to deploy")
Expand All @@ -41,74 +95,28 @@ task("deploy", "Deploys ZKsync SSO contracts")
.addOptionalParam("sessions", "address of the sessions module to use in the paymaster")
.addOptionalParam("beacon", "address of the beacon to use in the factory")
.addOptionalParam("fund", "amount of ETH to send to the paymaster", "0")
.addOptionalParam("file", "where to save all contract locations (it not using only)")
.setAction(async (cmd, hre) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { LOCAL_RICH_WALLETS, getProvider } = require("../test/utils");
console.log("Deploying to:", hre.network.name);
const provider = getProvider();

let privateKey: string;
if (hre.network.name == "inMemoryNode" || hre.network.name == "dockerizedNode") {
console.log("Using local rich wallet");
privateKey = LOCAL_RICH_WALLETS[0].privateKey;
cmd.fund = "1";
} else {
if (!process.env.WALLET_PRIVATE_KEY) throw "Wallet private key wasn't found in .env file!";
privateKey = process.env.WALLET_PRIVATE_KEY;
}

const deployer = new Wallet(privateKey, provider);
console.log("Deployer address:", deployer.address);

async function fundPaymaster(paymaster: string, fund?: string | number) {
if (fund && fund != 0) {
console.log("Funding paymaster with", fund, "ETH...");
await (
await deployer.sendTransaction({
to: paymaster,
value: ethers.parseEther(cmd.fund),
})
).wait();
console.log("Paymaster funded\n");
} else {
console.log("--fund flag not provided, skipping funding paymaster\n");
}
}

const deployer = getDeployer(hre, cmd);
if (!cmd.only) {
await deploy(WEBAUTH_NAME, deployer, !cmd.noProxy);
const sessions = await deploy(SESSIONS_NAME, deployer, !cmd.noProxy);
const passkey = await deploy(WEBAUTH_NAME, deployer, !cmd.noProxy);
const session = await deploy(SESSIONS_NAME, deployer, !cmd.noProxy);
const implementation = await deploy(ACCOUNT_IMPL_NAME, deployer, false);
const beacon = await deploy(BEACON_NAME, deployer, false, [implementation]);
const factory = await deploy(FACTORY_NAME, deployer, !cmd.noProxy, [beacon]);
const paymaster = await deploy(PAYMASTER_NAME, deployer, false, [factory, sessions]);
const accountFactory = await deploy(FACTORY_NAME, deployer, !cmd.noProxy, [beacon]);
const accountPaymaster = await deploy(PAYMASTER_NAME, deployer, false, [accountFactory, session]);

await fundPaymaster(paymaster, cmd.fund);
await fundPaymaster(deployer, accountPaymaster, cmd.fund);
if (cmd.file) {
writeFileSync(cmd.file, JSON.stringify({ session, passkey, accountFactory, accountPaymaster }));
}
} else {
let args: any[] = [];
const args = getArgs(cmd);

if (cmd.only == BEACON_NAME) {
if (!cmd.implementation) {
throw "Account implementation (--implementation <value>) address must be provided to deploy beacon";
}
args = [cmd.implementation];
}
if (cmd.only == FACTORY_NAME) {
if (!cmd.implementation) {
throw "Beacon (--beacon <value>) address must be provided to deploy factory";
}
args = [cmd.implementation];
}
if (cmd.only == PAYMASTER_NAME) {
if (!cmd.factory || !cmd.sessions) {
throw "Factory (--factory <value>) and SessionModule (--sessions <value>) addresses must be provided to deploy paymaster";
}
args = [cmd.factory, cmd.sessions];
}
const deployedContract = await deploy(cmd.only, deployer, false, args);

if (cmd.only == PAYMASTER_NAME) {
await fundPaymaster(deployedContract, cmd.fund);
await fundPaymaster(deployer, deployedContract, cmd.fund);
}
}
});
2 changes: 1 addition & 1 deletion src/validators/SessionKeyValidator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ contract SessionKeyValidator is IModuleValidator {
}

/// @inheritdoc IERC165
function supportsInterface(bytes4 interfaceId) external view override returns (bool) {
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return
interfaceId == type(IERC165).interfaceId ||
interfaceId == type(IModuleValidator).interfaceId ||
Expand Down
115 changes: 85 additions & 30 deletions src/validators/WebAuthValidator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -28,49 +28,89 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator {
bytes32 private constant LOW_S_MAX = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8;
bytes32 private constant HIGH_R_MAX = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551;

event PasskeyCreated(address indexed keyOwner, string originDomain);
event PasskeyCreated(address indexed keyOwner, string originDomain, bytes credentialId);
event PasskeyRemoved(address indexed keyOwner, string originDomain, bytes credentialId);

// The layout is unusual due to EIP-7562 storage read restrictions for validation phase.
mapping(string originDomain => mapping(address accountAddress => bytes32)) public lowerKeyHalf;
mapping(string originDomain => mapping(address accountAddress => bytes32)) public upperKeyHalf;
mapping(string originDomain => mapping(bytes credentialId => mapping(address accountAddress => bytes32 publicKey)))
public lowerKeyHalf;
mapping(string originDomain => mapping(bytes credentialId => mapping(address accountAddress => bytes32 publicKey)))
public upperKeyHalf;
Comment on lines +35 to +38
Copy link
Member

Choose a reason for hiding this comment

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

Let's combine these into a single mapping that maps to e.g. bytes32[2]


// so you can check if you are using this passkey on this or related domains
mapping(string originDomain => mapping(bytes credentialId => address accountAddress)) public keyExistsOnDomain;

struct PasskeyId {
string domain;
bytes credentialId;
}

/// @notice Runs on module install
/// @param data ABI-encoded WebAuthn passkey to add immediately, or empty if not needed
function onInstall(bytes calldata data) external override {
if (data.length > 0) {
require(addValidationKey(data), "WebAuthValidator: key already exists");
(bytes memory credentialId, bytes32[2] memory rawPublicKey, string memory originDomain) = abi.decode(
data,
(bytes, bytes32[2], string)
);
require(addValidationKey(credentialId, rawPublicKey, originDomain), "WebAuthValidator: key already exists");
}
}

/// @notice Runs on module uninstall
/// @param data ABI-encoded array of origin domains to remove keys for
function onUninstall(bytes calldata data) external override {
string[] memory domains = abi.decode(data, (string[]));
for (uint256 i = 0; i < domains.length; i++) {
string memory domain = domains[i];
lowerKeyHalf[domain][msg.sender] = 0x0;
upperKeyHalf[domain][msg.sender] = 0x0;
PasskeyId[] memory passkeyIds = abi.decode(data, (PasskeyId[]));
for (uint256 i = 0; i < passkeyIds.length; i++) {
PasskeyId memory passkeyId = passkeyIds[i];
_removeValidationKey(passkeyId.credentialId, passkeyId.domain);
}
}

/// @notice Adds a WebAuthn passkey for the caller
/// @param key ABI-encoded WebAuthn public key to add
/// @return true if the key was added, false if it was updated
function addValidationKey(bytes calldata key) public returns (bool) {
(bytes32[2] memory key32, string memory originDomain) = abi.decode(key, (bytes32[2], string));
bytes32 initialLowerHalf = lowerKeyHalf[originDomain][msg.sender];
bytes32 initialUpperHalf = upperKeyHalf[originDomain][msg.sender];
function removeValidationKey(bytes calldata credentialId, string calldata domain) external {
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason for having an internal/external pair of functions instead of having one public one?

return _removeValidationKey(credentialId, domain);
}

// we might want to support multiple passkeys per domain
lowerKeyHalf[originDomain][msg.sender] = key32[0];
upperKeyHalf[originDomain][msg.sender] = key32[1];
function _removeValidationKey(bytes memory credentialId, string memory domain) internal {
lowerKeyHalf[domain][credentialId][msg.sender] = 0x0;
upperKeyHalf[domain][credentialId][msg.sender] = 0x0;
if (keyExistsOnDomain[domain][credentialId] == msg.sender) {
keyExistsOnDomain[domain][credentialId] = address(0);
}
emit PasskeyRemoved(msg.sender, domain, credentialId);
}

// we're returning true if this was a new key, false for update
bool keyExists = uint256(initialLowerHalf) == 0 && uint256(initialUpperHalf) == 0;
/// @notice Adds a WebAuthn passkey for the caller
/// @param credentialId unique public identifier for the key
/// @param rawPublicKey ABI-encoded WebAuthn public key to add
/// @param originDomain the domain this associated with
/// @return true if the key was added, false if one already exists
function addValidationKey(
bytes memory credentialId,
bytes32[2] memory rawPublicKey,
string memory originDomain
) public returns (bool) {
bytes32 initialLowerHalf = lowerKeyHalf[originDomain][credentialId][msg.sender];
bytes32 initialUpperHalf = upperKeyHalf[originDomain][credentialId][msg.sender];
if (uint256(initialLowerHalf) != 0 || uint256(initialUpperHalf) != 0) {
return false;
}
if (keyExistsOnDomain[originDomain][credentialId] != address(0)) {
Copy link
Member

Choose a reason for hiding this comment

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

since it is impossible (from what I see) to have a zero key, is this check (and hence mapping) really necessary?

// this key already exists on the domain (but it was zero before?)
return false;
}
if (rawPublicKey[0] == 0 && rawPublicKey[1] == 0) {
// empty keys aren't valid, if attempting to clear, use remove
return false;
}

lowerKeyHalf[originDomain][credentialId][msg.sender] = rawPublicKey[0];
upperKeyHalf[originDomain][credentialId][msg.sender] = rawPublicKey[1];
keyExistsOnDomain[originDomain][credentialId] = msg.sender;
MiniRoman marked this conversation as resolved.
Show resolved Hide resolved

emit PasskeyCreated(msg.sender, originDomain);
emit PasskeyCreated(msg.sender, originDomain, credentialId);

return keyExists;
return true;
}

/// @notice Validates a WebAuthn signature
Expand Down Expand Up @@ -103,9 +143,12 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator {
/// @param fatSignature The signature to validate (authenticator data, client data, [r, s])
/// @return true if the signature is valid
function webAuthVerify(bytes32 transactionHash, bytes memory fatSignature) internal view returns (bool) {
(bytes memory authenticatorData, string memory clientDataJSON, bytes32[2] memory rs) = _decodeFatSignature(
fatSignature
);
(
bytes memory authenticatorData,
string memory clientDataJSON,
bytes32[2] memory rs,
bytes memory credentialId
) = _decodeFatSignature(fatSignature);

// prevent signature replay https://yondon.blog/2019/01/01/how-not-to-use-ecdsa/
if (rs[0] <= 0 || rs[0] > HIGH_R_MAX || rs[1] <= 0 || rs[1] > LOW_S_MAX) {
Expand Down Expand Up @@ -139,8 +182,8 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator {
// as passkeys are linked to domains, so the storage mapping reflects that
string memory origin = root.at('"origin"').value().decodeString();
bytes32[2] memory pubkey;
pubkey[0] = lowerKeyHalf[origin][msg.sender];
pubkey[1] = upperKeyHalf[origin][msg.sender];
pubkey[0] = lowerKeyHalf[origin][credentialId][msg.sender];
pubkey[1] = upperKeyHalf[origin][credentialId][msg.sender];
// This really only validates the origin is set
if (uint256(pubkey[0]) == 0 || uint256(pubkey[1]) == 0) {
return false;
Expand Down Expand Up @@ -180,8 +223,20 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator {

function _decodeFatSignature(
bytes memory fatSignature
) private pure returns (bytes memory authenticatorData, string memory clientDataSuffix, bytes32[2] memory rs) {
(authenticatorData, clientDataSuffix, rs) = abi.decode(fatSignature, (bytes, string, bytes32[2]));
)
private
pure
returns (
bytes memory authenticatorData,
string memory clientDataSuffix,
bytes32[2] memory rs,
bytes memory credentialId
)
{
(authenticatorData, clientDataSuffix, rs, credentialId) = abi.decode(
fatSignature,
(bytes, string, bytes32[2], bytes)
);
}

/// @notice Verifies a message using the P256 curve.
Expand Down
Loading